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»). Тесты переписаны под новое поведение.
11 KiB
Тестирование 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 |
Разбор конфига (reasoning_field/reasoning_empty_ok провайдера и их дефолты), приоритет источников (апстрим важнее провайдера), слияние патчей, выбор апстрима и лимиты конкурентности, заголовки, сессии; |
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; |
ThinkTagStreamTest |
Обвязка стрима streamSseWithThinkTags: разрез тега между data-событиями, сброс удержанного хвоста в финиш-чанке, прохождение служебных строк и [DONE], битый JSON, чанк без choices, strip. |
StreamDoneContractTest |
Контракт конца SSE: детектор finish_reason/[DONE] (в т.ч. разрезанных границей чтения), дописывание data: [DONE]\n\n в сыром passthrough и в think-обвязке при штатном закрытии без маркера, отсутствие маркера при обрыве без finish_reason, отсутствие дублирования. |
BackoffTest |
Экспоненциальный backoff: удвоение интервала отката до потолка (cap), сброс счётчика при успехе, окно охлаждения (isCoolingAt/remainingAt), ISO-8601-разбор cap (PT30M, P1D, PT1M30S) и конфиг-парсер parseIsoDuration (ленивые Y/M: P1M≈30d, P1Y≈365d; мусор — ошибка), скоупы: общий провайдерский счётчик на все его модели, приоритет upstreams[].backoff над провайдерским, независимость скоупов, backoff не настроен → без отката. |
MetricsTest |
/metrics: counters по исходам запросов (ok/4xx/429/402/5xx/net_err/cancelled), гейджи inflight/fail_streak/cooling_seconds (общий провайдерский скоуп виден в метриках), экранирование меток, маппинг HTTP-статуса → result. |
Проверка качества тестов (мутационная приёмка)
Приём по шагам:
- Забэкапить файл.
- Внести РОВНО одну поломку в боевой код.
- Прогнать
./gradlew cleanJvmTest jvmTest. - Посмотреть XML — тест, который не упал, считается пустым.
- Откатить (
git checkout -- <файл>).
Обязательно: cleanJvmTest обязателен, иначе прогон не перезапустится.
Проверенные мутации, каждая из которых ДОЛЖНА ронять тесты:
transformThinkMessageвозвращает null → падают тесты non-stream;transformThinkChunkвозвращает null → падают тесты чанков и стрима;- блок финиш-чанка в
streamSseWithThinkTagsне выполняется → падает тест про удержанный хвост; holdableSuffixвсегда 0 (хвост тега не удерживается) → падают тесты автомата, чанков и стрима;stripначинает отдавать рассуждения → падает тест автомата;missingDoneMarkerвсегдаfalse→ падают тесты сырого пути (rawStreamIsByteExactAndAppendsDone) и тест разрезанного маркера;if (sawFinishReason && !sawDone)→if (!sawDone)вstreamSseWithThinkTags→ падаетthinkStreamTruncatedNeedsNoMarker;if (sawFinishReason && !sawDone)→if (false)вstreamSseWithThinkTags→ падаетthinkStreamAppendsDoneWhenUpstreamClosedWithoutMarker.
Правило: боевой код нельзя подгонять под тест; если тест не проходит, неверен тест.
Поле рассуждений для 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.
Поправка после боевого 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. Реальный апстрим проверяется только после деплоя. Правка поля рассуждений — исключение: она проверена живым инстансом против настоящего Console Go (таблица выше) до релиза.