@@ -0,0 +1,391 @@
# Конфигурация 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` , мы
освобождаем его слот и, при наличии, пробуем **следующий свободный ** из списка
модели (фейловер); если свободных не осталось — отдаём `503` от прокси.
## Формат файла
Путь к файлу: по умолчанию ищем `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 # опционально; лимит по умолчанию для апстримов
patch: # уровень провайдера : ко всем его запросам
provider :
allow_fallbacks : false
- 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]
- 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` ):
если ни одного нет — запрос проксируется как есть (исходное тело клиента).
### Пример сборки тела (многослойный `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 , // ко всем запросам провайдера
)
@Serializable
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 , // к запросам этой апстрим-модели
)
@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` от провайдера — `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` , а сетевая/таймаут): как сейчас — лог с
сообщением.
Формат строки лога — один префикс `[llm-proxy]` , как сейчас, чтобы не ломать
существующий парсинг логов (если он есть).
## Миграция `podman-compose.yaml`
Блок `environment` упрощается: вместо `UPSTREAM_URL` / `ROUTER_API_KEY` /
`EXCLUDED_PROVIDERS` / `THINKING_MODELS` монтируется файл конфига и задаётся
`CONFIG_PATH` , а секреты (ключи) — через env-подстановку `${...}` внутри файла.