From 5add6a8e198f87109c53ff9f2ad53239ea9f8a74 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:17:16 -0500 Subject: [PATCH] fix(ai): preserve provider-defined responses item ids (#45094) --- packages/ai/src/protocols/open-responses.ts | 58 ++++++------------- packages/ai/src/protocols/openai-responses.ts | 18 ------ .../ai/test/provider/bedrock-mantle.test.ts | 37 +++++++++++- .../openai-compatible-responses.test.ts | 31 ++++++++-- .../ai/test/provider/openai-responses.test.ts | 58 +++++++++++++++---- 5 files changed, 125 insertions(+), 77 deletions(-) diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index bdbb6d7e3cf..d582af34637 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -196,7 +196,7 @@ type LoweredInputItem = // multiple streamed summary parts into the same item before flushing. type OpenResponsesReasoningInput = { type: "reasoning" - id: string + id?: string summary: Array<{ type: "summary_text"; text: string }> encrypted_content?: string | null } @@ -372,9 +372,6 @@ 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" | "hosted-tool" - export interface Extension { readonly id: string readonly name: string @@ -384,10 +381,6 @@ export interface Extension { readonly request: LLMRequest }) => MediaInput | undefined readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | 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 } @@ -447,53 +440,37 @@ 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}$/ +// Server-issued item ids need a nonempty prefix and suffix, but the prefix is +// provider-defined and does not necessarily identify the item's semantic type. const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => { const metadata = providerMetadata?.[providerMetadataKey] - return ProviderShared.isRecord(metadata) && - typeof metadata.itemId === "string" && - ITEM_ID_PATTERN.test(metadata.itemId) - ? metadata.itemId - : undefined + if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string") return undefined + const separator = metadata.itemId.indexOf("_") + return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined } -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 lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => { const id = itemID(part.providerMetadata, providerMetadataKey) return { type: "function_call", - ...(acceptsItemID(extension, "function-call", id) ? { id } : {}), + ...(id === undefined ? {} : { id }), call_id: part.id, name: part.name, arguments: ProviderShared.encodeJson(part.input), } } -const lowerReasoning = ( - part: ReasoningPart, - providerMetadataKey: string, - extension: Extension, -): OpenResponsesReasoningInput | undefined => { +const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => { const metadata = part.providerMetadata?.[providerMetadataKey] + if (!ProviderShared.isRecord(metadata)) return undefined const id = itemID(part.providerMetadata, providerMetadataKey) - if (!ProviderShared.isRecord(metadata) || !acceptsItemID(extension, "reasoning", id)) return undefined const encryptedContent = typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null ? metadata.reasoningEncryptedContent : undefined return { type: "reasoning", - id, + ...(id === undefined ? {} : { id }), summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [], encrypted_content: encryptedContent, } @@ -614,8 +591,7 @@ 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 rawID = itemID(part.providerMetadata, providerMetadataKey) - const id = acceptsItemID(extension, "message", rawID) ? rawID : undefined + const id = itemID(part.providerMetadata, providerMetadataKey) 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) @@ -640,23 +616,23 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques } if (part.type === "reasoning") { flushText() - const reasoning = lowerReasoning(part, providerMetadataKey, extension) + const reasoning = lowerReasoning(part, providerMetadataKey) if (!reasoning) continue - const existing = reasoningItems[reasoning.id] + const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id] if (existing) { existing.summary.push(...reasoning.summary) if (typeof reasoning.encrypted_content === "string") existing.encrypted_content = reasoning.encrypted_content continue } - reasoningItems[reasoning.id] = reasoning + if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning input.push(reasoning) continue } if (part.type === "tool-call") { flushText() if (part.providerExecuted === true) continue - input.push(lowerToolCall(part, providerMetadataKey, extension)) + input.push(lowerToolCall(part, providerMetadataKey)) continue } if (part.type === "tool-result" && part.providerExecuted === true) { @@ -668,7 +644,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques : Schema.is(HostedToolItem)(part.result.value) ? part.result.value : extension.lowerHostedToolItem?.(part.result.value) - if (acceptsItemID(extension, "hosted-tool", id) && hosted?.id === id) { + if (id !== undefined && hosted?.id === id) { if (!hostedToolItems.has(id)) { input.push(hosted) hostedToolItems.add(id) diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index 1d1ecff4647..a22f5e00d9d 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -86,28 +86,10 @@ 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 -// items 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 items keep generic validation. - "hosted-tool": [], -} - const extension = { id: ADAPTER, name: NAME, lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined), - 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/bedrock-mantle.test.ts b/packages/ai/test/provider/bedrock-mantle.test.ts index a56ef940415..56c3933196f 100644 --- a/packages/ai/test/provider/bedrock-mantle.test.ts +++ b/packages/ai/test/provider/bedrock-mantle.test.ts @@ -1,11 +1,11 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM } from "../../src/index.js" +import { LLM, Message } from "../../src/index.js" import { AmazonBedrockMantle } from "../../src/providers.js" import { compileRequest, LLMClient } from "../../src/route/client.js" import { it } from "../lib/effect.js" -import { dynamicResponse } from "../lib/http.js" +import { dynamicResponse, fixedResponse } from "../lib/http.js" import { sseEvents } from "../lib/sse.js" import { recordedTests } from "../recorded-test.js" @@ -83,6 +83,39 @@ describe("Amazon Bedrock Mantle provider", () => { expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }]) }), ) + + it.effect("replays reasoning with Mantle's message-prefixed item ids", () => + Effect.gen(function* () { + const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).responses("openai.gpt-oss-120b") + const item = { type: "reasoning", id: "msg_95d4d0af4350432a", encrypted_content: "mantle-state" } + const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", item }, + { type: "response.reasoning_summary_text.delta", item_id: item.id, delta: "Considering." }, + { type: "response.output_item.done", item }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + const prepared = yield* compileRequest( + LLM.request({ model, messages: [response.message, Message.user("Continue.")] }), + ) + + expect(prepared.body.input).toEqual([ + { + type: "reasoning", + id: "msg_95d4d0af4350432a", + summary: [{ type: "summary_text", text: "Considering." }], + encrypted_content: "mantle-state", + }, + { role: "user", content: [{ type: "input_text", text: "Continue." }] }, + ]) + }), + ) }) const recorded = recordedTests({ diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index bbcbbd7db89..30e7039e3f8 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -195,15 +195,19 @@ describe("Open Responses-compatible route", () => { 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)}` } }, + text: "Long.", + providerMetadata: { openresponses: { itemId: `history_${"a".repeat(64)}` } }, }, + { + type: "text", + text: "Opaque.", + providerMetadata: { openresponses: { itemId: "provider_value/with+symbols" } }, + }, + { type: "text", text: "No suffix.", providerMetadata: { openresponses: { itemId: "msg_" } } }, + { type: "text", text: "No prefix.", providerMetadata: { openresponses: { itemId: "_item" } } }, ]), ], }), @@ -218,8 +222,23 @@ describe("Open Responses-compatible route", () => { }, { type: "message", + id: `history_${"a".repeat(64)}`, role: "assistant", - content: [{ type: "output_text", text: "Dropped." }], + content: [{ type: "output_text", text: "Long." }], + }, + { + type: "message", + id: "provider_value/with+symbols", + role: "assistant", + content: [{ type: "output_text", text: "Opaque." }], + }, + { + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: "No suffix." }, + { type: "output_text", text: "No prefix." }, + ], }, ]) }), diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 553916b979d..95d45597b25 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -3150,38 +3150,50 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("drops replayed item ids outside the server's grammar", () => + it.effect("preserves provider-issued item ids and removes malformed ids without dropping items", () => 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)}` } }, + providerMetadata: { openai: { itemId: `message_${"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" } }, }, + { + type: "reasoning", + text: "Missing suffix.", + providerMetadata: { openai: { itemId: "rs_", reasoningEncryptedContent: "another-state" } }, + }, + { + type: "reasoning", + text: "No prefix separator.", + providerMetadata: { openai: { itemId: "550e8400-e29b-41d4-a716-446655440000" } }, + }, ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata: { openai: { itemId: "toolu_01A" } }, }), + ToolCallPart.make({ + id: "call_2", + name: "lookup", + input: { query: "news" }, + providerMetadata: { openai: { itemId: "fc_" } }, + }), ]), ], }), @@ -3190,18 +3202,44 @@ describe("OpenAI Responses route", () => { expect(prepared.body.input).toEqual([ { type: "message", + id: "history_1", role: "assistant", - content: [ - { type: "output_text", text: "Hello" }, - { type: "output_text", text: "World" }, - ], + content: [{ type: "output_text", text: "Hello" }], + }, + { + type: "message", + id: `message_${"a".repeat(64)}`, + role: "assistant", + content: [{ type: "output_text", text: "World" }], + }, + { + type: "reasoning", + id: "thinking_1", + summary: [{ type: "summary_text", text: "Checked the diff." }], + encrypted_content: "encrypted-state", + }, + { + type: "reasoning", + summary: [{ type: "summary_text", text: "Missing suffix." }], + encrypted_content: "another-state", + }, + { + type: "reasoning", + summary: [{ type: "summary_text", text: "No prefix separator." }], }, { type: "function_call", + id: "toolu_01A", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}', }, + { + type: "function_call", + call_id: "call_2", + name: "lookup", + arguments: '{"query":"news"}', + }, ]) }), )