Files

49 KiB
Raw Permalink Blame History

Конфигурация 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=<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:

  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>…</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-подстановку ${...} внутри файла.