Проблема (Vikunja #71): opencode показывал «модель ещё думает» ~45 с после конца генерации на апстриме MiniMax (модель codding-big), который НЕ присылает терминатор data: [DONE] и просто закрывает сокет. Bifrost для custom-провайдеров считает маркер обязательным, поэтому завершал стрим клиенту только на закрытии keep-alive-сокета llm-proxy (Ktor CIO connectionIdleTimeoutSeconds = 45). Правка: стрим клиенту завершается маркером data: [DONE] всегда, когда апстрим закрыл поток штатно (в потоке был непустой finish_reason), но сам маркера не прислал. Обрыв БЕЗ finish_reason маркер НЕ дописывает — иначе усечённый стрим выглядит как успешный (детект Bifrost SSEStreamEndedOnMarker). - Main.kt: SSE_DONE_MARKER, StreamEndDetector (скользящее окно 128 байт — маркер может разрезаться границей чтения), hasNonNullFinishReasonText (экранированные вхождения не считаются), hasFinishReason, streamRawWithDoneContract для ветки think_tags: off; в streamSseWithThinkTags — учёт sawDone/sawFinishReason и дописывание маркера после сброса хвостов сплиттеров. - StreamDoneContractTest.kt: 13 тестов (детектор, разрез маркера границей чтения, byte-exact passthrough, отсутствие дублирования, обрыв без finish_reason). - TESTING.md: строка про новый тест-файл + 3 пункта мутационной приёмки; - docs/sse-done-contract.md: ТЗ и разбор замеров. Проверено: ./gradlew clean jvmTest fatJar — 79 тестов (было 66), 0 падений; мутационная приёмка — ослабление охраны до `if (!sawDone)` роняет thinkStreamTruncatedNeedsNoMarker.
llm-proxy
Прозрачная прослойка/роутер OpenAI API. Принимает /v1/chat/completions и
/v1/models, маршрутизирует между объявленными в YAML апстримами по внутреннему
учёту конкурентности и мержит patch-слои (provider → upstream → model) в
тело запроса. Подробная спецификация конфига — в CONFIG.md.
Конфиг
Конфигурация — в YAML-файле (по умолчанию config.yaml в каталоге проекта,
CWD; переопределяется env CONFIG_PATH). Блоки: server (интерфейс биндинга
host + port, дефолты 0.0.0.0 / 8100), providers, upstreams,
models. Старый механизм env-переменных (UPSTREAM_URL, ROUTER_API_KEY,
EXCLUDED_PROVIDERS, THINKING_MODELS, PORT) удалён — его поведение теперь
в декларативном patch и блоке upstreams, а порт/интерфейс — в блоке
server.
Лимит конкурентности max_concurrency можно задать и на провайдере (лимит по
умолчанию для его апстримов), и на апстриме (перекрывает провайдерский); без
обоих — безлимит.
Обработку think-тегов включает опциональный флажок think_tags у провайдера или
апстрима (off по умолчанию, split — рассуждения из <think>…</think> уходят
в reasoning_content, strip — выбрасываются); работает и в стриме, и в
non-stream.
| Переменная | Default | Описание |
|---|---|---|
CONFIG_PATH |
config.yaml (CWD) |
Путь к YAML-конфигу |
| Ключ конфига | Default | Описание |
|---|---|---|
server.host |
0.0.0.0 |
Интерфейс/адрес биндинга |
server.port |
8100 |
Порт сервера |
Таймаут на запрос к нейронке (весь ответ, включая стриминг) — константа
UPSTREAM_REQUEST_TIMEOUT_MS = 5 минут; без неё дефолт CIO-движка Ktor — 15 секунд,
и длинные генерации обрываются. Значение логируется при старте.
Сборка и деплой
- CI (Gitea Actions, на release):
./gradlew fatJar→ образimages.binom.pw/llm-proxy:<tag>(zot). - Запуск на 76.179 (llm-router):
podman-compose up -dизpodman-compose.yaml(образ тянется изimages.binom.pw, сетьbifrost_default— туда же Bifrost ходит по имениllm-proxy). - Bifrost: в
config_providersпровайдераRouterainetwork_config_json.base_url=http://llm-proxy:8100(после смены — рестарт bifrost_gateway).