0ba9b7d739
Повторяющиеся сбои апстрима (5xx/429/402 и сетевые ошибки) увязываются экспоненциальным откатом: первая ошибка — отдых 1s, далее 2s, 4s, … до потолка из конфига (ISO-8601: P1M, PT15M…); успешный запрос сбрасывает счётчик. Приоритет: upstreams[].backoff (свой счётчик на модель) над providers[].backoff (общий счётчик на все модели провайдера). - Backoff.kt: BackoffGuard (Mutex/@Volatile, kotlinx-datetime) + BackoffRegistry (скоупы провайдер/модель); - pickFreeUpstream пропускает апстримы в откате (лог «~Ns — пропускаю»); - все апстримы модели в откате → 503 «all upstreams cooling, retry in Ns» + заголовок Retry-After; - некорректный ISO-8601 в backoff — warn + игнор поля; - BackoffTest (8 тестов) + обновлённые тесты pickFreeUpstream; - CONFIG.md/README/TESTING.md. Проверено: jvmTest, compileKotlinLinuxX64, linuxX64Test.
90 lines
9.0 KiB
Markdown
90 lines
9.0 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, запрет перезаписи непустого поля, неприкосновенность сообщений без `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`, отсутствие дублирования. |
|
||
| `BackoffTest` | Экспоненциальный backoff: удвоение интервала отката до потолка (cap), сброс счётчика при успехе, окно охлаждения (`isCoolingAt`/`remainingAt`), ISO-8601-разбор cap (`PT30M`, `P1D`, `PT1M30S`), скоупы: общий провайдерский счётчик на все его модели, приоритет `upstreams[].backoff` над провайдерским, независимость скоупов, backoff не настроен → без отката. |
|
||
|
||
## Проверка качества тестов (мутационная приёмка)
|
||
|
||
Приём по шагам:
|
||
|
||
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`.
|
||
|
||
## Известное ограничение
|
||
|
||
Живой стрим в реальном апстриме модульными тестами не проверяется: обвязка испытывается на синтетическом SSE через каналы ktor. Реальный апстрим проверяется только после деплоя.
|
||
Правка поля рассуждений — исключение: она проверена живым инстансом против настоящего Console Go (таблица выше) до релиза.
|
||
|