# Конфигурация llm-proxy (переход на YAML) ## Идея Вместо переменных среды конфигурация задаётся YAML-файлом. В нём три блока: 1. **`providers`** — объявлены бэкенды (url + ключ) с нашим внутренним человекочитаемым `id`. По этому `id` ссылаются апстримы. Опционально — `max_concurrency`: лимит по умолчанию для апстримов этого провайдера. 2. **`upstreams`** — каталог «удалённых» моделей. Каждая запись: внутренний `id`, ссылка на `providers[].id`, реальное имя модели у провайдера (`model`) и опциональный лимит конкурентности `max_concurrency`. Это и есть то, на что мы проксируем. 3. **`models`** — модели, видимые клиенту. Для каждой — «витринное» имя (`name`), список `upstreams` (ссылки на `upstreams[].id`, можно несколько). 4. **`patch`** — не отдельный блок, а опциональное поле, доступное **на всех уровнях** (`providers`, `upstreams`, `models`). Это JSON-подобная структура (`kotlinx.serialization.json.JsonObject`, в YAML пишется как обычный мап), которая **аккуратно вмердживается** в тело запроса. Пишется нативным YAML, без вложенного JSON-в-строке. При запросе к модели прокси берёт **первый из её `upstreams`, на котором прямо сейчас есть свободный слот** (по `max_concurrency`). Это позволяет держать провайдеров, допускающих лишь один одновременный запрос (`max_concurrency: 1`), рядом с многопоточными — и не превышать их лимиты. **Лимит конкурентности** апстрима берётся в порядке убывания специфичности: `upstreams[].max_concurrency` (самый конкретный) → `providers[].max_concurrency` (провайдера, на который ссылается апстрим) → безлимит. Т.е. задан у модели — берём её; у модели нет, но есть у провайдера — берём провайдерский. **Сборка тела запроса** — берём исходный JSON клиента и накладываем `patch` **послойно, в порядке возрастания специфичности**: сначала `provider.patch` (всё, что идёт через этого провайдера), затем `upstream.patch` (конкретная апстрим-модель),最后 `model.patch` (самый приоритетный, накладывается последним). Мерж — глубокий: вложенные объекты сливаются рекурсивно, скаляры/массивы по ключу заменяются значением из `patch`. **Маршрутизация целиком опирается на внутренний учёт** — мы сами считаем, сколько запросов сейчас идёт на каждый апстрим, и по этим счётчикам решаем, брать запрос или нет. Мы **не полагаемся** на `503`/`429` от самого апстрима, чтобы узнать, что он перегружен: такой ответ от провайдера — это уже сбой, а не способ управления нагрузкой. Если выбранный апстрим всё же вернул `5xx`/`429`/`402`, мы освобождаем его слот и, при наличии, пробуем **следующий свободный** из списка модели (фейловер); если свободных не осталось — отдаём `503` от прокси. `402` (insufficient balance / исчерпан лимит токенов) — ошибка аккаунта провайдера, но фейловер имеет смысл: следующий апстрим может быть платёжеспособен. Повторяющиеся сбои уводятся из ротации экспоненциальным **backoff** (поле `backoff`, ниже): упавший апстрим «отдыхает» 1s, 2s, 4s, … до заданного потолка, и прокси его не трогает. Если **все** апстримы модели в откате — `503` с заголовком `Retry-After`. ## Формат файла Путь к файлу: по умолчанию ищем `config.yaml` в **каталоге проекта** (текущем рабочем каталоге, откуда запущен процесс). Переопределить можно через env `CONFIG_PATH` — тогда берётся указанный путь (абсолютный или относительный). > **URL — в кавычках.** В YAML значение вида `https://...` парсится как вложенный > мап (`https:` — ключ), поэтому `url` (и любое значение с `://`) **обязательно > берите в кавычки**: `url: "https://routerai.ru/api/v1"`. ### Блок `server` — биндинг HTTP-сервера Блок `server` задаёт, **на какой интерфейс (`host`) и порт (`port`)** слушает прокси. Оба поля опциональны: дефолт `host: 0.0.0.0`, `port: 8100`. При старте сервер логирует фактический биндинг: `[llm-proxy] server: bind host= port=`. ```yaml server: host: 0.0.0.0 # интерфейс/адрес биндинга (дефолт 0.0.0.0) port: 8100 # порт (дефолт 8100) # 1) Бэкенды. id — наш внутренний идентификатор (на него ссылаются апстримы). providers: - id: routerai # любая строка, уникальная в рамках файла url: "https://routerai.ru/api/v1" key: "sk-..." # Bearer-ключ; можно подставлять из env max_concurrency: 4 # опционально; лимит по умолчанию для апстримов session_header: x-opencode-session # опционально; прокси считает сессию из истории patch: # уровень провайдера: ко всем его запросам provider: allow_fallbacks: false backoff: P1M # опционально; потолок экспоненциального backoff # на весь провайдер (ISO-8601: P1M, PT15M, P1D…) probe_interval: PT1S # опционально; фоновый пинг апстримов в откате # (как часто проверять, жив ли провайдер) - id: local-llama url: "http://10.0.0.5:8080/v1" key: "" # пусто, если бэкенд без авторизации max_concurrency: 1 # и локалка — один слот за раз # 2) Каталог апстрим-моделей. id — наш внутренний id (на него ссылаются модели). upstreams: - id: routerai-gpt4o # внутренний id апстрима provider: routerai # ссылка на providers[].id model: gpt-4o # реальное имя модели у провайдера max_concurrency: 4 # опционально; сколько одновременных запросов # допустимо (null/0 = безлимит) patch: # уровень апстрима provider: ignore: [deepseek] backoff: PT15M # опционально; собственный backoff этой модели # (приоритет над backoff провайдера) probe_interval: PT0S # опционально; пинг отключён у этой модели # (приоритет над probe_interval провайдера) - id: local-qwen provider: local-llama model: qwen2.5-72b-instruct max_concurrency: 1 # однослотовый провайдер: 1 запрос за раз # 3) Модели, видимые клиенту. models: - name: my-gpt # имя, под которым клиент запрашивает модель upstreams: # порядок = приоритет: сначала локальная видюха, - local-qwen # потом (фоллбэк) внешний провайдер - routerai-gpt4o patch: # уровень модели (накладывается последним) reasoning: enabled: false - name: deepseek-fast-no-think upstreams: - routerai-gpt4o patch: reasoning: enabled: false - name: local-qwen-only # только локалка, без фоллбэка upstreams: - local-qwen # patch необязателен на любом уровне — можно не указывать ``` > **Приоритет/фоллбэк.** Порядок записей в `upstreams` модели — это приоритет: > прокси берёт **первый свободный по порядку**. Типовой сценарий — сначала своя > локальная видюха (`max_concurrency: 1`, занята → следующий), а внешний провайдер > идёт вторым и принимает запрос, только когда локалка занята (или упала). > Чтобы внешний НЕ использовался, пока локалка свободна, — просто ставь локалку > первой; фоллбэк сработает автоматически при `claim() == null` у локалки. `patch` опционален на **любом** уровне (`providers` / `upstreams` / `models`): если ни одного нет — запрос проксируется как есть (исходное тело клиента). ### Сессия по истории (`session_header`) `providers[].session_header` (опционально) — имя HTTP-заголовка, который прокси **вычисляет сам** из истории сообщений и ставит в запрос к этому провайдеру. Нужно для API, требующих стабильный идентификатор сессии (например, `x-opencode-session`), когда клиент его не шлёт или шлёт не то. ```yaml providers: - id: some-provider url: "https://.../v1" session_header: x-opencode-session ``` Как считается id: 1. Берётся финальное тело запроса (после всех `patch`), из него — `messages`. 2. Цепочка **инкрементальных SHA-256 префикс-хэшей** начинается с первого `user`-сообщения (ведущий `system`-промпт игнорируется: он обычно одинаков у всех сессий клиента и как признак сессии бесполезен). 3. В реестре сессий ищется **наибольший общий префикс** (LCP) с уже виденной историей. Нашли — используется id той сессии; не нашли — создаётся новая (`id` = хэш всей истории на первом ходу). 4. Заголовок ставится **всегда** (клиентское значение перезаписывается). Итог: пока история одной сессии растёт (дописываются assistant/user-сообщения), id не меняется; разные диалоги получают разные id. > **Ограничения.** Реестр живёт в памяти (LRU: 1000 сессий / 6 часов) — при > рестарте прокси активные сессии получат новый id. Обрезка/суммаризация > истории рвёт общий префикс → сессия распадётся на новую. Диалоги с > одинаковым первым `user`-сообщением неразличимы (склеятся). ### Обработка think-тегов (`think_tags`) Некоторые провайдеры (например, **minimax**) отдают рассуждения модели не в отдельном поле `reasoning_content`, а прямо в `content`, обернув их тегами `…`. Флажок `think_tags` (опционально) разрешает прокси разрезать такой ответ и разложить его по полям. Поле доступно на двух уровнях: - `providers[].think_tags` — правило по умолчанию для всех апстримов провайдера; - `upstreams[].think_tags` — необязательное переопределение на конкретной апстрим-модели. Значения (строки): | Значение | Поведение | |---|---| | `off` | дефолт: ответ не меняется, теги остаются в `content` | | `split` | блоки `…` вырезаются из `content`, их текст уходит в `reasoning_content` | | `strip` | блоки вырезаются и выбрасываются — клиент рассуждений не видит | Разбор значения толерантный: `true` ≡ `split`, `false` ≡ `off`; любое неизвестное/пустое значение трактуется как `off` (прокси не падает). Приоритет резолва — по убыванию специфичности: `upstreams[].think_tags` (самый конкретный) → `providers[].think_tags` → `off`. То есть значение апстрима перекрывает провайдерское. ```yaml providers: - id: minimax url: "https://api.minimax.io/v1" key: "${MINIMAX_API_KEY}" think_tags: split # дефолт для всех апстримов провайдера upstreams: - id: minimax-m1 provider: minimax model: MiniMax-M1 # наследует split от провайдера - id: minimax-text-only provider: minimax model: MiniMax-Text-01 think_tags: strip # переопределение: рассуждения выбрасываем ``` Работает и в стриме, и в обычном (non-stream) ответе. Тег может прийти разрезанным между чанками SSE — прокси держит хвост, который может оказаться началом тега, и не отдаёт его клиенту до разрешения, поэтому огрызок тега не утечёт. Незакрытый `` в конце потока трактуется как «всё после него — рассуждения». Если `content` не строка (мультимодальный массив частей) — ответ не трогаем. Без флажка (`off`) ответ идёт байт-в-байт как раньше. ### Нативное поле рассуждений (`reasoning_field`) Некоторые шлюзы-апстримы (например, **Console Go / deepseek в thinking-режиме**) в thinking-режиме **требуют** вернуть им нативное поле рассуждений в **каждом** assistant-сообщении истории (не только в тех, что с `tool_calls`). Если его нет — апстрим отвечает `400` (`The `reasoning_content` in the thinking mode must be passed back to the API.`). Клиенты при этом рассуждения держат в своих форматах: `reasoning` (строка) и/или `reasoning_details` (массив `{type:"reasoning.text", text, ...}`) — а нативное поле `reasoning_content` в истории могут и не передавать. (Точно так же делает и сам opencode: для deepseek он добавляет reasoning-часть на каждом assistant-сообщении, даже пустую.) Поля (только на уровне **провайдера**, это свойство шлюза, а не модели): | Поле | Тип / дефолт | Значение | |---|---|---| | `providers[].reasoning_field` | строка / отсутствует | Имя нативного поля рассуждений у шлюза-апстрима (пример: `reasoning_content`) | | `providers[].reasoning_empty_ok` | булево / `false` | Писать ли пустую строку, если текста рассуждений нет вовсе | Зачем: при отправке запроса прокси **аддитивно** достраивает это поле в **каждом** assistant-сообщении — берёт текст из `reasoning` (если это непустая строка), иначе склеивает `reasoning_details[*].text` (только элементы без `type` или с `type == "reasoning.text"`, через `"\n"`) и записывает в поле `reasoning_field`, **если его там ещё нет**. Существующее непустое поле не перезаписывается. Ничего при этом не убирается и не переименовывается — клиентский `reasoning`/`reasoning_details` остаются на месте, поле просто дополняется. Тронуты только assistant-сообщения; `user`/`tool`/ `system` не меняются. Если текста рассуждений нет вовсе — поле не добавляется, кроме случая `reasoning_empty_ok: true` (тогда пишется пустая строка `""`). Если `reasoning_field` не задан — тело не меняется вовсе. ```yaml providers: - id: opencode url: "https://opencode.ai/zen/go/v1" reasoning_field: reasoning_content # Console Go / deepseek в thinking-режиме # reasoning_empty_ok: true # опционально; дефолт false ``` ### Экспоненциальный backoff (`backoff`) Если апстрим регулярно ошибается (`5xx`/`429`/`402` или сетевые ошибки), прокси не бьёт по нему на каждом запросе, а отправляет в **откат** (cooldown) — это паттерн экспоненциального бэкоффа / circuit breaker: чем дольше сервис молчит, тем дольше мы к нему не ходим. Пока апстрим в откате, роутер пропускает его и берёт следующий по списку модели. | Поле | Тип / дефолт | Значение | |---|---|---| | `providers[].backoff` | ISO-8601-длительность / отсутствует | Потолок отката **на весь провайдер**: счётчик общий для всех его моделей | | `upstreams[].backoff` | ISO-8601-длительность / отсутствует | Потолок отката **на конкретную модель**: счётчик индивидуальный | **Приоритет — модели.** Если `upstreams[].backoff` задан, у этой модели собственный счётчик и потолок (провайдерский `backoff` на неё не действует). Если у модели не задан, но задан у провайдера — счётчик общий на провайдера: сбой на одной модели охлаждает и все остальные модели этого провайдера. Если не задан нигде — backoff для этого апстрима выключен (остаются только конкурентность и фейловер). Поведение: - **первая** ошибка → отдых 1s; каждая следующая **удваивает** интервал (2s, 4s, 8s, …) до заданного потолка (cap); - **успешный** запрос сбрасывает счётчик (откат и удвоение начинаются заново); - апстрим в откате пропускается при выборе (лог: `upstream= в backoff-откате (~Ns) — пропускаю`); - если **все** апстримы модели в откате — прокси отдаёт `503` (`all upstreams cooling, retry in Ns`) с заголовком `Retry-After: N` (секунд до выхода первого апстрима из отката). Значение — ISO-8601-длительность: `PT30M` (30 минут), `PT1H15M`, `P1D` (сутки), `P1M` (месяц), `P1Y` (год). Годы/месяцы укорачиваются приближённо (1 год ≈ 365d, 1 месяц ≈ 30d) — kotlin.time `Duration.parse` не принимает Y/M (у них нет фиксированной длины), конфиг-парсер прокси расширяет формат. Некорректное значение — предупреждение в лог и поле просто игнорируется. ```yaml providers: - id: routerai url: "https://routerai.ru/api/v1" backoff: P1M # потолок на весь провайдер upstreams: - id: routerai-gpt4o provider: routerai model: gpt-4o backoff: PT15M # у модели своё: потолок 15m, провайдерский P1M не действует ``` При старте выводится, что настроено: `[llm-proxy] backoff: upstreams=routerai-gpt4o=PT15M providers=routerai=P1M`. ### Таймаут первого байта стрима (`first_byte_timeout`) Для `stream=true` запросов прокси следит, чтобы апстрим начал отдавать **что-нибудь** (первый байт тела) в течение заданного окна. Если апстрим молчит дольше — это трактуется как его сбой: слот конкурентности освобождается, ошибка идёт в backoff-откат, и роутер **фейловерит** на следующего свободного апстрима модели (при его наличии). Это закрывает кейс, когда апстрим принял запрос, но «завис» и никогда не начинает стримить. | Поле | Тип / дефолт | Значение | |---|---|---| | `providers[].first_byte_timeout` | ISO-8601-длительность / отсутствует | Окно ожидания первого байта для всех моделей провайдера | | `upstreams[].first_byte_timeout` | ISO-8601-длительность / отсутствует | Окно для конкретной модели (приоритет над провайдерским) | **Дефолт** (если не задан нигде): `PT30S`. **Приоритет — модели.** Если `upstreams[].first_byte_timeout` задан, он перекрывает провайдерский; если нет — берётся провайдерский; если нет и там — дефолт `PT30S`. **Отключение:** `PT0S` на провайдере или апстриме отключает watchdog для этого апстрима — тогда первый байт ждём без лимита (в рамках общего `upstream: request_timeout`, см. ниже). **Применяется только к `stream=true`.** Для `stream=false` (не-стриминговый ответ) действует только общий лимит `UPSTREAM_REQUEST_TIMEOUT_MS` (`5m`) — строки SSE там склеиваются в один ответ, и таймаут первого байта к нему неприменим. Поведение: - watchdog висит **на первом чтении** из тела стрим-ответа; как только пришёл хоть какой-то байт — лимит отключается, дальше стрим читается свободно (вплоть до общего request_timeout); - `data: [DONE]` приходит в теле — это уже «первый байт», таймаут не сработает; - при срабатывании — лог: `[llm-proxy] chat model= upstream= TIMEOUT: первый байт не пришёл за ms → фейловер` (+ `(backoff: отдых …)` если задан backoff), метрика `llm_proxy_requests_total{result="timeout"}`; - закрытие апстримом пустого стрима (EOF без единого байта) — **не** таймаут: это валидный короткий ответ, он проксируется как обычно. Значение — ISO-8601-длительность, формат как у `backoff` (`PT30S`, `PT1M30S`, `P1D`, …). ```yaml providers: - id: routerai url: "https://routerai.ru/api/v1" first_byte_timeout: PT30S # окно для всех моделей провайдера upstreams: - id: routerai-gpt4o provider: routerai model: gpt-4o first_byte_timeout: PT0S # у модели watchdog отключён ``` При старте выводится, что настроено: `[llm-proxy] first_byte_timeout: upstreams=routerai-gpt4o=PT0S providers=routerai=PT30S default=PT30S`. ### Фоновый пинг апстримов в откате (`probe_interval`) Когда апстрим «уходит в аут» надолго (backoff растёт до потолка), без пинга он вернётся в ротацию только по истечении интервала отката — это может быть минуты простоя, хотя модель уже могла ожить. `probe_interval` включает фоновый «пингер»: пока апстрим в backoff-откате, HealthProber каждые `probe_interval` шлёт ему минимальный запрос (`2+2=?`, `max_tokens=4`, `stream=true`) и слушает первый байт ответа. Как только апстрим ответил — его снимают с отката немедленно (`backoff.recordSuccess`), и он возвращается в общий пул, не дожидаясь истечения backoff-интервала. | Поле | Тип / дефолт | Значение | |---|---|---| | `providers[].probe_interval` | ISO-8601-длительность / отсутствует | Интервал пинга для всех моделей провайдера | | `upstreams[].probe_interval` | ISO-8601-длительность / отсутствует | Интервал для конкретной модели (приоритет над провайдерским) | **Дефолт** (если не задан нигде): `PT1S`. **Приоритет — модели.** Если `upstreams[].probe_interval` задан, он перекрывает провайдерский; если нет — берётся провайдерский; если нет и там — дефолт `PT1S`. **Отключение:** `PT0S` на провайдере или апстриме отключает пинг для этого апстрима — тогда он возвращается в пул только по истечении backoff-интервала. Особенности: - пингуются **только** апстримы, которые сейчас в backoff-откате; живой апстрим пинг не получает (проверка состояния — каждые `probe_interval`); - пинг **не занимает слот конкурентности** (идёт в обход `max_concurrency`), чтобы не отжимать слот у живых запросов; - ошибка пинга **не** считается в backoff (иначе каждая попытка удваивала бы интервал отката); учитывается только успех; - признак «ответил» — HTTP 2xx и первый байт тела за `first_byte_timeout` (тот же watchdog, что и у обычных запросов); - на пинг применяются патчи провайдера и апстрима (как к обычному запросу), чтобы он валидировал реальный путь; - при восстановлении — лог: `[llm-proxy] probe upstream= ответил — снят с backoff, вернулся в пул`, метрика `llm_proxy_probe_pings_total{result="ok"}`; - при молчании — лог `[llm-proxy] probe upstream= молчит — остаюсь в backoff`, метрика `llm_proxy_probe_pings_total{result="fail"}`. Значение — ISO-8601-длительность, формат как у `backoff` (`PT1S`, `PT500MS`, `PT5S`, …). ```yaml providers: - id: routerai url: "https://routerai.ru/api/v1" probe_interval: PT1S # пингуем все модели провайдера раз в секунду upstreams: - id: routerai-gpt4o provider: routerai model: gpt-4o probe_interval: PT0S # у модели пинг отключён (ждать истечения backoff) ``` При старте выводится, что настроено: `[llm-proxy] probe_interval: upstreams=routerai-gpt4o=PT0S providers=routerai=PT1S default=PT1S`. ### Prometheus-метрики (`/metrics`) Прокси отдаёт pull-метрики в Prometheus text-формате по `GET /metrics` (`text/plain; version=0.0.4`), без авторизации (внутренний контур). Достаточно включить скрейп в Prometheus/VictoriaMetrics — и в Grafana можно вести дашборды использования по провайдерам/моделям и алерты на деградацию. | Метрика | Тип | Смысл | |---|---|---| | `llm_proxy_requests_total{model, upstream, provider, result}` | counter | chat-запросы по исходу. `result`: `ok` — успех, `4xx` — ошибка запроса, `429`/`402`/`5xx` — исход с апстрима (каждая фейловер-попытка учитывается отдельно), `net_err` — сетевая ошибка/таймаут, `timeout` — апстрим не прислал первый байт стрима за `first_byte_timeout`, `cancelled` — клиент отвалился посреди стрима | | `llm_proxy_probe_pings_total{upstream, provider, result}` | counter | фоновые пинги апстримов в backoff-откате (HealthProber). `result`: `ok` — апстрим ответил (снят с отката), `fail` — молчит (остался в откате) | | `llm_proxy_upstream_inflight{upstream, provider}` | gauge | занятые слоты апстрима прямо сейчас (конкурентность) | | `llm_proxy_upstream_fail_streak{upstream, provider}` | gauge | счётчик сбоев подряд (backoff): сколько раз подряд упал | | `llm_proxy_upstream_cooling_seconds{upstream, provider}` | gauge | сколько секунд апстрим ещё в backoff-откате (0 = жив) | Метки: `model` — витринное имя модели, `upstream`/`provider` — внутренние id из конфига. Метрики live в памяти: при рестарте прокси сбрасываются (серить их будет Prometheus). Примеры для Grafana: - оборот по виртуальным моделям: `sum(rate(llm_proxy_requests_total[5m])) by (model)`; - оборот по провайдерам: `sum(rate(llm_proxy_requests_total[5m])) by (provider)`; - «провайдер умер»: `llm_proxy_upstream_cooling_seconds > 0` дольше N минут — алерт; - доля ошибок провайдера: `sum(rate(llm_proxy_requests_total{result=~"4xx|429|402|5xx|net_err|timeout"}[10m])) by (provider) / sum(rate(llm_proxy_requests_total[10m])) by (provider)`. ### Пример сборки тела (многослойный `patch`) Берём модель `my-gpt` (из примера выше), маршрут уходит на апстрим `routerai-gpt4o` (провайдер `routerai`). Клиент шлёт: ```json { "model": "my-gpt", "messages": [{ "role": "user", "content": "привет" }], "temperature": 0.7 } ``` Слои `patch`, накладываемые в порядке `provider → upstream → model`: | Слой | Что добавляет | |---|---| | `providers.routerai.patch` | `provider.allow_fallbacks = false` | | `upstreams.routerai-gpt4o.patch` | `provider.ignore = ["deepseek"]` | | `models.my-gpt.patch` | `reasoning.enabled = false` | Плюс подмена `model: "my-gpt"` → `model: "gpt-4o"` (реальное имя апстрима). Глубокий мерж сливает `provider` из двух слоёв в один объект. **Итоговое тело**, уходящее на `https://routerai.ru/api/v1/chat/completions`: ```json { "model": "gpt-4o", "messages": [{ "role": "user", "content": "привет" }], "temperature": 0.7, "provider": { "allow_fallbacks": false, "ignore": ["deepseek"] }, "reasoning": { "enabled": false } } ``` Если бы вместо `routerai-gpt4o` сработал фоллбэк на `local-qwen` — слой `upstreams.routerai-gpt4o.patch` не применился бы (нет у локалки), и в теле не было бы `provider.ignore`/`allow_fallbacks`; модель стала бы `qwen2.5-72b-instruct`, а `model.patch` (`reasoning.enabled=false`) остался бы — он не зависит от апстрима. ## Ссылка на env в ключах Чтобы не хранить ключи в файле, `key` можно резолвить из переменной среды (подстановка вида `${ROUTER_API_KEY}`): ```yaml providers: - id: routerai url: "https://routerai.ru/api/v1" key: "${ROUTER_API_KEY}" ``` ## Что меняется в коде (`Main.kt`) > **Старая концепция выпиливается целиком.** Текущий код — это «один апстрим на > одну переменную среды»: `UPSTREAM_URL`, `ROUTER_API_KEY`, `EXCLUDED_PROVIDERS`, > `THINKING_MODELS`, функция `patchBody(...)`, чтение `SGLANG_COMPAT`, а также > дублирование каталога `/v1/models` с суффиксом `-no-think`. Всё это **удаляется** > без обратной совместимости — сервис становится YAML-декларативным роутером > (разделы ниже). Env-переменные конфигурации провайдеров/моделей больше не > поддерживаются; остаётся только `CONFIG_PATH` (порт/интерфейс — в блоке > `server` файла). ### 1. Модель конфига (сериализуемые классы) ```kotlin // patch — универсальная JSON-подобная структура (kotlinx JsonObject), // декодируется из YAML-мапа. null = патч отсутствует. @Serializable data class ProviderConf( val id: String, val url: String, val key: String = "", val max_concurrency: Int? = null, val patch: JsonObject? = null, val session_header: String? = null, val think_tags: String? = null, val reasoning_field: String? = null, val reasoning_empty_ok: Boolean = false, val backoff: Duration? = null, // потолок backoff на провайдера (ISO-8601) val first_byte_timeout: Duration? = null, // окно первого байта стрима (ISO-8601) val probe_interval: Duration? = null, // интервал фонового пинга (ISO-8601) ) data class UpstreamConf( val id: String, // наш внутренний id апстрима val provider: String, // ссылка на ProviderConf.id val model: String, // реальное имя модели у провайдера val max_concurrency: Int? = null,// опционально; null/0 = безлимит val patch: JsonObject? = null, // к запросам этой апстрим-модели val think_tags: String? = null, // переопределение think-режима модели val backoff: Duration? = null, // потолок backoff на модель (приоритет над провайдерским) val first_byte_timeout: Duration? = null, // окно первого байта стрима (приоритет над провайдерским) val probe_interval: Duration? = null, // интервал фонового пинга (приоритет над провайдерским) ) @Serializable data class ModelConf( val name: String, // «витринное» имя для клиента val upstreams: List, // ссылки на UpstreamConf.id val patch: JsonObject? = null, // к запросам этой модели (самый приоритетный) ) @Serializable data class Config( val providers: List = emptyList(), val upstreams: List = emptyList(), val models: List = emptyList(), ) ``` > **Тип `patch`.** В YAML пишется как обычный мап, а yamlkt декодирует его в > `kotlinx.serialization.json.JsonObject` (через `JsonObject.serializer()` / > мост yamlkt→JsonElement). Так `patch` — это аккуратная JSON-структура, а не > строка, и мержить её в тело запроса тривиально. Глубокий мерж двух `JsonObject` (поля `patch` перезаписывают/дополняют `base` рекурсивно по вложенным объектам): ```kotlin fun merge(base: JsonObject, patch: JsonObject): JsonObject { val merged = base.toMutableMap() for ((k, v) in patch) { merged[k] = when { v is JsonObject && merged[k] is JsonObject -> merge(merged[k] as JsonObject, v) // рекурсивно для вложенных else -> v // скаляр/массив — заменяем } } return JsonObject(merged) } ``` Загрузка: ```kotlin // Дефолт — config.yaml в каталоге проекта (CWD). CONFIG_PATH переопределяет путь. val path = System.getenv("CONFIG_PATH") ?: "config.yaml" val configFile = File(path) // относительный путь резолвится от CWD проекта if (!configFile.exists()) error("config not found: ${configFile.absolutePath}") // Весь YAML читается как YamlElement-дерево, затем маппится в Config вручную // (поле patch сразу конвертируется в kotlinx JsonObject). Вложенный // @Serializable-класс с полем YamlElement yamlkt читает некорректно. val root = Yaml.decodeYamlFromString(configFile.readText(Charsets.UTF_8)) val config = parseConfig(root) val providersById = config.providers.associateBy { it.id } val upstreamsById = config.upstreams.associateBy { it.id } ``` ### 2. Учёт конкурентности (runtime) На старте заводим счётчик активных запросов на каждый апстрим — это **единственный источник истины** про занятость. Никаких опросов апстрима и реакции на его `503`. ```kotlin // Эффективный лимит апстрима резолвится при старте: свой max_concurrency, иначе // провайдерский, иначе безлимит. На счётчике лежит уже итоговый лимит. val active = config.upstreams.associate { it.id to UpstreamCounter( it.max_concurrency ?: providersById[it.provider]?.max_concurrency ?: Int.MAX_VALUE, ) } ``` Отбор свободного апстрима — атомарно: пытаемся инкрементировать счётчик, только если он меньше лимита. Если ни один из `upstreams` модели не свободен — отдаём `503` (`all upstreams busy`), слоты при этом не трогаем. Слот освобождается (`decrement`) в `finally` по завершении проксирования (успех/ошибка/отмена). ```kotlin // Атомарно занимает слот у первого свободного апстрима; вернёт null, если все заняты. fun claim(up: List): UpstreamConf? = up.firstOrNull { u -> val counter = active.getValue(u.id) val cur = counter.get() cur < counter.limit && counter.compareAndSet(cur, cur + 1) } // Освободить слот (в finally). fun release(u: UpstreamConf) = active.getValue(u.id).decrementAndGet() ``` ### 3. `handleChat` — выбор апстрима, подмена модели и мерж `patch` Вместо текущей логики с `EXCLUDED_PROVIDERS` / `THINKING_MODELS`: - берём из тела `model`; - ищем `config.models.first { it.name == model }` (404, если нет); - резолвим `upstreamsById` для списка `model.upstreams`; - **цикл по свободным апстримам** (наш внутренний учёт): - `claim(...)` занимает слот первого свободного апстрима; если свободных нет — сразу `503` (`all upstreams busy`) — решение по нашим счётчикам, не по апстриму; - резолвим `providersById[upstream.provider]` (ошибка старта, если `id` нет); - подменяем в теле `model` → `upstream.model`; - **накладываем `patch` послойно** (исходное тело → `provider.patch` → `upstream.patch` → `model.patch`), каждый через `merge(...)`; слои с `patch == null` пропускаем. Итог — финальное тело запроса; - шлём на `provider.url + /chat/completions` с `Authorization: Bearer provider.key`; - при ответе `5xx`/`429`/`402` от провайдера — `release(upstream)` и переходим к следующему свободному апстриму из списка (фейловер); исчерпали список — отдаём `503` (`all upstreams failed`); - при успехе/отмене клиента — `release(upstream)` в `finally` и выходим. `patchBody(excluded, thinking)` и env-переменные `EXCLUDED_PROVIDERS` / `THINKING_MODELS` / `UPSTREAM_URL` / `ROUTER_API_KEY` **убираются** — их функциональность теперь в декларативном `patch` и блоке `upstreams`. ### 4. `handleModels` — отдаём свой каталог Вместо проксирования `/v1/models` на апстрим — формируем ответ из `config.models`, отдавая `id = model.name` для каждой записи. Суффикс `-no-think` как отдельная модель теперь просто объявляется в конфиге (с нужным `patch`), логика дублирования каталога удаляется. ### 5. Env, которые остаются - `CONFIG_PATH` — путь к YAML; по умолчанию `config.yaml` в каталоге проекта (CWD), можно задать абсолютный или относительный путь. Порт/интерфейс биндинга больше не задаются через env — они в блоке `server` файла конфига (`server.host`, `server.port`; дефолты `0.0.0.0` / `8100`). ### 6. Таймаут запроса к апстриму Целое время на один запрос к нейронке (от отправки до получения всего ответа, включая стриминг) — константа `UPSTREAM_REQUEST_TIMEOUT_MS` = **5 минут** (`src/commonMain/kotlin/pw/binom/llmproxy/Timeouts.kt`). Задается лимитом `requestTimeout` CIO-движка Ktor — без этого работает дефолт движка **15 секунд**, который обрывает длинные LLM-генерации. По достижении лимита запрос отменяется, слот конкурентности освобождается. Значение выводится в лог при старте. ### 7. Логирование Логируем через `logback` (зависимость `logback-classic` уже в проекте; `println` заменяем на `Logger`). Ключевые события: - **Старт**: сколько провайдеров/апстримов/моделей загружено; предупреждение, если `upstreams[].id` или `models[].upstreams` ссылаются на несуществующий `id` (проблема конфигурации). - **Биндинг сервера**: `server: bind host= port=` — фактический интерфейс и порт из блока `server` конфига. - **Таймаут апстрима**: `upstream: request_timeout=ms` — лимит времени на запрос к нейронке (константа из раздела 6). - **Принят запрос** (`model=<витрина>`, upstream=, provider=`): занят слот `active[id]=N/limit`. - **Отклонён запрос** — `503 all upstreams busy` для `model=<витрина>`: состояние слотов всех апстримов модели (`id=N/limit`, ...). - **Фейловер**: `upstream= вернул ` → переход к следующему свободному `upstream=`. - **Завершение** (успех/ошибка/отмена клиента): длительность, статус апстрима, освобождён слот `active[id]=N/limit`. - **Ошибка апстрима** (не `5xx`/`429`/`402`, а сетевая/таймаут): как сейчас — лог с сообщением. - **Backoff**: при фейловере строка дополняется интервалом отдыха (`(backoff: отдых PT…)`); при пропуске апстрима в откате — `upstream= в backoff-откате (~Ns) — пропускаю`; при старте — список настроенных backoff (`backoff: upstreams=… providers=…`). - **Таймаут первого байта стрима**: при срабатывании watchdog'а — `upstream= TIMEOUT: первый байт не пришёл за ms → фейловер`; при старте — список настроенных `first_byte_timeout` (`first_byte_timeout: upstreams=… providers=… default=PT30S`). - **Все в откате** — `503 all upstreams cooling, retry in Ns` для `model=<витрина>` с заголовком `Retry-After: N`. Формат строки лога — один префикс `[llm-proxy]`, как сейчас, чтобы не ломать существующий парсинг логов (если он есть). ## Миграция `podman-compose.yaml` Блок `environment` упрощается: вместо `UPSTREAM_URL` / `ROUTER_API_KEY` / `EXCLUDED_PROVIDERS` / `THINKING_MODELS` монтируется файл конфига и задаётся `CONFIG_PATH`, а секреты (ключи) — через env-подстановку `${...}` внутри файла.