Files
subochev 293964d8f1
Build LLM Proxy / Build and push (release) Successful in 41s
fix: reasoning_field на каждом assistant-сообщении (не только с tool_calls)
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»). Тесты переписаны под новое поведение.
2026-09-13 22:36:33 +03:00

11 KiB
Raw Permalink Blame History

Тестирование 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.

Проверка качества тестов (мутационная приёмка)

Приём по шагам:

  1. Забэкапить файл.
  2. Внести РОВНО одну поломку в боевой код.
  3. Прогнать ./gradlew cleanJvmTest jvmTest.
  4. Посмотреть XML — тест, который не упал, считается пустым.
  5. Откатить (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 (таблица выше) до релиза.