mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 16:11:52 +00:00
fix(ai): sanitize outbound provider request surrogates (#44880)
This commit is contained in:
parent
ed582d1bdb
commit
3deac93d27
6 changed files with 163 additions and 4 deletions
|
|
@ -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<Service, never, RequestExecutor.Service> = 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,
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<Body, Prepared, Frame, Event, State>(
|
|||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
12
packages/ai/src/utils/sanitize.ts
Normal file
12
packages/ai/src/utils/sanitize.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { isRecord } from "./record.js"
|
||||
|
||||
export const sanitizeSurrogates = <T>(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
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue