diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 7f2c537b74e..5872fb71112 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -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) diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index efe3a3ba5dc..76bc7d9070a 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -293,7 +293,7 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent const content: Array> = [] 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, -): 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 diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 3ccf8eafcae..5ded5ced2c8 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -273,7 +273,7 @@ const lowerToolConfig = (toolChoice: NonNullable) => }) 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 = 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 diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 1dd449319eb..4ebc81157e1 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -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 }), })), ) diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 95d0249144f..9f3b59dbaf1 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -322,7 +322,11 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* ( const content: Array> = [] 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 = 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), })), }, diff --git a/packages/ai/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts index 49d8acef386..f1fc9297c0f 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -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])|(? * (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) => {