12 Commits
2 .. 7

Author SHA1 Message Date
subochev e251ca5326 docs: CONFIG.md (описание YAML-конфига); .gitignore для tooling-каталогов
Build LLM Proxy / Build and push (release) Successful in 43s
2026-09-11 03:09:18 +03:00
subochev 0fb99060f9 feat: пересылка клиентских заголовков в апстрим (кроме служебных); Authorization — только ключ провайдера 2026-09-11 03:09:18 +03:00
subochev c3aaa914fb Dockerfile: temurin 17 -> 21 (KMP-код собран Java 21, class 65)
Build LLM Proxy / Build and push (release) Successful in 29s
2026-08-30 19:13:26 +03:00
subochev 019af32bf5 fiх: CI target shadowJar -> fatJar (новый KMP-болд)
Build LLM Proxy / Build and push (release) Successful in 28s
2026-08-30 19:10:31 +03:00
subochev 85d16eccc3 fix
Build LLM Proxy / Build and push (release) Failing after 14s
2026-08-30 19:02:23 +03:00
subochev bad7e13eb8 fix 2026-08-30 14:12:56 +03:00
subochev d75172434e fix 2026-08-30 14:06:35 +03:00
subochev 995c5fba0e fix 2026-08-30 13:12:15 +03:00
subochev f213b139da feat: отмена non-stream запросов — апстрим как stream + ensureActive + HttpRequestLifecycle (Ktor 3.5.2); рвём генерацию при обрыве клиента
Build LLM Proxy / Build and push (release) Successful in 29s
2026-08-21 03:19:57 +03:00
subochev 350ef663c0 feat: SGLANG_COMPAT — chat_template_kwargs.enable_thinking=false при -no-think (llama.cpp/sglang)
Build LLM Proxy / Build and push (release) Successful in 21s
2026-08-21 02:53:52 +03:00
Hermes Agent f45ed0d11c feat: тайминг-лог запросов ([llm-proxy] chat model=... status=... в Nms)
Build LLM Proxy / Build and push (release) Successful in 22s
2026-08-20 17:39:18 +03:00
Hermes Agent 8fc743860c fix: compose — external network bifrost_default вместо network_mode 2026-08-20 13:25:16 +03:00
20 changed files with 1613 additions and 253 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: 'Build jar' - name: 'Build jar'
uses: https://git.binom.pw/subochev/devops/build-gradle@main uses: https://git.binom.pw/subochev/devops/build-gradle@main
with: with:
target: shadowJar target: fatJar
- name: 'Build Docker Image' - name: 'Build Docker Image'
uses: https://git.binom.pw/subochev/devops/build-docker@main uses: https://git.binom.pw/subochev/devops/build-docker@main
with: with:
+9
View File
@@ -2,3 +2,12 @@ build/
.gradle/ .gradle/
*.jar *.jar
!gradle/wrapper/gradle-wrapper.jar !gradle/wrapper/gradle-wrapper.jar
# локальный конфиг с секретами (ключ API) — не коммитим
config.yaml
# IDE / tooling
.idea/
.kotlin/
.cortexkit/
.veai/
+391
View File
@@ -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-подстановку `${...}` внутри файла.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM docker.io/library/eclipse-temurin:17-jre-alpine FROM docker.io/library/eclipse-temurin:21-jre-alpine
COPY build/libs/llm-proxy.jar /app/llm-proxy.jar COPY build/libs/llm-proxy.jar /app/llm-proxy.jar
WORKDIR /app WORKDIR /app
EXPOSE 8100 EXPOSE 8100
+26 -23
View File
@@ -1,37 +1,40 @@
# llm-proxy # llm-proxy
Прозрачная прослойка OpenAI API перед RouterAI (routerai.ru). Принимает Прозрачная прослойка/роутер OpenAI API. Принимает `/v1/chat/completions` и
`/v1/chat/completions` и `/v1/models`, модифицирует запрос и проксирует дальше. `/v1/models`, маршрутизирует между объявленными в YAML апстримами по внутреннему
учёту конкурентности и мержит `patch`-слои (`provider` → `upstream` → `model`) в
тело запроса. Подробная спецификация конфига — в `CONFIG.md`.
## Что добавляет в запрос ## Конфиг
1. **`provider.ignore`** — исключение дорогих провайдеров (список из env Конфигурация — в YAML-файле (по умолчанию `config.yaml` в каталоге проекта,
`EXCLUDED_PROVIDERS`, напр. `deepseek`) + `allow_fallbacks: false` CWD; переопределяется env `CONFIG_PATH`). Блоки: `server` (интерфейс биндинга
(иначе ignore не жёсткий — RouterAI может уйти на исключённого резервной `host` + `port`, дефолты `0.0.0.0` / `8100`), `providers`, `upstreams`,
попыткой). `models`. Старый механизм env-переменных (`UPSTREAM_URL`, `ROUTER_API_KEY`,
2. **`reasoning: {"enabled": false}`** — для моделей с суффиксом `-no-think` `EXCLUDED_PROVIDERS`, `THINKING_MODELS`, `PORT`) удалён — его поведение теперь
(отключение думанья; проверено на `deepseek/deepseek-v4-flash-0731`: в декларативном `patch` и блоке `upstreams`, а порт/интерфейс — в блоке
работает `reasoning.enabled=false`, не работает `include_reasoning=false`). `server`.
Суффикс снимается перед отправкой — RouterAI видит оригинальное имя.
## Каталог /v1/models Лимит конкурентности `max_concurrency` можно задать и на провайдере (лимит по
умолчанию для его апстримов), и на апстриме (перекрывает провайдерский); без
Модели, чей id содержит любую из подстрок `THINKING_MODELS`, дублируются в обоих — безлимит.
каталоге с суффиксом `-no-think` (напр. `deepseek/deepseek-v4-flash-0731-no-think`).
## Конфиг (env)
| Переменная | Default | Описание | | Переменная | Default | Описание |
|---|---|---| |---|---|---|
| `PORT` | 8100 | Порт сервера | | `CONFIG_PATH` | `config.yaml` (CWD) | Путь к YAML-конфигу |
| `UPSTREAM_URL` | `https://routerai.ru/api/v1` | Куда проксировать |
| `ROUTER_API_KEY` | — (обязателен) | Bearer-ключ RouterAI | | Ключ конфига | Default | Описание |
| `EXCLUDED_PROVIDERS` | пусто | slug'и провайдеров через запятую | |---|---|---|
| `THINKING_MODELS` | `deepseek/deepseek-v4-flash-0731,deepseek/deepseek-v4-flash` | подстроки id «думающих» моделей | | `server.host` | `0.0.0.0` | Интерфейс/адрес биндинга |
| `server.port` | `8100` | Порт сервера |
Таймаут на запрос к нейронке (весь ответ, включая стриминг) — константа
`UPSTREAM_REQUEST_TIMEOUT_MS` = 5 минут; без неё дефолт CIO-движка Ktor — 15 секунд,
и длинные генерации обрываются. Значение логируется при старте.
## Сборка и деплой ## Сборка и деплой
- CI (Gitea Actions, на release): `./gradlew shadowJar` → образ - CI (Gitea Actions, на release): `./gradlew fatJar` → образ
`images.binom.pw/llm-proxy:<tag>` (zot). `images.binom.pw/llm-proxy:<tag>` (zot).
- Запуск на 76.179 (llm-router): `podman-compose up -d` из `podman-compose.yaml` - Запуск на 76.179 (llm-router): `podman-compose up -d` из `podman-compose.yaml`
(образ тянется из `images.binom.pw`, сеть `bifrost_default` — туда же Bifrost (образ тянется из `images.binom.pw`, сеть `bifrost_default` — туда же Bifrost
+42 -23
View File
@@ -1,7 +1,6 @@
plugins { plugins {
kotlin("jvm") version "2.4.10" alias(libs.plugins.kotlinMultiplatform)
application alias(libs.plugins.kotlinSerialization)
id("com.gradleup.shadow") version "8.3.5"
} }
group = "pw.binom" group = "pw.binom"
@@ -11,31 +10,51 @@ repositories {
mavenCentral() mavenCentral()
} }
dependencies {
implementation("io.ktor:ktor-server-core:3.1.2")
implementation("io.ktor:ktor-server-cio:3.1.2")
implementation("io.ktor:ktor-server-content-negotiation:3.1.2")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
implementation("ch.qos.logback:logback-classic:1.5.18")
}
application {
mainClass.set("pw.binom.llmproxy.MainKt")
}
kotlin { kotlin {
compilerOptions { jvm()
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) linuxX64()
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.serialization.json)
implementation(libs.yamlkt)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.io.core)
}
}
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
val jvmMain by getting {
dependencies {
runtimeOnly(libs.logback.classic)
}
}
val jvmTest by getting {
dependencies {
implementation(libs.junit.jupiter)
runtimeOnly(libs.junit.platform.launcher)
}
}
} }
} }
java { tasks.withType<Test> {
sourceCompatibility = JavaVersion.VERSION_17 useJUnitPlatform()
targetCompatibility = JavaVersion.VERSION_17
} }
tasks.shadowJar { tasks.register<Jar>("fatJar") {
archiveFileName.set("llm-proxy.jar") archiveFileName.set("llm-proxy.jar")
mergeServiceFiles() manifest { attributes("Main-Class" to "pw.binom.llmproxy.MainKt") }
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from(kotlin.jvm().compilations["main"].output)
from(configurations.getByName("jvmRuntimeClasspath").map { if (it.isDirectory) it else zipTree(it) })
} }
+32
View File
@@ -0,0 +1,32 @@
[versions]
kotlin = "2.4.10"
kotlinxSerialization = "1.8.1"
ktor = "3.5.2"
yamlkt = "0.13.0"
logback = "1.5.18"
junit = "5.10.2"
coroutines = "1.10.2"
datetime = "0.6.2"
kotlinxIo = "0.9.1"
[libraries]
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
yamlkt = { module = "net.mamoe.yamlkt:yamlkt", version.ref = "yamlkt" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIo" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
[plugins]
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
shadow = { id = "com.gradleup.shadow", version = "8.3.5" }
+20 -9
View File
@@ -1,17 +1,28 @@
# llm-proxy — прослойка OpenAI API: добавляет provider.ignore (исключение # llm-proxy — YAML-декларативный роутер OpenAI API (подробности в CONFIG.md).
# дорогих провайдеров) + allow_fallbacks:false и reasoning.enabled:false для # Принимает /v1/chat/completions и /v1/models, маршрутизирует между апстримами
# моделей с суффиксом "-no-think", проксирует на RouterAI. Ответ — как есть. # по внутреннему учёту конкурентности и мержит patch-слои в тело запроса.
# #
# Запуск на 76.179 (llm-router): podman-compose up -d # Запуск на 76.179 (llm-router): podman-compose up -d
# Образ тянется из нашего реестра (zot): images.binom.pw/llm-proxy:<tag>
services: services:
llm-proxy: llm-proxy:
image: images.binom.pw/llm-proxy:latest image: images.binom.pw/llm-proxy:latest
container_name: llm-proxy container_name: llm-proxy
restart: unless-stopped restart: unless-stopped
network_mode: bifrost_default networks:
- bifrost
environment: environment:
- PORT=8100 # Путь к конфигу внутри контейнера (файл монтируется ниже).
- UPSTREAM_URL=https://routerai.ru/api/v1 - CONFIG_PATH=/config/config.yaml
- ROUTER_API_KEY=__SET_FROM_CONFIG_DB__ volumes:
- EXCLUDED_PROVIDERS=deepseek # Монтируем свой config.yaml (секреты — через ${...} внутри файла,
- THINKING_MODELS=deepseek/deepseek-v4-flash-0731,deepseek/deepseek-v4-flash # резолвятся из env контейнера, напр. ROUTER_API_KEY).
- ./config.yaml:/config/config.yaml:ro
# Если ключи в config.yaml ссылаются на env (${ROUTER_API_KEY}), пробросим их:
# environment:
# - ROUTER_API_KEY=__SET_FROM_CONFIG_DB__
networks:
bifrost:
external: true
name: bifrost_default
@@ -0,0 +1,17 @@
package pw.binom.llmproxy
import kotlinx.io.Buffer
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readByteArray
internal fun readConfigText(path: String): String {
val src = SystemFileSystem.source(Path(path))
try {
val buf = Buffer()
while (src.readAtMostTo(buf, Long.MAX_VALUE) > 0L) { }
return buf.readByteArray().decodeToString()
} finally {
src.close()
}
}
@@ -0,0 +1,3 @@
package pw.binom.llmproxy
expect fun getEnv(name: String): String?
@@ -0,0 +1,13 @@
package pw.binom.llmproxy
enum class LogLevel { INFO, WARN, ERROR }
fun emitLog(level: LogLevel, message: String) {
println("[llm-proxy] ${level.name.lowercase()} $message")
}
object log {
inline fun info(lazyMessage: () -> Any?) = emitLog(LogLevel.INFO, lazyMessage()?.toString() ?: "null")
inline fun warn(lazyMessage: () -> Any?) = emitLog(LogLevel.WARN, lazyMessage()?.toString() ?: "null")
inline fun error(lazyMessage: () -> Any?) = emitLog(LogLevel.ERROR, lazyMessage()?.toString() ?: "null")
}
@@ -0,0 +1,638 @@
package pw.binom.llmproxy
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.headers
import io.ktor.utils.io.readAvailable
import io.ktor.http.Headers
import io.ktor.client.request.preparePost
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.request.receiveText
import io.ktor.server.response.respondBytesWriter
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.writeFully
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.datetime.Clock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import net.mamoe.yamlkt.Yaml
import net.mamoe.yamlkt.YamlElement
import net.mamoe.yamlkt.YamlList
import net.mamoe.yamlkt.YamlLiteral
import net.mamoe.yamlkt.YamlMap
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
/**
* Прослойка OpenAI API (YAML-декларативный роутер, Kotlin Multiplatform).
*
* Конфигурация целиком в YAML-файле (см. CONFIG.md): объявляются провайдеры
* (`providers`), каталог апстрим-моделей (`upstreams`) и модели, видимые клиенту
* (`models`). Маршрутизация между апстримами модели — по внутреннему учёту
* конкурентности (AtomicInt на апстрим); в тело запроса послойно
* вмердживаются `patch` (provider → upstream → model).
*
* Env (только эти):
* - CONFIG_PATH путь к YAML; default `config.yaml` в каталоге проекта (CWD)
*
* Порт/интерфейс биндинга задаются блоком `server` в YAML (см. CONFIG.md).
*/
fun main() {
val path = getEnv("CONFIG_PATH") ?: "config.yaml"
val root = Yaml.decodeYamlFromString(readConfigText(path))
val config = parseConfig(root)
val providersById = config.providers.associateBy { it.id }
val upstreamsById = config.upstreams.associateBy { it.id }
config.upstreams.forEach { up ->
if (up.provider !in providersById) {
log.warn { "[llm-proxy] upstream '${up.id}' ссылается на несуществующего provider '${up.provider}'" }
}
}
config.models.forEach { m ->
m.upstreams.forEach { ref ->
if (ref !in upstreamsById) {
log.warn { "[llm-proxy] model '${m.name}' ссылается на несуществующий upstream '$ref'" }
}
}
}
val active = config.upstreams.associate { up ->
up.id to UpstreamCounter(effectiveConcurrencyLimit(up, providersById[up.provider]))
}
log.info {
"[llm-proxy] загружено: providers=${config.providers.size}, " +
"upstreams=${config.upstreams.size}, models=${config.models.size} (config=$path)"
}
log.info {
"[llm-proxy] server: bind host=${config.server.host} port=${config.server.port}"
}
log.info {
"[llm-proxy] upstream: request_timeout=${UPSTREAM_REQUEST_TIMEOUT_MS}ms"
}
val http = createHttpClient()
startServer(config.server.host, config.server.port) {
proxyModule(config, providersById, upstreamsById, active, http)
}
}
private val json = Json { ignoreUnknownKeys = true }
fun Application.proxyModule(
config: Config,
providersById: Map<String, ProviderConf>,
upstreamsById: Map<String, UpstreamConf>,
active: Map<String, UpstreamCounter>,
http: HttpClient,
) {
routing {
post("/v1/chat/completions") {
handleChat(call, config, providersById, upstreamsById, active, http)
}
get("/v1/models") {
handleModels(call, config)
}
}
}
private suspend fun handleChat(
call: ApplicationCall,
config: Config,
providersById: Map<String, ProviderConf>,
upstreamsById: Map<String, UpstreamConf>,
active: Map<String, UpstreamCounter>,
http: HttpClient,
) {
val raw = call.receiveText()
if (raw.isBlank()) {
call.respondText(errorJson("empty body"), ContentType.Application.Json, HttpStatusCode.BadRequest)
return
}
val bodyJson = try {
json.parseToJsonElement(raw).jsonObject
} catch (e: Exception) {
call.respondText(errorJson("bad json: ${e.message}"), ContentType.Application.Json, HttpStatusCode.BadRequest)
return
}
val modelName = bodyJson["model"]?.jsonPrimitive?.content ?: "?"
val modelConf = config.models.firstOrNull { it.name == modelName }
if (modelConf == null) {
call.respondText(errorJson("unknown model: $modelName"), ContentType.Application.Json, HttpStatusCode.NotFound)
return
}
val pool = modelConf.upstreams.mapNotNull { upstreamsById[it] }
if (pool.isEmpty()) {
call.respondText(errorJson("model $modelName has no valid upstreams"), ContentType.Application.Json, HttpStatusCode.BadGateway)
return
}
val clientWantsStream = bodyJson["stream"]?.jsonPrimitive?.content == "true"
val failed = mutableSetOf<String>()
var anyClaimed = false
while (true) {
val up = pickFreeUpstream(pool, active, failed) ?: break
anyClaimed = true
val start = TimeSource.Monotonic.markNow()
try {
val provider = providersById[up.provider]
if (provider == null) {
log.error { "[llm-proxy] upstream '${up.id}': provider '${up.provider}' не найден (конфиг)" }
failed.add(up.id)
continue
}
val patched = buildBody(bodyJson, provider, up, modelConf)
val forwarded = if (clientWantsStream) {
patched
} else {
val m = patched.toMutableMap().apply {
this["stream"] = JsonPrimitive(true)
val so = (this["stream_options"] as? JsonObject)?.toMutableMap() ?: mutableMapOf()
so["include_usage"] = JsonPrimitive(true)
this["stream_options"] = JsonObject(so)
}
JsonObject(m)
}
val url = provider.url.trimEnd('/') + "/chat/completions"
val providerKey = resolveEnv(provider.key)
var failover = false
var responded = false
var upstreamStatus = 0
http.preparePost(url) {
headers {
headersToForward(call.request.headers).forEach { (name, values) ->
appendAll(name, values)
}
// Авторизация — всегда наша (ключ провайдера из конфига);
// клиентский Authorization не пересылается. Если у провайдера
// ключ не задан — Authorization не отправляем вовсе.
if (providerKey.isNotEmpty()) {
set("Authorization", "Bearer $providerKey")
}
set("Content-Type", "application/json")
}
setBody(forwarded.toString())
}.execute { resp ->
upstreamStatus = resp.status.value
if (upstreamStatus >= 500 || upstreamStatus == 429) {
log.warn { "[llm-proxy] model=$modelName upstream=${up.id} вернул $upstreamStatus → фейловер" }
failed.add(up.id)
failover = true
return@execute
}
responded = true
if (clientWantsStream) {
val ct = resp.headers["Content-Type"] ?: "text/event-stream"
val status = HttpStatusCode.fromValue(upstreamStatus)
call.respondBytesWriter(ContentType.parse(ct), status) {
val ch = resp.body<ByteReadChannel>()
val buf = ByteArray(8192)
while (true) {
val n = ch.readAvailable(buf)
if (n == -1) break
if (n > 0) {
writeFully(buf, 0, n)
flush()
}
}
}
log.info {
"[llm-proxy] chat model=$modelName upstream=${up.id} provider=${provider.id} " +
"status=$upstreamStatus за ${start.elapsedNow().inWholeMilliseconds}ms stream=true"
}
} else {
val ct = resp.headers["Content-Type"] ?: "application/json"
val full = resp.body<String>()
val out = if (ct.contains("text/event-stream")) rebuildFromChunks(full) else full
val outCt = runCatching {
if (ct.contains("text/event-stream") &&
Json.parseToJsonElement(out).jsonObject["error"] != null
) {
ContentType.Application.Json
} else {
ContentType.parse(ct)
}
}.getOrDefault(ContentType.parse(ct))
call.respondText(out, outCt, HttpStatusCode.fromValue(upstreamStatus))
log.info {
"[llm-proxy] chat model=$modelName upstream=${up.id} provider=${provider.id} " +
"status=$upstreamStatus за ${start.elapsedNow().inWholeMilliseconds}ms stream=false"
}
}
}
if (failover) continue
if (responded) return
} catch (e: CancellationException) {
log.info { "[llm-proxy] chat model=$modelName upstream=${up.id} ОТМЕНЕНО клиентом за ${start.elapsedNow().inWholeMilliseconds}ms" }
throw e
} catch (e: Exception) {
log.error { "[llm-proxy] chat model=$modelName upstream=${up.id} ОШИБКА: ${e.message} за ${start.elapsedNow().inWholeMilliseconds}ms → фейловер" }
failed.add(up.id)
continue
} finally {
release(up, active)
}
}
if (anyClaimed) {
call.respondText(errorJson("all upstreams failed"), ContentType.Application.Json, HttpStatusCode.ServiceUnavailable)
} else {
call.respondText(errorJson("all upstreams busy"), ContentType.Application.Json, HttpStatusCode.ServiceUnavailable)
}
}
private fun errorJson(msg: String): String =
"""{"error":{"message":"$msg"}}"""
/** Hop-by-hop и прокси-специфичные заголовки, которые НЕ пересылаются в апстрим. */
private val SKIP_HEADER_NAMES = setOf(
"host",
"content-length",
"transfer-encoding",
"te",
"connection",
"proxy-connection",
"keep-alive",
"upgrade",
// Authorization управляется прокси явно (ключ провайдера), клиентский
// не пересылается
"authorization",
)
/**
* Заголовки входящего запроса для пересылки в апстрим: все, кроме служебных
* ([SKIP_HEADER_NAMES]). Тело может быть изменено `patch`, а соединение до
* апстрима — другое, поэтому Content-Length/Transfer-Encoding/Host/Connection
* управляет сам прокси (клиентский Ktor выставит свои автоматически).
* `Authorization` тоже в списке пропусков — прокси управляет им явно
* (ключ провайдера из конфига; клиентский не пересылается).
*/
internal fun headersToForward(request: Headers): Map<String, List<String>> =
request.entries()
.filter { (name, _) -> name.lowercase() !in SKIP_HEADER_NAMES }
.associate { (name, values) -> name to values }
/**
* Сборка тела запроса: подмена `model` на реальное имя апстрима + глубокий
* послойный мерж `patch` в порядке provider → upstream → model.
*/
internal fun buildBody(base: JsonObject, provider: ProviderConf, up: UpstreamConf, model: ModelConf): JsonObject {
val withModel = base.toMutableMap().apply { this["model"] = JsonPrimitive(up.model) }
var acc = JsonObject(withModel)
listOf(provider.patch, up.patch, model.patch).filterNotNull().forEach { patch ->
acc = merge(acc, patch)
}
return acc
}
/** Конвертация YAML-дерева (YamlElement) в kotlinx JsonElement для мержа в тело. */
internal fun yamlToJson(el: YamlElement): JsonElement = when (el) {
is YamlMap -> JsonObject(el.mapKeys { (k, _) -> yamlKey(k) }.mapValues { (_, v) -> yamlToJson(v) })
is YamlList -> JsonArray(el.map { yamlToJson(it) })
is YamlLiteral -> yamlLiteralToJson(el)
else -> JsonNull
}
internal fun yamlKey(key: YamlElement): String =
if (key is YamlLiteral) key.content else key.toString()
internal fun yamlLiteralToJson(lit: YamlLiteral): JsonPrimitive {
val c = lit.content
val bool = c.toBooleanStrictOrNull()
val lng = c.toLongOrNull()
val dbl = c.toDoubleOrNull()
return when {
bool != null -> JsonPrimitive(bool)
lng != null -> JsonPrimitive(lng)
dbl != null -> JsonPrimitive(dbl)
else -> JsonPrimitive(c)
}
}
/** Глубокий мерж: вложенные объекты сливаются рекурсивно, скаляры/массивы заменяются. */
internal 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)
}
/** Подстановка `${ENV}` в строках ключей/url из переменных среды. */
internal fun resolveEnv(s: String): String =
"""\$\{([^}]+)\}""".toRegex().replace(s) { m -> getEnv(m.groupValues[1]) ?: "" }
/** Атомарно занять слот у апстрима (по эффективному лимиту); false, если все заняты. */
internal fun tryClaim(up: UpstreamConf, active: Map<String, UpstreamCounter>): Boolean =
active.getValue(up.id).tryClaim()
/**
* Эффективный лимит конкурентности апстрима: значение у апстрима (модели), если
* задано; иначе у провайдера; иначе безлимит.
*/
internal fun effectiveConcurrencyLimit(up: UpstreamConf, provider: ProviderConf?): Int =
up.max_concurrency ?: provider?.max_concurrency ?: Int.MAX_VALUE
/** Освободить слот апстрима (в finally по завершении проксирования). */
internal fun release(up: UpstreamConf, active: Map<String, UpstreamCounter>) {
active.getValue(up.id).release()
}
class UpstreamCounter(private val limit: Int, initial: Int = 0) {
private val lock = Mutex()
@Volatile
private var count: Int = initial
fun tryClaim(): Boolean {
if (!lock.tryLock()) return false
try {
return if (count >= limit) false else {
count++
true
}
} finally {
lock.unlock()
}
}
fun release(): Unit {
if (!lock.tryLock()) return
try {
if (count > 0) count--
} finally {
lock.unlock()
}
}
val current: Int
get() {
if (!lock.tryLock()) return -1
try {
return count
} finally {
lock.unlock()
}
}
}
/**
* Выбор апстрима для попытки: первый по порядку (приоритету) апстрим из `pool`,
* у которого свободен слот и который ещё не в `excluded` (не упал ранее).
* Сразу занимает слот (через [tryClaim]). Если свободных нет — возвращает null.
*/
internal fun pickFreeUpstream(
pool: List<UpstreamConf>,
active: Map<String, UpstreamCounter>,
excluded: Set<String>,
): UpstreamConf? =
pool.firstOrNull { up -> up.id !in excluded && tryClaim(up, active) }
private suspend fun handleModels(call: ApplicationCall, config: Config) {
val created = TimeSource.Monotonic.markNow().elapsedNow().inWholeSeconds
val data = config.models.map { m ->
JsonObject(
mapOf(
"id" to JsonPrimitive(m.name),
"object" to JsonPrimitive("model"),
"created" to JsonPrimitive(created),
"owned_by" to JsonPrimitive("llm-proxy"),
),
)
}
val body = JsonObject(mapOf("object" to JsonPrimitive("list"), "data" to JsonArray(data)))
call.respondText(body.toString(), ContentType.Application.Json, HttpStatusCode.OK)
}
/**
* Собрать полный chat.completion из SSE-чанков апстрима (для non-stream клиентов).
* Сохраняются все значимые поля, которые отдаёт апстрим: id/created/model/
* system_fingerprint/service_tier, usage, несколько choices (по index) с
* role/content/reasoning_content/tool_calls/finish_reason/logprobs.
*/
internal fun rebuildFromChunks(sse: String): String {
var id = ""
var created: Long? = null
var model = ""
var systemFingerprint: String? = null
var serviceTier: String? = null
var usage: JsonObject? = null
var provider: JsonElement? = null
var error: JsonObject? = null
class MutableChoice {
var role: String? = null
val content = StringBuilder()
val reasoning = StringBuilder()
var finishReason: String? = null
var logprobs: JsonElement? = null
val toolCalls = mutableListOf<JsonObject>()
}
val choices = mutableMapOf<Int, MutableChoice>()
sse.lineSequence().forEach { line ->
if (!line.startsWith("data:")) return@forEach
val data = line.removePrefix("data:").trim()
if (data.isEmpty() || data == "[DONE]") return@forEach
val obj = runCatching { json.parseToJsonElement(data).jsonObject }.getOrNull() ?: return@forEach
if (id.isEmpty()) id = obj["id"]?.jsonPrimitive?.content ?: ""
if (created == null) created = obj["created"]?.jsonPrimitive?.content?.toLongOrNull()
if (model.isEmpty()) model = obj["model"]?.jsonPrimitive?.content ?: ""
if (systemFingerprint == null) systemFingerprint = obj["system_fingerprint"]?.jsonPrimitive?.content
if (serviceTier == null) serviceTier = obj["service_tier"]?.jsonPrimitive?.content
if (provider == null) provider = obj["provider"]
(obj["error"] as? JsonObject)?.let { error = it }
(obj["usage"] as? JsonObject)?.let { usage = it }
val chArr = obj["choices"]?.jsonArray ?: return@forEach
for (ch in chArr) {
val c = ch.jsonObject
val idx = c["index"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0
val mc = choices.getOrPut(idx) { MutableChoice() }
val delta = c["delta"]?.jsonObject
if (delta != null) {
if (mc.role == null) mc.role = delta["role"]?.jsonPrimitive?.content
delta["content"]?.jsonPrimitive?.content?.takeIf { it != "null" }?.let { mc.content.append(it) }
delta["reasoning_content"]?.jsonPrimitive?.content?.takeIf { it != "null" }?.let { mc.reasoning.append(it) }
delta["tool_calls"]?.jsonArray?.forEach { tc -> (tc as? JsonObject)?.let { mc.toolCalls.add(it) } }
}
c["finish_reason"]?.jsonPrimitive?.content?.takeIf { it.isNotEmpty() && it != "null" }?.let { mc.finishReason = it }
c["logprobs"]?.let { mc.logprobs = it }
}
}
// Апстрим вернул ошибку (в SSE она приходит чанком data: {"error":{...}}).
// Не «проглатываем» — отдаём клиенту как есть.
if (error != null) {
return JsonObject(mapOf("error" to error)).toString()
}
val choiceArr = choices.entries.sortedBy { it.key }.map { (idx, mc) ->
val msg = mutableMapOf<String, JsonElement>(
"role" to JsonPrimitive(mc.role ?: "assistant"),
"content" to JsonPrimitive(mc.content.toString()),
)
if (mc.reasoning.isNotEmpty()) msg["reasoning_content"] = JsonPrimitive(mc.reasoning.toString())
if (mc.toolCalls.isNotEmpty()) msg["tool_calls"] = JsonArray(mc.toolCalls)
val ch = mutableMapOf<String, JsonElement>(
"index" to JsonPrimitive(idx),
"message" to JsonObject(msg),
"finish_reason" to JsonPrimitive(mc.finishReason ?: "stop"),
)
mc.logprobs?.let { ch["logprobs"] = it }
JsonObject(ch)
}
val root = mutableMapOf<String, JsonElement>(
"id" to JsonPrimitive(id),
"object" to JsonPrimitive("chat.completion"),
"created" to JsonPrimitive(created ?: Clock.System.now().epochSeconds),
"model" to JsonPrimitive(model),
"choices" to JsonArray(choiceArr),
)
systemFingerprint?.let { root["system_fingerprint"] = JsonPrimitive(it) }
serviceTier?.let { root["service_tier"] = JsonPrimitive(it) }
provider?.let { root["provider"] = it }
usage?.let { root["usage"] = it }
return JsonObject(root).toString()
}
@Serializable
data class ProviderConf(
val id: String,
val url: String,
val key: String = "",
val max_concurrency: Int? = null,
val patch: JsonObject? = null,
)
data class UpstreamConf(
val id: String,
val provider: String,
val model: String,
val max_concurrency: Int? = null,
val patch: JsonObject? = null,
)
data class ModelConf(
val name: String,
val upstreams: List<String>,
val patch: JsonObject? = null,
)
data class ServerConf(
val host: String = "0.0.0.0",
val port: Int = 8100,
)
data class Config(
val server: ServerConf,
val providers: List<ProviderConf>,
val upstreams: List<UpstreamConf>,
val models: List<ModelConf>,
)
/**
* Ручной разбор YAML-дерева (YamlElement) в конфиг. Поле `patch` сразу
* конвертируется в kotlinx JsonObject (merge`ить в тело тривиально).
* Декодирование всего документа как YamlElement надёжнее, чем вложенный
* `@Serializable`-класс с полем YamlElement (yamlkt криво читает списки).
*/
internal fun parseConfig(root: YamlElement): Config {
val rootMap = root as? YamlMap ?: error("config root must be a map")
val top = rootMap.toMap()
fun list(key: String): List<YamlElement> {
val v = top[key] ?: return emptyList()
return (v as? YamlList)?.map { it } ?: emptyList()
}
val serverMap = (top["server"] as? YamlMap)?.toMap()
val server = if (serverMap != null) {
ServerConf(
host = serverMap.strOrNull("host") ?: "0.0.0.0",
port = serverMap.strOrNull("port")?.toIntOrNull() ?: 8100,
)
} else {
ServerConf()
}
val providers = list("providers").map { entry ->
val m = (entry as YamlMap).toMap()
ProviderConf(
id = m.str("id"),
url = m.str("url"),
key = m.strOrNull("key") ?: "",
max_concurrency = m.strOrNull("max_concurrency")?.toIntOrNull(),
patch = m.yamlMapOrNull("patch")?.let { yamlToJson(it) as JsonObject },
)
}
val upstreams = list("upstreams").map { entry ->
val m = (entry as YamlMap).toMap()
UpstreamConf(
id = m.str("id"),
provider = m.str("provider"),
model = m.str("model"),
max_concurrency = m.strOrNull("max_concurrency")?.toIntOrNull(),
patch = m.yamlMapOrNull("patch")?.let { yamlToJson(it) as JsonObject },
)
}
val models = list("models").map { entry ->
val m = (entry as YamlMap).toMap()
ModelConf(
name = m.str("name"),
upstreams = (m["upstreams"] as? YamlList)?.map { (it as YamlLiteral).content } ?: emptyList(),
patch = m.yamlMapOrNull("patch")?.let { yamlToJson(it) as JsonObject },
)
}
return Config(server, providers, upstreams, models)
}
/** YamlMap -> Map<String, YamlElement> (ключи YAML — строковые скаляры). */
internal fun YamlMap.toMap(): Map<String, YamlElement> =
entries.associate { (it.key as YamlLiteral).content to it.value }
internal fun Map<String, YamlElement>.str(key: String): String =
(this[key] as? YamlLiteral)?.content ?: error("config: missing string '$key'")
internal fun Map<String, YamlElement>.strOrNull(key: String): String? =
(this[key] as? YamlLiteral)?.content
internal fun Map<String, YamlElement>.yamlMapOrNull(key: String): YamlMap? =
this[key] as? YamlMap
@@ -0,0 +1,10 @@
package pw.binom.llmproxy
import io.ktor.client.HttpClient
import io.ktor.server.application.Application
/** Платформенное создание HTTP-клиента (движок задаётся в actual). */
expect fun createHttpClient(): HttpClient
/** Платформенный запуск Ktor-сервера (движок задаётся в actual). */
expect fun startServer(host: String, port: Int, module: Application.() -> Unit)
@@ -0,0 +1,4 @@
package pw.binom.llmproxy
/** Предел времени на весь запрос к апстриму, мс: от отправки до приёма всего ответа (включая стриминг). */
const val UPSTREAM_REQUEST_TIMEOUT_MS: Long = 5 * 60_000L
@@ -0,0 +1,357 @@
package pw.binom.llmproxy
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import io.ktor.http.headersOf
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import net.mamoe.yamlkt.Yaml
class ConfigLogicTest {
@Test
fun mergeDeepMergesNestedObjectsAndReplacesScalars() {
val base = Json.parseToJsonElement("""{"a":{"x":1,"y":2},"b":1}""").jsonObject
val patch = Json.parseToJsonElement("""{"a":{"y":3,"z":4},"c":5}""").jsonObject
val merged = merge(base, patch)
assertEquals("""{"a":{"x":1,"y":3,"z":4},"b":1,"c":5}""", merged.toString())
}
@Test
fun mergeReplacesArraysByKey() {
val base = Json.parseToJsonElement("""{"provider":{"ignore":["a"]}}""").jsonObject
val patch = Json.parseToJsonElement("""{"provider":{"ignore":["b","c"]}}""").jsonObject
assertEquals("""{"provider":{"ignore":["b","c"]}}""", merge(base, patch).toString())
}
@Test
fun parseConfigReadsAllBlocksAndConvertsPatchToJsonObject() {
val yaml = """
providers:
- id: p1
url: "https://x.ru/api/v1"
key: "k"
max_concurrency: 4
patch:
provider:
allow_fallbacks: false
upstreams:
- id: u1
provider: p1
model: real-1
max_concurrency: 2
patch:
provider:
ignore: [deepseek]
- id: bad
provider: missing
model: x
models:
- name: m1
upstreams: [u1, bad]
patch:
reasoning:
enabled: false
""".trimIndent()
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
assertEquals(1, cfg.providers.size)
assertEquals("https://x.ru/api/v1", cfg.providers[0].url)
assertEquals(4, cfg.providers[0].max_concurrency)
assertEquals(2, cfg.upstreams.size)
assertEquals(2, cfg.upstreams[0].max_concurrency)
assertEquals(listOf("u1", "bad"), cfg.models[0].upstreams)
assertEquals("""{"provider":{"allow_fallbacks":false}}""", cfg.providers[0].patch.toString())
assertEquals("""{"provider":{"ignore":["deepseek"]}}""", cfg.upstreams[0].patch.toString())
assertEquals("""{"reasoning":{"enabled":false}}""", cfg.models[0].patch.toString())
}
@Test
fun parseConfigReadsServerBlock() {
val yaml = """
server:
host: 127.0.0.1
port: 9200
models:
- name: m1
upstreams: []
""".trimIndent()
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
assertEquals("127.0.0.1", cfg.server.host)
assertEquals(9200, cfg.server.port)
}
@Test
fun parseConfigServerDefaultsWhenBlockAbsent() {
val yaml = """
models:
- name: m1
upstreams: []
""".trimIndent()
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
assertEquals("0.0.0.0", cfg.server.host)
assertEquals(8100, cfg.server.port)
}
@Test
fun parseConfigServerFieldDefaultsPerField() {
val yaml = """
server:
host: 192.168.88.10
models:
- name: m1
upstreams: []
""".trimIndent()
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
assertEquals("192.168.88.10", cfg.server.host)
assertEquals(8100, cfg.server.port)
}
@Test
fun parseConfigOmitsPatchWhenAbsent() {
val yaml = """
providers:
- id: p1
url: "https://x.ru/api/v1"
upstreams:
- id: u1
provider: p1
model: real-1
models:
- name: m1
upstreams: [u1]
""".trimIndent()
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
assertEquals(null, cfg.providers[0].patch)
assertEquals(null, cfg.models[0].patch)
assertEquals(null, cfg.upstreams[0].max_concurrency)
}
@Test
fun buildBodySubstitutesModelAndAppliesLayersInOrder() {
val provider = ProviderConf(
"p1", "https://x", "", null,
Json.parseToJsonElement("""{"provider":{"allow_fallbacks":false}}""").jsonObject,
)
val up = UpstreamConf(
"u1", "p1", "real-1", null,
Json.parseToJsonElement("""{"provider":{"ignore":["deepseek"]}}""").jsonObject,
)
val model = ModelConf(
"m1", listOf("u1"),
Json.parseToJsonElement("""{"reasoning":{"enabled":false}}""").jsonObject,
)
val body = Json.parseToJsonElement(
"""{"model":"m1","messages":[],"temperature":0.7}""",
).jsonObject
val out = buildBody(body, provider, up, model)
assertEquals(
"""{"model":"real-1","messages":[],"temperature":0.7,"provider":{"allow_fallbacks":false,"ignore":["deepseek"]},"reasoning":{"enabled":false}}""",
out.toString(),
)
}
@Test
fun buildBodyWithNoPatchesReturnsClientBodyWithSubstitutedModel() {
val provider = ProviderConf("p1", "https://x", "")
val up = UpstreamConf("u1", "p1", "real-1", null, null)
val model = ModelConf("m1", listOf("u1"), null)
val body = Json.parseToJsonElement("""{"model":"m1","a":1}""").jsonObject
assertEquals("""{"model":"real-1","a":1}""", buildBody(body, provider, up, model).toString())
}
@Test
fun resolveEnvSubstitutesMissingVarWithEmptyAndPassesLiteralThrough() {
assertEquals("", resolveEnv("\${LLM_PROXY_TEST_MISSING_VAR}"))
assertEquals("plain", resolveEnv("plain"))
assertEquals("pre--post", resolveEnv("pre-\${LLM_PROXY_TEST_MISSING_VAR}-post"))
}
@Test
fun tryClaimRespectsMaxConcurrencyAndReleaseFreesSlot() {
val active = mapOf("u1" to UpstreamCounter(1))
val up = UpstreamConf("u1", "p", "m", 1, null)
assertTrue(tryClaim(up, active))
assertFalse(tryClaim(up, active))
release(up, active)
assertTrue(tryClaim(up, active))
}
@Test
fun tryClaimUnlimitedWhenMaxConcurrencyIsNull() {
val active = mapOf("u2" to UpstreamCounter(Int.MAX_VALUE))
val up = UpstreamConf("u2", "p", "m", null, null)
assertTrue(tryClaim(up, active))
assertTrue(tryClaim(up, active))
}
@Test
fun effectiveConcurrencyLimitPrefersUpstreamThenProvider() {
val providerWithLimit = ProviderConf("p1", "https://x", "", 3, null)
val providerWithoutLimit = ProviderConf("p2", "https://x", "", null, null)
assertEquals(
2,
effectiveConcurrencyLimit(UpstreamConf("u1", "p1", "m", 2, null), providerWithLimit),
)
assertEquals(
3,
effectiveConcurrencyLimit(UpstreamConf("u1", "p1", "m", null, null), providerWithLimit),
)
assertEquals(
Int.MAX_VALUE,
effectiveConcurrencyLimit(UpstreamConf("u1", "p2", "m", null, null), providerWithoutLimit),
)
assertEquals(
Int.MAX_VALUE,
effectiveConcurrencyLimit(UpstreamConf("u1", "missing", "m", null, null), null),
)
}
@Test
fun pickFreeUpstreamReturnsFirstFreeInDeclarationOrder() {
val active = mapOf("u1" to UpstreamCounter(1), "u2" to UpstreamCounter(2))
val pool = listOf(
UpstreamConf("u1", "p", "m", 1, null),
UpstreamConf("u2", "p", "m", 2, null),
)
val up = pickFreeUpstream(pool, active, emptySet())
assertEquals("u1", up?.id)
// слот реально занят
assertEquals(1, active.getValue("u1").current)
}
@Test
fun pickFreeUpstreamSkipsExcluded() {
val active = mapOf("u1" to UpstreamCounter(1), "u2" to UpstreamCounter(2))
val pool = listOf(
UpstreamConf("u1", "p", "m", 1, null),
UpstreamConf("u2", "p", "m", 2, null),
)
val up = pickFreeUpstream(pool, active, setOf("u1"))
assertEquals("u2", up?.id)
}
@Test
fun pickFreeUpstreamReturnsNullWhenAllBusy() {
val active = mapOf("u1" to UpstreamCounter(1, 1)) // уже на лимите 1
val pool = listOf(UpstreamConf("u1", "p", "m", 1, null))
assertEquals(null, pickFreeUpstream(pool, active, emptySet()))
}
@Test
fun pickFreeUpstreamImplementsFailoverOrder() {
// dead исключён (упал ранее) — выбирается следующий живой u1
val active = mapOf("dead" to UpstreamCounter(1), "u1" to UpstreamCounter(1))
val pool = listOf(
UpstreamConf("dead", "p", "m", 1, null),
UpstreamConf("u1", "p", "m", 1, null),
)
val up = pickFreeUpstream(pool, active, setOf("dead"))
assertEquals("u1", up?.id)
}
@Test
fun pickFreeUpstreamExhaustsConcurrencyThenReturnsNull() {
val active = mapOf("u1" to UpstreamCounter(1), "u2" to UpstreamCounter(1))
val pool = listOf(
UpstreamConf("u1", "p", "m", 1, null),
UpstreamConf("u2", "p", "m", 1, null),
)
assertEquals("u1", pickFreeUpstream(pool, active, emptySet())?.id)
assertEquals("u2", pickFreeUpstream(pool, active, emptySet())?.id)
assertEquals(null, pickFreeUpstream(pool, active, emptySet())?.id)
}
@Test
fun headersToForwardKeepsUnknownAndDropsServiceHeaders() {
val req = headersOf(
"X-Opencode-Session" to listOf("abc-123"),
"X-Custom" to listOf("a", "b"),
"Host" to listOf("proxy:8100"),
"Content-Length" to listOf("42"),
"Transfer-Encoding" to listOf("chunked"),
"Connection" to listOf("keep-alive"),
"TE" to listOf("trailers"),
"Proxy-Connection" to listOf("keep-alive"),
"Upgrade" to listOf("h2c"),
"Authorization" to listOf("Bearer client-secret"),
"Accept" to listOf("*/*"),
)
val out = headersToForward(req)
assertEquals(listOf("abc-123"), out["X-Opencode-Session"])
assertEquals(listOf("a", "b"), out["X-Custom"])
assertEquals(listOf("*/*"), out["Accept"])
assertEquals(3, out.size)
assertEquals(null, out["Authorization"])
}
@Test
fun headersToForwardIsCaseInsensitiveOnSkipSet() {
val out = headersToForward(
headersOf(
"HOST" to listOf("x"),
"Content-length" to listOf("1"),
"x-opencode-session" to listOf("s"),
),
)
assertEquals(1, out.size)
assertEquals(listOf("s"), out["x-opencode-session"])
}
@Test
fun rebuildFromChunksPreservesAllUpstreamFields() {
val sse = """
data: {"id":"c1","object":"chat.completion.chunk","created":123,"model":"m","provider":"Baidu","system_fingerprint":"sf1","choices":[{"index":0,"delta":{"role":"assistant","content":"He"},"finish_reason":null}]}
data: {"id":"c1","created":123,"model":"m","provider":"Baidu","choices":[{"index":0,"delta":{"content":"llo"},"finish_reason":null}]}
data: {"id":"c1","created":123,"model":"m","provider":"Baidu","choices":[{"index":1,"delta":{"content":"B"},"finish_reason":null}]}
data: {"id":"c1","created":123,"model":"m","provider":"Baidu","choices":[{"index":0,"delta":{},"finish_reason":"stop"},{"index":1,"delta":{},"finish_reason":"stop"}]}
data: {"id":"c1","created":123,"model":"m","provider":"Baidu","usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}
data: [DONE]
""".trimIndent()
val out = Json.parseToJsonElement(rebuildFromChunks(sse)).jsonObject
assertEquals("c1", out["id"]?.jsonPrimitive?.content)
assertEquals("m", out["model"]?.jsonPrimitive?.content)
assertEquals("Baidu", out["provider"]?.jsonPrimitive?.content)
assertEquals("sf1", out["system_fingerprint"]?.jsonPrimitive?.content)
assertEquals("chat.completion", out["object"]?.jsonPrimitive?.content)
assertEquals(123, out["created"]?.jsonPrimitive?.content?.toLong())
assertEquals(3, out["usage"]?.jsonObject?.get("total_tokens")?.jsonPrimitive?.content?.toInt())
val choices = out["choices"]?.jsonArray ?: error("no choices")
assertEquals(2, choices.size)
assertEquals("Hello", choices[0].jsonObject["message"]?.jsonObject?.get("content")?.jsonPrimitive?.content)
assertEquals("B", choices[1].jsonObject["message"]?.jsonObject?.get("content")?.jsonPrimitive?.content)
assertEquals("stop", choices[0].jsonObject["finish_reason"]?.jsonPrimitive?.content)
}
@Test
fun rebuildFromChunksReturnsUpstreamErrorVerbatim() {
val sse = """
data: {"id":"c1","error":{"message":"Unsupported field 'foo'","type":"invalid_request"},"choices":[]}
data: [DONE]
""".trimIndent()
val out = Json.parseToJsonElement(rebuildFromChunks(sse)).jsonObject
val err = out["error"]?.jsonObject ?: error("error block missing")
assertEquals("Unsupported field 'foo'", err["message"]?.jsonPrimitive?.content)
}
}
@@ -0,0 +1,3 @@
package pw.binom.llmproxy
actual fun getEnv(name: String): String? = System.getenv(name)
@@ -0,0 +1,19 @@
package pw.binom.llmproxy
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.server.application.Application
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.engine.embeddedServer
actual fun createHttpClient(): HttpClient = HttpClient(CIO) {
engine {
requestTimeout = UPSTREAM_REQUEST_TIMEOUT_MS
}
}
actual fun startServer(host: String, port: Int, module: Application.() -> Unit) {
embeddedServer(ServerCIO, port = port, host = host) {
module()
}.start(wait = true)
}
@@ -0,0 +1,8 @@
package pw.binom.llmproxy
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.toKString
import platform.posix.getenv
@OptIn(ExperimentalForeignApi::class)
actual fun getEnv(name: String): String? = getenv(name)?.toKString()
@@ -0,0 +1,19 @@
package pw.binom.llmproxy
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.server.application.Application
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.engine.embeddedServer
actual fun createHttpClient(): HttpClient = HttpClient(CIO) {
engine {
requestTimeout = UPSTREAM_REQUEST_TIMEOUT_MS
}
}
actual fun startServer(host: String, port: Int, module: Application.() -> Unit) {
embeddedServer(ServerCIO, port = port, host = host) {
module()
}.start(wait = true)
}
-196
View File
@@ -1,196 +0,0 @@
package pw.binom.llmproxy
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.ktor.server.request.receiveText
import io.ktor.server.response.respondBytes
import io.ktor.server.response.respondOutputStream
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
/**
* Прослойка OpenAI API: принимает chat/completions, добавляет в JSON
* `provider.ignore` (исключение дорогих провайдеров) + `allow_fallbacks: false`
* и прозрачно проксирует на RouterAI (routerai.ru/api/v1). Ответ — как есть,
* включая SSE-стрим.
*
* Трюк «-no-think»: модели из [THINKING_MODELS] в каталоге /v1/models дублируются
* с суффиксом `-no-think`; запрос на такой id получает `reasoning: {"enabled": false}`
* (модель не думает — не жрёт токены на reasoning). Проверено на
* deepseek/deepseek-v4-flash-0731: reasoning.enabled=false глушит думанье.
*
* Конфиг (env):
* - PORT (default 8100)
* - UPSTREAM_URL (default https://routerai.ru/api/v1)
* - ROUTER_API_KEY — Bearer-ключ RouterAI (обязателен)
* - EXCLUDED_PROVIDERS — slug'и провайдеров через запятую (напр. "deepseek")
* - THINKING_MODELS — подстроки id «думающих» моделей через запятую
* (напр. "deepseek/deepseek-v4-flash-0731,deepseek/deepseek-r1")
*/
fun main() {
val port = System.getenv("PORT")?.toIntOrNull() ?: 8100
val upstream = System.getenv("UPSTREAM_URL") ?: "https://routerai.ru/api/v1"
val apiKey = System.getenv("ROUTER_API_KEY")
?: throw IllegalStateException("ROUTER_API_KEY required")
val excluded = (System.getenv("EXCLUDED_PROVIDERS") ?: "")
.split(",").map { it.trim() }.filter { it.isNotEmpty() }.distinct()
val thinking = (System.getenv("THINKING_MODELS") ?: "deepseek/deepseek-v4-flash-0731,deepseek/deepseek-v4-flash")
.split(",").map { it.trim() }.filter { it.isNotEmpty() }.distinct()
embeddedServer(CIO, port = port, host = "0.0.0.0") {
proxyModule(upstream, apiKey, excluded, thinking)
}.start(wait = true)
}
private val json = Json { ignoreUnknownKeys = true }
/** Прокси-клиент: один на процесс (HttpClient потокобезопасен). */
private val http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build()
fun Application.proxyModule(upstream: String, apiKey: String, excluded: List<String>, thinking: List<String>) {
routing {
post("/v1/chat/completions") {
handleChat(call, upstream, apiKey, excluded, thinking)
}
get("/v1/models") {
handleModels(call, upstream, apiKey, thinking)
}
}
}
private suspend fun handleChat(
call: ApplicationCall,
upstream: String,
apiKey: String,
excluded: List<String>,
thinking: List<String>,
) {
val raw = call.receiveText()
if (raw.isBlank()) {
call.respondBytes(
"""{"error":{"message":"empty body"}}""".toByteArray(),
ContentType.Application.Json, HttpStatusCode.BadRequest,
)
return
}
val patched = try {
patchBody(raw, excluded, thinking)
} catch (e: Exception) {
call.respondBytes(
"""{"error":{"message":"bad json: ${e.message}"}}""".toByteArray(),
ContentType.Application.Json, HttpStatusCode.BadRequest,
)
return
}
val req = HttpRequest.newBuilder()
.uri(URI.create(upstream.trimEnd('/') + "/chat/completions"))
.header("Authorization", "Bearer $apiKey")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(patched))
.build()
val stream = json.parseToJsonElement(raw).jsonObject["stream"]?.jsonPrimitive?.content == "true"
if (stream) {
// SSE-стрим: транслируем как есть, чанк за чанком.
val resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream())
val ct = resp.headers().firstValue("content-type").orElse("text/event-stream")
call.respondOutputStream(ContentType.parse(ct), HttpStatusCode.fromValue(resp.statusCode())) {
resp.body().use { input -> input.copyTo(this, 8192) }
}
} else {
val resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray())
val ct = resp.headers().firstValue("content-type").orElse("application/json")
call.respondBytes(resp.body(), ContentType.parse(ct), HttpStatusCode.fromValue(resp.statusCode()))
}
}
/**
* Патч запроса: (1) если модель оканчивается на "-no-think" — снять суффикс и
* добавить `reasoning: {"enabled": false}` (не думать); (2) добавить
* `provider.ignore` (объединяя с присланным клиентом) и `allow_fallbacks: false` —
* иначе ignore не жёсткий: RouterAI может уйти на исключённого провайдера
* резервной попыткой (см. гайд provider-selection).
*/
internal fun patchBody(raw: String, excluded: List<String>, thinking: List<String>): String {
val root = json.parseToJsonElement(raw).jsonObject.toMutableMap()
val model = root["model"]?.jsonPrimitive?.content ?: ""
if (model.endsWith("-no-think")) {
root["model"] = JsonPrimitive(model.removeSuffix("-no-think"))
if (root["reasoning"] == null) {
root["reasoning"] = JsonObject(mapOf("enabled" to JsonPrimitive(false)))
}
}
val provider = root["provider"]?.jsonObject?.toMutableMap() ?: mutableMapOf()
val existing = provider["ignore"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()
provider["ignore"] = JsonArray((existing + excluded).distinct().map { JsonPrimitive(it) })
if (provider["allow_fallbacks"] == null) {
provider["allow_fallbacks"] = JsonPrimitive(false)
}
root["provider"] = JsonObject(provider)
return JsonObject(root).toString()
}
private suspend fun handleModels(
call: ApplicationCall,
upstream: String,
apiKey: String,
thinking: List<String>,
) {
val req = HttpRequest.newBuilder()
.uri(URI.create(upstream.trimEnd('/') + "/models"))
.header("Authorization", "Bearer $apiKey")
.GET()
.build()
val resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray())
val ct = resp.headers().firstValue("content-type").orElse("application/json")
val body = if (thinking.isNotEmpty()) {
patchModelsCatalog(String(resp.body(), Charsets.UTF_8), thinking).toByteArray(Charsets.UTF_8)
} else {
resp.body()
}
call.respondBytes(body, ContentType.parse(ct), HttpStatusCode.fromValue(resp.statusCode()))
}
/**
* Дублировать «думающие» модели в каталоге с суффиксом "-no-think":
* каждая модель, чей id содержит любую из подстрок [thinking], получает копию
* с id = "<оригинал>-no-think".
*/
internal fun patchModelsCatalog(raw: String, thinking: List<String>): String {
val root = json.parseToJsonElement(raw).jsonObject.toMutableMap()
val data = root["data"]?.jsonArray?.map { it.jsonObject } ?: emptyList()
if (data.isEmpty()) return raw
val copies = data.filter { m ->
val id = m["id"]?.jsonPrimitive?.content ?: ""
thinking.any { id.contains(it) }
}.map { m ->
val id = m["id"]?.jsonPrimitive?.content ?: ""
JsonObject(m.toMutableMap().apply { this["id"] = JsonPrimitive(id + "-no-think") })
}
if (copies.isEmpty()) return raw
root["data"] = JsonArray(data + copies)
return JsonObject(root).toString()
}