# Конфигурация 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 / исчерпан лимит токенов) — ошибка аккаунта провайдера, но фейловер имеет смысл: следующий апстрим может быть платёжеспособен. ## Формат файла Путь к файлу: по умолчанию ищем `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 # опционально; лимит по умолчанию для апстримов session_header: x-opencode-session # опционально; прокси считает сессию из истории 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`): если ни одного нет — запрос проксируется как есть (исходное тело клиента). ### Сессия по истории (`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_tags` (опционально) разрешает прокси разрезать такой ответ и разложить его по полям. Поле доступно на двух уровнях: - `providers[].think_tags` — правило по умолчанию для всех апстримов провайдера; - `upstreams[].think_tags` — необязательное переопределение на конкретной апстрим-модели. Значения (строки): | Значение | Поведение | |---|---| | `off` | дефолт: ответ не меняется, теги остаются в `content` | | `split` | блоки `…` вырезаются из `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 — прокси держит хвост, который может оказаться началом тега, и не отдаёт его клиенту до разрешения, поэтому огрызок тега не утечёт. Незакрытый `` в конце потока трактуется как «всё после него — рассуждения». Если `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` в истории могут и не передавать. Поля (только на уровне **провайдера**, это свойство шлюза, а не модели): | Поле | Тип / дефолт | Значение | |---|---|---| | `providers[].reasoning_field` | строка / отсутствует | Имя нативного поля рассуждений у шлюза-апстрима (пример: `reasoning_content`) | | `providers[].reasoning_empty_ok` | булево / `false` | Писать ли пустую строку, если текста рассуждений нет вовсе | Зачем: при отправке запроса прокси **аддитивно** достраивает это поле в каждом assistant-сообщении с непустым `tool_calls` — берёт текст из `reasoning` (если это непустая строка), иначе склеивает `reasoning_details[*].text` (только элементы без `type` или с `type == "reasoning.text"`, через `"\n"`) и записывает в поле `reasoning_field`, **если его там ещё нет**. Существующее непустое поле не перезаписывается. Ничего при этом не убирается и не переименовывается — клиентский `reasoning`/`reasoning_details` остаются на месте, поле просто дополняется. Тронуто только assistant-сообщение с непустым `tool_calls`; сообщения без `tool_calls` и `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 ``` ### Пример сборки тела (многослойный `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, ) @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`/`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= 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`/`402`, а сетевая/таймаут): как сейчас — лог с сообщением. Формат строки лога — один префикс `[llm-proxy]`, как сейчас, чтобы не ломать существующий парсинг логов (если он есть). ## Миграция `podman-compose.yaml` Блок `environment` упрощается: вместо `UPSTREAM_URL` / `ROUTER_API_KEY` / `EXCLUDED_PROVIDERS` / `THINKING_MODELS` монтируется файл конфига и задаётся `CONFIG_PATH`, а секреты (ключи) — через env-подстановку `${...}` внутри файла.