feat: пересылка клиентских заголовков в апстрим (кроме служебных); Authorization — только ключ провайдера
This commit is contained in:
@@ -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<String, List<String>> =
|
||||
request.entries()
|
||||
.filter { (name, _) -> name.lowercase() !in SKIP_HEADER_NAMES }
|
||||
.associate { (name, values) -> name to values }
|
||||
|
||||
/**
|
||||
* Сборка тела запроса: подмена `model` на реальное имя апстрима + глубокий
|
||||
* послойный мерж `patch` в порядке provider → upstream → model.
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
Reference in New Issue
Block a user