From 8154485b3b36c8b74c67125369a594bbc5bdbcf4 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 12 Sep 2026 23:46:42 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=B0=D1=82=D0=B8=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=B5=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D1=83=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B9=20(reasoning?= =?UTF-8?q?=5Ffield)=20=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?400=20=D0=BE=D1=82=20Console=20Go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Диагноз: апстрим deepseek-v4.1-flash (провайдер opencode, Console Go) в thinking-режиме требует reasoning_content в assistant-сообщениях с tool_calls, а клиент opencode присылает рассуждения как reasoning + reasoning_details. Отсюда 400 'The reasoning_content in the thinking mode must be passed back to the API'. - providers[].reasoning_field / reasoning_empty_ok: прокси аддитивно достраивает нативное поле в assistant-сообщениях с непустым tool_calls (текст из reasoning или reasoning_details[].text, тип reasoning.text); существующее непустое поле не перезаписывается, ничего не переименовывается, прочие сообщения не трогаются; - устойчивость разбора ответов к JSON-null (choices/delta/content/tool_calls) — было 290 фейловеров с локального апстрима на платные из-за нашего же исключения; - лог: класс исключения в сообщении об ошибке; для 4xx логируется тело ответа апстрима (читается безопасно: 4xx — не стрим); - тесты: ReasoningFieldTest (13), NullToleranceTest (6), ConfigLogicTest (+2) — 100 всего; - CONFIG.md, TESTING.md (фактические замеры A/B против Console Go). --- CONFIG.md | 47 ++++- TESTING.md | 41 ++++- .../kotlin/pw/binom/llmproxy/Main.kt | 117 ++++++++++--- .../pw/binom/llmproxy/ConfigLogicTest.kt | 37 ++++ .../pw/binom/llmproxy/NullToleranceTest.kt | 71 ++++++++ .../pw/binom/llmproxy/ReasoningFieldTest.kt | 165 ++++++++++++++++++ 6 files changed, 448 insertions(+), 30 deletions(-) create mode 100644 src/commonTest/kotlin/pw/binom/llmproxy/NullToleranceTest.kt create mode 100644 src/commonTest/kotlin/pw/binom/llmproxy/ReasoningFieldTest.kt diff --git a/CONFIG.md b/CONFIG.md index 5656d3d..94306b8 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -218,6 +218,44 @@ upstreams: рассуждения». Если `content` не строка (мультимодальный массив частей) — ответ не трогаем. Без флажка (`off`) ответ идёт байт-в-байт как раньше. +### Нативное поле рассуждений (`reasoning_field`) + +Некоторые шлюзы-апстримы (например, **Console Go / deepseek в thinking-режиме**) +в thinking-режиме **требуют** вернуть им нативное поле рассуждений в каждом +assistant-сообщении с непустым `tool_calls`. Если его нет — апстрим отвечает +`400` (`The `reasoning_content` in the thinking mode must be passed back to the +API.`). Клиенты при этом рассуждения держат в своих форматах: `reasoning` +(строка) и/или `reasoning_details` (массив `{type:"reasoning.text", text, ...}`) +— а нативное поле `reasoning_content` в истории могут и не передавать. + +Поля (только на уровне **провайдера**, это свойство шлюза, а не модели): + +| Поле | Тип / дефолт | Значение | +|---|---|---| +| `providers[].reasoning_field` | строка / отсутствует | Имя нативного поля рассуждений у шлюза-апстрима (пример: `reasoning_content`) | +| `providers[].reasoning_empty_ok` | булево / `false` | Писать ли пустую строку, если текста рассуждений нет вовсе | + +Зачем: при отправке запроса прокси **аддитивно** достраивает это поле в каждом +assistant-сообщении с непустым `tool_calls` — берёт текст из `reasoning` +(если это непустая строка), иначе склеивает `reasoning_details[*].text` +(только элементы без `type` или с `type == "reasoning.text"`, через `"\n"`) и +записывает в поле `reasoning_field`, **если его там ещё нет**. Существующее +непустое поле не перезаписывается. Ничего при этом не убирается и не +переименовывается — клиентский `reasoning`/`reasoning_details` остаются на месте, +поле просто дополняется. Тронуто только assistant-сообщение с непустым +`tool_calls`; сообщения без `tool_calls` и `user`/`tool`/`system` не меняются. +Если текста рассуждений нет вовсе — поле не добавляется, кроме случая +`reasoning_empty_ok: true` (тогда пишется пустая строка `""`). Если +`reasoning_field` не задан — тело не меняется вовсе. + +```yaml +providers: + - id: opencode + url: "https://opencode.ai/zen/go/v1" + reasoning_field: reasoning_content # Console Go / deepseek в thinking-режиме + # reasoning_empty_ok: true # опционально; дефолт false +``` + ### Пример сборки тела (многослойный `patch`) Берём модель `my-gpt` (из примера выше), маршрут уходит на апстрим @@ -296,9 +334,12 @@ data class ProviderConf( val id: String, val url: String, val key: String = "", - val max_concurrency: Int? = null, // лимит по умолчанию для апстримов провайдера - val patch: JsonObject? = null, // ко всем запросам провайдера - val session_header: String? = null, // заголовок-сессия, считается из истории + val max_concurrency: Int? = null, + val patch: JsonObject? = null, + val session_header: String? = null, + val think_tags: String? = null, + val reasoning_field: String? = null, + val reasoning_empty_ok: Boolean = false, ) @Serializable diff --git a/TESTING.md b/TESTING.md index 45505da..0875ab1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -13,7 +13,9 @@ | Файл | Что проверяет | | --- | --- | | `ThinkTagSplitterTest` | Автомат рассечения think-тегов: passthrough при off, вырезание рассуждений при split, отбрасывание при strip, удержание разрезанного тега, несколько блоков, незакрытый блок; | -| `ConfigLogicTest` | Разбор конфига, приоритет источников (апстрим важнее провайдера), слияние патчей, выбор апстрима и лимиты конкурентности, заголовки, сессии; | +| `ConfigLogicTest` | Разбор конфига (`reasoning_field`/`reasoning_empty_ok` провайдера и их дефолты), приоритет источников (апстрим важнее провайдера), слияние патчей, выбор апстрима и лимиты конкурентности, заголовки, сессии; | +| `ReasoningFieldTest` | Достройка нативного поля рассуждений (`applyReasoningField`/`reasoningTextOf`): форма клиента opencode (`reasoning` + `reasoning_details`), чужой `type` в details, склейка нескольких details, запрет перезаписи непустого поля, неприкосновенность сообщений без `tool_calls` (в т.ч. `[]`) и `user`/`tool`, пустая строка при `emptyOk`, `reasoning_field: null`, отсутствие `messages`, сохранение порядка и прочих полей; | +| `NullToleranceTest` | Устойчивость разбора ответов апстрима к JSON-`null` (`choices`, `delta.tool_calls`, `delta.content`) — пропуск вместо исключения; | | `ThinkTagTransformTest` | Non-stream путь `transformThinkMessage`: перенос рассуждений в `reasoning_content`, дописывание к уже имеющемуся, strip, незакрытый блок, отсутствие изменений → null; | | `ThinkTagChunkTest` | SSE-чанки `transformThinkChunk`: удержание хвоста тега между чанками, независимые сплиттеры по index, удаление пустого `content`; | | `ThinkTagStreamTest` | Обвязка стрима `streamSseWithThinkTags`: разрез тега между data-событиями, сброс удержанного хвоста в финиш-чанке, прохождение служебных строк и `[DONE]`, битый JSON, чанк без choices, strip. | @@ -44,6 +46,43 @@ Правило: боевой код нельзя подгонять под тест; если тест не проходит, неверен тест. +## Поле рассуждений для Console Go (релиз 11) — замеры приёмки + +Причина правки: апстрим `deepseek-v4.1-flash` у провайдера `opencode` (Console Go) в thinking-режиме +требует `reasoning_content` в assistant-сообщениях с `tool_calls`, а клиент opencode присылает +рассуждения как `reasoning` + `reasoning_details` — отсюда `400 The reasoning_content in the +thinking mode must be passed back to the API`. + +**Границы требования (замер прямыми запросами к Console Go, одинаковое тело):** + +| assistant-сообщение | HTTP | +| --- | --- | +| с `tool_calls`, без reasoning вовсе | 400 | +| с `tool_calls`, `reasoning` + `reasoning_details` (форма opencode) | 400 | +| с `tool_calls`, только `reasoning_details` | 400 | +| с `tool_calls`, `reasoning_content` + `reasoning` + `reasoning_details` (аддитивно) | 200 | +| с `tool_calls`, `reasoning_content: ""` | 200 | +| без `tool_calls`, без reasoning | 200 | + +**Живая приёмка (локальный инстанс на 8101, конфиг-копия боевого, модель с единственным апстримом +Console Go; тело — как у opencode: assistant + `reasoning` + `reasoning_details` + `tool_calls`):** + +| Конфиг | `stream=false` | `stream=true` | +| --- | --- | --- | +| без правки (`reasoning_field` не задан) | **400** — та самая ошибка про `reasoning_content` | **400** | +| с правкой (`reasoning_field: reasoning_content`) | **200**, модель продолжила диалог после tool-результата | **200** | + +**Регрессия на боевых цепочках (тот же инстанс, то же тело с `tool_calls`):** `codding-big` → 200 +(ушло на `minimax-m3`, поле не добавляется — флаг объявлен только у провайдера `opencode`), +`codding` → 200 (`qwen-3.8`, local), `assistant` → 200 (Console Go с правкой). + +**Мутационная приёмка:** 4 мутации из ТЗ отработал кодер, две проверены вручную +(`cleanAllTests jvmTest`): снятие охраны `tool_calls` в `applyReasoningField` → падают +`assistantWithoutToolCallsIsUntouched` и `assistantWithEmptyToolCallsIsUntouched`; +возврат `obj["choices"]?.jsonArray` в `rebuildFromChunks` → падает `rebuildFromChunksToleratesNullChoices`. + ## Известное ограничение Живой стрим в реальном апстриме модульными тестами не проверяется: обвязка испытывается на синтетическом SSE через каналы ktor. Реальный апстрим проверяется только после деплоя. +Правка поля рассуждений — исключение: она проверена живым инстансом против настоящего Console Go (таблица выше) до релиза. + diff --git a/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt b/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt index 0aff6dc..5c39058 100644 --- a/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt +++ b/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt @@ -176,7 +176,8 @@ private suspend fun handleChat( continue } - val patched = buildBody(bodyJson, provider, up, modelConf) + val patched0 = buildBody(bodyJson, provider, up, modelConf) + val patched = applyReasoningField(patched0, provider.reasoning_field, provider.reasoning_empty_ok) val forwarded = if (clientWantsStream) { patched } else { @@ -234,6 +235,15 @@ private suspend fun handleChat( failover = true return@execute } + if (upstreamStatus in 400..499) { + // 4xx — JSON-тело, не стрим: читаем безопасно и отдаём клиенту как есть. + val errorBody = runCatching { resp.body() }.getOrDefault("") + log.warn { "[llm-proxy] model=$modelName upstream=${up.id} вернул $upstreamStatus errorBody=${errorBody.take(500)}" } + val ct = resp.headers["Content-Type"] ?: "application/json" + call.respondText(errorBody, ContentType.parse(ct), HttpStatusCode.fromValue(upstreamStatus)) + responded = true + return@execute + } responded = true if (clientWantsStream) { val ct = resp.headers["Content-Type"] ?: "text/event-stream" @@ -284,7 +294,7 @@ private suspend fun handleChat( log.info { "[llm-proxy] chat model=$modelName upstream=${up.id} ОТМЕНЕНО клиентом за ${start.elapsedNow().inWholeMilliseconds}ms" } throw e } catch (e: Exception) { - log.error { "[llm-proxy] chat model=$modelName upstream=${up.id} ОШИБКА: ${e.message} за ${start.elapsedNow().inWholeMilliseconds}ms → фейловер" } + log.error { "[llm-proxy] chat model=$modelName upstream=${up.id} ОШИБКА: ${e::class.simpleName}: ${e.message} за ${start.elapsedNow().inWholeMilliseconds}ms → фейловер" } failed.add(up.id) continue } finally { @@ -538,27 +548,27 @@ internal fun rebuildFromChunks(sse: String): String { val data = line.removePrefix("data:").trim() if (data.isEmpty() || data == "[DONE]") return@forEach val obj = runCatching { json.parseToJsonElement(data).jsonObject }.getOrNull() ?: return@forEach - if (id.isEmpty()) id = obj["id"]?.jsonPrimitive?.content ?: "" - if (created == null) created = obj["created"]?.jsonPrimitive?.content?.toLongOrNull() - if (model.isEmpty()) model = obj["model"]?.jsonPrimitive?.content ?: "" - if (systemFingerprint == null) systemFingerprint = obj["system_fingerprint"]?.jsonPrimitive?.content - if (serviceTier == null) serviceTier = obj["service_tier"]?.jsonPrimitive?.content + if (id.isEmpty()) id = (obj["id"] as? JsonPrimitive)?.content ?: "" + if (created == null) created = (obj["created"] as? JsonPrimitive)?.content?.toLongOrNull() + if (model.isEmpty()) model = (obj["model"] as? JsonPrimitive)?.content ?: "" + if (systemFingerprint == null) systemFingerprint = (obj["system_fingerprint"] as? JsonPrimitive)?.content + if (serviceTier == null) serviceTier = (obj["service_tier"] as? JsonPrimitive)?.content if (provider == null) provider = obj["provider"] (obj["error"] as? JsonObject)?.let { error = it } (obj["usage"] as? JsonObject)?.let { usage = it } - val chArr = obj["choices"]?.jsonArray ?: return@forEach + val chArr = obj["choices"] as? JsonArray ?: return@forEach for (ch in chArr) { - val c = ch.jsonObject - val idx = c["index"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0 + val c = ch as? JsonObject ?: continue + val idx = (c["index"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0 val mc = choices.getOrPut(idx) { MutableChoice() } - val delta = c["delta"]?.jsonObject + val delta = c["delta"] as? JsonObject if (delta != null) { - if (mc.role == null) mc.role = delta["role"]?.jsonPrimitive?.content - delta["content"]?.jsonPrimitive?.content?.takeIf { it != "null" }?.let { mc.content.append(it) } - delta["reasoning_content"]?.jsonPrimitive?.content?.takeIf { it != "null" }?.let { mc.reasoning.append(it) } - delta["tool_calls"]?.jsonArray?.forEach { tc -> (tc as? JsonObject)?.let { mc.toolCalls.add(it) } } + if (mc.role == null) mc.role = (delta["role"] as? JsonPrimitive)?.content + (delta["content"] as? JsonPrimitive)?.content?.takeIf { it != "null" }?.let { mc.content.append(it) } + (delta["reasoning_content"] as? JsonPrimitive)?.content?.takeIf { it != "null" }?.let { mc.reasoning.append(it) } + (delta["tool_calls"] as? JsonArray)?.forEach { tc -> (tc as? JsonObject)?.let { mc.toolCalls.add(it) } } } - c["finish_reason"]?.jsonPrimitive?.content?.takeIf { it.isNotEmpty() && it != "null" }?.let { mc.finishReason = it } + (c["finish_reason"] as? JsonPrimitive)?.content?.takeIf { it.isNotEmpty() && it != "null" }?.let { mc.finishReason = it } c["logprobs"]?.let { mc.logprobs = it } } } @@ -610,12 +620,12 @@ internal fun rebuildFromChunks(sse: String): String { * изменился → null (отдать исходную строку как есть). */ internal fun transformThinkMessage(obj: JsonObject, thinkMode: String): String? { - val choices = obj["choices"]?.jsonArray ?: return null + val choices = obj["choices"] as? JsonArray ?: return null val addReasoning = thinkMode == "split" var changed = false val newChoices = choices.map { choiceEl -> - val choice = choiceEl.jsonObject - val message = choice["message"]?.jsonObject ?: return@map choiceEl + val choice = choiceEl as? JsonObject ?: return@map choiceEl + val message = choice["message"] as? JsonObject ?: return@map choiceEl val contentStr = (message["content"] as? JsonPrimitive)?.takeIf { it.isString }?.content if (contentStr == null) return@map choiceEl val splitter = ThinkTagSplitter(thinkMode) @@ -636,6 +646,55 @@ internal fun transformThinkMessage(obj: JsonObject, thinkMode: String): String? return JsonObject(obj.toMutableMap().apply { this["choices"] = JsonArray(newChoices) }).toString() } +/** + * Достроить нативное поле рассуждений для апстримов, которые его требуют + * (Console Go / deepseek в thinking-режиме): если у провайдера объявлено + * `reasoningField`, то в каждом assistant-сообщении с непустым `tool_calls` + * добавляем это поле, ЕСЛИ его там ещё нет. Текст берём из `reasoning` + * (строка) или из `reasoning_details` (элементы с `type == "reasoning.text"`). + * Существующее непустое поле НЕ перезаписываем. При полном отсутствии текста + * пишем пустую строку, только если `emptyOk`. + * Тело возвращается без изменений (тот же объект), если менять нечего. + */ +internal fun applyReasoningField(body: JsonObject, field: String?, emptyOk: Boolean): JsonObject { + if (field == null) return body + val messages = body["messages"] as? JsonArray ?: return body + var changed = false + val newMessages = messages.map { el -> + val msg = el as? JsonObject ?: return@map el + val role = (msg["role"] as? JsonPrimitive)?.takeIf { it.isString }?.content + if (role != "assistant") return@map el + val toolCalls = msg["tool_calls"] as? JsonArray + if (toolCalls == null || toolCalls.isEmpty()) return@map el + val existing = (msg[field] as? JsonPrimitive)?.takeIf { it.isString }?.content + if (existing != null && existing.isNotEmpty()) return@map el + val text = reasoningTextOf(msg) + if (text.isEmpty() && !emptyOk) return@map el + changed = true + JsonObject(msg.toMutableMap().apply { this[field] = JsonPrimitive(text) }) + } + if (!changed) return body + return JsonObject(body.toMutableMap().apply { this["messages"] = JsonArray(newMessages) }) +} + +/** + * Текст рассуждений сообщения: `reasoning` (если непустая строка), иначе + * склейка `reasoning_details[*].text` через "\n" — только элементы, у которых + * `type` отсутствует или равен "reasoning.text". + */ +internal fun reasoningTextOf(msg: JsonObject): String { + val reasoning = (msg["reasoning"] as? JsonPrimitive)?.takeIf { it.isString }?.content + if (reasoning != null && reasoning.isNotEmpty()) return reasoning + val details = msg["reasoning_details"] as? JsonArray ?: return "" + val parts = details.mapNotNull { el -> + val d = el as? JsonObject ?: return@mapNotNull null + val type = (d["type"] as? JsonPrimitive)?.takeIf { it.isString }?.content + if (type != null && type != "reasoning.text") return@mapNotNull null + (d["text"] as? JsonPrimitive)?.takeIf { it.isString }?.content + } + return parts.joinToString("\n") +} + /** Терминальный маркер SSE, который клиенты (в т.ч. Bifrost) ждут как конец потока. */ private const val SSE_DONE_MARKER = "data: [DONE]\n\n" @@ -665,9 +724,11 @@ internal fun hasNonNullFinishReasonText(text: String): Boolean { /** Структурная проверка чанка: в `choices[*].finish_reason` есть непустая строка. */ internal fun hasFinishReason(obj: JsonObject): Boolean = - obj["choices"]?.jsonArray?.any { el -> - val fr = (el.jsonObject["finish_reason"] as? JsonPrimitive)?.takeIf { it.isString }?.content - !fr.isNullOrEmpty() + (obj["choices"] as? JsonArray)?.any { el -> + (el as? JsonObject)?.let { choice -> + val fr = (choice["finish_reason"] as? JsonPrimitive)?.takeIf { it.isString }?.content + !fr.isNullOrEmpty() + } ?: false } ?: false /** @@ -805,14 +866,14 @@ internal fun transformThinkChunk( thinkMode: String, addReasoning: Boolean, ): String? { - val choices = obj["choices"]?.jsonArray ?: return null + val choices = obj["choices"] as? JsonArray ?: return null var changed = false val newChoices = choices.map { choiceEl -> - val choice = choiceEl.jsonObject - val delta = choice["delta"]?.jsonObject + val choice = choiceEl as? JsonObject ?: return@map choiceEl + val delta = choice["delta"] as? JsonObject val contentStr = (delta?.get("content") as? JsonPrimitive)?.takeIf { it.isString }?.content if (contentStr == null) return@map choiceEl - val idx = choice["index"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0 + val idx = (choice["index"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0 val splitter = splitters.getOrPut(idx) { ThinkTagSplitter(thinkMode) } val (newContent, reasoning) = splitter.feed(contentStr) changed = true @@ -840,6 +901,8 @@ data class ProviderConf( val patch: JsonObject? = null, val session_header: String? = null, val think_tags: String? = null, + val reasoning_field: String? = null, + val reasoning_empty_ok: Boolean = false, ) data class UpstreamConf( @@ -904,6 +967,8 @@ internal fun parseConfig(root: YamlElement): Config { patch = m.yamlMapOrNull("patch")?.let { yamlToJson(it) as JsonObject }, session_header = m.strOrNull("session_header"), think_tags = m.strOrNull("think_tags"), + reasoning_field = m.strOrNull("reasoning_field"), + reasoning_empty_ok = m.strOrNull("reasoning_empty_ok")?.toBooleanStrictOrNull() ?: false, ) } diff --git a/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt b/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt index 3396029..a75f9bc 100644 --- a/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt +++ b/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt @@ -546,4 +546,41 @@ class ConfigLogicTest { assertEquals("off", effectiveThinkTags(upNull, prov(null))) assertEquals("off", effectiveThinkTags(upNull, null)) } + + @Test + fun parseConfigReadsReasoningFieldAndEmptyOkOnProvider() { + val yaml = """ + providers: + - id: p1 + url: "https://x.ru/api/v1" + reasoning_field: reasoning_content + reasoning_empty_ok: true + - id: p2 + url: "https://y.ru/api/v1" + reasoning_empty_ok: false + models: + - name: m1 + upstreams: [] + """.trimIndent() + val cfg = parseConfig(Yaml.decodeYamlFromString(yaml)) + assertEquals("reasoning_content", cfg.providers[0].reasoning_field) + assertEquals(true, cfg.providers[0].reasoning_empty_ok) + assertEquals(null, cfg.providers[1].reasoning_field) + assertEquals(false, cfg.providers[1].reasoning_empty_ok) + } + + @Test + fun parseConfigDefaultsReasoningFieldsWhenAbsent() { + val yaml = """ + providers: + - id: p1 + url: "https://x.ru/api/v1" + models: + - name: m1 + upstreams: [] + """.trimIndent() + val cfg = parseConfig(Yaml.decodeYamlFromString(yaml)) + assertEquals(null, cfg.providers[0].reasoning_field) + assertEquals(false, cfg.providers[0].reasoning_empty_ok) + } } diff --git a/src/commonTest/kotlin/pw/binom/llmproxy/NullToleranceTest.kt b/src/commonTest/kotlin/pw/binom/llmproxy/NullToleranceTest.kt new file mode 100644 index 0000000..de8e5b6 --- /dev/null +++ b/src/commonTest/kotlin/pw/binom/llmproxy/NullToleranceTest.kt @@ -0,0 +1,71 @@ +package pw.binom.llmproxy + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class NullToleranceTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun rebuildFromChunksToleratesNullChoices() { + // Чанк с choices:null не должен ронять сборку; значимые поля сохраняются. + val sse = """ + data: {"id":"c1","created":123,"model":"m","choices":null} + data: {"id":"c1","created":123,"model":"m","choices":[{"index":0,"delta":{"content":"Hi"}}]} + data: [DONE] + """.trimIndent() + val out = json.parseToJsonElement(rebuildFromChunks(sse)).jsonObject + assertEquals("c1", out["id"]?.jsonPrimitive?.content) + assertEquals("m", out["model"]?.jsonPrimitive?.content) + assertEquals(123, out["created"]?.jsonPrimitive?.content?.toLong()) + val choice = out["choices"]?.jsonArray?.get(0)?.jsonObject + assertEquals("Hi", choice?.get("message")?.jsonObject?.get("content")?.jsonPrimitive?.content) + } + + @Test + fun rebuildFromChunksToleratesNullDeltaToolCalls() { + val sse = """ + data: {"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":null},"finish_reason":"stop"}]} + data: [DONE] + """.trimIndent() + val out = json.parseToJsonElement(rebuildFromChunks(sse)).jsonObject + val choice = out["choices"]?.jsonArray?.get(0)?.jsonObject + assertEquals("assistant", choice?.get("message")?.jsonObject?.get("role")?.jsonPrimitive?.content) + } + + @Test + fun rebuildFromChunksToleratesNullContent() { + val sse = """ + data: {"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":"stop"}]} + data: [DONE] + """.trimIndent() + val out = json.parseToJsonElement(rebuildFromChunks(sse)).jsonObject + val choice = out["choices"]?.jsonArray?.get(0)?.jsonObject + assertEquals("assistant", choice?.get("message")?.jsonObject?.get("role")?.jsonPrimitive?.content) + } + + @Test + fun hasFinishReasonToleratesNullChoices() { + val obj = json.parseToJsonElement("""{"choices":null}""").jsonObject + assertFalse(hasFinishReason(obj)) + } + + @Test + fun transformThinkChunkToleratesNullChoices() { + val obj = json.parseToJsonElement("""{"choices":null}""").jsonObject + assertNull(transformThinkChunk(obj, mutableMapOf(), "split", true)) + } + + @Test + fun transformThinkMessageToleratesNullChoices() { + val obj = json.parseToJsonElement("""{"choices":null}""").jsonObject + assertNull(transformThinkMessage(obj, "split")) + } +} diff --git a/src/commonTest/kotlin/pw/binom/llmproxy/ReasoningFieldTest.kt b/src/commonTest/kotlin/pw/binom/llmproxy/ReasoningFieldTest.kt new file mode 100644 index 0000000..73ab776 --- /dev/null +++ b/src/commonTest/kotlin/pw/binom/llmproxy/ReasoningFieldTest.kt @@ -0,0 +1,165 @@ +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.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject + +class ReasoningFieldTest { + + private val json = Json { ignoreUnknownKeys = true } + + // Минимальный непустой tool_call для assistant-сообщения. + private val TC = """{"id":"t1","type":"function","function":{"name":"f","arguments":"{}"}}""" + + private fun str(obj: JsonObject, key: String): String? = (obj[key] as? JsonPrimitive)?.content + + private fun outMsgs(out: JsonObject): List = + (out["messages"] as? JsonArray)?.mapNotNull { it as? JsonObject } ?: emptyList() + + // Собрать тело `{"messages":[...]}` и прогнать через applyReasoningField. + private fun apply(messagesJson: String, field: String?, emptyOk: Boolean): JsonObject { + val body = json.parseToJsonElement("""{"messages":[$messagesJson]}""").jsonObject + return applyReasoningField(body, field, emptyOk) + } + + @Test + fun documentedOpencodeShapeGetsNativeReasoningContent() { + // Форма клиента opencode: reasoning (строка) + reasoning_details (массив), + // нативного поля нет — добавляем его, текст берём из рассуждений. + val m = """{"role":"assistant","tool_calls":[$TC],"reasoning":"R","reasoning_details":[{"type":"reasoning.text","text":"R","index":0}]}""" + val out = apply(m, "reasoning_content", false) + val m0 = outMsgs(out)[0] + assertEquals("R", str(m0, "reasoning_content")) + assertEquals("R", str(m0, "reasoning")) + assertTrue((m0["tool_calls"] as? JsonArray)?.isNotEmpty() == true) + assertTrue((m0["reasoning_details"] as? JsonArray)?.isNotEmpty() == true) + } + + @Test + fun reasoningDetailsTextIsCopiedIntoReasoningContent() { + // Только reasoning_details (без строки reasoning) — текст всё равно достаётся. + val m = """{"role":"assistant","tool_calls":[$TC],"reasoning_details":[{"type":"reasoning.text","text":"D"}]}""" + val out = apply(m, "reasoning_content", false) + assertEquals("D", str(outMsgs(out)[0], "reasoning_content")) + } + + @Test + fun reasoningDetailsWithForeignTypeAreIgnored() { + // Чужой тип (reasoning.encrypted) игнорируется, берётся только reasoning.text. + val m = """{"role":"assistant","tool_calls":[$TC],"reasoning_details":[{"type":"reasoning.encrypted","text":"X"},{"type":"reasoning.text","text":"T"}]}""" + val out = apply(m, "reasoning_content", false) + assertEquals("T", str(outMsgs(out)[0], "reasoning_content")) + } + + @Test + fun multipleReasoningDetailsAreJoinedInOrder() { + // Два reasoning.text склеиваются через "\n" в порядке массива. + val m = """{"role":"assistant","tool_calls":[$TC],"reasoning_details":[{"type":"reasoning.text","text":"a"},{"type":"reasoning.text","text":"b"}]}""" + val out = apply(m, "reasoning_content", false) + assertEquals("a\nb", str(outMsgs(out)[0], "reasoning_content")) + } + + @Test + fun existingNonEmptyReasoningContentIsNotOverwritten() { + // Непустое нативное поле не перезаписываем текстом из рассуждений. + val m = """{"role":"assistant","tool_calls":[$TC],"reasoning":"R","reasoning_content":"нативное"}""" + val out = apply(m, "reasoning_content", false) + assertEquals("нативное", str(outMsgs(out)[0], "reasoning_content")) + } + + @Test + fun assistantWithoutToolCallsIsUntouched() { + // Без tool_calls assistant-сообщение не трогаем (тело идентично). + val m = """{"role":"assistant","reasoning":"R","content":"hi"}""" + val out = apply(m, "reasoning_content", false) + assertEquals("""{"messages":[$m]}""", out.toString()) + assertFalse(outMsgs(out)[0].containsKey("reasoning_content")) + } + + @Test + fun assistantWithEmptyToolCallsIsUntouched() { + // Пустой массив tool_calls = не трогаем (тело идентично). + val m = """{"role":"assistant","tool_calls":[],"reasoning":"R"}""" + val out = apply(m, "reasoning_content", false) + assertEquals("""{"messages":[$m]}""", out.toString()) + } + + @Test + fun userAndToolMessagesAreUntouched() { + // user/tool/system с теми же полями — не assistant, не трогаем. + val msgs = """ + {"role":"user","tool_calls":[$TC],"reasoning":"R"}, + {"role":"tool","tool_calls":[$TC],"reasoning":"R"}, + {"role":"system","tool_calls":[$TC],"reasoning":"R"} + """.trimIndent().replace("\n", " ") + val out = apply(msgs, "reasoning_content", false) + val roles = outMsgs(out).map { str(it, "role") } + assertEquals(listOf("user", "tool", "system"), roles) + outMsgs(out).forEach { assertFalse(it.containsKey("reasoning_content")) } + } + + @Test + fun emptyTextWithEmptyOkFillsEmptyString() { + // Рассуждений нет вовсе, но emptyOk=true — пишем пустую строку. + val m = """{"role":"assistant","tool_calls":[$TC]}""" + val out = apply(m, "reasoning_content", true) + val m0 = outMsgs(out)[0] + assertEquals("", str(m0, "reasoning_content")) + assertTrue(m0.containsKey("reasoning_content")) + } + + @Test + fun emptyTextWithoutEmptyOkLeavesMessageUnchanged() { + // Рассуждений нет и emptyOk=false — сообщение не трогаем. + val m = """{"role":"assistant","tool_calls":[$TC]}""" + val out = apply(m, "reasoning_content", false) + assertEquals("""{"messages":[$m]}""", out.toString()) + assertFalse(outMsgs(out)[0].containsKey("reasoning_content")) + } + + @Test + fun reasoningFieldNullLeavesBodyUntouched() { + // Поле не объявлено у провайдера (null) — тело не трогаем. + val m = """{"role":"assistant","tool_calls":[$TC],"reasoning":"R"}""" + val out = apply(m, null, false) + assertEquals("""{"messages":[$m]}""", out.toString()) + } + + @Test + fun messagesAbsentOrNotArrayLeavesBodyUntouched() { + // Нет `messages` — не трогаем. + val noMessages = json.parseToJsonElement("""{"model":"m1"}""").jsonObject + assertEquals(noMessages.toString(), applyReasoningField(noMessages, "reasoning_content", false).toString()) + + // `messages: null` (JsonNull) — тоже не трогаем. + val nullMessages = json.parseToJsonElement("""{"messages":null}""").jsonObject + val out = applyReasoningField(nullMessages, "reasoning_content", false) + assertEquals(nullMessages.toString(), out.toString()) + } + + @Test + fun messageOrderAndOtherFieldsArePreserved() { + // Меняется только 2-е (assistant с tool_calls), порядок и остальные поля на месте. + val msgs = """ + {"role":"user","content":"hi"}, + {"role":"assistant","tool_calls":[$TC],"reasoning":"R","content":""}, + {"role":"tool","tool_call_id":"t1","content":"ok"}, + {"role":"user","content":"again"} + """.trimIndent().replace("\n", " ") + val out = apply(msgs, "reasoning_content", false) + val list = outMsgs(out) + assertEquals(listOf("user", "assistant", "tool", "user"), list.map { str(it, "role") }) + assertEquals("R", str(list[1], "reasoning_content")) + assertTrue((list[1]["tool_calls"] as? JsonArray)?.isNotEmpty() == true) + assertTrue(list[1].containsKey("reasoning")) + assertEquals("hi", str(list[0], "content")) + assertEquals("ok", str(list[2], "content")) + assertEquals("again", str(list[3], "content")) + } +}