fix: reasoning_field на каждом assistant-сообщении (не только с tool_calls)
Build LLM Proxy / Build and push (release) Successful in 41s
Build LLM Proxy / Build and push (release) Successful in 41s
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»). Тесты переписаны под новое поведение.
This commit is contained in:
@@ -231,12 +231,15 @@ upstreams:
|
|||||||
### Нативное поле рассуждений (`reasoning_field`)
|
### Нативное поле рассуждений (`reasoning_field`)
|
||||||
|
|
||||||
Некоторые шлюзы-апстримы (например, **Console Go / deepseek в thinking-режиме**)
|
Некоторые шлюзы-апстримы (например, **Console Go / deepseek в thinking-режиме**)
|
||||||
в thinking-режиме **требуют** вернуть им нативное поле рассуждений в каждом
|
в thinking-режиме **требуют** вернуть им нативное поле рассуждений в **каждом**
|
||||||
assistant-сообщении с непустым `tool_calls`. Если его нет — апстрим отвечает
|
assistant-сообщении истории (не только в тех, что с `tool_calls`). Если его нет —
|
||||||
`400` (`The `reasoning_content` in the thinking mode must be passed back to the
|
апстрим отвечает `400`
|
||||||
|
(`The `reasoning_content` in the thinking mode must be passed back to the
|
||||||
API.`). Клиенты при этом рассуждения держат в своих форматах: `reasoning`
|
API.`). Клиенты при этом рассуждения держат в своих форматах: `reasoning`
|
||||||
(строка) и/или `reasoning_details` (массив `{type:"reasoning.text", text, ...}`)
|
(строка) и/или `reasoning_details` (массив `{type:"reasoning.text", text, ...}`)
|
||||||
— а нативное поле `reasoning_content` в истории могут и не передавать.
|
— а нативное поле `reasoning_content` в истории могут и не передавать.
|
||||||
|
(Точно так же делает и сам opencode: для deepseek он добавляет reasoning-часть
|
||||||
|
на каждом assistant-сообщении, даже пустую.)
|
||||||
|
|
||||||
Поля (только на уровне **провайдера**, это свойство шлюза, а не модели):
|
Поля (только на уровне **провайдера**, это свойство шлюза, а не модели):
|
||||||
|
|
||||||
@@ -245,15 +248,15 @@ API.`). Клиенты при этом рассуждения держат в с
|
|||||||
| `providers[].reasoning_field` | строка / отсутствует | Имя нативного поля рассуждений у шлюза-апстрима (пример: `reasoning_content`) |
|
| `providers[].reasoning_field` | строка / отсутствует | Имя нативного поля рассуждений у шлюза-апстрима (пример: `reasoning_content`) |
|
||||||
| `providers[].reasoning_empty_ok` | булево / `false` | Писать ли пустую строку, если текста рассуждений нет вовсе |
|
| `providers[].reasoning_empty_ok` | булево / `false` | Писать ли пустую строку, если текста рассуждений нет вовсе |
|
||||||
|
|
||||||
Зачем: при отправке запроса прокси **аддитивно** достраивает это поле в каждом
|
Зачем: при отправке запроса прокси **аддитивно** достраивает это поле в
|
||||||
assistant-сообщении с непустым `tool_calls` — берёт текст из `reasoning`
|
**каждом** assistant-сообщении — берёт текст из `reasoning`
|
||||||
(если это непустая строка), иначе склеивает `reasoning_details[*].text`
|
(если это непустая строка), иначе склеивает `reasoning_details[*].text`
|
||||||
(только элементы без `type` или с `type == "reasoning.text"`, через `"\n"`) и
|
(только элементы без `type` или с `type == "reasoning.text"`, через `"\n"`) и
|
||||||
записывает в поле `reasoning_field`, **если его там ещё нет**. Существующее
|
записывает в поле `reasoning_field`, **если его там ещё нет**. Существующее
|
||||||
непустое поле не перезаписывается. Ничего при этом не убирается и не
|
непустое поле не перезаписывается. Ничего при этом не убирается и не
|
||||||
переименовывается — клиентский `reasoning`/`reasoning_details` остаются на месте,
|
переименовывается — клиентский `reasoning`/`reasoning_details` остаются на месте,
|
||||||
поле просто дополняется. Тронуто только assistant-сообщение с непустым
|
поле просто дополняется. Тронуты только assistant-сообщения; `user`/`tool`/
|
||||||
`tool_calls`; сообщения без `tool_calls` и `user`/`tool`/`system` не меняются.
|
`system` не меняются.
|
||||||
Если текста рассуждений нет вовсе — поле не добавляется, кроме случая
|
Если текста рассуждений нет вовсе — поле не добавляется, кроме случая
|
||||||
`reasoning_empty_ok: true` (тогда пишется пустая строка `""`). Если
|
`reasoning_empty_ok: true` (тогда пишется пустая строка `""`). Если
|
||||||
`reasoning_field` не задан — тело не меняется вовсе.
|
`reasoning_field` не задан — тело не меняется вовсе.
|
||||||
|
|||||||
+15
-1
@@ -14,7 +14,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `ThinkTagSplitterTest` | Автомат рассечения think-тегов: passthrough при off, вырезание рассуждений при split, отбрасывание при strip, удержание разрезанного тега, несколько блоков, незакрытый блок; |
|
| `ThinkTagSplitterTest` | Автомат рассечения think-тегов: passthrough при off, вырезание рассуждений при split, отбрасывание при strip, удержание разрезанного тега, несколько блоков, незакрытый блок; |
|
||||||
| `ConfigLogicTest` | Разбор конфига (`reasoning_field`/`reasoning_empty_ok` провайдера и их дефолты), приоритет источников (апстрим важнее провайдера), слияние патчей, выбор апстрима и лимиты конкурентности, заголовки, сессии; |
|
| `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`) — пропуск вместо исключения; |
|
| `NullToleranceTest` | Устойчивость разбора ответов апстрима к JSON-`null` (`choices`, `delta.tool_calls`, `delta.content`) — пропуск вместо исключения; |
|
||||||
| `ThinkTagTransformTest` | Non-stream путь `transformThinkMessage`: перенос рассуждений в `reasoning_content`, дописывание к уже имеющемуся, strip, незакрытый блок, отсутствие изменений → null; |
|
| `ThinkTagTransformTest` | Non-stream путь `transformThinkMessage`: перенос рассуждений в `reasoning_content`, дописывание к уже имеющемуся, strip, незакрытый блок, отсутствие изменений → null; |
|
||||||
| `ThinkTagChunkTest` | SSE-чанки `transformThinkChunk`: удержание хвоста тега между чанками, независимые сплиттеры по index, удаление пустого `content`; |
|
| `ThinkTagChunkTest` | SSE-чанки `transformThinkChunk`: удержание хвоста тега между чанками, независимые сплиттеры по index, удаление пустого `content`; |
|
||||||
@@ -83,6 +83,20 @@ Console Go; тело — как у opencode: assistant + `reasoning` + `reasonin
|
|||||||
`assistantWithoutToolCallsIsUntouched` и `assistantWithEmptyToolCallsIsUntouched`;
|
`assistantWithoutToolCallsIsUntouched` и `assistantWithEmptyToolCallsIsUntouched`;
|
||||||
возврат `obj["choices"]?.jsonArray` в `rebuildFromChunks` → падает `rebuildFromChunksToleratesNullChoices`.
|
возврат `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. Реальный апстрим проверяется только после деплоя.
|
Живой стрим в реальном апстриме модульными тестами не проверяется: обвязка испытывается на синтетическом SSE через каналы ktor. Реальный апстрим проверяется только после деплоя.
|
||||||
|
|||||||
@@ -710,11 +710,14 @@ internal fun transformThinkMessage(obj: JsonObject, thinkMode: String): String?
|
|||||||
/**
|
/**
|
||||||
* Достроить нативное поле рассуждений для апстримов, которые его требуют
|
* Достроить нативное поле рассуждений для апстримов, которые его требуют
|
||||||
* (Console Go / deepseek в thinking-режиме): если у провайдера объявлено
|
* (Console Go / deepseek в thinking-режиме): если у провайдера объявлено
|
||||||
* `reasoningField`, то в каждом assistant-сообщении с непустым `tool_calls`
|
* `reasoningField`, то в каждом assistant-сообщении добавляем это поле,
|
||||||
* добавляем это поле, ЕСЛИ его там ещё нет. Текст берём из `reasoning`
|
* ЕСЛИ его там ещё нет (не только в тех, что с tool_calls — deepseek
|
||||||
|
* требует `reasoning_content` на КАЖДОМ ассистент-сообщении в thinking-режиме,
|
||||||
|
* см. «must be passed back to the API»). Текст берём из `reasoning`
|
||||||
* (строка) или из `reasoning_details` (элементы с `type == "reasoning.text"`).
|
* (строка) или из `reasoning_details` (элементы с `type == "reasoning.text"`).
|
||||||
* Существующее непустое поле НЕ перезаписываем. При полном отсутствии текста
|
* Существующее непустое поле НЕ перезаписываем. При полном отсутствии текста
|
||||||
* пишем пустую строку, только если `emptyOk`.
|
* пишем пустую строку, только если `emptyOk` — апстрим требует самого
|
||||||
|
* НАЛИЧИЯ поля, даже пустого (так делает и сам opencode).
|
||||||
* Тело возвращается без изменений (тот же объект), если менять нечего.
|
* Тело возвращается без изменений (тот же объект), если менять нечего.
|
||||||
*/
|
*/
|
||||||
internal fun applyReasoningField(body: JsonObject, field: String?, emptyOk: Boolean): JsonObject {
|
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 msg = el as? JsonObject ?: return@map el
|
||||||
val role = (msg["role"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
val role = (msg["role"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
if (role != "assistant") return@map el
|
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
|
val existing = (msg[field] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
if (existing != null && existing.isNotEmpty()) return@map el
|
if (existing != null && existing.isNotEmpty()) return@map el
|
||||||
val text = reasoningTextOf(msg)
|
val text = reasoningTextOf(msg)
|
||||||
|
|||||||
@@ -74,20 +74,42 @@ class ReasoningFieldTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun assistantWithoutToolCallsIsUntouched() {
|
fun assistantWithoutToolCallsAlsoGetsField() {
|
||||||
// Без tool_calls assistant-сообщение не трогаем (тело идентично).
|
// DeepSeek требует reasoning_content на КАЖДОМ assistant-сообщении
|
||||||
|
// (thinking-режим), а не только на тех, что с tool_calls.
|
||||||
val m = """{"role":"assistant","reasoning":"R","content":"hi"}"""
|
val m = """{"role":"assistant","reasoning":"R","content":"hi"}"""
|
||||||
val out = apply(m, "reasoning_content", false)
|
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())
|
assertEquals("""{"messages":[$m]}""", out.toString())
|
||||||
assertFalse(outMsgs(out)[0].containsKey("reasoning_content"))
|
assertFalse(outMsgs(out)[0].containsKey("reasoning_content"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun assistantWithEmptyToolCallsIsUntouched() {
|
fun assistantWithoutToolCallsWithEmptyOkFillsEmpty() {
|
||||||
// Пустой массив tool_calls = не трогаем (тело идентично).
|
// Без 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 m = """{"role":"assistant","tool_calls":[],"reasoning":"R"}"""
|
||||||
val out = apply(m, "reasoning_content", false)
|
val out = apply(m, "reasoning_content", false)
|
||||||
assertEquals("""{"messages":[$m]}""", out.toString())
|
assertEquals("R", str(outMsgs(out)[0], "reasoning_content"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user