From 334015a2bdd4f9159d970618aef99ce1fc9f48bd Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 11 Sep 2026 17:40:06 +0300 Subject: [PATCH] =?UTF-8?q?test:=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=20think=5Ftags=20=E2=80=94=20non-stream,=20SSE-?= =?UTF-8?q?=D1=87=D0=B0=D0=BD=D0=BA=D0=B8,=20=D0=BE=D0=B1=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D1=81=D1=82=D1=80=D0=B8=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transformThinkMessage/transformThinkChunk/streamSseWithThinkTags сделаны internal ради тестируемости (логика не менялась) - +24 теста: ThinkTagTransformTest (9), ThinkTagChunkTest (8), ThinkTagStreamTest (7) - ConfigLogicTest: ассерт приоритета источников был неразличающим (провайдер и апстрим давали одинаковый результат) — заменён на различающиеся значения - kotlinx-coroutines-test для тестов каналов ktor - Gitea Actions: шаг Run tests (jvmTest) — раньше CI тесты не гонял вовсе - TESTING.md: что покрыто + мутационная приёмка --- .gitea/workflows/build.yml | 4 + TESTING.md | 45 +++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + .../kotlin/pw/binom/llmproxy/Main.kt | 4 +- .../pw/binom/llmproxy/ConfigLogicTest.kt | 2 +- .../pw/binom/llmproxy/ThinkTagChunkTest.kt | 179 ++++++++++++++++++ .../pw/binom/llmproxy/ThinkTagStreamTest.kt | 179 ++++++++++++++++++ .../binom/llmproxy/ThinkTagTransformTest.kt | 174 +++++++++++++++++ 9 files changed, 586 insertions(+), 3 deletions(-) create mode 100644 TESTING.md create mode 100644 src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagChunkTest.kt create mode 100644 src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagStreamTest.kt create mode 100644 src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagTransformTest.kt diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 4b861fd..e574096 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -9,6 +9,10 @@ jobs: steps: - name: 'Checkout' uses: https://github.com/actions/checkout@v4 + - name: 'Run tests' + uses: https://git.binom.pw/subochev/devops/build-gradle@main + with: + target: jvmTest - name: 'Build jar' uses: https://git.binom.pw/subochev/devops/build-gradle@main with: diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..c669e24 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,45 @@ +# Тестирование llm-proxy + +Все тесты — обычные модульные, живут в `src/commonTest/kotlin/pw/binom/llmproxy/`. + +## Запуск + +- `./gradlew jvmTest` — прогнать все тесты; +- `./gradlew clean jvmTest fatJar` — полная сборка с нуля; +- результат смотреть в `build/test-results/jvmTest/*.xml` (атрибуты `tests`/`failures`/`errors`), потому что строки вида «N tests completed» печатаются только при падениях. + +## Что покрыто + +| Файл | Что проверяет | +| --- | --- | +| `ThinkTagSplitterTest` | Автомат рассечения think-тегов: passthrough при off, вырезание рассуждений при split, отбрасывание при strip, удержание разрезанного тега, несколько блоков, незакрытый блок; | +| `ConfigLogicTest` | Разбор конфига, приоритет источников (апстрим важнее провайдера), слияние патчей, выбор апстрима и лимиты конкурентности, заголовки, сессии; | +| `ThinkTagTransformTest` | Non-stream путь `transformThinkMessage`: перенос рассуждений в `reasoning_content`, дописывание к уже имеющемуся, strip, незакрытый блок, отсутствие изменений → null; | +| `ThinkTagChunkTest` | SSE-чанки `transformThinkChunk`: удержание хвоста тега между чанками, независимые сплиттеры по index, удаление пустого `content`; | +| `ThinkTagStreamTest` | Обвязка стрима `streamSseWithThinkTags`: разрез тега между data-событиями, сброс удержанного хвоста в финиш-чанке, прохождение служебных строк и `[DONE]`, битый JSON, чанк без choices, strip. | + +## Проверка качества тестов (мутационная приёмка) + +Приём по шагам: + +1. Забэкапить файл. +2. Внести РОВНО одну поломку в боевой код. +3. Прогнать `./gradlew cleanJvmTest jvmTest`. +4. Посмотреть XML — тест, который не упал, считается пустым. +5. Откатить (`git checkout -- <файл>`). + +Обязательно: `cleanJvmTest` обязателен, иначе прогон не перезапустится. + +Проверенные мутации, каждая из которых ДОЛЖНА ронять тесты: + +- `transformThinkMessage` возвращает null → падают тесты non-stream; +- `transformThinkChunk` возвращает null → падают тесты чанков и стрима; +- блок финиш-чанка в `streamSseWithThinkTags` не выполняется → падает тест про удержанный хвост; +- `holdableSuffix` всегда 0 (хвост тега не удерживается) → падают тесты автомата, чанков и стрима; +- `strip` начинает отдавать рассуждения → падает тест автомата. + +Правило: боевой код нельзя подгонять под тест; если тест не проходит, неверен тест. + +## Известное ограничение + +Живой стрим в реальном апстриме модульными тестами не проверяется: обвязка испытывается на синтетическом SSE через каналы ktor. Реальный апстрим проверяется только после деплоя. diff --git a/build.gradle.kts b/build.gradle.kts index d15979c..05be3a5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { val commonTest by getting { dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } } val jvmMain by getting { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d126f75..3a639c6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa yamlkt = { module = "net.mamoe.yamlkt:yamlkt", version.ref = "yamlkt" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIo" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" } diff --git a/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt b/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt index a988bf0..4daa380 100644 --- a/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt +++ b/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt @@ -652,7 +652,7 @@ internal fun transformThinkMessage(obj: JsonObject, thinkMode: String): String? * исходная строка. Каждую строку сразу `flush()`, чтобы стрим не «залипал» в * буфере. В конце потока накопленные хвосты сплиттеров сбрасываются финиш-чанком. */ -private suspend fun ByteWriteChannel.streamSseWithThinkTags(source: ByteReadChannel, thinkMode: String) { +internal suspend fun ByteWriteChannel.streamSseWithThinkTags(source: ByteReadChannel, thinkMode: String) { val splitters = mutableMapOf() val addReasoning = thinkMode == "split" while (true) { @@ -710,7 +710,7 @@ private suspend fun ByteWriteChannel.streamSseWithThinkTags(source: ByteReadChan * Чанк без `choices` или без строкового `delta.content` не меняется — * возвращается null (отдать исходную строку как есть). */ -private fun transformThinkChunk( +internal fun transformThinkChunk( obj: JsonObject, splitters: MutableMap, thinkMode: String, diff --git a/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt b/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt index faf781d..3396029 100644 --- a/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt +++ b/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt @@ -533,7 +533,7 @@ class ConfigLogicTest { // значение у апстрима — берётся оно, провайдер игнорируется assertEquals("split", effectiveThinkTags(upSplit, null)) - assertEquals("split", effectiveThinkTags(upSplit, prov("true"))) + assertEquals("split", effectiveThinkTags(upSplit, prov("strip"))) // апстрим null — берётся провайдерский assertEquals("split", effectiveThinkTags(upNull, prov("split"))) diff --git a/src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagChunkTest.kt b/src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagChunkTest.kt new file mode 100644 index 0000000..98703e3 --- /dev/null +++ b/src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagChunkTest.kt @@ -0,0 +1,179 @@ +package pw.binom.llmproxy + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +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 + +class ThinkTagChunkTest { + + // Литералы тегов OPEN/CLOSE из ThinkTagSplitter собираем из символьных + // кусочков, чтобы не записывать тег единой строкой в исходнике. + private val openTag = '<' + "think" + '>' + private val closeTag = '<' + "/think" + '>' + // Маркер рассуждения (5 букв) собираем из Unicode-кодов, без единой строки. + private val reasoning = listOf(0x0420, 0x0410, 0x0417, 0x0423, 0x041C) + .map { Char(it) } + .joinToString("") + + private fun deltaOf(result: String, index: Int): JsonObject = + Json.parseToJsonElement(result).jsonObject + .get("choices")!!.jsonArray[index].jsonObject + .get("delta")!!.jsonObject + + @Test + fun chunkSplitHoldsTailAcrossChunks() { + // Хвост открывающего тега, разрезанный на границе чанков, переживает + // два отдельных вызова через общий splitters (getOrPut по index). + val splitters = mutableMapOf() + + // Чанк 1: текст + обрезанный префикс открывающего тега (не завершает тег). + val prefix = openTag.dropLast(1) + val obj1 = Json.parseToJsonElement( + """{"choices":[{"index":0,"delta":{"content":"текст$prefix"}}]}""", + ).jsonObject + val r1 = transformThinkChunk(obj1, splitters, "split", true) + assertNotNull(r1) + val d1 = deltaOf(r1, 0) + assertEquals("текст", d1["content"]!!.jsonPrimitive.content) + assertFalse(d1.containsKey("reasoning_content")) + + // Чанк 2 (тот же splitters): остаток открывающего тега + маркер. + val rest = openTag.last() + val obj2 = Json.parseToJsonElement( + """{"choices":[{"index":0,"delta":{"content":"$rest$reasoning"}}]}""", + ).jsonObject + val r2 = transformThinkChunk(obj2, splitters, "split", true) + assertNotNull(r2) + val d2 = deltaOf(r2, 0) + assertFalse(d2.containsKey("content")) + assertEquals(reasoning, d2["reasoning_content"]!!.jsonPrimitive.content) + } + + @Test + fun chunkStripRemovesReasoning() { + // Режим strip: блок целиком (теги + рассуждения) вырезается, + // content склеивается в «AB», ключа reasoning_content нет. + val splitters = mutableMapOf() + val c = "A" + openTag + reasoning + closeTag + "B" + val obj = Json.parseToJsonElement( + """{"choices":[{"index":0,"delta":{"content":"$c"}}]}""", + ).jsonObject + val r = transformThinkChunk(obj, splitters, "strip", false) + assertNotNull(r) + val d = deltaOf(r, 0) + assertEquals("AB", d["content"]!!.jsonPrimitive.content) + assertFalse(d.containsKey("reasoning_content")) + } + + @Test + fun chunkDropsEmptyContentFieldWhenAllGoesToReasoning() { + // Режим split: весь контент чанка — think-блок, поэтому content пуст + // и ключ убирается из delta, а рассуждения уходят в reasoning_content. + val splitters = mutableMapOf() + val c = openTag + reasoning + closeTag + val obj = Json.parseToJsonElement( + """{"choices":[{"index":0,"delta":{"content":"$c"}}]}""", + ).jsonObject + val r = transformThinkChunk(obj, splitters, "split", true) + assertNotNull(r) + val d = deltaOf(r, 0) + assertFalse(d.containsKey("content")) + assertEquals(reasoning, d["reasoning_content"]!!.jsonPrimitive.content) + } + + @Test + fun chunkReturnsNullWhenNoChoices() { + // Нет ключа choices — чанк не трогаем, отдаём исходную строку (null). + val splitters = mutableMapOf() + val obj = Json.parseToJsonElement("""{"model":"m"}""").jsonObject + assertNull(transformThinkChunk(obj, splitters, "split", true)) + } + + @Test + fun chunkReturnsNullWhenDeltaContentNotAString() { + // content = null (JsonNull) и content = массив частей — не строка, + // значит choice не трогаем, весь чанк не меняется -> null. + val s1 = mutableMapOf() + val o1 = Json.parseToJsonElement( + """{"choices":[{"index":0,"delta":{"content":null}}]}""", + ).jsonObject + assertNull(transformThinkChunk(o1, s1, "split", true)) + + val s2 = mutableMapOf() + val o2 = Json.parseToJsonElement( + """{"choices":[{"index":0,"delta":{"content":[{"type":"text","text":"hi"}]}}]}""", + ).jsonObject + assertNull(transformThinkChunk(o2, s2, "split", true)) + } + + @Test + fun chunkSeparateSplittersPerIndex() { + // Один объект splitters на все три вызова: по каждому index держим + // своего сплиттера, поэтому обрезанные хвосты по index не путаются. + val splitters = mutableMapOf() + val prefix = openTag.dropLast(1) + val rest = openTag.last() + + // Вызов 1: два choice — index 0 «A»+префикс тега, index 1 «B»+тот же префикс. + val c1 = """{"choices":[{"index":0,"delta":{"content":"A$prefix"}},{"index":1,"delta":{"content":"B$prefix"}}]}""" + val r1 = transformThinkChunk(Json.parseToJsonElement(c1).jsonObject, splitters, "split", true) + assertNotNull(r1) + + // Вызов 2: index 1 получает остаток тега + маркер → рассуждения у index 1. + val c2 = """{"choices":[{"index":1,"delta":{"content":"$rest${reasoning}1"}}]}""" + val r2 = transformThinkChunk(Json.parseToJsonElement(c2).jsonObject, splitters, "split", true) + assertNotNull(r2) + val d2 = deltaOf(r2, 0) + assertEquals(reasoning + "1", d2["reasoning_content"]!!.jsonPrimitive.content) + + // Вызов 3: index 0 получает остаток тега + маркер → рассуждения у index 0. + val c3 = """{"choices":[{"index":0,"delta":{"content":"$rest${reasoning}0"}}]}""" + val r3 = transformThinkChunk(Json.parseToJsonElement(c3).jsonObject, splitters, "split", true) + assertNotNull(r3) + val d3 = deltaOf(r3, 0) + assertEquals(reasoning + "0", d3["reasoning_content"]!!.jsonPrimitive.content) + } + + @Test + fun chunkKeepsUntouchedChoiceAndFinishReason() { + // Первый choice целиком идёт через split; второй без ключа content + // (в нём только finish_reason) — остаётся нетронутым, как и был. + val splitters = mutableMapOf() + val c = "A" + openTag + reasoning + closeTag + "B" + val c1 = """{"choices":[{"index":0,"delta":{"content":"$c"}},{"index":1,"delta":{"finish_reason":"stop"}}]}""" + val r = transformThinkChunk(Json.parseToJsonElement(c1).jsonObject, splitters, "split", true) + assertNotNull(r) + val d0 = deltaOf(r, 0) + assertEquals("AB", d0["content"]!!.jsonPrimitive.content) + assertEquals(reasoning, d0["reasoning_content"]!!.jsonPrimitive.content) + val d1 = deltaOf(r, 1) + assertEquals("stop", d1["finish_reason"]!!.jsonPrimitive.content) + assertFalse(d1.containsKey("content")) + } + + @Test + fun chunkIndexDefaultsToZeroWhenAbsent() { + // Ключ index отсутствует — оба раза используем сплиттер под индекс 0, + // поэтому хвост первого чанка доживал до рассуждений второго. + val splitters = mutableMapOf() + val prefix = openTag.dropLast(1) + val rest = openTag.last() + + val c1 = """{"choices":[{"delta":{"content":"A$prefix"}}]}""" + val r1 = transformThinkChunk(Json.parseToJsonElement(c1).jsonObject, splitters, "split", true) + assertNotNull(r1) + + val c2 = """{"choices":[{"delta":{"content":"$rest$reasoning"}}]}""" + val r2 = transformThinkChunk(Json.parseToJsonElement(c2).jsonObject, splitters, "split", true) + assertNotNull(r2) + val d2 = deltaOf(r2, 0) + assertEquals(reasoning, d2["reasoning_content"]!!.jsonPrimitive.content) + } +} diff --git a/src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagStreamTest.kt b/src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagStreamTest.kt new file mode 100644 index 0000000..d645112 --- /dev/null +++ b/src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagStreamTest.kt @@ -0,0 +1,179 @@ +package pw.binom.llmproxy + +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.close +import io.ktor.utils.io.readAvailable +import io.ktor.utils.io.writeFully +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +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 + +class ThinkTagStreamTest { + + /** Прогнать SSE-текст через боевую обвязку и вернуть то, что она записала. */ + private suspend fun runStream(input: String, mode: String): String { + val src = ByteChannel(autoFlush = true) + src.writeFully(input.encodeToByteArray()) + src.close(null) + val out = ByteChannel(autoFlush = true) + out.streamSseWithThinkTags(src, mode) + out.close(null) + val sb = StringBuilder() + val buf = ByteArray(512) + while (true) { + val n = out.readAvailable(buf) + if (n == -1) break + if (n > 0) sb.append(buf.decodeToString(0, n)) + } + return sb.toString() + } + + // Литералы тегов OPEN/CLOSE из ThinkTagSplitter собираем из символьных + // кусочков, чтобы не записывать тег единой строкой в исходнике. + private val openTag = '<' + "think" + '>' + private val closeTag = '<' + "/think" + '>' + // Маркер рассуждения (5 букв) собираем из Unicode-кодов, без единой строки. + private val reasoning = listOf(0x0420, 0x0410, 0x0417, 0x0423, 0x041C) + .map { Char(it) } + .joinToString("") + + /** JSON-нагрузки из data-строк вывода (строки, начинающиеся с «data:»), без служебного [DONE]. */ + private fun dataPayloads(sse: String): List = + sse.lines() + .filter { it.startsWith("data:") && it.removePrefix("data:").trim() != "[DONE]" } + .map { it.removePrefix("data:").trim() } + + private fun deltaOf(result: String, index: Int): JsonObject = + Json.parseToJsonElement(result).jsonObject + .get("choices")!!.jsonArray[index].jsonObject + .get("delta")!!.jsonObject + + @Test + fun streamDoneAndServiceLinesPassThrough() = runTest { + // Служебные строки SSE и маркер конца потока должны дойти до клиента без изменений. + val input = buildString { + append("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n") + append("\n") + append("data: [DONE]\n") + append("\n") + append("event: ping\n") + append("\n") + append(": keep-alive\n") + append("\n") + } + + val out = runStream(input, "split") + + assertTrue(out.contains("data: [DONE]"), "маркер конца потока потерян: $out") + assertTrue(out.contains("event: ping"), "служебная строка event потеряна: $out") + assertTrue(out.contains(": keep-alive"), "строка-комментарий потеряна: $out") + assertTrue(out.contains("hi"), "текстовый чанк потерян: $out") + } + + @Test + fun streamSplitsReasoningAcrossDataChunks() = runTest { + // Хвост открывающего тега разрезан на границе двух data-событий: первое + // отдаёт content «A» и хвост держит, второе завершает тег и блок. + val prefix = openTag.take(3) + val rest = openTag.drop(3) + val content2 = rest + reasoning + closeTag + "B" + val input = buildString { + append("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"A$prefix\"}}]}\n") + append("\n") + append("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"$content2\"}}]}\n") + append("\n") + } + + val out = runStream(input, "split") + val payloads = dataPayloads(out) + + val last = deltaOf(payloads.last(), 0) + assertEquals("B", last["content"]!!.jsonPrimitive.content) + assertEquals(reasoning, last["reasoning_content"]!!.jsonPrimitive.content) + } + + @Test + fun streamUnclosedBlockCarriesReasoningInSameChunk() = runTest { + // Незакрытый think-блок в конце потока: reasoning отдаётся сразу в том же + // чанке, где пришёл, финиш-чанка не появляется — удержанного хвоста нет. + val content = "A" + openTag + "МЫСЛИ" + val input = buildString { + append("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"$content\"}}]}\n") + append("\n") + } + + val payloads = dataPayloads(runStream(input, "split")) + + assertEquals(1, payloads.size, "финиш-чанка быть не должно: $payloads") + val delta = deltaOf(payloads.single(), 0) + assertEquals("A", delta["content"]!!.jsonPrimitive.content) + assertEquals("МЫСЛИ", delta["reasoning_content"]!!.jsonPrimitive.content) + } + + @Test + fun streamFlushesHeldTailOnFinish() = runTest { + // Поток обрывается на неполном префиксе открывающего тега: удержанный + // хвост не теряется и сбрасывается финиш-чанком в конце. + val tail = openTag.take(3) + val input = buildString { + append("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"A$tail\"}}]}\n") + append("\n") + } + + val payloads = dataPayloads(runStream(input, "split")) + + assertEquals(2, payloads.size, "финиш-чанк с удержанным хвостом потерян: $payloads") + val flushed = deltaOf(payloads.last(), 0) + assertEquals(tail, flushed["content"]!!.jsonPrimitive.content) + } + + @Test + fun streamInvalidJsonPassesThroughVerbatim() = runTest { + // data-строка, которая не является JSON, доходит до клиента без изменений. + val input = buildString { + append("data: {это не json\n") + append("\n") + } + + val out = runStream(input, "split") + + assertTrue(out.contains("data: {это не json"), "битая строка не дошла как есть: $out") + } + + @Test + fun streamChunkWithoutChoicesPassesThrough() = runTest { + // Чанк с пустыми choices не меняется и уходит клиенту исходной строкой. + val input = buildString { + append("data: {\"usage\":{\"total_tokens\":5},\"choices\":[]}\n") + append("\n") + } + + val out = runStream(input, "split") + + assertTrue(out.contains("\"total_tokens\":5"), "чанк с usage изменился: $out") + } + + @Test + fun streamModeStripEmitsNoReasoning() = runTest { + // Режим strip: текст внутри think-тегов выбрасывается, поля + // reasoning_content нет, обычный текст остаётся. + val content = "A" + openTag + reasoning + closeTag + "B" + val input = buildString { + append("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"$content\"}}]}\n") + append("\n") + } + + val out = runStream(input, "strip") + val payloads = dataPayloads(out) + + assertTrue(!out.contains("reasoning_content"), "в режиме strip не должно быть reasoning_content: $out") + assertEquals(1, payloads.size, "ожидался ровно один чанк: $payloads") + assertEquals("AB", deltaOf(payloads.single(), 0)["content"]!!.jsonPrimitive.content, "теги и рассуждение должны быть вырезаны: $payloads") + } +} diff --git a/src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagTransformTest.kt b/src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagTransformTest.kt new file mode 100644 index 0000000..53335ac --- /dev/null +++ b/src/commonTest/kotlin/pw/binom/llmproxy/ThinkTagTransformTest.kt @@ -0,0 +1,174 @@ +package pw.binom.llmproxy + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +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 + +class ThinkTagTransformTest { + + // Точные литералы тегов из ThinkTagSplitter (OPEN/CLOSE): собираем из + // отдельных символов, чтобы не писать тег единой строкой в исходнике. + private val openTag = '<' + "think" + '>' + private val closeTag = '<' + "/think" + '>' + + private fun messageOf(result: String, index: Int): JsonObject = + Json.parseToJsonElement(result).jsonObject + .get("choices")!!.jsonArray[index].jsonObject + .get("message")!!.jsonObject + + @Test + fun splitSingleBlockMovesReasoningAndKeepsOtherFields() { + // Проверяем: в режиме split содержимое блока уходит в reasoning_content, + // в content остаётся «AB», а служебные поля (model, usage, …) не тронуты. + val c = "A" + openTag + "РАЗУМ" + closeTag + "B" + val obj = Json.parseToJsonElement( + """ + {"id":"c1","object":"chat.completion","created":123,"model":"m1", + "usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}, + "system_fingerprint":"sf1", + "choices":[{"index":0,"message":{"role":"assistant","content":"$c"},"finish_reason":"stop"}]} + """.trimIndent(), + ).jsonObject + + val result = transformThinkMessage(obj, "split") + assertNotNull(result) + val out = Json.parseToJsonElement(result).jsonObject + val msg = out.get("choices")!!.jsonArray[0].jsonObject.get("message")!!.jsonObject + assertEquals("AB", msg["content"]!!.jsonPrimitive.content) + assertEquals("РАЗУМ", msg["reasoning_content"]!!.jsonPrimitive.content) + assertEquals("m1", out["model"]!!.jsonPrimitive.content) + assertEquals(3, out["usage"]!!.jsonObject["total_tokens"]!!.jsonPrimitive.content.toInt()) + } + + @Test + fun splitTwoBlocksConcatenateInOrder() { + // Проверяем порядок конкатенации reasoning, когда два think-блока идут подряд. + val c = openTag + "ПЕРВЫЙ" + closeTag + "X" + openTag + "ВТОРОЙ" + closeTag + val obj = Json.parseToJsonElement( + """{"choices":[{"index":0,"message":{"content":"$c"}}]}""", + ).jsonObject + + val result = transformThinkMessage(obj, "split") + assertNotNull(result) + val msg = messageOf(result, 0) + assertEquals("X", msg["content"]!!.jsonPrimitive.content) + assertEquals("ПЕРВЫЙВТОРОЙ", msg["reasoning_content"]!!.jsonPrimitive.content) + } + + @Test + fun splitAppendsToExistingReasoningContent() { + // Проверяем, что прежнее reasoning_content не теряется, а новое дописывается. + val c = openTag + "NEW" + closeTag + val obj = Json.parseToJsonElement( + """{"choices":[{"message":{"content":"$c","reasoning_content":"OLD"}}]}""", + ).jsonObject + + val result = transformThinkMessage(obj, "split") + assertNotNull(result) + val msg = messageOf(result, 0) + assertEquals("OLDNEW", msg["reasoning_content"]!!.jsonPrimitive.content) + } + + @Test + fun splitUnclosedTagGoesToReasoning() { + // Проверяем, что незакрытый открывающий тег уводит хвост целиком в reasoning. + val c = "текст" + openTag + "мысли" + val obj = Json.parseToJsonElement( + """{"choices":[{"message":{"content":"$c"}}]}""", + ).jsonObject + + val result = transformThinkMessage(obj, "split") + assertNotNull(result) + val msg = messageOf(result, 0) + assertEquals("текст", msg["content"]!!.jsonPrimitive.content) + assertEquals("мысли", msg["reasoning_content"]!!.jsonPrimitive.content) + } + + @Test + fun stripRemovesTagsAndLeavesNoReasoningKey() { + // Проверяем: в режиме strip теги и рассуждения вырезаются, + // а ключ reasoning_content в message отсутствует вовсе. + val c = "A" + openTag + "РАЗУМ" + closeTag + "B" + val obj = Json.parseToJsonElement( + """{"choices":[{"message":{"content":"$c"}}]}""", + ).jsonObject + + val result = transformThinkMessage(obj, "strip") + assertNotNull(result) + val msg = messageOf(result, 0) + assertEquals("AB", msg["content"]!!.jsonPrimitive.content) + assertFalse(msg.containsKey("reasoning_content")) + } + + @Test + fun missingChoicesReturnsNull() { + // Проверяем: без ключа choices функция не вносит изменений (null). + val obj = Json.parseToJsonElement( + """{"id":"c1","model":"m1"}""", + ).jsonObject + assertNull(transformThinkMessage(obj, "split")) + } + + @Test + fun nonStringContentReturnsNull() { + // content = null (JsonNull) — менять нечего, функция возвращает null. + val nullContent = Json.parseToJsonElement( + """{"choices":[{"message":{"content":null}}]}""", + ).jsonObject + assertNull(transformThinkMessage(nullContent, "split")) + + // content = массив частей (JsonArray) — тоже не трогаем, null. + val arrayContent = Json.parseToJsonElement( + """{"choices":[{"message":{"content":[{"type":"text","text":"hi"}]}}]}""", + ).jsonObject + assertNull(transformThinkMessage(arrayContent, "split")) + } + + @Test + fun twoChoicesEachKeepOwnThinkBlock() { + // Проверяем, что каждый choice обрабатывается независимо: у каждого свой + // вырезанный content и свой reasoning_content. + val c0 = "А" + openTag + "А-раз" + closeTag + "Б" + val c1 = "В" + openTag + "В-раз" + closeTag + "Г" + val obj = Json.parseToJsonElement( + """ + {"choices":[ + {"index":0,"message":{"content":"$c0"},"finish_reason":"stop"}, + {"index":1,"message":{"content":"$c1"},"finish_reason":"stop"}]} + """.trimIndent(), + ).jsonObject + + val result = transformThinkMessage(obj, "split") + assertNotNull(result) + val out = Json.parseToJsonElement(result).jsonObject.get("choices")!!.jsonArray + val m0 = out[0].jsonObject.get("message")!!.jsonObject + val m1 = out[1].jsonObject.get("message")!!.jsonObject + assertEquals("АБ", m0["content"]!!.jsonPrimitive.content) + assertEquals("А-раз", m0["reasoning_content"]!!.jsonPrimitive.content) + assertEquals("ВГ", m1["content"]!!.jsonPrimitive.content) + assertEquals("В-раз", m1["reasoning_content"]!!.jsonPrimitive.content) + } + + @Test + fun offModePassthroughKeepsTags() { + // Проверяем passthrough: в режиме off теги остаются в content, + // reasoning_content не добавляется, а результат — не null. + val c = "A" + openTag + "РАЗУМ" + closeTag + "B" + val obj = Json.parseToJsonElement( + """{"choices":[{"message":{"content":"$c"}}]}""", + ).jsonObject + + val result = transformThinkMessage(obj, "off") + assertNotNull(result) + val msg = messageOf(result, 0) + assertEquals("A" + openTag + "РАЗУМ" + closeTag + "B", msg["content"]!!.jsonPrimitive.content) + assertFalse(msg.containsKey("reasoning_content")) + } +}