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`)
|
||||
|
||||
Некоторые шлюзы-апстримы (например, **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` не задан — тело не меняется вовсе.
|
||||
|
||||
+15
-1
@@ -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. Реальный апстрим проверяется только после деплоя.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user