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.ApplicationCall
|
||||||
import io.ktor.server.application.call
|
import io.ktor.server.application.call
|
||||||
import io.ktor.server.application.install
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.request.httpMethod
|
||||||
import io.ktor.server.request.receiveText
|
import io.ktor.server.request.receiveText
|
||||||
|
import io.ktor.server.request.uri
|
||||||
import io.ktor.server.response.respondBytesWriter
|
import io.ktor.server.response.respondBytesWriter
|
||||||
import io.ktor.server.response.respondText
|
import io.ktor.server.response.respondText
|
||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
@@ -127,6 +129,7 @@ private suspend fun handleChat(
|
|||||||
active: Map<String, UpstreamCounter>,
|
active: Map<String, UpstreamCounter>,
|
||||||
http: HttpClient,
|
http: HttpClient,
|
||||||
) {
|
) {
|
||||||
|
log.info { "[llm-proxy] chat ${call.request.httpMethod.value} ${call.request.uri} headers: ${formatHeadersForLog(call.request.headers)}" }
|
||||||
val raw = call.receiveText()
|
val raw = call.receiveText()
|
||||||
if (raw.isBlank()) {
|
if (raw.isBlank()) {
|
||||||
call.respondText(errorJson("empty body"), ContentType.Application.Json, HttpStatusCode.BadRequest)
|
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 url = provider.url.trimEnd('/') + "/chat/completions"
|
||||||
val providerKey = resolveEnv(provider.key)
|
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 failover = false
|
||||||
var responded = false
|
var responded = false
|
||||||
var upstreamStatus = 0
|
var upstreamStatus = 0
|
||||||
|
|
||||||
http.preparePost(url) {
|
http.preparePost(url) {
|
||||||
headers {
|
headers {
|
||||||
headersToForward(call.request.headers).forEach { (name, values) ->
|
forwardedHeaders.forEach { (name, values) ->
|
||||||
appendAll(name, values)
|
appendAll(name, values)
|
||||||
}
|
}
|
||||||
// Авторизация — всегда наша (ключ провайдера из конфига);
|
// Авторизация — всегда наша (ключ провайдера из конфига);
|
||||||
@@ -287,6 +298,9 @@ private val SKIP_HEADER_NAMES = setOf(
|
|||||||
// Authorization управляется прокси явно (ключ провайдера), клиентский
|
// Authorization управляется прокси явно (ключ провайдера), клиентский
|
||||||
// не пересылается
|
// не пересылается
|
||||||
"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 }
|
.filter { (name, _) -> name.lowercase() !in SKIP_HEADER_NAMES }
|
||||||
.associate { (name, values) -> name to values }
|
.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` на реальное имя апстрима + глубокий
|
* Сборка тела запроса: подмена `model` на реальное имя апстрима + глубокий
|
||||||
* послойный мерж `patch` в порядке provider → upstream → model.
|
* послойный мерж `patch` в порядке provider → upstream → model.
|
||||||
@@ -422,6 +459,7 @@ internal fun pickFreeUpstream(
|
|||||||
pool.firstOrNull { up -> up.id !in excluded && tryClaim(up, active) }
|
pool.firstOrNull { up -> up.id !in excluded && tryClaim(up, active) }
|
||||||
|
|
||||||
private suspend fun handleModels(call: ApplicationCall, config: Config) {
|
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 created = TimeSource.Monotonic.markNow().elapsedNow().inWholeSeconds
|
||||||
val data = config.models.map { m ->
|
val data = config.models.map { m ->
|
||||||
JsonObject(
|
JsonObject(
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ class ConfigLogicTest {
|
|||||||
"Proxy-Connection" to listOf("keep-alive"),
|
"Proxy-Connection" to listOf("keep-alive"),
|
||||||
"Upgrade" to listOf("h2c"),
|
"Upgrade" to listOf("h2c"),
|
||||||
"Authorization" to listOf("Bearer client-secret"),
|
"Authorization" to listOf("Bearer client-secret"),
|
||||||
|
"Content-Type" to listOf("application/x-www-form-urlencoded"),
|
||||||
"Accept" to listOf("*/*"),
|
"Accept" to listOf("*/*"),
|
||||||
)
|
)
|
||||||
val out = headersToForward(req)
|
val out = headersToForward(req)
|
||||||
@@ -297,6 +298,7 @@ class ConfigLogicTest {
|
|||||||
assertEquals(listOf("*/*"), out["Accept"])
|
assertEquals(listOf("*/*"), out["Accept"])
|
||||||
assertEquals(3, out.size)
|
assertEquals(3, out.size)
|
||||||
assertEquals(null, out["Authorization"])
|
assertEquals(null, out["Authorization"])
|
||||||
|
assertEquals(null, out["Content-Type"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -312,6 +314,32 @@ class ConfigLogicTest {
|
|||||||
assertEquals(listOf("s"), out["x-opencode-session"])
|
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
|
@Test
|
||||||
fun rebuildFromChunksPreservesAllUpstreamFields() {
|
fun rebuildFromChunksPreservesAllUpstreamFields() {
|
||||||
val sse = """
|
val sse = """
|
||||||
|
|||||||
Reference in New Issue
Block a user