From 3deac93d274e04dc738200b85e192cb70d703c44 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:53:47 -0500 Subject: [PATCH] fix(ai): sanitize outbound provider request surrogates (#44880) --- packages/ai/src/image-client.ts | 16 ++++- packages/ai/src/route/client.ts | 4 +- packages/ai/src/utils/sanitize.ts | 12 ++++ packages/ai/test/compile.test.ts | 69 ++++++++++++++++++- .../ai/test/provider/openai-responses.test.ts | 41 +++++++++++ packages/ai/test/provider/zai-images.test.ts | 25 +++++++ 6 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 packages/ai/src/utils/sanitize.ts diff --git a/packages/ai/src/image-client.ts b/packages/ai/src/image-client.ts index ea098253990..9b695791b3c 100644 --- a/packages/ai/src/image-client.ts +++ b/packages/ai/src/image-client.ts @@ -1,7 +1,8 @@ import { Context, Effect, Layer } from "effect" import { RequestExecutor } from "./route/executor.js" +import { mergeHttpOptions, type AIError } from "./schema/index.js" +import { sanitizeSurrogates } from "./utils/sanitize.js" import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image.js" -import type { AIError } from "./schema/index.js" export type Execute = RequestExecutor.Interface["execute"] @@ -26,7 +27,18 @@ export const layer: Layer.Layer = Layer Effect.gen(function* () { const executor = yield* RequestExecutor.Service return Service.of({ - generate: (request) => request.model.route.generate(request, executor.execute), + generate: (request) => + request.model.route.generate( + { + ...sanitizeSurrogates({ + ...request, + model: undefined, + http: mergeHttpOptions(request.model.http, request.http), + }), + model: request.model, + }, + executor.execute, + ), }) }), ) diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 0c3090a2716..4c31b0725d4 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -7,6 +7,7 @@ import { HttpTransport } from "./transport/index.js" import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js" import type { Protocol } from "./protocol.js" import { applyCachePolicy } from "../cache-policy.js" +import { sanitizeSurrogates } from "../utils/sanitize.js" import * as ProviderShared from "../protocols/shared.js" import type { ProtocolID, ProviderOptions } from "../schema/index.js" import { @@ -400,7 +401,8 @@ export function make( } const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) { - const resolved = applyCachePolicy(resolveRequestOptions(request)) + const original = applyCachePolicy(resolveRequestOptions(request)) + const resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined })) const route = resolved.model.route const body = yield* route.body diff --git a/packages/ai/src/utils/sanitize.ts b/packages/ai/src/utils/sanitize.ts new file mode 100644 index 00000000000..017c74c29b6 --- /dev/null +++ b/packages/ai/src/utils/sanitize.ts @@ -0,0 +1,12 @@ +import { isRecord } from "./record.js" + +export const sanitizeSurrogates = (value: T): T => { + if (typeof value === "string") return value.toWellFormed() as T + if (Array.isArray(value)) return value.map(sanitizeSurrogates) as T + if (value instanceof Uint8Array || value instanceof Error) return value + if (isRecord(value)) + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key.toWellFormed(), sanitizeSurrogates(entry)]), + ) as T + return value +} diff --git a/packages/ai/test/compile.test.ts b/packages/ai/test/compile.test.ts index c09bc553c03..70645c05161 100644 --- a/packages/ai/test/compile.test.ts +++ b/packages/ai/test/compile.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Ref, Schema } from "effect" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { LLM, mergeProviderOptions } from "../src/index.js" +import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js" import { AnthropicMessages, OpenAIChat } from "../src/protocols.js" import { Auth, LLMClient } from "../src/route.js" import { compileRequest } from "../src/route/client.js" @@ -247,6 +247,73 @@ describe("request option precedence", () => { }), ) + it.effect("sanitizes outbound JSON without an HTTP overlay", () => + LLMClient.generate( + LLM.request({ + model: OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4o-mini" }), + prompt: "hello \uD800 \u{1F600}", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + expect(decodeJson(input.text)).toMatchObject({ + messages: [{ role: "user", content: "hello \uFFFD \u{1F600}" }], + }) + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("sanitizes unpaired surrogates throughout outbound JSON", () => + LLMClient.generate( + LLM.request({ + model: OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4o-mini" }), + system: "system \uD800 \u{1F600}", + messages: [ + Message.user("user \uDC00"), + Message.assistant([ + Message.text("assistant \uD800"), + ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "input \uDC00" } }), + ]), + Message.tool({ id: "call_1", name: "lookup", result: { output: "result \uD800" } }), + ], + http: { body: { metadata: { "key\uD800": ["overlay \uDC00", "valid \u{1F600}"] } } }, + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + expect(decodeJson(input.text)).toMatchObject({ + messages: [ + { role: "system", content: "system \uFFFD \u{1F600}" }, + { role: "user", content: "user \uFFFD" }, + { + role: "assistant", + content: "assistant \uFFFD", + tool_calls: [{ function: { arguments: '{"query":"input \uFFFD"}' } }], + }, + { role: "tool", content: '{"output":"result \uFFFD"}' }, + ], + metadata: { "key\uFFFD": ["overlay \uFFFD", "valid \u{1F600}"] }, + }) + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + it.effect("applies raw body overlays after protocol lowering", () => LLMClient.generate( LLM.request({ diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index a456a833420..452601de90e 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -816,6 +816,47 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("sanitizes outbound WebSocket requests and HTTP fallback bodies", () => + Effect.gen(function* () { + const message = yield* Ref.make("") + const body = yield* Ref.make("") + yield* LLMClient.generate( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"), + prompt: "Say \uD800hello \u{1F600}.", + http: { body: { metadata: { source: "overlay\uDC00" } } }, + }), + { + webSocket: { + execute: (exchange) => + Effect.gen(function* () { + yield* exchange.driver.create(undefined).pipe(Effect.flatMap((create) => Ref.set(message, create.message))) + return { frames: exchange.fallback(), complete: Effect.void } + }), + }, + }, + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + yield* Ref.set(body, input.text) + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + + const expected = { + input: [{ role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] }], + metadata: { source: "overlay\uFFFD" }, + } + expect(JSON.parse(yield* Ref.get(message))).toMatchObject(expected) + expect(JSON.parse(yield* Ref.get(body))).toMatchObject(expected) + }), + ) + it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () => Effect.gen(function* () { const deps = Layer.succeed( diff --git a/packages/ai/test/provider/zai-images.test.ts b/packages/ai/test/provider/zai-images.test.ts index 7a0f0220610..3b0a28994dc 100644 --- a/packages/ai/test/provider/zai-images.test.ts +++ b/packages/ai/test/provider/zai-images.test.ts @@ -78,6 +78,31 @@ describe("Z.ai Images", () => { ), ) + it.effect("sanitizes unpaired surrogates in outbound image requests", () => + Image.generate({ + model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image("model"), + prompt: "A red circle \uD800 on a white background \u{1F600}", + }).pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => { + expect(JSON.parse(input.text)).toMatchObject({ + prompt: "A red circle \uFFFD on a white background \u{1F600}", + metadata: { source: "default\uFFFD" }, + }) + return Effect.succeed( + input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), { + headers: { "content-type": "application/json" }, + }), + ) + }), + ), + ), + ), + ), + ) + it.effect("lets raw native options override aliases", () => Image.generate({ model: ZAI.configure({ apiKey: "test" }).image("model"),