fix(ai): sanitize unpaired surrogates in all chat protocols

Add ProviderShared.sanitizeSurrogates (removes lone high/low surrogates
that break JSON serialization, preserves valid emoji) and apply to every
model-visible text field: system prompts (joinText), user text,
assistant text/reasoning, tool result text, and tool call content across
OpenAI Chat, Open Responses, Anthropic Messages, Gemini, and Bedrock
Converse. Ensures providers like OpenAI, DeepSeek, Zai, and Amazon
Bedrock receive valid UTF-16.
This commit is contained in:
Aiden Cline
2026-08-23 22:18:54 -05:00
parent 4876f416cc
commit 27826c61a4
6 changed files with 59 additions and 31 deletions
@@ -438,7 +438,8 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
// Tool results may carry structured text, images, and documents. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fnUntraced(function* (item: Tool.Content) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
if (item.type === "text")
return { type: "text" as const, text: ProviderShared.sanitizeSurrogates(item.text) } satisfies AnthropicTextBlock
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
})
@@ -503,7 +504,7 @@ const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUp
role: "system" as const,
content: content.map((part) => ({
type: "text" as const,
text: part.text,
text: ProviderShared.sanitizeSurrogates(part.text),
cache_control: cacheControl(breakpoints, part.cache),
})),
}
@@ -524,7 +525,11 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
const part = yield* ProviderShared.wrappedSystemUpdate("Anthropic Messages", message)
const block = { type: "text" as const, text: part.text, cache_control: cacheControl(breakpoints, part.cache) }
const block = {
type: "text" as const,
text: ProviderShared.sanitizeSurrogates(part.text),
cache_control: cacheControl(breakpoints, part.cache),
}
const previous = messages.at(-1)
if (previous?.role === "user")
messages[messages.length - 1] = { role: "user", content: [...previous.content, block] }
@@ -536,7 +541,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicUserBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
content.push({ type: "text", text: ProviderShared.sanitizeSurrogates(part.text), cache_control: cacheControl(breakpoints, part.cache) })
continue
}
if (part.type === "media") {
@@ -553,7 +558,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicAssistantBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
content.push({ type: "text", text: ProviderShared.sanitizeSurrogates(part.text), cache_control: cacheControl(breakpoints, part.cache) })
continue
}
if (part.type === "reasoning") {
@@ -566,7 +571,11 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
content.push({ type: "redacted_thinking", data: redactedData })
continue
}
content.push({ type: "thinking", thinking: part.text, signature })
content.push({
type: "thinking",
thinking: ProviderShared.sanitizeSurrogates(part.text),
signature,
})
continue
}
if (part.type === "tool-call") {
@@ -659,7 +668,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
? undefined
: request.system.map((part) => ({
type: "text" as const,
text: part.text,
text: ProviderShared.sanitizeSurrogates(part.text),
cache_control: cacheControl(breakpoints, part.cache),
}))
const messages = yield* lowerMessages(request, breakpoints)
@@ -293,7 +293,7 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
const content: Array<Schema.Schema.Type<typeof BedrockToolResultContentItem>> = []
for (const item of part.result.value) {
if (item.type === "text") {
content.push({ text: item.text })
content.push({ text: ProviderShared.sanitizeSurrogates(item.text) })
continue
}
const media = yield* BedrockMedia.lower({
@@ -326,7 +326,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
for (const message of request.messages) {
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message)
const content = textWithCache(breakpoints, part.text, part.cache)
const content = textWithCache(breakpoints, ProviderShared.sanitizeSurrogates(part.text), part.cache)
const previous = messages.at(-1)
if (previous?.role === "user")
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
@@ -340,7 +340,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
if (!ProviderShared.supportsContent(part, ["text", "media"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"])
if (part.type === "text") {
content.push(...textWithCache(breakpoints, part.text, part.cache))
content.push(...textWithCache(breakpoints, ProviderShared.sanitizeSurrogates(part.text), part.cache))
continue
}
if (part.type === "media") {
@@ -365,7 +365,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
"tool-call",
])
if (part.type === "text") {
content.push(...textWithCache(breakpoints, part.text, part.cache))
content.push(...textWithCache(breakpoints, ProviderShared.sanitizeSurrogates(part.text), part.cache))
continue
}
if (part.type === "reasoning") {
@@ -375,7 +375,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
}
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
content.push({ reasoningContent: { reasoningText: { text: ProviderShared.sanitizeSurrogates(part.text), signature } } })
continue
}
if (part.type === "tool-call") {
@@ -409,7 +409,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
): BedrockSystemBlock[] =>
system.flatMap((part) => textWithCache(breakpoints, ProviderShared.sanitizeSurrogates(part.text), part.cache))
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
+4 -4
View File
@@ -273,7 +273,7 @@ const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
})
const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPart | MediaPart) {
if (part.type === "text") return { text: part.text }
if (part.type === "text") return { text: ProviderShared.sanitizeSurrogates(part.text) }
const media = ProviderShared.normalizeMedia(part)
return { inlineData: { mimeType: media.mime, data: media.base64 } }
})
@@ -335,11 +335,11 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
if (part.type === "text") {
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
parts.push({ text: ProviderShared.sanitizeSurrogates(part.text), thoughtSignature: thoughtSignature(part.providerMetadata) })
continue
}
if (part.type === "reasoning") {
parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
parts.push({ text: ProviderShared.sanitizeSurrogates(part.text), thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
continue
}
if (part.type === "tool-call") {
@@ -379,7 +379,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
continue
}
const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
const text = content.filter((item) => item.type === "text").map((item) => ProviderShared.sanitizeSurrogates(item.text))
const media: GeminiInlineDataPart[] = []
for (const item of content) {
if (item.type === "text") continue
+5 -5
View File
@@ -405,7 +405,7 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
return {
type: "reasoning",
id,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
summary: part.text.length > 0 ? [{ type: "summary_text", text: ProviderShared.sanitizeSurrogates(part.text) }] : [],
encrypted_content: encryptedContent,
}
}
@@ -440,7 +440,7 @@ const lowerUserContent = Effect.fnUntraced(function* (
request: LLMRequest,
extension: Extension,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "text") return { type: "input_text" as const, text: ProviderShared.sanitizeSurrogates(part.text) }
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
})
@@ -459,7 +459,7 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
request: LLMRequest,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
if (item.type === "text") return { type: "input_text" as const, text: ProviderShared.sanitizeSurrogates(item.text) }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
@@ -473,7 +473,7 @@ const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
request: LLMRequest,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
if (item.type === "text") return { type: "input_text" as const, text: ProviderShared.sanitizeSurrogates(item.text) }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
@@ -541,7 +541,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
content: group.parts.map((part) => ({ type: "output_text" as const, text: ProviderShared.sanitizeSurrogates(part.text) })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
)
+16 -5
View File
@@ -322,7 +322,11 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
content.push({
type: "text",
text: ProviderShared.sanitizeSurrogates(part.text),
cache_control: options.cacheControl?.(part.cache),
})
continue
}
if (part.type === "media") {
@@ -363,7 +367,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
continue
}
}
const text = reasoning.map((part) => part.text).join("")
const text = reasoning.map((part) => ProviderShared.sanitizeSurrogates(part.text)).join("")
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
@@ -384,7 +388,12 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined)
const result = {
role: "assistant" as const,
content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "",
content:
content.length > 0
? content.map((part) => ProviderShared.sanitizeSurrogates(part.text)).join("")
: toolCalls.length > 0
? null
: "",
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
...(details !== undefined ? { reasoning_details: details } : {}),
...(cacheControl !== undefined ? { cache_control: cacheControl } : {}),
@@ -412,7 +421,9 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
continue
}
const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
const text = content
.filter((item) => item.type === "text")
.map((item) => ProviderShared.sanitizeSurrogates(item.text))
messages.push({
role: "tool",
tool_call_id: part.id,
@@ -449,7 +460,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
role: "system",
content: request.system.map((part) => ({
type: "text",
text: part.text,
text: ProviderShared.sanitizeSurrogates(part.text),
cache_control: options.cacheControl?.(part.cache),
})),
},
+11 -4
View File
@@ -35,6 +35,12 @@ export const clampPromptCacheKey = (key: string | undefined): string | undefined
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
}
// Removes unpaired surrogates that break JSON serialization. Valid paired
// surrogates (emoji etc) are preserved. Used for every model-visible text
// field before wire encoding.
export const sanitizeSurrogates = (text: string): string =>
text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "")
/**
* Streaming tool-call accumulator. Adapters that build a tool call across
* multiple `tool-input-delta` chunks store the partial JSON input string here
@@ -115,7 +121,8 @@ export const parseJson = (route: string, input: string, message: string) =>
* (OpenAI Chat `system` content, OpenAI Responses `system` content, Gemini
* `systemInstruction.parts[].text`).
*/
export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => parts.map((part) => part.text).join("\n")
export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) =>
parts.map((part) => sanitizeSurrogates(part.text)).join("\n")
const escapeSystemUpdateText = (text: string) =>
text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
@@ -186,15 +193,15 @@ export const normalizeToolFile = (part: Tool.FileContent) =>
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
export const toolResultText = (part: ToolResultPart) => {
if (part.result.type === "text") return String(part.result.value)
if (part.result.type === "text") return sanitizeSurrogates(String(part.result.value))
if (part.result.type === "error") {
const value = part.result.value
const prototype =
typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value)
const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null
return structured && isJson(value) ? encodeJson(value) : String(value)
return sanitizeSurrogates(structured && isJson(value) ? encodeJson(value) : String(value))
}
return encodeJson(part.result.value)
return sanitizeSurrogates(encodeJson(part.result.value))
}
export const errorText = (error: unknown) => {