756 lines
49 KiB
Markdown
756 lines
49 KiB
Markdown
# Конфигурация 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>`.
|
||
|
||
```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>…</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`. То есть значение апстрима
|
||
перекрывает провайдерское.
|
||
|
||
```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 — прокси держит хвост, который может оказаться
|
||
началом тега, и не отдаёт его клиенту до разрешения, поэтому огрызок тега не
|
||
утечёт. Незакрытый `<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` не задан — тело не меняется вовсе.
|
||
|
||
```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=<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 (у них нет фиксированной длины), конфиг-парсер прокси расширяет формат.
|
||
Некорректное значение — предупреждение в лог и поле просто игнорируется.
|
||
|
||
```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=<m> upstream=<id> TIMEOUT: первый байт не пришёл за <N>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=<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`, …).
|
||
|
||
```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<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`
|
||
рекурсивно по вложенным объектам):
|
||
|
||
```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>): 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=<id>, provider=<id>`): занят
|
||
слот `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-подстановку `${...}` внутри файла.
|