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 = """