mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 14:43:37 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user