Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efdce75ee9 |
@@ -14,7 +14,9 @@ import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.request.httpMethod
|
||||
import io.ktor.server.request.receiveText
|
||||
import io.ktor.server.request.uri
|
||||
import io.ktor.server.response.respondBytesWriter
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
@@ -127,6 +129,7 @@ private suspend fun handleChat(
|
||||
active: Map<String, UpstreamCounter>,
|
||||
http: HttpClient,
|
||||
) {
|
||||
log.info { "[llm-proxy] chat ${call.request.httpMethod.value} ${call.request.uri} headers: ${formatHeadersForLog(call.request.headers)}" }
|
||||
val raw = call.receiveText()
|
||||
if (raw.isBlank()) {
|
||||
call.respondText(errorJson("empty body"), ContentType.Application.Json, HttpStatusCode.BadRequest)
|
||||
@@ -183,13 +186,21 @@ private suspend fun handleChat(
|
||||
|
||||
val url = provider.url.trimEnd('/') + "/chat/completions"
|
||||
val providerKey = resolveEnv(provider.key)
|
||||
val forwardedHeaders = headersToForward(call.request.headers)
|
||||
val outgoingHeaders = forwardedHeaders.toMutableMap().apply {
|
||||
this["Content-Type"] = listOf("application/json")
|
||||
if (providerKey.isNotEmpty()) this["Authorization"] = listOf("Bearer $providerKey")
|
||||
}
|
||||
log.info {
|
||||
"[llm-proxy] chat model=$modelName upstream=${up.id} → $url headers: ${formatHeadersForLog(outgoingHeaders)}"
|
||||
}
|
||||
var failover = false
|
||||
var responded = false
|
||||
var upstreamStatus = 0
|
||||
|
||||
http.preparePost(url) {
|
||||
headers {
|
||||
headersToForward(call.request.headers).forEach { (name, values) ->
|
||||
forwardedHeaders.forEach { (name, values) ->
|
||||
appendAll(name, values)
|
||||
}
|
||||
// Авторизация — всегда наша (ключ провайдера из конфига);
|
||||
@@ -287,6 +298,9 @@ private val SKIP_HEADER_NAMES = setOf(
|
||||
// Authorization управляется прокси явно (ключ провайдера), клиентский
|
||||
// не пересылается
|
||||
"authorization",
|
||||
// Content-Type всегда наш (application/json: тело мержится как JSON),
|
||||
// клиентский не пересылаем, чтобы не ушло двух заголовков
|
||||
"content-type",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -302,6 +316,29 @@ internal fun headersToForward(request: Headers): Map<String, List<String>> =
|
||||
.filter { (name, _) -> name.lowercase() !in SKIP_HEADER_NAMES }
|
||||
.associate { (name, values) -> name to values }
|
||||
|
||||
/** Заголовки, значения которых маскируются в логах (секреты клиента). */
|
||||
private val SENSITIVE_HEADER_NAMES = setOf(
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"x-api-key",
|
||||
"api-key",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
)
|
||||
|
||||
/**
|
||||
* Заголовки в виде строки для лога (`name=v1|v2, ...`). Значения чувствительных
|
||||
* имён ([SENSITIVE_HEADER_NAMES]) маскируются `***`, чтобы не светить секреты.
|
||||
*/
|
||||
internal fun formatHeadersForLog(headers: Map<String, List<String>>): String =
|
||||
headers.entries.joinToString(", ") { (name, values) ->
|
||||
val shown = if (name.lowercase() in SENSITIVE_HEADER_NAMES) values.map { "***" } else values
|
||||
"$name=${shown.joinToString("|")}"
|
||||
}
|
||||
|
||||
internal fun formatHeadersForLog(headers: Headers): String =
|
||||
formatHeadersForLog(headers.entries().associate { (name, values) -> name to values })
|
||||
|
||||
/**
|
||||
* Сборка тела запроса: подмена `model` на реальное имя апстрима + глубокий
|
||||
* послойный мерж `patch` в порядке provider → upstream → model.
|
||||
@@ -422,6 +459,7 @@ internal fun pickFreeUpstream(
|
||||
pool.firstOrNull { up -> up.id !in excluded && tryClaim(up, active) }
|
||||
|
||||
private suspend fun handleModels(call: ApplicationCall, config: Config) {
|
||||
log.info { "[llm-proxy] models ${call.request.httpMethod.value} ${call.request.uri} headers: ${formatHeadersForLog(call.request.headers)}" }
|
||||
val created = TimeSource.Monotonic.markNow().elapsedNow().inWholeSeconds
|
||||
val data = config.models.map { m ->
|
||||
JsonObject(
|
||||
|
||||
@@ -289,6 +289,7 @@ class ConfigLogicTest {
|
||||
"Proxy-Connection" to listOf("keep-alive"),
|
||||
"Upgrade" to listOf("h2c"),
|
||||
"Authorization" to listOf("Bearer client-secret"),
|
||||
"Content-Type" to listOf("application/x-www-form-urlencoded"),
|
||||
"Accept" to listOf("*/*"),
|
||||
)
|
||||
val out = headersToForward(req)
|
||||
@@ -297,6 +298,7 @@ class ConfigLogicTest {
|
||||
assertEquals(listOf("*/*"), out["Accept"])
|
||||
assertEquals(3, out.size)
|
||||
assertEquals(null, out["Authorization"])
|
||||
assertEquals(null, out["Content-Type"])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -312,6 +314,32 @@ class ConfigLogicTest {
|
||||
assertEquals(listOf("s"), out["x-opencode-session"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatHeadersForLogMasksSecretsAndKeepsOthers() {
|
||||
val line = formatHeadersForLog(
|
||||
headersOf(
|
||||
"X-Opencode-Session" to listOf("abc-123"),
|
||||
"Authorization" to listOf("Bearer super-secret"),
|
||||
"x-api-key" to listOf("key-1"),
|
||||
"Cookie" to listOf("session=deadbeef"),
|
||||
),
|
||||
)
|
||||
assertTrue(line.contains("X-Opencode-Session=abc-123"))
|
||||
assertTrue(line.contains("Authorization=***"))
|
||||
assertTrue(line.contains("x-api-key=***"))
|
||||
assertTrue(line.contains("Cookie=***"))
|
||||
assertFalse(line.contains("super-secret"))
|
||||
assertFalse(line.contains("deadbeef"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatHeadersForLogJoinsMultipleValues() {
|
||||
val line = formatHeadersForLog(
|
||||
headersOf("X-Custom" to listOf("a", "b")),
|
||||
)
|
||||
assertEquals("X-Custom=a|b", line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rebuildFromChunksPreservesAllUpstreamFields() {
|
||||
val sse = """
|
||||
|
||||
Reference in New Issue
Block a user