feat: пересылка клиентских заголовков в апстрим (кроме служебных); Authorization — только ключ провайдера

This commit is contained in:
2026-09-11 03:09:18 +03:00
parent c3aaa914fb
commit 0fb99060f9
2 changed files with 80 additions and 3 deletions
@@ -2,8 +2,9 @@ package pw.binom.llmproxy
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body 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.utils.io.readAvailable
import io.ktor.http.Headers
import io.ktor.client.request.preparePost import io.ktor.client.request.preparePost
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
@@ -181,13 +182,24 @@ private suspend fun handleChat(
} }
val url = provider.url.trimEnd('/') + "/chat/completions" val url = provider.url.trimEnd('/') + "/chat/completions"
val providerKey = resolveEnv(provider.key)
var failover = false var failover = false
var responded = false var responded = false
var upstreamStatus = 0 var upstreamStatus = 0
http.preparePost(url) { http.preparePost(url) {
header("Authorization", "Bearer ${resolveEnv(provider.key)}") headers {
header("Content-Type", "application/json") 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()) setBody(forwarded.toString())
}.execute { resp -> }.execute { resp ->
upstreamStatus = resp.status.value upstreamStatus = resp.status.value
@@ -262,6 +274,34 @@ private suspend fun handleChat(
private fun errorJson(msg: String): String = private fun errorJson(msg: String): String =
"""{"error":{"message":"$msg"}}""" """{"error":{"message":"$msg"}}"""
/** Hop-by-hop и прокси-специфичные заголовки, которые НЕ пересылаются в апстрим. */
private val SKIP_HEADER_NAMES = setOf(
"host",
"content-length",
"transfer-encoding",
"te",
"connection",
"proxy-connection",
"keep-alive",
"upgrade",
// Authorization управляется прокси явно (ключ провайдера), клиентский
// не пересылается
"authorization",
)
/**
* Заголовки входящего запроса для пересылки в апстрим: все, кроме служебных
* ([SKIP_HEADER_NAMES]). Тело может быть изменено `patch`, а соединение до
* апстрима — другое, поэтому Content-Length/Transfer-Encoding/Host/Connection
* управляет сам прокси (клиентский Ktor выставит свои автоматически).
* `Authorization` тоже в списке пропусков — прокси управляет им явно
* (ключ провайдера из конфига; клиентский не пересылается).
*/
internal fun headersToForward(request: Headers): Map<String, List<String>> =
request.entries()
.filter { (name, _) -> name.lowercase() !in SKIP_HEADER_NAMES }
.associate { (name, values) -> name to values }
/** /**
* Сборка тела запроса: подмена `model` на реальное имя апстрима + глубокий * Сборка тела запроса: подмена `model` на реальное имя апстрима + глубокий
* послойный мерж `patch` в порядке provider → upstream → model. * послойный мерж `patch` в порядке provider → upstream → model.
@@ -4,6 +4,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
import io.ktor.http.headersOf
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
@@ -275,6 +276,42 @@ class ConfigLogicTest {
assertEquals(null, pickFreeUpstream(pool, active, emptySet())?.id) assertEquals(null, pickFreeUpstream(pool, active, emptySet())?.id)
} }
@Test
fun headersToForwardKeepsUnknownAndDropsServiceHeaders() {
val req = headersOf(
"X-Opencode-Session" to listOf("abc-123"),
"X-Custom" to listOf("a", "b"),
"Host" to listOf("proxy:8100"),
"Content-Length" to listOf("42"),
"Transfer-Encoding" to listOf("chunked"),
"Connection" to listOf("keep-alive"),
"TE" to listOf("trailers"),
"Proxy-Connection" to listOf("keep-alive"),
"Upgrade" to listOf("h2c"),
"Authorization" to listOf("Bearer client-secret"),
"Accept" to listOf("*/*"),
)
val out = headersToForward(req)
assertEquals(listOf("abc-123"), out["X-Opencode-Session"])
assertEquals(listOf("a", "b"), out["X-Custom"])
assertEquals(listOf("*/*"), out["Accept"])
assertEquals(3, out.size)
assertEquals(null, out["Authorization"])
}
@Test
fun headersToForwardIsCaseInsensitiveOnSkipSet() {
val out = headersToForward(
headersOf(
"HOST" to listOf("x"),
"Content-length" to listOf("1"),
"x-opencode-session" to listOf("s"),
),
)
assertEquals(1, out.size)
assertEquals(listOf("s"), out["x-opencode-session"])
}
@Test @Test
fun rebuildFromChunksPreservesAllUpstreamFields() { fun rebuildFromChunksPreservesAllUpstreamFields() {
val sse = """ val sse = """