diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 24dcfb3db9b..33594399697 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -288,6 +288,7 @@ export const Event = Schema.StructWithRest( arguments: Schema.optional(Schema.String), text: Schema.optional(Schema.String), item_id: Schema.optional(Schema.String), + output_index: Schema.optional(Schema.Number), summary_index: Schema.optional(Schema.Number), item: Schema.optional(StreamItem), response: Schema.optional( @@ -341,6 +342,7 @@ export interface ParserState { readonly tools: ToolStream.State readonly hasFunctionCall: boolean readonly lifecycle: Lifecycle.State + readonly outputItems: Readonly> readonly messageItems: ReadonlySet readonly messagePhases: Readonly> readonly reasoningItems: Readonly> @@ -818,6 +820,9 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events] } +export const outputItemID = (state: ParserState, event: Event) => + event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id) + export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => { const item = state.reasoningItems[itemID] if (!event.delta || !item) return [state, NO_EVENTS] @@ -1201,7 +1206,11 @@ export const providerFailure = (id: string, event: Event, fallback: string) => { const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback) -export const step = (state: ParserState, event: Event) => { +export const step = (state: ParserState, input: Event) => { + const event = + input.item_id && outputItemID(state, input) !== input.item_id + ? { ...input, item_id: outputItemID(state, input) } + : input if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") { if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`) return Effect.succeed( @@ -1243,7 +1252,14 @@ export const step = (state: ParserState, event: Event) => { if (event.type === "response.output_item.added") { if (event.item?.type === "message" && !event.item.id) return ProviderShared.eventError(state.id, `${event.type} message is missing id`) - return Effect.succeed(onOutputItemAdded(state, event)) + return Effect.succeed( + onOutputItemAdded( + event.output_index !== undefined && event.item?.id + ? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } } + : state, + event, + ), + ) } if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done") return event.item_id @@ -1278,6 +1294,7 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse hasFunctionCall: false, tools: ToolStream.empty(), lifecycle: Lifecycle.initial(), + outputItems: {}, messageItems: new Set(), messagePhases: {}, reasoningItems: {}, diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index e63e6da1bb6..b230e8f1f5f 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -166,7 +166,9 @@ const HOSTED_TOOLS = { const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => { if (event.type === "response.reasoning_text.delta") return event.item_id - ? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id)) + ? Effect.succeed( + OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id), + ) : ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`) if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS)) return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS) diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index 93ade26eb71..066a01894be 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -225,6 +225,31 @@ describe("Open Responses-compatible route", () => { }), ) + it.effect("routes response deltas by output index", () => + Effect.gen(function* () { + const model = configure({ + apiKey: "test-key", + baseURL: "https://responses.example.test/v1", + }).model("example-model") + const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } }, + { type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" }, + { type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.message.content).toEqual([ + { type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } }, + ]) + }), + ) + it.effect("finalizes pending function calls from completed response output", () => 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 0bcf24a26be..e3eba0b3e02 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -830,7 +830,9 @@ describe("OpenAI Responses route", () => { webSocket: { execute: (exchange) => Effect.gen(function* () { - yield* exchange.driver.create(undefined).pipe(Effect.flatMap((create) => Ref.set(message, create.message))) + yield* exchange.driver + .create(undefined) + .pipe(Effect.flatMap((create) => Ref.set(message, create.message))) return { frames: exchange.fallback(), complete: Effect.void } }), }, @@ -2051,6 +2053,163 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("routes assistant text by output index when its item id disagrees", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } }, + { type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" }, + { type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.text).toBe("Indexed") + expect(response.message.content).toEqual([ + { type: "text", text: "Indexed", providerMetadata: { openai: { itemId: "msg_1" } } }, + ]) + }), + ) + + it.effect("routes interleaved function calls by output index", () => + Effect.gen(function* () { + const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "first", arguments: "" } + const second = { type: "function_call", id: "fc_2", call_id: "call_2", name: "second", arguments: "" } + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", output_index: 1, item: first }, + { type: "response.output_item.added", output_index: 3, item: second }, + { type: "response.function_call_arguments.delta", output_index: 1, item_id: "fc_2", delta: '{"a":' }, + { type: "response.function_call_arguments.delta", output_index: 3, item_id: "fc_1", delta: '{"b":' }, + { + type: "response.function_call_arguments.done", + output_index: 3, + item_id: "fc_1", + arguments: '{"b":2}', + }, + { + type: "response.function_call_arguments.done", + output_index: 1, + item_id: "fc_2", + arguments: '{"a":1}', + }, + { type: "response.output_item.done", output_index: 1, item: { ...first, arguments: '{"a":1}' } }, + { type: "response.output_item.done", output_index: 3, item: { ...second, arguments: '{"b":2}' } }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([ + { id: "call_1", text: '{"a":' }, + { id: "call_2", text: '{"b":' }, + { id: "call_2", text: "2}" }, + { id: "call_1", text: "1}" }, + ]) + expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([ + expect.objectContaining({ id: "call_1", name: "first", input: { a: 1 } }), + expect.objectContaining({ id: "call_2", name: "second", input: { b: 2 } }), + ]) + }), + ) + + it.effect("routes reasoning summary events by output index", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + output_index: 4, + item: { type: "reasoning", id: "rs_1" }, + }, + { + type: "response.reasoning_summary_part.added", + output_index: 4, + item_id: "wrong_reasoning", + summary_index: 0, + }, + { + type: "response.reasoning_summary_text.delta", + output_index: 4, + item_id: "wrong_reasoning", + summary_index: 0, + delta: "Thinking", + }, + { + type: "response.reasoning_summary_part.done", + output_index: 4, + item_id: "wrong_reasoning", + summary_index: 0, + }, + { + type: "response.output_item.done", + output_index: 4, + item: { type: "reasoning", id: "rs_1", encrypted_content: "state" }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("Thinking") + expect(response.message.content).toEqual([ + { + type: "reasoning", + text: "Thinking", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "state" } }, + }, + ]) + }), + ) + + it.effect("routes native reasoning text deltas by output index", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", output_index: 1, item: { type: "reasoning", id: "rs_1" } }, + { type: "response.reasoning_text.delta", output_index: 1, item_id: "wrong_reasoning", delta: "Raw" }, + { type: "response.output_item.done", output_index: 1, item: { type: "reasoning", id: "rs_1" } }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("Raw") + }), + ) + + it.effect("falls back to item ids when an output index was not registered", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", item: { type: "message", id: "msg_1" } }, + { type: "response.output_text.delta", output_index: 9, item_id: "msg_1", delta: "Fallback" }, + { type: "response.output_item.done", item: { type: "message", id: "msg_1" } }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.text).toBe("Fallback") + }), + ) + it.effect("rejects output text events without the spec-required item id", () => Effect.gen(function* () { const error = yield* LLMClient.generate(request).pipe( @@ -2070,6 +2229,25 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("requires item ids even when their output index is known", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } }, + { type: "response.output_text.delta", output_index: 0, delta: "Missing item ID" }, + ), + ), + ), + Effect.flip, + ) + + expect(error.reason._tag).toBe("InvalidProviderOutput") + expect(error.message).toContain("response.output_text.delta is missing item_id") + }), + ) + it.effect("ignores deltas without a matching output item", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe( diff --git a/packages/ai/test/provider/xai-responses.test.ts b/packages/ai/test/provider/xai-responses.test.ts index eeb1d1e8de6..a09ea69d49b 100644 --- a/packages/ai/test/provider/xai-responses.test.ts +++ b/packages/ai/test/provider/xai-responses.test.ts @@ -70,6 +70,42 @@ describe("xAI Responses route", () => { }), ) + it.effect("routes xAI reasoning summaries by output index", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + output_index: 3, + item: { type: "reasoning", id: "reasoning_1" }, + }, + { + type: "response.reasoning_summary_text.delta", + output_index: 3, + item_id: "wrong_reasoning", + summary_index: 0, + delta: "Considering.", + }, + { + type: "response.output_item.done", + output_index: 3, + item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" }, + }, + { type: "response.completed", response: { id: "response_1" } }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("Considering.") + expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({ + providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } }, + }) + }), + ) + it.effect("parses xAI hosted tool items", () => Effect.gen(function* () { const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(