49 KiB
Конфигурация llm-proxy (переход на YAML)
Идея
Вместо переменных среды конфигурация задаётся YAML-файлом. В нём три блока:
-
providers— объявлены бэкенды (url + ключ) с нашим внутренним человекочитаемымid. По этомуidссылаются апстримы. Опционально —max_concurrency: лимит по умолчанию для апстримов этого провайдера. -
upstreams— каталог «удалённых» моделей. Каждая запись: внутреннийid, ссылка наproviders[].id, реальное имя модели у провайдера (model) и опциональный лимит конкурентностиmax_concurrency. Это и есть то, на что мы проксируем. -
models— модели, видимые клиенту. Для каждой — «витринное» имя (name), списокupstreams(ссылки наupstreams[].id, можно несколько). -
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=<host> port=<port>.
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), когда клиент его не шлёт или шлёт не то.
providers:
- id: some-provider
url: "https://.../v1"
session_header: x-opencode-session
Как считается id:
- Берётся финальное тело запроса (после всех
patch), из него —messages. - Цепочка инкрементальных SHA-256 префикс-хэшей начинается с первого
user-сообщения (ведущийsystem-промпт игнорируется: он обычно одинаков у всех сессий клиента и как признак сессии бесполезен). - В реестре сессий ищется наибольший общий префикс (LCP) с уже виденной
историей. Нашли — используется id той сессии; не нашли — создаётся новая
(
id= хэш всей истории на первом ходу). - Заголовок ставится всегда (клиентское значение перезаписывается).
Итог: пока история одной сессии растёт (дописываются assistant/user-сообщения), id не меняется; разные диалоги получают разные id.
Ограничения. Реестр живёт в памяти (LRU: 1000 сессий / 6 часов) — при рестарте прокси активные сессии получат новый id. Обрезка/суммаризация истории рвёт общий префикс → сессия распадётся на новую. Диалоги с одинаковым первым
user-сообщением неразличимы (склеятся).
Обработка think-тегов (think_tags)
Некоторые провайдеры (например, minimax) отдают рассуждения модели не в
отдельном поле reasoning_content, а прямо в content, обернув их тегами
<think>…</think>. Флажок think_tags (опционально) разрешает прокси разрезать
такой ответ и разложить его по полям.
Поле доступно на двух уровнях:
providers[].think_tags— правило по умолчанию для всех апстримов провайдера;upstreams[].think_tags— необязательное переопределение на конкретной апстрим-модели.
Значения (строки):
| Значение | Поведение |
|---|---|
off |
дефолт: ответ не меняется, теги остаются в content |
split |
блоки <think>…</think> вырезаются из content, их текст уходит в reasoning_content |
strip |
блоки вырезаются и выбрасываются — клиент рассуждений не видит |
Разбор значения толерантный: true ≡ split, false ≡ off; любое
неизвестное/пустое значение трактуется как off (прокси не падает).
Приоритет резолва — по убыванию специфичности: upstreams[].think_tags (самый
конкретный) → providers[].think_tags → off. То есть значение апстрима
перекрывает провайдерское.
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 — прокси держит хвост, который может оказаться
началом тега, и не отдаёт его клиенту до разрешения, поэтому огрызок тега не
утечёт. Незакрытый <think> в конце потока трактуется как «всё после него —
рассуждения». Если 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 не задан — тело не меняется вовсе.
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=<id> в 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 (у них нет фиксированной длины), конфиг-парсер прокси расширяет формат.
Некорректное значение — предупреждение в лог и поле просто игнорируется.
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=<m> upstream=<id> TIMEOUT: первый байт не пришёл за <N>ms → фейловер(+(backoff: отдых …)если задан backoff), метрикаllm_proxy_requests_total{result="timeout"}; - закрытие апстримом пустого стрима (EOF без единого байта) — не таймаут: это валидный короткий ответ, он проксируется как обычно.
Значение — ISO-8601-длительность, формат как у backoff (PT30S,
PT1M30S, P1D, …).
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=<id> ответил — снят с backoff, вернулся в пул, метрикаllm_proxy_probe_pings_total{result="ok"}; - при молчании — лог
[llm-proxy] probe upstream=<id> молчит — остаюсь в backoff, метрикаllm_proxy_probe_pings_total{result="fail"}.
Значение — ISO-8601-длительность, формат как у backoff (PT1S, PT500MS,
PT5S, …).
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). Клиент шлёт:
{
"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:
{
"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}):
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. Модель конфига (сериализуемые классы)
// 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<String>, // ссылки на UpstreamConf.id
val patch: JsonObject? = null, // к запросам этой модели (самый приоритетный)
)
@Serializable
data class Config(
val providers: List<ProviderConf> = emptyList(),
val upstreams: List<UpstreamConf> = emptyList(),
val models: List<ModelConf> = emptyList(),
)
Тип
patch. В YAML пишется как обычный мап, а yamlkt декодирует его вkotlinx.serialization.json.JsonObject(черезJsonObject.serializer()/ мост yamlkt→JsonElement). Такpatch— это аккуратная JSON-структура, а не строка, и мержить её в тело запроса тривиально.
Глубокий мерж двух JsonObject (поля patch перезаписывают/дополняют base
рекурсивно по вложенным объектам):
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)
}
Загрузка:
// Дефолт — 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.
// Эффективный лимит апстрима резолвится при старте: свой 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 по завершении проксирования (успех/ошибка/отмена).
// Атомарно занимает слот у первого свободного апстрима; вернёт null, если все заняты.
fun claim(up: List<UpstreamConf>): 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=<host> port=<port>— фактический интерфейс и порт из блокаserverконфига. - Таймаут апстрима:
upstream: request_timeout=<N>ms— лимит времени на запрос к нейронке (константа из раздела 6). - Принят запрос (
model=<витрина>, upstream=, provider=): занят слотactive[id]=N/limit`. - Отклонён запрос —
503 all upstreams busyдляmodel=<витрина>: состояние слотов всех апстримов модели (id=N/limit, ...). - Фейловер:
upstream=<id> вернул <status>→ переход к следующему свободномуupstream=<id>. - Завершение (успех/ошибка/отмена клиента): длительность, статус апстрима,
освобождён слот
active[id]=N/limit. - Ошибка апстрима (не
5xx/429/402, а сетевая/таймаут): как сейчас — лог с сообщением. - Backoff: при фейловере строка дополняется интервалом отдыха
(
(backoff: отдых PT…)); при пропуске апстрима в откате —upstream=<id> в backoff-откате (~Ns) — пропускаю; при старте — список настроенных backoff (backoff: upstreams=… providers=…). - Таймаут первого байта стрима: при срабатывании watchdog'а —
upstream=<id> TIMEOUT: первый байт не пришёл за <N>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-подстановку ${...} внутри файла.