From 0fb99060f9f2c57e2e80bd7f4fd2600eee9cf0e4 Mon Sep 17 00:00:00 2001 From: subochev Date: Fri, 11 Sep 2026 03:09:18 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D1=85=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=B2=20=D0=B0=D0=BF=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BC=20(=D0=BA=D1=80=D0=BE=D0=BC=D0=B5=20=D1=81=D0=BB?= =?UTF-8?q?=D1=83=D0=B6=D0=B5=D0=B1=D0=BD=D1=8B=D1=85);=20Authorization=20?= =?UTF-8?q?=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/pw/binom/llmproxy/Main.kt | 46 +++++++++++++++++-- .../pw/binom/llmproxy/ConfigLogicTest.kt | 37 +++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt b/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt index e332eed..5f6ea69 100644 --- a/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt +++ b/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt @@ -2,8 +2,9 @@ package pw.binom.llmproxy import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.request.header +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 @@ -181,13 +182,24 @@ private suspend fun handleChat( } val url = provider.url.trimEnd('/') + "/chat/completions" + val providerKey = resolveEnv(provider.key) var failover = false var responded = false var upstreamStatus = 0 http.preparePost(url) { - header("Authorization", "Bearer ${resolveEnv(provider.key)}") - header("Content-Type", "application/json") + 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 @@ -262,6 +274,34 @@ private suspend fun handleChat( 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> = + request.entries() + .filter { (name, _) -> name.lowercase() !in SKIP_HEADER_NAMES } + .associate { (name, values) -> name to values } + /** * Сборка тела запроса: подмена `model` на реальное имя апстрима + глубокий * послойный мерж `patch` в порядке provider → upstream → model. diff --git a/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt b/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt index 7e5c593..5b5baee 100644 --- a/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt +++ b/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt @@ -4,6 +4,7 @@ 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 @@ -275,6 +276,42 @@ class ConfigLogicTest { 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 = """