148 lines
5.6 KiB
Kotlin
148 lines
5.6 KiB
Kotlin
package pw.binom.llmproxy
|
|
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.test.TestScope
|
|
import kotlinx.coroutines.test.runTest
|
|
import kotlinx.serialization.json.Json
|
|
import kotlinx.serialization.json.jsonArray
|
|
import kotlinx.serialization.json.jsonObject
|
|
import kotlinx.serialization.json.jsonPrimitive
|
|
import kotlin.test.Test
|
|
import kotlin.test.assertEquals
|
|
import kotlin.test.assertNull
|
|
import kotlin.time.Duration.Companion.milliseconds
|
|
import kotlin.time.Duration.Companion.minutes
|
|
import kotlin.time.Duration.Companion.seconds
|
|
|
|
class HealthProberTest {
|
|
|
|
private val json = Json { ignoreUnknownKeys = true }
|
|
|
|
@Test
|
|
fun effectiveProbeIntervalPrefersUpstreamThenProviderThenDefault() {
|
|
val provider = ProviderConf("p", "https://x", "", null, null, probe_interval = 2.seconds)
|
|
val upstream = UpstreamConf("u", "p", "m", null, null, null, null, null, probe_interval = 5.milliseconds)
|
|
val upstreamNoOwn = UpstreamConf("u2", "p", "m")
|
|
assertEquals(5.milliseconds, effectiveProbeInterval(upstream, provider))
|
|
assertEquals(2.seconds, effectiveProbeInterval(upstreamNoOwn, provider))
|
|
assertEquals(DEFAULT_PROBE_INTERVAL, effectiveProbeInterval(upstreamNoOwn, null))
|
|
}
|
|
|
|
@Test
|
|
fun effectiveProbeIntervalZeroDisablesProber() {
|
|
val up = UpstreamConf("u", "p", "m", null, null, null, null, null, probe_interval = 0.milliseconds)
|
|
assertNull(effectiveProbeInterval(up, null))
|
|
|
|
val providerZero = ProviderConf("p", "https://x", "", null, null, probe_interval = 0.milliseconds)
|
|
assertNull(effectiveProbeInterval(UpstreamConf("u2", "p", "m"), providerZero))
|
|
}
|
|
|
|
@Test
|
|
fun buildProbeBodyIsMinimalChatWithModelReplacedAndPatchesApplied() {
|
|
val provider = ProviderConf(
|
|
"p", "https://x", "", null,
|
|
Json.parseToJsonElement("""{"provider":{"allow_fallbacks":false}}""").jsonObject,
|
|
probe_interval = 1.seconds,
|
|
)
|
|
val up = UpstreamConf(
|
|
"u", "p", "real-1", null,
|
|
Json.parseToJsonElement("""{"temperature":0.5}""").jsonObject,
|
|
null, null, null, 1.seconds,
|
|
)
|
|
|
|
val body = buildProbeBody(up, provider)
|
|
|
|
assertEquals("real-1", body["model"]?.jsonPrimitive?.content)
|
|
assertEquals("2+2=?", body["messages"]?.jsonArray?.first()?.jsonObject?.get("content")?.jsonPrimitive?.content)
|
|
assertEquals("true", body["stream"]?.jsonPrimitive?.content)
|
|
assertEquals("4", body["max_tokens"]?.jsonPrimitive?.content)
|
|
assertEquals("false", body["provider"]?.jsonObject?.get("allow_fallbacks")?.jsonPrimitive?.content)
|
|
assertEquals("0.5", body["temperature"]?.jsonPrimitive?.content)
|
|
}
|
|
|
|
@Test
|
|
fun buildProbeBodyIgnoresModelPatch() {
|
|
val up = UpstreamConf("u", "p", "real-1")
|
|
val provider = ProviderConf("p", "https://x")
|
|
val body = buildProbeBody(up, provider)
|
|
assertEquals(
|
|
"""{"model":"real-1","messages":[{"role":"user","content":"2+2=?"}],"max_tokens":4,"stream":true}""",
|
|
body.toString(),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun probeLoopRecoversUpstreamWhenPingSucceeds() = runTest {
|
|
val provider = ProviderConf("p", "https://x", "", null, null, backoff = 5.minutes)
|
|
val up = UpstreamConf("u", "p", "m", null, null, null, backoff = 5.minutes, probe_interval = 5.milliseconds)
|
|
val backoff = BackoffRegistry(mapOf("p" to provider), mapOf("u" to up))
|
|
backoff.recordFailure(up)
|
|
assertEquals(true, backoff.isCooling(up))
|
|
|
|
val metrics = MetricsRegistry()
|
|
val prober = HealthProber(
|
|
providersById = mapOf("p" to provider),
|
|
upstreams = listOf(up),
|
|
http = createHttpClient(),
|
|
backoff = backoff,
|
|
metrics = metrics,
|
|
scope = this,
|
|
)
|
|
var pings = 0
|
|
val job = launch {
|
|
prober.probeLoop(up, 5.milliseconds) { pinged ->
|
|
pings++
|
|
assertEquals("u", pinged.id)
|
|
true
|
|
}
|
|
}
|
|
runUntil { pings >= 1 }
|
|
// успех уже зафиксирован (recordSuccess) — даём циклу несколько
|
|
// итераций и убеждаемся, что повторных пингов не было
|
|
delay(10.milliseconds)
|
|
job.cancel()
|
|
|
|
assertEquals(1, pings)
|
|
assertEquals(false, backoff.isCooling(up))
|
|
}
|
|
|
|
@Test
|
|
fun probeLoopKeepsCoolingWhenPingFails() = runTest {
|
|
val provider = ProviderConf("p", "https://x", "", null, null, backoff = 5.minutes)
|
|
val up = UpstreamConf("u", "p", "m", null, null, null, backoff = 5.minutes, probe_interval = 5.milliseconds)
|
|
val backoff = BackoffRegistry(mapOf("p" to provider), mapOf("u" to up))
|
|
backoff.recordFailure(up)
|
|
|
|
val metrics = MetricsRegistry()
|
|
val prober = HealthProber(
|
|
providersById = mapOf("p" to provider),
|
|
upstreams = listOf(up),
|
|
http = createHttpClient(),
|
|
backoff = backoff,
|
|
metrics = metrics,
|
|
scope = this,
|
|
)
|
|
var pings = 0
|
|
val job = launch {
|
|
prober.probeLoop(up, 5.milliseconds) { _ ->
|
|
pings++
|
|
false
|
|
}
|
|
}
|
|
runUntil { pings >= 2 }
|
|
job.cancel()
|
|
|
|
assertEquals(true, backoff.isCooling(up))
|
|
}
|
|
|
|
private suspend fun TestScope.runUntil(condition: () -> Boolean) {
|
|
var i = 0
|
|
while (!condition() && i < 10_000) {
|
|
delay(1)
|
|
i++
|
|
}
|
|
if (!condition()) throw AssertionError("condition not met in time")
|
|
}
|
|
|
|
} |