11 Commits
1 ... 6

Author SHA1 Message Date
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
Hermes Agent 0843db20db fix: Dockerfile COPY build/libs/llm-proxy.jar (CI-путь)
Build LLM Proxy / Build and push (release) Successful in 27s
2026-08-20 13:18:58 +03:00
19 changed files with 1140 additions and 254 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: 'Build jar'
uses: https://git.binom.pw/subochev/devops/build-gradle@main
with:
target: shadowJar
target: fatJar
- name: 'Build Docker Image'
uses: https://git.binom.pw/subochev/devops/build-docker@main
with:
+3
View File
@@ -2,3 +2,6 @@ build/
.gradle/
*.jar
!gradle/wrapper/gradle-wrapper.jar
# локальный конфиг с секретами (ключ API) — не коммитим
config.yaml
+2 -2
View File
@@ -1,5 +1,5 @@
FROM docker.io/library/eclipse-temurin:17-jre-alpine
COPY llm-proxy.jar /app/llm-proxy.jar
FROM docker.io/library/eclipse-temurin:21-jre-alpine
COPY build/libs/llm-proxy.jar /app/llm-proxy.jar
WORKDIR /app
EXPOSE 8100
ENTRYPOINT ["java", "-jar", "/app/llm-proxy.jar"]
+26 -23
View File
@@ -1,37 +1,40 @@
# llm-proxy
Прозрачная прослойка OpenAI API перед RouterAI (routerai.ru). Принимает
`/v1/chat/completions` и `/v1/models`, модифицирует запрос и проксирует дальше.
Прозрачная прослойка/роутер OpenAI API. Принимает `/v1/chat/completions` и
`/v1/models`, маршрутизирует между объявленными в YAML апстримами по внутреннему
учёту конкурентности и мержит `patch`-слои (`provider` → `upstream` → `model`) в
тело запроса. Подробная спецификация конфига — в `CONFIG.md`.
## Что добавляет в запрос
## Конфиг
1. **`provider.ignore`** — исключение дорогих провайдеров (список из env
`EXCLUDED_PROVIDERS`, напр. `deepseek`) + `allow_fallbacks: false`
(иначе ignore не жёсткий — RouterAI может уйти на исключённого резервной
попыткой).
2. **`reasoning: {"enabled": false}`** — для моделей с суффиксом `-no-think`
(отключение думанья; проверено на `deepseek/deepseek-v4-flash-0731`:
работает `reasoning.enabled=false`, не работает `include_reasoning=false`).
Суффикс снимается перед отправкой — RouterAI видит оригинальное имя.
Конфигурация — в YAML-файле (по умолчанию `config.yaml` в каталоге проекта,
CWD; переопределяется env `CONFIG_PATH`). Блоки: `server` (интерфейс биндинга
`host` + `port`, дефолты `0.0.0.0` / `8100`), `providers`, `upstreams`,
`models`. Старый механизм env-переменных (`UPSTREAM_URL`, `ROUTER_API_KEY`,
`EXCLUDED_PROVIDERS`, `THINKING_MODELS`, `PORT`) удалён — его поведение теперь
в декларативном `patch` и блоке `upstreams`, а порт/интерфейс — в блоке
`server`.
## Каталог /v1/models
Модели, чей id содержит любую из подстрок `THINKING_MODELS`, дублируются в
каталоге с суффиксом `-no-think` (напр. `deepseek/deepseek-v4-flash-0731-no-think`).
## Конфиг (env)
Лимит конкурентности `max_concurrency` можно задать и на провайдере (лимит по
умолчанию для его апстримов), и на апстриме (перекрывает провайдерский); без
обоих — безлимит.
| Переменная | Default | Описание |
|---|---|---|
| `PORT` | 8100 | Порт сервера |
| `UPSTREAM_URL` | `https://routerai.ru/api/v1` | Куда проксировать |
| `ROUTER_API_KEY` | — (обязателен) | Bearer-ключ RouterAI |
| `EXCLUDED_PROVIDERS` | пусто | slug'и провайдеров через запятую |
| `THINKING_MODELS` | `deepseek/deepseek-v4-flash-0731,deepseek/deepseek-v4-flash` | подстроки id «думающих» моделей |
| `CONFIG_PATH` | `config.yaml` (CWD) | Путь к YAML-конфигу |
| Ключ конфига | Default | Описание |
|---|---|---|
| `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).
- Запуск на 76.179 (llm-router): `podman-compose up -d` из `podman-compose.yaml`
(образ тянется из `images.binom.pw`, сеть `bifrost_default` — туда же Bifrost
+42 -23
View File
@@ -1,7 +1,6 @@
plugins {
kotlin("jvm") version "2.4.10"
application
id("com.gradleup.shadow") version "8.3.5"
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
group = "pw.binom"
@@ -11,31 +10,51 @@ repositories {
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 {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
jvm()
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 {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
tasks.withType<Test> {
useJUnitPlatform()
}
tasks.shadowJar {
tasks.register<Jar>("fatJar") {
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 (исключение
# дорогих провайдеров) + allow_fallbacks:false и reasoning.enabled:false для
# моделей с суффиксом "-no-think", проксирует на RouterAI. Ответ — как есть.
# llm-proxy — YAML-декларативный роутер OpenAI API (подробности в CONFIG.md).
# Принимает /v1/chat/completions и /v1/models, маршрутизирует между апстримами
# по внутреннему учёту конкурентности и мержит patch-слои в тело запроса.
#
# Запуск на 76.179 (llm-router): podman-compose up -d
# Образ тянется из нашего реестра (zot): images.binom.pw/llm-proxy:<tag>
services:
llm-proxy:
image: images.binom.pw/llm-proxy:latest
container_name: llm-proxy
restart: unless-stopped
network_mode: bifrost_default
networks:
- bifrost
environment:
- PORT=8100
- UPSTREAM_URL=https://routerai.ru/api/v1
- ROUTER_API_KEY=__SET_FROM_CONFIG_DB__
- EXCLUDED_PROVIDERS=deepseek
- THINKING_MODELS=deepseek/deepseek-v4-flash-0731,deepseek/deepseek-v4-flash
# Путь к конфигу внутри контейнера (файл монтируется ниже).
- CONFIG_PATH=/config/config.yaml
volumes:
# Монтируем свой config.yaml (секреты — через ${...} внутри файла,
# резолвятся из 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,598 @@
package pw.binom.llmproxy
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.header
import io.ktor.utils.io.readAvailable
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"
var failover = false
var responded = false
var upstreamStatus = 0
http.preparePost(url) {
header("Authorization", "Bearer ${resolveEnv(provider.key)}")
header("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"}}"""
/**
* Сборка тела запроса: подмена `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,320 @@
package pw.binom.llmproxy
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
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 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()
}