Files
llm-proxy/TESTING.md
T
subochev 0ba9b7d739 feat: экспоненциальный backoff (providers[].backoff / upstreams[].backoff)
Повторяющиеся сбои апстрима (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.
2026-09-13 20:33:36 +03:00

9.0 KiB
Raw 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, запрет перезаписи непустого поля, неприкосновенность сообщений без 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 (таблица выше) до релиза.