Диагноз: апстрим 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).
8.4 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, запрет перезаписи непустого поля, неприкосновенность сообщений без 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. |
StreamDoneContractTest |
Контракт конца SSE: детектор finish_reason/[DONE] (в т.ч. разрезанных границей чтения), дописывание data: [DONE]\n\n в сыром passthrough и в think-обвязке при штатном закрытии без маркера, отсутствие маркера при обрыве без finish_reason, отсутствие дублирования. |
Проверка качества тестов (мутационная приёмка)
Приём по шагам:
- Забэкапить файл.
- Внести РОВНО одну поломку в боевой код.
- Прогнать
./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.
Известное ограничение
Живой стрим в реальном апстриме модульными тестами не проверяется: обвязка испытывается на синтетическом SSE через каналы ktor. Реальный апстрим проверяется только после деплоя. Правка поля рассуждений — исключение: она проверена живым инстансом против настоящего Console Go (таблица выше) до релиза.