Некоторые провайдеры (minimax) отдают рассуждения модели текстом внутри content, обернув их тегами think. Новое опциональное поле think_tags у providers[] и upstreams[] (приоритет: апстрим -> провайдер -> off, толерантный разбор true/false, неизвестное = off). - off: поведение не меняется (сырой байтовый passthrough в стриме); - split: блоки think вырезаются из content, текст уходит в reasoning_content; - strip: вырезаются и выбрасываются. Стрим разбирается построчно: отдельный ThinkTagSplitter на каждый choices[].index, хвост, который может оказаться началом разрезанного тега, не отдаётся до разрешения, на конце потока — finish(). Non-stream: тот же трансформ для message.content (и для результата rebuildFromChunks), уже существующий reasoning_content дописывается, а не теряется. Тесты: ThinkTagSplitterTest (7), разбор конфига и эффективный режим в ConfigLogicTest (2) — всего 42 теста, 0 падений. CONFIG.md + README.
29 KiB
Конфигурация llm-proxy (переход на YAML)
Идея
Вместо переменных среды конфигурация задаётся YAML-файлом. В нём три блока:
-
providers— объявлены бэкенды (url + ключ) с нашим внутренним человекочитаемымid. По этомуidссылаются апстримы. Опционально —max_concurrency: лимит по умолчанию для апстримов этого провайдера. -
upstreams— каталог «удалённых» моделей. Каждая запись: внутреннийid, ссылка наproviders[].id, реальное имя модели у провайдера (model) и опциональный лимит конкурентностиmax_concurrency. Это и есть то, на что мы проксируем. -
models— модели, видимые клиенту. Для каждой — «витринное» имя (name), списокupstreams(ссылки наupstreams[].id, можно несколько). -
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>.
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), когда клиент его не шлёт или шлёт не то.
providers:
- id: some-provider
url: "https://.../v1"
session_header: x-opencode-session
Как считается id:
- Берётся финальное тело запроса (после всех
patch), из него —messages. - Цепочка инкрементальных SHA-256 префикс-хэшей начинается с первого
user-сообщения (ведущийsystem-промпт игнорируется: он обычно одинаков у всех сессий клиента и как признак сессии бесполезен). - В реестре сессий ищется наибольший общий префикс (LCP) с уже виденной
историей. Нашли — используется id той сессии; не нашли — создаётся новая
(
id= хэш всей истории на первом ходу). - Заголовок ставится всегда (клиентское значение перезаписывается).
Итог: пока история одной сессии растёт (дописываются 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) ответ идёт байт-в-байт как раньше.
Пример сборки тела (многослойный 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, // заголовок-сессия, считается из истории
)
@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
рекурсивно по вложенным объектам):
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от провайдера —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, а сетевая/таймаут): как сейчас — лог с сообщением.
Формат строки лога — один префикс [llm-proxy], как сейчас, чтобы не ломать
существующий парсинг логов (если он есть).
Миграция podman-compose.yaml
Блок environment упрощается: вместо UPSTREAM_URL / ROUTER_API_KEY /
EXCLUDED_PROVIDERS / THINKING_MODELS монтируется файл конфига и задаётся
CONFIG_PATH, а секреты (ключи) — через env-подстановку ${...} внутри файла.