feat: вычисление сессии по истории (LCP) для providers[].session_header
- SHA-256 на commonMain + SessionRegistry (LCP по префикс-хэшам, LRU 1000/6ч, Mutex) - providers[].session_header: прокси сам ставит/перезаписывает заголовок сессии - цепочка хэшей стартует с первого user-сообщения (system не склеивает сессии) - CONFIG.md + тесты (SHA-256, LCP, LRU/TTL, парсинг, стабильность префиксов)
This commit is contained in:
@@ -382,4 +382,116 @@ class ConfigLogicTest {
|
||||
val err = out["error"]?.jsonObject ?: error("error block missing")
|
||||
assertEquals("Unsupported field 'foo'", err["message"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sha256MatchesKnownVectors() {
|
||||
assertEquals(
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
sha256Hex(""),
|
||||
)
|
||||
assertEquals(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
sha256Hex("abc"),
|
||||
)
|
||||
assertEquals(
|
||||
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1",
|
||||
sha256Hex("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionPrefixHashesAreStableWhenHistoryGrows() {
|
||||
val first = Json.parseToJsonElement(
|
||||
"""{"messages":[{"role":"system","content":"S"},{"role":"user","content":"hi"}]}""",
|
||||
).jsonObject
|
||||
val second = Json.parseToJsonElement(
|
||||
"""{"messages":[{"role":"system","content":"S"},{"role":"user","content":"hi"},{"role":"assistant","content":"yo"},{"role":"user","content":"again"}]}""",
|
||||
).jsonObject
|
||||
|
||||
val h1 = sessionPrefixHashes(first)
|
||||
val h2 = sessionPrefixHashes(second)
|
||||
|
||||
// Цепочка начинается с первого user — system-преамбула не хэшируется.
|
||||
assertEquals(1, h1.size)
|
||||
assertEquals(3, h2.size)
|
||||
assertEquals(h1, h2.take(1))
|
||||
assertEquals(h1.last(), h2[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionPrefixHashesIgnoreLeadingSystemSoSharedPromptDoesNotCollide() {
|
||||
val a = Json.parseToJsonElement(
|
||||
"""{"messages":[{"role":"system","content":"SAME"},{"role":"user","content":"session A"}]}""",
|
||||
).jsonObject
|
||||
val b = Json.parseToJsonElement(
|
||||
"""{"messages":[{"role":"system","content":"SAME"},{"role":"user","content":"session B"}]}""",
|
||||
).jsonObject
|
||||
// разные первые user-сообщения → разные хэши, несмотря на общий system
|
||||
assertFalse(sessionPrefixHashes(a) == sessionPrefixHashes(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionPrefixHashesFallBackToWholeBodyWithoutMessages() {
|
||||
val body = Json.parseToJsonElement("""{"model":"m"}""").jsonObject
|
||||
assertEquals(listOf(sha256Hex(body.toString())), sessionPrefixHashes(body))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionRegistryReusesIdByLongestCommonPrefix() {
|
||||
val reg = SessionRegistry()
|
||||
val first = reg.resolveLocked(listOf("H1"), 0)
|
||||
assertEquals("H1", first)
|
||||
|
||||
// история выросла: [H1, H2, H3] — самый длинный известный префикс H1
|
||||
val next = reg.resolveLocked(listOf("H1", "H2", "H3"), 1)
|
||||
assertEquals("H1", next)
|
||||
|
||||
// и дальше — id не меняется
|
||||
val deep = reg.resolveLocked(listOf("H1", "H2", "H3", "H4"), 2)
|
||||
assertEquals("H1", deep)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionRegistryCreatesNewIdForDifferentHistory() {
|
||||
val reg = SessionRegistry()
|
||||
assertEquals("A1", reg.resolveLocked(listOf("A1"), 0))
|
||||
assertEquals("B1", reg.resolveLocked(listOf("B1"), 0))
|
||||
assertEquals("A1", reg.resolveLocked(listOf("A1", "A2"), 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionRegistryEvictsLruWhenOverCapacity() {
|
||||
val reg = SessionRegistry(maxSessions = 2, ttlMillis = Long.MAX_VALUE)
|
||||
reg.resolveLocked(listOf("A1"), 0)
|
||||
reg.resolveLocked(listOf("B1"), 1)
|
||||
reg.resolveLocked(listOf("C1"), 2) // A вытеснена
|
||||
// a1 больше неизвестен → новая сессия с id = хэш всей истории A2
|
||||
assertEquals("A2", reg.resolveLocked(listOf("A1", "A2"), 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionRegistryEvictsExpiredByTtl() {
|
||||
val reg = SessionRegistry(maxSessions = 100, ttlMillis = 1000)
|
||||
reg.resolveLocked(listOf("A1"), 0)
|
||||
reg.resolveLocked(listOf("B1"), 2000) // A протухла
|
||||
assertEquals("A2", reg.resolveLocked(listOf("A1", "A2"), 2001))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseConfigReadsSessionHeader() {
|
||||
val yaml = """
|
||||
providers:
|
||||
- id: p1
|
||||
url: "https://x.ru/api/v1"
|
||||
session_header: x-opencode-session
|
||||
- id: p2
|
||||
url: "https://y.ru/api/v1"
|
||||
models:
|
||||
- name: m1
|
||||
upstreams: []
|
||||
""".trimIndent()
|
||||
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
|
||||
assertEquals("x-opencode-session", cfg.providers[0].session_header)
|
||||
assertEquals(null, cfg.providers[1].session_header)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user