llm: sanitize malformed request Unicode

This commit is contained in:
2026-08-15 21:22:45 +08:00
parent e3e296bd5e
commit 46bf992b6b
2 changed files with 71 additions and 1 deletions
+28 -1
View File
@@ -166,7 +166,9 @@ class ModelService(
extraBody?.forEach { (key, value) -> extraBody?.forEach { (key, value) ->
requestJson[key] = value requestJson[key] = value
} }
return JsonObject(requestJson).toString() // Ktor's text writer rejects lone UTF-16 surrogates. They can enter chat history through
// malformed gateway text, so normalize only invalid code units before encoding the body.
return JsonObject(requestJson).toString().replaceUnpairedSurrogates()
} }
internal fun decodeStreamChunk(rawJson: String): ChatCompletionChunk? { internal fun decodeStreamChunk(rawJson: String): ChatCompletionChunk? {
@@ -237,6 +239,31 @@ class ModelService(
if (normalized.length <= maxChars) normalized else normalized.take(maxChars) + "...[截断]" if (normalized.length <= maxChars) normalized else normalized.take(maxChars) + "...[截断]"
} }
private fun String.replaceUnpairedSurrogates(): String {
var output: StringBuilder? = null
var index = 0
while (index < length) {
val current = this[index]
when {
Character.isHighSurrogate(current) &&
index + 1 < length && Character.isLowSurrogate(this[index + 1]) -> {
output?.append(current)?.append(this[index + 1])
index += 2
}
Character.isSurrogate(current) -> {
if (output == null) output = StringBuilder(length).append(this, 0, index)
output.append('\uFFFD')
index++
}
else -> {
output?.append(current)
index++
}
}
}
return output?.toString() ?: this
}
private fun isSafetyRejection(code: String?, message: String?): Boolean { private fun isSafetyRejection(code: String?, message: String?): Boolean {
val normalizedCode = code.orEmpty().lowercase() val normalizedCode = code.orEmpty().lowercase()
val normalizedMessage = message.orEmpty().lowercase() val normalizedMessage = message.orEmpty().lowercase()
+43
View File
@@ -22,6 +22,7 @@ import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Protocol import okhttp3.Protocol
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
@@ -250,6 +251,48 @@ class ModelServiceTest {
} }
} }
@Test
fun sendsRequestsContainingUnpairedSurrogates() = runBlocking {
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
val receivedBody = AtomicReference<String>()
val responseBody = listOf(
"""data: {"id":"chunk-1","object":"chat.completion.chunk","created":1,"model":"test-model","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]}""",
"data: [DONE]",
"",
).joinToString("\n\n").toByteArray()
server.createContext("/v1/chat/completions") { exchange ->
receivedBody.set(String(exchange.requestBody.readBytes(), Charsets.UTF_8))
exchange.responseHeaders.add("Content-Type", "text/event-stream")
exchange.sendResponseHeaders(200, responseBody.size.toLong())
exchange.responseBody.use { body -> body.write(responseBody) }
}
server.start()
val localService = ModelService(
baseUrl = "http://127.0.0.1:${server.address.port}/v1/",
token = "test",
timeout = 1.seconds,
firstChunkTimeout = 1.seconds,
)
try {
val content = StringBuilder()
localService.chatCompletions(
ChatCompletionRequest(
model = ModelId("test-model"),
messages = listOf(ChatMessage.User("emoji \uD83D\uDE00 before\uD800middle\uDC00after")),
)
).collect { chunk ->
chunk.choices.firstOrNull()?.delta?.content?.let(content::append)
}
assertEquals("ok", content.toString())
assertTrue(receivedBody.get().contains("emoji \uD83D\uDE00 before\uFFFDmiddle\uFFFDafter"))
} finally {
localService.httpClient.close()
server.stop(0)
}
}
@Test @Test
fun queuesFlowsBeforeStartingWork() = runBlocking { fun queuesFlowsBeforeStartingWork() = runBlocking {
val active = AtomicInteger() val active = AtomicInteger()