From e251ca5326e0c85cfe7d23be366e7d939ad7cf59 Mon Sep 17 00:00:00 2001 From: subochev Date: Fri, 11 Sep 2026 03:09:18 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20CONFIG.md=20(=D0=BE=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20YAML-=D0=BA=D0=BE=D0=BD=D1=84=D0=B8?= =?UTF-8?q?=D0=B3=D0=B0);=20.gitignore=20=D0=B4=D0=BB=D1=8F=20tooling-?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + CONFIG.md | 391 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 CONFIG.md diff --git a/.gitignore b/.gitignore index 371ef37..0df938b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,9 @@ build/ # локальный конфиг с секретами (ключ API) — не коммитим config.yaml +# IDE / tooling +.idea/ +.kotlin/ +.cortexkit/ +.veai/ + diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 0000000..cec8634 --- /dev/null +++ b/CONFIG.md @@ -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= 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, // ссылки на UpstreamConf.id + val patch: JsonObject? = null, // к запросам этой модели (самый приоритетный) +) + +@Serializable +data class Config( + val providers: List = emptyList(), + val upstreams: List = emptyList(), + val models: List = emptyList(), +) +``` + +> **Тип `patch`.** В YAML пишется как обычный мап, а yamlkt декодирует его в +> `kotlinx.serialization.json.JsonObject` (через `JsonObject.serializer()` / +> мост yamlkt→JsonElement). Так `patch` — это аккуратная JSON-структура, а не +> строка, и мержить её в тело запроса тривиально. + +Глубокий мерж двух `JsonObject` (поля `patch` перезаписывают/дополняют `base` +рекурсивно по вложенным объектам): + +```kotlin +fun merge(base: JsonObject, patch: JsonObject): JsonObject { + val merged = base.toMutableMap() + for ((k, v) in patch) { + merged[k] = when { + v is JsonObject && merged[k] is JsonObject -> + merge(merged[k] as JsonObject, v) // рекурсивно для вложенных + else -> v // скаляр/массив — заменяем + } + } + return JsonObject(merged) +} +``` + +Загрузка: + +```kotlin +// Дефолт — config.yaml в каталоге проекта (CWD). CONFIG_PATH переопределяет путь. +val path = System.getenv("CONFIG_PATH") ?: "config.yaml" +val configFile = File(path) // относительный путь резолвится от CWD проекта +if (!configFile.exists()) error("config not found: ${configFile.absolutePath}") +// Весь YAML читается как YamlElement-дерево, затем маппится в Config вручную +// (поле patch сразу конвертируется в kotlinx JsonObject). Вложенный +// @Serializable-класс с полем YamlElement yamlkt читает некорректно. +val root = Yaml.decodeYamlFromString(configFile.readText(Charsets.UTF_8)) +val config = parseConfig(root) +val providersById = config.providers.associateBy { it.id } +val upstreamsById = config.upstreams.associateBy { it.id } +``` + +### 2. Учёт конкурентности (runtime) + +На старте заводим счётчик активных запросов на каждый апстрим — это **единственный +источник истины** про занятость. Никаких опросов апстрима и реакции на его `503`. + +```kotlin +// Эффективный лимит апстрима резолвится при старте: свой max_concurrency, иначе +// провайдерский, иначе безлимит. На счётчике лежит уже итоговый лимит. +val active = config.upstreams.associate { + it.id to UpstreamCounter( + it.max_concurrency + ?: providersById[it.provider]?.max_concurrency + ?: Int.MAX_VALUE, + ) +} +``` + +Отбор свободного апстрима — атомарно: пытаемся инкрементировать счётчик, только +если он меньше лимита. Если ни один из `upstreams` модели не свободен — отдаём +`503` (`all upstreams busy`), слоты при этом не трогаем. Слот освобождается +(`decrement`) в `finally` по завершении проксирования (успех/ошибка/отмена). + +```kotlin +// Атомарно занимает слот у первого свободного апстрима; вернёт null, если все заняты. +fun claim(up: List): UpstreamConf? = up.firstOrNull { u -> + val counter = active.getValue(u.id) + val cur = counter.get() + cur < counter.limit && counter.compareAndSet(cur, cur + 1) +} + +// Освободить слот (в finally). +fun release(u: UpstreamConf) = active.getValue(u.id).decrementAndGet() +``` + +### 3. `handleChat` — выбор апстрима, подмена модели и мерж `patch` + +Вместо текущей логики с `EXCLUDED_PROVIDERS` / `THINKING_MODELS`: + +- берём из тела `model`; +- ищем `config.models.first { it.name == model }` (404, если нет); +- резолвим `upstreamsById` для списка `model.upstreams`; +- **цикл по свободным апстримам** (наш внутренний учёт): + - `claim(...)` занимает слот первого свободного апстрима; если свободных нет — + сразу `503` (`all upstreams busy`) — решение по нашим счётчикам, не по апстриму; + - резолвим `providersById[upstream.provider]` (ошибка старта, если `id` нет); + - подменяем в теле `model` → `upstream.model`; + - **накладываем `patch` послойно** (исходное тело → `provider.patch` → + `upstream.patch` → `model.patch`), каждый через `merge(...)`; слои с `patch + == null` пропускаем. Итог — финальное тело запроса; + - шлём на `provider.url + /chat/completions` с `Authorization: Bearer provider.key`; + - при ответе `5xx`/`429` от провайдера — `release(upstream)` и переходим к + следующему свободному апстриму из списка (фейловер); исчерпали список — + отдаём `503` (`all upstreams failed`); + - при успехе/отмене клиента — `release(upstream)` в `finally` и выходим. + +`patchBody(excluded, thinking)` и env-переменные `EXCLUDED_PROVIDERS` / +`THINKING_MODELS` / `UPSTREAM_URL` / `ROUTER_API_KEY` **убираются** — их +функциональность теперь в декларативном `patch` и блоке `upstreams`. + +### 4. `handleModels` — отдаём свой каталог + +Вместо проксирования `/v1/models` на апстрим — формируем ответ из +`config.models`, отдавая `id = model.name` для каждой записи. Суффикс `-no-think` +как отдельная модель теперь просто объявляется в конфиге (с нужным `patch`), +логика дублирования каталога удаляется. + +### 5. Env, которые остаются + +- `CONFIG_PATH` — путь к YAML; по умолчанию `config.yaml` в каталоге проекта + (CWD), можно задать абсолютный или относительный путь. + +Порт/интерфейс биндинга больше не задаются через env — они в блоке `server` +файла конфига (`server.host`, `server.port`; дефолты `0.0.0.0` / `8100`). + +### 6. Таймаут запроса к апстриму + +Целое время на один запрос к нейронке (от отправки до получения всего ответа, +включая стриминг) — константа `UPSTREAM_REQUEST_TIMEOUT_MS` = **5 минут** +(`src/commonMain/kotlin/pw/binom/llmproxy/Timeouts.kt`). Задается лимитом +`requestTimeout` CIO-движка Ktor — без этого работает дефолт движка **15 секунд**, +который обрывает длинные LLM-генерации. По достижении лимита запрос отменяется, +слот конкурентности освобождается. Значение выводится в лог при старте. + +### 7. Логирование + +Логируем через `logback` (зависимость `logback-classic` уже в проекте; `println` +заменяем на `Logger`). Ключевые события: + +- **Старт**: сколько провайдеров/апстримов/моделей загружено; предупреждение, + если `upstreams[].id` или `models[].upstreams` ссылаются на несуществующий + `id` (проблема конфигурации). +- **Биндинг сервера**: `server: bind host= port=` — фактический + интерфейс и порт из блока `server` конфига. +- **Таймаут апстрима**: `upstream: request_timeout=ms` — лимит времени на + запрос к нейронке (константа из раздела 6). +- **Принят запрос** (`model=<витрина>`, upstream=, provider=`): занят + слот `active[id]=N/limit`. +- **Отклонён запрос** — `503 all upstreams busy` для `model=<витрина>`: + состояние слотов всех апстримов модели (`id=N/limit`, ...). +- **Фейловер**: `upstream= вернул ` → переход к следующему + свободному `upstream=`. +- **Завершение** (успех/ошибка/отмена клиента): длительность, статус апстрима, + освобождён слот `active[id]=N/limit`. +- **Ошибка апстрима** (не `5xx`/`429`, а сетевая/таймаут): как сейчас — лог с + сообщением. + +Формат строки лога — один префикс `[llm-proxy]`, как сейчас, чтобы не ломать +существующий парсинг логов (если он есть). + +## Миграция `podman-compose.yaml` + +Блок `environment` упрощается: вместо `UPSTREAM_URL` / `ROUTER_API_KEY` / +`EXCLUDED_PROVIDERS` / `THINKING_MODELS` монтируется файл конфига и задаётся +`CONFIG_PATH`, а секреты (ключи) — через env-подстановку `${...}` внутри файла.