From 293964d8f1126addaa88fc96949e9b2f694ea871 Mon Sep 17 00:00:00 2001 From: subochev Date: Sun, 13 Sep 2026 22:36:33 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20reasoning=5Ffield=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=B6=D0=B4=D0=BE=D0=BC=20assistant-=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B8=20(=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=81=20tool=5Fcalls)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Console Go (deepseek в thinking-режиме) в stateful-сессиях требует reasoning_content на КАЖДОМ assistant-сообщении истории — живые 400 «The reasoning_content in the thinking mode must be passed back to the API» (19:01, session 890fc602) шли именно на чистых ассистент-турах без tool_calls, которые старый гейт пропускал. Снял гейт: поле дописывается на каждом assistant-сообщении (текст из reasoning / reasoning_details, пустая строка при reasoning_empty_ok) — как это делает сам opencode («Deepseek requires all assistant messages to have reasoning on them»). Тесты переписаны под новое поведение. --- CONFIG.md | 17 ++++++---- TESTING.md | 16 +++++++++- .../kotlin/pw/binom/llmproxy/Main.kt | 11 ++++--- .../pw/binom/llmproxy/ReasoningFieldTest.kt | 32 ++++++++++++++++--- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/CONFIG.md b/CONFIG.md index 5a57186..c31e49b 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -231,12 +231,15 @@ upstreams: ### Нативное поле рассуждений (`reasoning_field`) Некоторые шлюзы-апстримы (например, **Console Go / deepseek в thinking-режиме**) -в thinking-режиме **требуют** вернуть им нативное поле рассуждений в каждом -assistant-сообщении с непустым `tool_calls`. Если его нет — апстрим отвечает -`400` (`The `reasoning_content` in the thinking mode must be passed back to the +в 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` в истории могут и не передавать. +(Точно так же делает и сам opencode: для deepseek он добавляет reasoning-часть +на каждом assistant-сообщении, даже пустую.) Поля (только на уровне **провайдера**, это свойство шлюза, а не модели): @@ -245,15 +248,15 @@ API.`). Клиенты при этом рассуждения держат в с | `providers[].reasoning_field` | строка / отсутствует | Имя нативного поля рассуждений у шлюза-апстрима (пример: `reasoning_content`) | | `providers[].reasoning_empty_ok` | булево / `false` | Писать ли пустую строку, если текста рассуждений нет вовсе | -Зачем: при отправке запроса прокси **аддитивно** достраивает это поле в каждом -assistant-сообщении с непустым `tool_calls` — берёт текст из `reasoning` +Зачем: при отправке запроса прокси **аддитивно** достраивает это поле в +**каждом** assistant-сообщении — берёт текст из `reasoning` (если это непустая строка), иначе склеивает `reasoning_details[*].text` (только элементы без `type` или с `type == "reasoning.text"`, через `"\n"`) и записывает в поле `reasoning_field`, **если его там ещё нет**. Существующее непустое поле не перезаписывается. Ничего при этом не убирается и не переименовывается — клиентский `reasoning`/`reasoning_details` остаются на месте, -поле просто дополняется. Тронуто только assistant-сообщение с непустым -`tool_calls`; сообщения без `tool_calls` и `user`/`tool`/`system` не меняются. +поле просто дополняется. Тронуты только assistant-сообщения; `user`/`tool`/ +`system` не меняются. Если текста рассуждений нет вовсе — поле не добавляется, кроме случая `reasoning_empty_ok: true` (тогда пишется пустая строка `""`). Если `reasoning_field` не задан — тело не меняется вовсе. diff --git a/TESTING.md b/TESTING.md index 9c662a5..1a7b24d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -14,7 +14,7 @@ | --- | --- | | `ThinkTagSplitterTest` | Автомат рассечения think-тегов: passthrough при off, вырезание рассуждений при split, отбрасывание при strip, удержание разрезанного тега, несколько блоков, незакрытый блок; | | `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`, сохранение порядка и прочих полей; | +| `ReasoningFieldTest` | Достройка нативного поля рассуждений (`applyReasoningField`/`reasoningTextOf`): форма клиента opencode (`reasoning` + `reasoning_details`), чужой `type` в details, склейка нескольких details, запрет перезаписи непустого поля, заполнение поля на **каждом** assistant-сообщении (с `tool_calls`, без, `tool_calls: []` — без текста и `emptyOk=false` не трогаем; `emptyOk=true` — пустая строка), неприкосновенность `user`/`tool`/`system`, `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`; | @@ -83,6 +83,20 @@ Console Go; тело — как у opencode: assistant + `reasoning` + `reasonin `assistantWithoutToolCallsIsUntouched` и `assistantWithEmptyToolCallsIsUntouched`; возврат `obj["choices"]?.jsonArray` в `rebuildFromChunks` → падает `rebuildFromChunksToleratesNullChoices`. +**Поправка после боевого 400 (2026-09-13):** живые stateful-сессии Console Go +(сессия, где глюч уже видел thinking-ответы) всё-таки требовали +`reasoning_content` и на assistant-сообщениях **без** `tool_calls` — свежая +сессия такой истории прощала (все формы выше — 200). Опция «на каждом +assistant-сообщении» совпадает с тем, что делает сам opencode («Deepseek +requires all assistant messages to have reasoning on them», пустая +reasoning-часть дописывается тоже). Охрана `tool_calls` в +`applyReasoningField` снята: поле дописывается на каждом assistant-сообщении +(текст — из `reasoning`/`reasoning_details`, при `emptyOk` — пустая строка). +Тесты `assistantWithoutToolCallsIsUntouched`/`assistantWithEmptyToolCallsIsUntouched` +заменены на `assistantWithoutToolCallsAlsoGetsField`, +`assistantWithoutReasoningAndNoEmptyOkIsUntouched`, +`assistantWithoutToolCallsWithEmptyOkFillsEmpty`, `assistantWithEmptyToolCallsGetsField`. + ## Известное ограничение Живой стрим в реальном апстриме модульными тестами не проверяется: обвязка испытывается на синтетическом SSE через каналы ktor. Реальный апстрим проверяется только после деплоя. diff --git a/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt b/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt index ed10b82..6bd865d 100644 --- a/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt +++ b/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt @@ -710,11 +710,14 @@ internal fun transformThinkMessage(obj: JsonObject, thinkMode: String): String? /** * Достроить нативное поле рассуждений для апстримов, которые его требуют * (Console Go / deepseek в thinking-режиме): если у провайдера объявлено - * `reasoningField`, то в каждом assistant-сообщении с непустым `tool_calls` - * добавляем это поле, ЕСЛИ его там ещё нет. Текст берём из `reasoning` + * `reasoningField`, то в каждом assistant-сообщении добавляем это поле, + * ЕСЛИ его там ещё нет (не только в тех, что с tool_calls — deepseek + * требует `reasoning_content` на КАЖДОМ ассистент-сообщении в thinking-режиме, + * см. «must be passed back to the API»). Текст берём из `reasoning` * (строка) или из `reasoning_details` (элементы с `type == "reasoning.text"`). * Существующее непустое поле НЕ перезаписываем. При полном отсутствии текста - * пишем пустую строку, только если `emptyOk`. + * пишем пустую строку, только если `emptyOk` — апстрим требует самого + * НАЛИЧИЯ поля, даже пустого (так делает и сам opencode). * Тело возвращается без изменений (тот же объект), если менять нечего. */ internal fun applyReasoningField(body: JsonObject, field: String?, emptyOk: Boolean): JsonObject { @@ -725,8 +728,6 @@ internal fun applyReasoningField(body: JsonObject, field: String?, emptyOk: Bool 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) diff --git a/src/commonTest/kotlin/pw/binom/llmproxy/ReasoningFieldTest.kt b/src/commonTest/kotlin/pw/binom/llmproxy/ReasoningFieldTest.kt index 73ab776..78365bf 100644 --- a/src/commonTest/kotlin/pw/binom/llmproxy/ReasoningFieldTest.kt +++ b/src/commonTest/kotlin/pw/binom/llmproxy/ReasoningFieldTest.kt @@ -74,20 +74,42 @@ class ReasoningFieldTest { } @Test - fun assistantWithoutToolCallsIsUntouched() { - // Без tool_calls assistant-сообщение не трогаем (тело идентично). + fun assistantWithoutToolCallsAlsoGetsField() { + // DeepSeek требует reasoning_content на КАЖДОМ assistant-сообщении + // (thinking-режим), а не только на тех, что с tool_calls. val m = """{"role":"assistant","reasoning":"R","content":"hi"}""" val out = apply(m, "reasoning_content", false) + assertEquals("R", str(outMsgs(out)[0], "reasoning_content")) + } + + @Test + fun assistantWithoutReasoningAndNoEmptyOkIsUntouched() { + // assistant-сообщение без рассуждений и без tool_calls: + // текста нет, emptyOk=false — поле не добавляем. + val m = """{"role":"assistant","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 = не трогаем (тело идентично). + fun assistantWithoutToolCallsWithEmptyOkFillsEmpty() { + // Без tool_calls и без текста, но emptyOk=true — дописываем пустую строку, + // т.к. апстриму важно само НАЛИЧИЕ поля. + val m = """{"role":"assistant","content":"hi"}""" + val out = apply(m, "reasoning_content", true) + val m0 = outMsgs(out)[0] + assertEquals("", str(m0, "reasoning_content")) + assertTrue(m0.containsKey("reasoning_content")) + } + + @Test + fun assistantWithEmptyToolCallsGetsField() { + // Пустой массив tool_calls больше не исключает сообщение из обработки: + // текст из reasoning дописывается в нативное поле. val m = """{"role":"assistant","tool_calls":[],"reasoning":"R"}""" val out = apply(m, "reasoning_content", false) - assertEquals("""{"messages":[$m]}""", out.toString()) + assertEquals("R", str(outMsgs(out)[0], "reasoning_content")) } @Test