From 8b65bd53bfca86482ca9babb05473f569ddddc45 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:08:16 -0500 Subject: [PATCH] fix(ai): drop invalid item ids when replaying responses history (#44587) --- packages/ai/src/protocols/open-responses.ts | 48 +++++-- packages/ai/src/protocols/openai-responses.ts | 19 +++ .../openai-compatible-responses.test.ts | 41 ++++++ .../ai/test/provider/openai-responses.test.ts | 126 +++++++++++++++--- 4 files changed, 206 insertions(+), 28 deletions(-) diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 0b7c7b0714b..f767fb2e9b7 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -313,6 +313,9 @@ export const Event = Schema.StructWithRest( ) export type Event = Schema.Schema.Type +// Which lowered input item a persisted item id is about to be attached to. +export type ItemKind = "message" | "reasoning" | "function-call" | "reference" + export interface Extension { readonly id: string readonly name: string @@ -321,6 +324,10 @@ export interface Extension { readonly media: ProviderShared.NormalizedMedia readonly request: LLMRequest }) => MediaInput | undefined + // Optional grammar check applied before a persisted item id is resent as + // part of replayed history. Returning false drops the id; every lowered + // item treats a dropped id the same as an absent one. + readonly acceptsItemID?: (kind: ItemKind, id: string) => boolean } const BASE: Extension = { id: ADAPTER, name: NAME } @@ -376,28 +383,46 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable ({ type: "function" as const, name: toolName }), }) +// Servers validate item ids on replayed history, and a malformed or oversized +// id can fail an otherwise valid request. Only server-issued tokens are worth +// resending; anything else is treated as absent so the item is resent without +// an id (or skipped, for items that cannot be expressed without one). +const ITEM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/ const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => { const metadata = providerMetadata?.[providerMetadataKey] - return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0 + return ProviderShared.isRecord(metadata) && + typeof metadata.itemId === "string" && + ITEM_ID_PATTERN.test(metadata.itemId) ? metadata.itemId : undefined } -const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => { +const acceptsItemID = (extension: Extension, kind: ItemKind, id: string | undefined): id is string => + id !== undefined && (extension.acceptsItemID?.(kind, id) ?? true) + +const lowerToolCall = ( + part: ToolCallPart, + providerMetadataKey: string, + extension: Extension, +): OpenResponsesInputItem => { const id = itemID(part.providerMetadata, providerMetadataKey) return { type: "function_call", - ...(id ? { id } : {}), + ...(acceptsItemID(extension, "function-call", id) ? { id } : {}), call_id: part.id, name: part.name, arguments: ProviderShared.encodeJson(part.input), } } -const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => { +const lowerReasoning = ( + part: ReasoningPart, + providerMetadataKey: string, + extension: Extension, +): OpenResponsesReasoningInput | undefined => { const metadata = part.providerMetadata?.[providerMetadataKey] const id = itemID(part.providerMetadata, providerMetadataKey) - if (!ProviderShared.isRecord(metadata) || !id) return undefined + if (!ProviderShared.isRecord(metadata) || !acceptsItemID(extension, "reasoning", id)) return undefined const encryptedContent = typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null ? metadata.reasoningEncryptedContent @@ -529,7 +554,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }> >((groups, part) => { const metadata = part.providerMetadata?.[providerMetadataKey] - const id = itemID(part.providerMetadata, providerMetadataKey) + const rawID = itemID(part.providerMetadata, providerMetadataKey) + const id = acceptsItemID(extension, "message", rawID) ? rawID : undefined const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined const group = groups.at(-1) if (group && group.id === id && group.phase === phase) group.parts.push(part) @@ -554,7 +580,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques } if (part.type === "reasoning") { flushText() - const reasoning = lowerReasoning(part, providerMetadataKey) + const reasoning = lowerReasoning(part, providerMetadataKey, extension) if (!reasoning) continue if (store !== false) { if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id }) @@ -575,13 +601,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques if (part.type === "tool-call") { flushText() if (part.providerExecuted === true) continue - input.push(lowerToolCall(part, providerMetadataKey)) + input.push(lowerToolCall(part, providerMetadataKey, extension)) continue } if (part.type === "tool-result" && part.providerExecuted === true) { flushText() const id = itemID(part.providerMetadata, providerMetadataKey) - if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id }) + const reference = acceptsItemID(extension, "reference", id) ? id : undefined + if (store !== false && reference && !hostedToolReferences.has(reference)) + input.push({ type: "item_reference", id: reference }) if (store === false) { // The server is not storing this exchange, so the tool outcome has to // travel in the input. Non-content results degrade to their text form. @@ -596,7 +624,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques ), }) } - if (id) hostedToolReferences.add(id) + if (reference) hostedToolReferences.add(reference) continue } return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [ diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index 0f381a29b9d..ae2c8ca89c8 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -51,9 +51,28 @@ const OpenAIResponsesBody = Schema.Struct({ }) export type OpenAIResponsesBody = Schema.Schema.Type +// Replayed items are paired with stored server state by id, so a foreign or +// synthetic token can fail request validation even when `call_id` pairing is +// intact. Only resend ids in each item kind's own grammar; hosted tool +// references keep generic validation because every hosted tool mints its own +// prefix. The same allowlist approach codex uses before resending history +// (codex-rs core/src/client.rs, `prepare_response_items_for_request`). +const ITEM_ID_PREFIXES: Record> = { + message: ["msg_"], + reasoning: ["rs_"], + "function-call": ["fc_"], + // Every hosted tool mints its own id prefix, so references keep generic + // validation only. + reference: [], +} + const extension = { id: ADAPTER, name: NAME, + acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => { + const prefixes = ITEM_ID_PREFIXES[kind] + return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix)) + }, } satisfies OpenResponses.Extension const nativeImageToolInput = (tool: ToolDefinition) => { diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index f2fe17d7306..859312c4f6d 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -132,6 +132,47 @@ describe("Open Responses-compatible route", () => { }), ) + it.effect("keeps foreign item id grammars but drops malformed ids", () => + Effect.gen(function* () { + const model = configure({ + apiKey: "test-key", + baseURL: "https://responses.example.test/v1", + }).model("example-model") + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.assistant([ + // The baseline does not enforce a provider id grammar, so a + // non-OpenAI but well-formed token is resent as-is. + { type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } }, + // Shape violations are dropped even without a grammar policy. + { + type: "text", + text: "Dropped.", + providerMetadata: { openresponses: { itemId: `m${"a".repeat(64)}` } }, + }, + ]), + ], + }), + ) + + expect(prepared.body.input).toEqual([ + { + type: "message", + id: "history_1", + role: "assistant", + content: [{ type: "output_text", text: "Kept." }], + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Dropped." }], + }, + ]) + }), + ) + it.effect("preserves nullable phases in the forgiving Open Responses baseline", () => Effect.gen(function* () { const model = configure({ diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index cd020775e26..3587efc1f6b 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -2419,12 +2419,102 @@ describe("OpenAI Responses route", () => { expect(prepared.body.input).toEqual([ { role: "user", content: [{ type: "input_text", text: "Search." }] }, - { role: "user", content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }] }, + { + role: "user", + content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }], + }, { role: "user", content: [{ type: "input_text", text: "Continue." }] }, ]) }), ) + it.effect("drops replayed item ids outside the server's grammar", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.assistant([ + // Fails the message id prefix. + { + type: "text", + text: "Hello", + providerMetadata: { openai: { itemId: "history_1" } }, + }, + // Oversized for the Responses item id limit. + { + type: "text", + text: "World", + providerMetadata: { openai: { itemId: `m${"a".repeat(64)}` } }, + }, + // Fails the reasoning id prefix, so the whole item is unreplayable + // statelessly and is skipped rather than sent malformed. + { + type: "reasoning", + text: "Checked the diff.", + providerMetadata: { openai: { itemId: "thinking_1", reasoningEncryptedContent: "encrypted-state" } }, + }, + ToolCallPart.make({ + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerMetadata: { openai: { itemId: "toolu_01A" } }, + }), + ]), + ], + }), + ) + + expect(prepared.body.input).toEqual([ + { + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: "Hello" }, + { type: "output_text", text: "World" }, + ], + }, + { + type: "function_call", + call_id: "call_1", + name: "lookup", + arguments: '{"query":"weather"}', + }, + ]) + }), + ) + + it.effect("keeps well-formed hosted references and drops malformed ones under storage", () => + Effect.gen(function* () { + const hostedResult = (itemId: string) => [ + ToolCallPart.make({ + id: itemId, + name: "web_search", + input: { query: "effect 4" }, + providerExecuted: true, + providerMetadata: { openai: { itemId } }, + }), + { + type: "tool-result" as const, + id: itemId, + name: "web_search", + result: { type: "json" as const, value: { status: "completed" } }, + providerExecuted: true as const, + providerMetadata: { openai: { itemId } }, + }, + ] + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [Message.assistant(hostedResult("ws_1")), Message.assistant(hostedResult("bad ref"))], + providerOptions: { store: true }, + }), + ) + + expect(prepared.body.input).toEqual([{ type: "item_reference", id: "ws_1" }]) + }), + ) + it.effect("continues stateless hosted image generation with the generated image", () => Effect.gen(function* () { const imageTool = OpenAI.imageGeneration({ action: "edit" }) @@ -2548,15 +2638,15 @@ describe("OpenAI Responses route", () => { const body = sseEvents( { type: "response.output_item.added", - item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" }, + item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" }, }, - { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' }, - { type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' }, + { type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query"' }, + { type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: ':"weather"}' }, { type: "response.output_item.done", item: { type: "function_call", - id: "item_1", + id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}', @@ -2585,7 +2675,7 @@ describe("OpenAI Responses route", () => { type: "tool-input-start", id: "call_1", name: "lookup", - providerMetadata: { openai: { itemId: "item_1" } }, + providerMetadata: { openai: { itemId: "fc_item_1" } }, }, { type: "tool-input-delta", @@ -2603,7 +2693,7 @@ describe("OpenAI Responses route", () => { type: "tool-input-end", id: "call_1", name: "lookup", - providerMetadata: { openai: { itemId: "item_1" } }, + providerMetadata: { openai: { itemId: "fc_item_1" } }, }, { type: "tool-call", @@ -2611,7 +2701,7 @@ describe("OpenAI Responses route", () => { name: "lookup", input: { query: "weather" }, providerExecuted: undefined, - providerMetadata: { openai: { itemId: "item_1" } }, + providerMetadata: { openai: { itemId: "fc_item_1" } }, }, { type: "step-finish", @@ -2632,7 +2722,7 @@ describe("OpenAI Responses route", () => { expect(prepared.body.input).toEqual([ { type: "function_call", - id: "item_1", + id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}', @@ -2646,7 +2736,7 @@ describe("OpenAI Responses route", () => { const body = sseEvents( { type: "response.output_item.added", - item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" }, + item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" }, }, { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, ) @@ -2658,7 +2748,7 @@ describe("OpenAI Responses route", () => { type: "tool-input-end", id: "call_1", name: "lookup", - providerMetadata: { openai: { itemId: "item_1" } }, + providerMetadata: { openai: { itemId: "fc_item_1" } }, }, { type: "tool-call", @@ -2666,7 +2756,7 @@ describe("OpenAI Responses route", () => { name: "lookup", input: {}, providerExecuted: undefined, - providerMetadata: { openai: { itemId: "item_1" } }, + providerMetadata: { openai: { itemId: "fc_item_1" } }, }, ], ) @@ -2679,14 +2769,14 @@ describe("OpenAI Responses route", () => { const body = sseEvents( { type: "response.output_item.added", - item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" }, + item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" }, }, - { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' }, + { type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' }, { type: "response.output_item.done", item: { type: "function_call", - id: "item_1", + id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: '{"query":"partial', @@ -2718,7 +2808,7 @@ describe("OpenAI Responses route", () => { type: "response.output_item.done", item: { type: "function_call", - id: "item_1", + id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: '{"query":"partial', @@ -2747,7 +2837,7 @@ describe("OpenAI Responses route", () => { type: "response.output_item.done", item: { type: "function_call", - id: "item_1", + id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}', @@ -2761,7 +2851,7 @@ describe("OpenAI Responses route", () => { expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ id: "call_1", - providerMetadata: { openai: { itemId: "item_1" } }, + providerMetadata: { openai: { itemId: "fc_item_1" } }, }) }), )