feat: пересылка клиентских заголовков в апстрим (кроме служебных); Authorization — только ключ провайдера
This commit is contained in:
@@ -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 = """
|
||||||
|
|||||||
Reference in New Issue
Block a user