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.LineEnding import io.ktor.utils.io.readAvailable import io.ktor.utils.io.readLine 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.httpMethod import io.ktor.server.request.receiveText import io.ktor.server.request.uri 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() val sessions = SessionRegistry() startServer(config.server.host, config.server.port) { proxyModule(config, providersById, upstreamsById, active, sessions, http) } } private val json = Json { ignoreUnknownKeys = true } fun Application.proxyModule( config: Config, providersById: Map, upstreamsById: Map, active: Map, sessions: SessionRegistry, http: HttpClient, ) { routing { post("/v1/chat/completions") { handleChat(call, config, providersById, upstreamsById, active, sessions, http) } get("/v1/models") { handleModels(call, config) } } } private suspend fun handleChat( call: ApplicationCall, config: Config, providersById: Map, upstreamsById: Map, active: Map, sessions: SessionRegistry, http: HttpClient, ) { log.info { "[llm-proxy] chat ${call.request.httpMethod.value} ${call.request.uri} headers: ${formatHeadersForLog(call.request.headers)}" } 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() 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 patched0 = buildBody(bodyJson, provider, up, modelConf) val patched = applyReasoningField(patched0, provider.reasoning_field, provider.reasoning_empty_ok) 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) val sessionHeader = provider.session_header // Клиентский одноимённый заголовок не пробрасываем: при метке // значение x-opencode-session всегда вычисляем сами (LCP по истории). val forwardedHeaders = headersToForward(call.request.headers) .filterKeys { sessionHeader == null || !it.equals(sessionHeader, ignoreCase = true) } val sessionId = sessionHeader?.let { sessions.resolve(sessionPrefixHashes(forwarded)) } val outgoingHeaders = forwardedHeaders.toMutableMap().apply { this["Content-Type"] = listOf("application/json") if (providerKey.isNotEmpty()) this["Authorization"] = listOf("Bearer $providerKey") if (sessionHeader != null && sessionId != null) this[sessionHeader] = listOf(sessionId) } log.info { "[llm-proxy] chat model=$modelName upstream=${up.id} session=${sessionId ?: "-"} → $url headers: ${formatHeadersForLog(outgoingHeaders)}" } var failover = false var responded = false var upstreamStatus = 0 http.preparePost(url) { headers { forwardedHeaders.forEach { (name, values) -> appendAll(name, values) } // Авторизация — всегда наша (ключ провайдера из конфига); // клиентский Authorization не пересылается. Если у провайдера // ключ не задан — Authorization не отправляем вовсе. if (providerKey.isNotEmpty()) { set("Authorization", "Bearer $providerKey") } set("Content-Type", "application/json") if (sessionHeader != null && sessionId != null) { set(sessionHeader, sessionId) } } setBody(forwarded.toString()) }.execute { resp -> upstreamStatus = resp.status.value if (upstreamStatus >= 500 || upstreamStatus == 429 || upstreamStatus == 402) { log.warn { "[llm-proxy] model=$modelName upstream=${up.id} вернул $upstreamStatus → фейловер" } failed.add(up.id) failover = true return@execute } if (upstreamStatus in 400..499) { // 4xx — JSON-тело, не стрим: читаем безопасно и отдаём клиенту как есть. val errorBody = runCatching { resp.body() }.getOrDefault("") log.warn { "[llm-proxy] model=$modelName upstream=${up.id} вернул $upstreamStatus errorBody=${errorBody.take(500)}" } val ct = resp.headers["Content-Type"] ?: "application/json" call.respondText(errorBody, ContentType.parse(ct), HttpStatusCode.fromValue(upstreamStatus)) responded = true return@execute } responded = true if (clientWantsStream) { val ct = resp.headers["Content-Type"] ?: "text/event-stream" val status = HttpStatusCode.fromValue(upstreamStatus) val thinkMode = effectiveThinkTags(up, provider) call.respondBytesWriter(ContentType.parse(ct), status) { val ch = resp.body() if (thinkMode == "off") { // Флажок не выставлен — сырой байтовый passthrough + выравнивание конца потока. streamRawWithDoneContract(ch) } else { streamSseWithThinkTags(ch, thinkMode) } } 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() val thinkMode = effectiveThinkTags(up, provider) val rebuilt = if (ct.contains("text/event-stream")) rebuildFromChunks(full) else full val out = if (thinkMode != "off") ( runCatching { transformThinkMessage(json.parseToJsonElement(rebuilt).jsonObject, thinkMode) } .getOrNull() ?: rebuilt ) else rebuilt 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::class.simpleName}: ${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", // Content-Type всегда наш (application/json: тело мержится как JSON), // клиентский не пересылаем, чтобы не ушло двух заголовков "content-type", ) /** * Заголовки входящего запроса для пересылки в апстрим: все, кроме служебных * ([SKIP_HEADER_NAMES]). Тело может быть изменено `patch`, а соединение до * апстрима — другое, поэтому Content-Length/Transfer-Encoding/Host/Connection * управляет сам прокси (клиентский Ktor выставит свои автоматически). * `Authorization` тоже в списке пропусков — прокси управляет им явно * (ключ провайдера из конфига; клиентский не пересылается). */ internal fun headersToForward(request: Headers): Map> = request.entries() .filter { (name, _) -> name.lowercase() !in SKIP_HEADER_NAMES } .associate { (name, values) -> name to values } /** Заголовки, значения которых маскируются в логах (секреты клиента). */ private val SENSITIVE_HEADER_NAMES = setOf( "authorization", "proxy-authorization", "x-api-key", "api-key", "cookie", "set-cookie", ) /** * Заголовки в виде строки для лога (`name=v1|v2, ...`). Значения чувствительных * имён ([SENSITIVE_HEADER_NAMES]) маскируются `***`, чтобы не светить секреты. */ internal fun formatHeadersForLog(headers: Map>): String = headers.entries.joinToString(", ") { (name, values) -> val shown = if (name.lowercase() in SENSITIVE_HEADER_NAMES) values.map { "***" } else values "$name=${shown.joinToString("|")}" } internal fun formatHeadersForLog(headers: Headers): String = formatHeadersForLog(headers.entries().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): Boolean = active.getValue(up.id).tryClaim() /** * Эффективный лимит конкурентности апстрима: значение у апстрима (модели), если * задано; иначе у провайдера; иначе безлимит. */ internal fun effectiveConcurrencyLimit(up: UpstreamConf, provider: ProviderConf?): Int = up.max_concurrency ?: provider?.max_concurrency ?: Int.MAX_VALUE /** * Эффективные think_tags апстрима: значение у апстрима, если задано; иначе у * провайдера; иначе "off". Значения "true" трактуются как "split", "false" и * любое неизвестное/пустое — как "off". */ internal fun effectiveThinkTags(up: UpstreamConf, provider: ProviderConf?): String { val raw = up.think_tags ?: provider?.think_tags ?: "off" return when (raw) { "split", "strip" -> raw "true" -> "split" else -> "off" } } /** Освободить слот апстрима (в finally по завершении проксирования). */ internal fun release(up: UpstreamConf, active: Map) { 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, active: Map, excluded: Set, ): UpstreamConf? = pool.firstOrNull { up -> up.id !in excluded && tryClaim(up, active) } private suspend fun handleModels(call: ApplicationCall, config: Config) { log.info { "[llm-proxy] models ${call.request.httpMethod.value} ${call.request.uri} headers: ${formatHeadersForLog(call.request.headers)}" } 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() } val choices = mutableMapOf() 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"] as? JsonPrimitive)?.content ?: "" if (created == null) created = (obj["created"] as? JsonPrimitive)?.content?.toLongOrNull() if (model.isEmpty()) model = (obj["model"] as? JsonPrimitive)?.content ?: "" if (systemFingerprint == null) systemFingerprint = (obj["system_fingerprint"] as? JsonPrimitive)?.content if (serviceTier == null) serviceTier = (obj["service_tier"] as? 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"] as? JsonArray ?: return@forEach for (ch in chArr) { val c = ch as? JsonObject ?: continue val idx = (c["index"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0 val mc = choices.getOrPut(idx) { MutableChoice() } val delta = c["delta"] as? JsonObject if (delta != null) { if (mc.role == null) mc.role = (delta["role"] as? JsonPrimitive)?.content (delta["content"] as? JsonPrimitive)?.content?.takeIf { it != "null" }?.let { mc.content.append(it) } (delta["reasoning_content"] as? JsonPrimitive)?.content?.takeIf { it != "null" }?.let { mc.reasoning.append(it) } (delta["tool_calls"] as? JsonArray)?.forEach { tc -> (tc as? JsonObject)?.let { mc.toolCalls.add(it) } } } (c["finish_reason"] as? 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( "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( "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( "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() } /** * Пересобрать полный chat.completion (обычный JSON или результат * [rebuildFromChunks]): по каждому choice взять `message.content` — только если * это JSON-строка (массив частей не трогаем) — и прогнать целиком через * [ThinkTagSplitter] (feed + finish). Остаток возвращается в `message.content`, * вырезанное — в `message.reasoning_content` (режим split; при strip не * добавляем). Если `reasoning_content` уже был непустой строкой — новое * дописываем в конец существующего, не теряя прежнее. Ни один choice не * изменился → null (отдать исходную строку как есть). */ internal fun transformThinkMessage(obj: JsonObject, thinkMode: String): String? { val choices = obj["choices"] as? JsonArray ?: return null val addReasoning = thinkMode == "split" var changed = false val newChoices = choices.map { choiceEl -> val choice = choiceEl as? JsonObject ?: return@map choiceEl val message = choice["message"] as? JsonObject ?: return@map choiceEl val contentStr = (message["content"] as? JsonPrimitive)?.takeIf { it.isString }?.content if (contentStr == null) return@map choiceEl val splitter = ThinkTagSplitter(thinkMode) val (outContent, outReasoning) = splitter.feed(contentStr) val (tailContent, tailReasoning) = splitter.finish() val reasoning = outReasoning + tailReasoning changed = true val newMessage = message.toMutableMap().apply { this["content"] = JsonPrimitive(outContent + tailContent) if (addReasoning && reasoning.isNotEmpty()) { val existing = (this["reasoning_content"] as? JsonPrimitive)?.takeIf { it.isString }?.content this["reasoning_content"] = JsonPrimitive((existing ?: "") + reasoning) } } JsonObject(choice.toMutableMap().apply { this["message"] = JsonObject(newMessage) }) } if (!changed) return null return JsonObject(obj.toMutableMap().apply { this["choices"] = JsonArray(newChoices) }).toString() } /** * Достроить нативное поле рассуждений для апстримов, которые его требуют * (Console Go / deepseek в thinking-режиме): если у провайдера объявлено * `reasoningField`, то в каждом assistant-сообщении с непустым `tool_calls` * добавляем это поле, ЕСЛИ его там ещё нет. Текст берём из `reasoning` * (строка) или из `reasoning_details` (элементы с `type == "reasoning.text"`). * Существующее непустое поле НЕ перезаписываем. При полном отсутствии текста * пишем пустую строку, только если `emptyOk`. * Тело возвращается без изменений (тот же объект), если менять нечего. */ internal fun applyReasoningField(body: JsonObject, field: String?, emptyOk: Boolean): JsonObject { if (field == null) return body val messages = body["messages"] as? JsonArray ?: return body var changed = false val newMessages = messages.map { el -> val msg = el as? JsonObject ?: return@map el val role = (msg["role"] as? JsonPrimitive)?.takeIf { it.isString }?.content if (role != "assistant") return@map el val toolCalls = msg["tool_calls"] as? JsonArray if (toolCalls == null || toolCalls.isEmpty()) return@map el val existing = (msg[field] as? JsonPrimitive)?.takeIf { it.isString }?.content if (existing != null && existing.isNotEmpty()) return@map el val text = reasoningTextOf(msg) if (text.isEmpty() && !emptyOk) return@map el changed = true JsonObject(msg.toMutableMap().apply { this[field] = JsonPrimitive(text) }) } if (!changed) return body return JsonObject(body.toMutableMap().apply { this["messages"] = JsonArray(newMessages) }) } /** * Текст рассуждений сообщения: `reasoning` (если непустая строка), иначе * склейка `reasoning_details[*].text` через "\n" — только элементы, у которых * `type` отсутствует или равен "reasoning.text". */ internal fun reasoningTextOf(msg: JsonObject): String { val reasoning = (msg["reasoning"] as? JsonPrimitive)?.takeIf { it.isString }?.content if (reasoning != null && reasoning.isNotEmpty()) return reasoning val details = msg["reasoning_details"] as? JsonArray ?: return "" val parts = details.mapNotNull { el -> val d = el as? JsonObject ?: return@mapNotNull null val type = (d["type"] as? JsonPrimitive)?.takeIf { it.isString }?.content if (type != null && type != "reasoning.text") return@mapNotNull null (d["text"] as? JsonPrimitive)?.takeIf { it.isString }?.content } return parts.joinToString("\n") } /** Терминальный маркер SSE, который клиенты (в т.ч. Bifrost) ждут как конец потока. */ private const val SSE_DONE_MARKER = "data: [DONE]\n\n" /** Размер скользящего окна детектора: маркер может разрезаться границей чтения. */ private const val STREAM_SCAN_WINDOW = 128 /** * Есть ли в тексте `finish_reason` со значением, отличным от `null`. Экранированные * вхождения (`\"finish_reason\\\":\\\"stop\\\"` внутри содержимого ответа) не считаются: * смотрим только на неэкранированную кавычку. */ internal fun hasNonNullFinishReasonText(text: String): Boolean { var from = 0 while (true) { val i = text.indexOf("finish_reason", from) if (i < 0) return false val quote = i - 1 val escaped = quote >= 0 && text[quote] == '"' && quote > 0 && text[quote - 1] == '\\' if (!escaped) { val rest = text.substring(i + "finish_reason".length) .dropWhile { it == ' ' || it == ':' || it == '"' } if (!rest.startsWith("null")) return true } from = i + 1 } } /** Структурная проверка чанка: в `choices[*].finish_reason` есть непустая строка. */ internal fun hasFinishReason(obj: JsonObject): Boolean = (obj["choices"] as? JsonArray)?.any { el -> (el as? JsonObject)?.let { choice -> val fr = (choice["finish_reason"] as? JsonPrimitive)?.takeIf { it.isString }?.content !fr.isNullOrEmpty() } ?: false } ?: false /** * Детектор контракта конца SSE для сырого passthrough-потока (`think_tags: off`). * Копит скользящее окно последних байт, чтобы маркер `finish_reason`/`[DONE]`, * разрезанный границей чтения, всё равно был распознан, и запоминает два факта: * видели ли непустой `finish_reason` и видели ли `[DONE]`. */ internal class StreamEndDetector(private val windowSize: Int = STREAM_SCAN_WINDOW) { var sawFinishReason = false private set var sawDone = false private set private var window: String = "" fun feed(bytes: ByteArray, from: Int = 0, length: Int = bytes.size) { if (length <= 0) return val scan = window + bytes.decodeToString(from, from + length) if (!sawDone && scan.contains("[DONE]")) sawDone = true if (!sawFinishReason && hasNonNullFinishReasonText(scan)) sawFinishReason = true window = if (scan.length > windowSize) scan.substring(scan.length - windowSize) else scan } /** * Маркер нужен, только если апстрим закрыл поток после штатного `finish_reason`, * но сам `[DONE]` не прислал. При обрыве БЕЗ `finish_reason` маркер НЕ дописываем. */ val missingDoneMarker: Boolean get() = sawFinishReason && !sawDone } /** * Сырой байтовый passthrough стрима (`think_tags: off`) с выравниванием контракта * конца потока: байты уходят клиенту как есть, а если апстрим закрыл поток, не * прислав `data: [DONE]`, но `finish_reason` в потоке был — дописываем маркер. */ internal suspend fun ByteWriteChannel.streamRawWithDoneContract(source: ByteReadChannel) { val detector = StreamEndDetector() val buf = ByteArray(8192) while (true) { val n = source.readAvailable(buf) if (n == -1) break if (n > 0) { writeFully(buf, 0, n) flush() detector.feed(buf, 0, n) } } if (detector.missingDoneMarker) { emitUtf8(SSE_DONE_MARKER) flush() } } /** * Построчный разбор SSE-стрима с рассечением think-тегов. Строки, не начинающиеся * с `data:`, и `data: [DONE]` уходят клиенту без изменений (с `\n`). Прочие * `data:`-строки парсятся и прогоняются через [transformThinkChunk]; результат * записывается как `data: \n\n` (событие-граница SSE), а при ошибке парса — * исходная строка. Каждую строку сразу `flush()`, чтобы стрим не «залипал» в * буфере. В конце потока накопленные хвосты сплиттеров сбрасываются финиш-чанком. */ internal suspend fun ByteWriteChannel.streamSseWithThinkTags(source: ByteReadChannel, thinkMode: String) { val splitters = mutableMapOf() val addReasoning = thinkMode == "split" var sawDone = false var sawFinishReason = false while (true) { val line = source.readLine(LineEnding.Lenient) ?: break when { line.startsWith("data:") -> { val payload = line.removePrefix("data:").trim() if (payload == "[DONE]") { sawDone = true emitUtf8(SSE_DONE_MARKER) } else { val obj = runCatching { json.parseToJsonElement(payload).jsonObject }.getOrNull() if (obj != null && hasFinishReason(obj)) sawFinishReason = true val out = obj?.let { transformThinkChunk(it, splitters, thinkMode, addReasoning) } if (out == null) emitUtf8("$line\n\n") else emitUtf8("data: $out\n\n") } } else -> emitUtf8("$line\n") } flush() } if (thinkMode == "split" || thinkMode == "strip") { splitters.forEach { (idx, sp) -> val (tail, reasoning) = sp.finish() val hasContent = tail.isNotEmpty() val hasReasoning = addReasoning && reasoning.isNotEmpty() if (!hasContent && !hasReasoning) return@forEach val delta = mutableMapOf() if (hasContent) delta["content"] = JsonPrimitive(tail) if (hasReasoning) delta["reasoning_content"] = JsonPrimitive(reasoning) val chunk = JsonObject( mutableMapOf( "choices" to JsonArray( listOf( JsonObject( mutableMapOf( "index" to JsonPrimitive(idx), "delta" to JsonObject(delta), ), ), ), ), ), ) emitUtf8("data: $chunk\n\n") flush() } } // После сброса хвостов дописываем маркер, только если апстрим его не прислал, // а `finish_reason` в потоке был (при обрыве без него маркер НЕ дописываем). if (sawFinishReason && !sawDone) { emitUtf8(SSE_DONE_MARKER) flush() } } /** * Пересобрать SSE-чанк: по каждому choice (ключ `index`, дефолт 0) взять * `delta.content` (только если это JSON-строка; массив частей не трогаем) и * прогнать через [ThinkTagSplitter] для этого index. Остаток возвращается в * `delta.content` (поле убирается, если пустое); вырезанное — в * `delta.reasoning_content` (только режим split, при strip не добавляем). * Чанк без `choices` или без строкового `delta.content` не меняется — * возвращается null (отдать исходную строку как есть). */ internal fun transformThinkChunk( obj: JsonObject, splitters: MutableMap, thinkMode: String, addReasoning: Boolean, ): String? { val choices = obj["choices"] as? JsonArray ?: return null var changed = false val newChoices = choices.map { choiceEl -> val choice = choiceEl as? JsonObject ?: return@map choiceEl val delta = choice["delta"] as? JsonObject val contentStr = (delta?.get("content") as? JsonPrimitive)?.takeIf { it.isString }?.content if (contentStr == null) return@map choiceEl val idx = (choice["index"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0 val splitter = splitters.getOrPut(idx) { ThinkTagSplitter(thinkMode) } val (newContent, reasoning) = splitter.feed(contentStr) changed = true val newDelta = delta.toMutableMap() if (newContent.isEmpty()) newDelta.remove("content") else newDelta["content"] = JsonPrimitive(newContent) if (addReasoning && reasoning.isNotEmpty()) newDelta["reasoning_content"] = JsonPrimitive(reasoning) JsonObject(choice.toMutableMap().apply { this["delta"] = JsonObject(newDelta) }) } if (!changed) return null return JsonObject(obj.toMutableMap().apply { this["choices"] = JsonArray(newChoices) }).toString() } /** Записать строку как UTF-8 байты (KMP-безопасно, без java.io). */ private suspend fun ByteWriteChannel.emitUtf8(text: String) { val bytes = text.encodeToByteArray() writeFully(bytes, 0, bytes.size) } @Serializable data class ProviderConf( val id: String, val url: String, val key: String = "", val max_concurrency: Int? = null, val patch: JsonObject? = null, val session_header: String? = null, val think_tags: String? = null, val reasoning_field: String? = null, val reasoning_empty_ok: Boolean = false, ) data class UpstreamConf( val id: String, val provider: String, val model: String, val max_concurrency: Int? = null, val patch: JsonObject? = null, val think_tags: String? = null, ) data class ModelConf( val name: String, val upstreams: List, 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, val upstreams: List, val models: List, ) /** * Ручной разбор 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 { 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 }, session_header = m.strOrNull("session_header"), think_tags = m.strOrNull("think_tags"), reasoning_field = m.strOrNull("reasoning_field"), reasoning_empty_ok = m.strOrNull("reasoning_empty_ok")?.toBooleanStrictOrNull() ?: false, ) } 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 }, think_tags = m.strOrNull("think_tags"), ) } 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 (ключи YAML — строковые скаляры). */ internal fun YamlMap.toMap(): Map = entries.associate { (it.key as YamlLiteral).content to it.value } internal fun Map.str(key: String): String = (this[key] as? YamlLiteral)?.content ?: error("config: missing string '$key'") internal fun Map.strOrNull(key: String): String? = (this[key] as? YamlLiteral)?.content internal fun Map.yamlMapOrNull(key: String): YamlMap? = this[key] as? YamlMap