293964d8f1
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»). Тесты переписаны под новое поведение.
105 lines
11 KiB
Markdown
105 lines
11 KiB
Markdown
# Тестирование 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 (таблица выше) до релиза.
|
||
|