fix
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
package pw.binom.llmproxy
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import net.mamoe.yamlkt.Yaml
|
||||
|
||||
class ConfigLogicTest {
|
||||
|
||||
@Test
|
||||
fun mergeDeepMergesNestedObjectsAndReplacesScalars() {
|
||||
val base = Json.parseToJsonElement("""{"a":{"x":1,"y":2},"b":1}""").jsonObject
|
||||
val patch = Json.parseToJsonElement("""{"a":{"y":3,"z":4},"c":5}""").jsonObject
|
||||
val merged = merge(base, patch)
|
||||
assertEquals("""{"a":{"x":1,"y":3,"z":4},"b":1,"c":5}""", merged.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeReplacesArraysByKey() {
|
||||
val base = Json.parseToJsonElement("""{"provider":{"ignore":["a"]}}""").jsonObject
|
||||
val patch = Json.parseToJsonElement("""{"provider":{"ignore":["b","c"]}}""").jsonObject
|
||||
assertEquals("""{"provider":{"ignore":["b","c"]}}""", merge(base, patch).toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseConfigReadsAllBlocksAndConvertsPatchToJsonObject() {
|
||||
val yaml = """
|
||||
providers:
|
||||
- id: p1
|
||||
url: "https://x.ru/api/v1"
|
||||
key: "k"
|
||||
patch:
|
||||
provider:
|
||||
allow_fallbacks: false
|
||||
upstreams:
|
||||
- id: u1
|
||||
provider: p1
|
||||
model: real-1
|
||||
max_concurrency: 2
|
||||
patch:
|
||||
provider:
|
||||
ignore: [deepseek]
|
||||
- id: bad
|
||||
provider: missing
|
||||
model: x
|
||||
models:
|
||||
- name: m1
|
||||
upstreams: [u1, bad]
|
||||
patch:
|
||||
reasoning:
|
||||
enabled: false
|
||||
""".trimIndent()
|
||||
|
||||
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
|
||||
|
||||
assertEquals(1, cfg.providers.size)
|
||||
assertEquals("https://x.ru/api/v1", cfg.providers[0].url)
|
||||
assertEquals(2, cfg.upstreams.size)
|
||||
assertEquals(2, cfg.upstreams[0].max_concurrency)
|
||||
assertEquals(listOf("u1", "bad"), cfg.models[0].upstreams)
|
||||
|
||||
assertEquals("""{"provider":{"allow_fallbacks":false}}""", cfg.providers[0].patch.toString())
|
||||
assertEquals("""{"provider":{"ignore":["deepseek"]}}""", cfg.upstreams[0].patch.toString())
|
||||
assertEquals("""{"reasoning":{"enabled":false}}""", cfg.models[0].patch.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseConfigOmitsPatchWhenAbsent() {
|
||||
val yaml = """
|
||||
providers:
|
||||
- id: p1
|
||||
url: "https://x.ru/api/v1"
|
||||
upstreams:
|
||||
- id: u1
|
||||
provider: p1
|
||||
model: real-1
|
||||
models:
|
||||
- name: m1
|
||||
upstreams: [u1]
|
||||
""".trimIndent()
|
||||
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
|
||||
assertEquals(null, cfg.providers[0].patch)
|
||||
assertEquals(null, cfg.models[0].patch)
|
||||
assertEquals(null, cfg.upstreams[0].max_concurrency)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildBodySubstitutesModelAndAppliesLayersInOrder() {
|
||||
val provider = ProviderConf(
|
||||
"p1", "https://x", "",
|
||||
Json.parseToJsonElement("""{"provider":{"allow_fallbacks":false}}""").jsonObject,
|
||||
)
|
||||
val up = UpstreamConf(
|
||||
"u1", "p1", "real-1", null,
|
||||
Json.parseToJsonElement("""{"provider":{"ignore":["deepseek"]}}""").jsonObject,
|
||||
)
|
||||
val model = ModelConf(
|
||||
"m1", listOf("u1"),
|
||||
Json.parseToJsonElement("""{"reasoning":{"enabled":false}}""").jsonObject,
|
||||
)
|
||||
val body = Json.parseToJsonElement(
|
||||
"""{"model":"m1","messages":[],"temperature":0.7}""",
|
||||
).jsonObject
|
||||
|
||||
val out = buildBody(body, provider, up, model)
|
||||
assertEquals(
|
||||
"""{"model":"real-1","messages":[],"temperature":0.7,"provider":{"allow_fallbacks":false,"ignore":["deepseek"]},"reasoning":{"enabled":false}}""",
|
||||
out.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildBodyWithNoPatchesReturnsClientBodyWithSubstitutedModel() {
|
||||
val provider = ProviderConf("p1", "https://x", "")
|
||||
val up = UpstreamConf("u1", "p1", "real-1", null, null)
|
||||
val model = ModelConf("m1", listOf("u1"), null)
|
||||
val body = Json.parseToJsonElement("""{"model":"m1","a":1}""").jsonObject
|
||||
assertEquals("""{"model":"real-1","a":1}""", buildBody(body, provider, up, model).toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveEnvSubstitutesMissingVarWithEmptyAndPassesLiteralThrough() {
|
||||
assertEquals("", resolveEnv("\${LLM_PROXY_TEST_MISSING_VAR}"))
|
||||
assertEquals("plain", resolveEnv("plain"))
|
||||
assertEquals("pre--post", resolveEnv("pre-\${LLM_PROXY_TEST_MISSING_VAR}-post"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tryClaimRespectsMaxConcurrencyAndReleaseFreesSlot() {
|
||||
val active = mapOf("u1" to UpstreamCounter(0))
|
||||
val up = UpstreamConf("u1", "p", "m", 1, null)
|
||||
assertTrue(tryClaim(up, active))
|
||||
assertFalse(tryClaim(up, active))
|
||||
release(up, active)
|
||||
assertTrue(tryClaim(up, active))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tryClaimUnlimitedWhenMaxConcurrencyIsNull() {
|
||||
val active = mapOf("u2" to UpstreamCounter(0))
|
||||
val up = UpstreamConf("u2", "p", "m", null, null)
|
||||
assertTrue(tryClaim(up, active))
|
||||
assertTrue(tryClaim(up, active))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickFreeUpstreamReturnsFirstFreeInDeclarationOrder() {
|
||||
val active = mapOf("u1" to UpstreamCounter(0), "u2" to UpstreamCounter(0))
|
||||
val pool = listOf(
|
||||
UpstreamConf("u1", "p", "m", 1, null),
|
||||
UpstreamConf("u2", "p", "m", 2, null),
|
||||
)
|
||||
val up = pickFreeUpstream(pool, active, emptySet())
|
||||
assertEquals("u1", up?.id)
|
||||
// слот реально занят
|
||||
assertEquals(1, active.getValue("u1").current)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickFreeUpstreamSkipsExcluded() {
|
||||
val active = mapOf("u1" to UpstreamCounter(0), "u2" to UpstreamCounter(0))
|
||||
val pool = listOf(
|
||||
UpstreamConf("u1", "p", "m", 1, null),
|
||||
UpstreamConf("u2", "p", "m", 2, null),
|
||||
)
|
||||
val up = pickFreeUpstream(pool, active, setOf("u1"))
|
||||
assertEquals("u2", up?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickFreeUpstreamReturnsNullWhenAllBusy() {
|
||||
val active = mapOf("u1" to UpstreamCounter(1, 1)) // уже на лимите 1
|
||||
val pool = listOf(UpstreamConf("u1", "p", "m", 1, null))
|
||||
assertEquals(null, pickFreeUpstream(pool, active, emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickFreeUpstreamImplementsFailoverOrder() {
|
||||
// dead исключён (упал ранее) — выбирается следующий живой u1
|
||||
val active = mapOf("dead" to UpstreamCounter(0), "u1" to UpstreamCounter(0))
|
||||
val pool = listOf(
|
||||
UpstreamConf("dead", "p", "m", 1, null),
|
||||
UpstreamConf("u1", "p", "m", 1, null),
|
||||
)
|
||||
val up = pickFreeUpstream(pool, active, setOf("dead"))
|
||||
assertEquals("u1", up?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickFreeUpstreamExhaustsConcurrencyThenReturnsNull() {
|
||||
val active = mapOf("u1" to UpstreamCounter(0), "u2" to UpstreamCounter(0))
|
||||
val pool = listOf(
|
||||
UpstreamConf("u1", "p", "m", 1, null),
|
||||
UpstreamConf("u2", "p", "m", 1, null),
|
||||
)
|
||||
assertEquals("u1", pickFreeUpstream(pool, active, emptySet())?.id)
|
||||
assertEquals("u2", pickFreeUpstream(pool, active, emptySet())?.id)
|
||||
assertEquals(null, pickFreeUpstream(pool, active, emptySet())?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rebuildFromChunksPreservesAllUpstreamFields() {
|
||||
val sse = """
|
||||
data: {"id":"c1","object":"chat.completion.chunk","created":123,"model":"m","provider":"Baidu","system_fingerprint":"sf1","choices":[{"index":0,"delta":{"role":"assistant","content":"He"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"c1","created":123,"model":"m","provider":"Baidu","choices":[{"index":0,"delta":{"content":"llo"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"c1","created":123,"model":"m","provider":"Baidu","choices":[{"index":1,"delta":{"content":"B"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"c1","created":123,"model":"m","provider":"Baidu","choices":[{"index":0,"delta":{},"finish_reason":"stop"},{"index":1,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: {"id":"c1","created":123,"model":"m","provider":"Baidu","usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}
|
||||
|
||||
data: [DONE]
|
||||
""".trimIndent()
|
||||
|
||||
val out = Json.parseToJsonElement(rebuildFromChunks(sse)).jsonObject
|
||||
assertEquals("c1", out["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("m", out["model"]?.jsonPrimitive?.content)
|
||||
assertEquals("Baidu", out["provider"]?.jsonPrimitive?.content)
|
||||
assertEquals("sf1", out["system_fingerprint"]?.jsonPrimitive?.content)
|
||||
assertEquals("chat.completion", out["object"]?.jsonPrimitive?.content)
|
||||
assertEquals(123, out["created"]?.jsonPrimitive?.content?.toLong())
|
||||
assertEquals(3, out["usage"]?.jsonObject?.get("total_tokens")?.jsonPrimitive?.content?.toInt())
|
||||
val choices = out["choices"]?.jsonArray ?: error("no choices")
|
||||
assertEquals(2, choices.size)
|
||||
assertEquals("Hello", choices[0].jsonObject["message"]?.jsonObject?.get("content")?.jsonPrimitive?.content)
|
||||
assertEquals("B", choices[1].jsonObject["message"]?.jsonObject?.get("content")?.jsonPrimitive?.content)
|
||||
assertEquals("stop", choices[0].jsonObject["finish_reason"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rebuildFromChunksReturnsUpstreamErrorVerbatim() {
|
||||
val sse = """
|
||||
data: {"id":"c1","error":{"message":"Unsupported field 'foo'","type":"invalid_request"},"choices":[]}
|
||||
|
||||
data: [DONE]
|
||||
""".trimIndent()
|
||||
val out = Json.parseToJsonElement(rebuildFromChunks(sse)).jsonObject
|
||||
val err = out["error"]?.jsonObject ?: error("error block missing")
|
||||
assertEquals("Unsupported field 'foo'", err["message"]?.jsonPrimitive?.content)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user