diff --git a/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt b/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt index 5f6ea69..05ac8fb 100644 --- a/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt +++ b/src/commonMain/kotlin/pw/binom/llmproxy/Main.kt @@ -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, 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> = .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 = + 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( diff --git a/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt b/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt index 5b5baee..bef175a 100644 --- a/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt +++ b/src/commonTest/kotlin/pw/binom/llmproxy/ConfigLogicTest.kt @@ -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 = """