diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index f767fb2e9b7..554997c4333 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -353,6 +353,10 @@ interface ReasoningStreamItem { // strings, but typing the map as `Record` documents intent // and matches the wire field. readonly summaryParts: Readonly> + // Summary indexes that received at least one streamed delta. The `:0` block + // is started eagerly when the item opens, so block existence cannot tell + // whether a `.done` final would duplicate streamed text. + readonly deltaIndexes: ReadonlySet } // ============================================================================= @@ -805,19 +809,33 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes } export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => { - if (!event.delta || !state.reasoningItems[itemID]) return [state, NO_EVENTS] + const item = state.reasoningItems[itemID] + if (!event.delta || !item) return [state, NO_EVENTS] + const index = event.summary_index ?? 0 const events: LLMEvent[] = [] - const id = `${itemID}:${event.summary_index ?? 0}` return [ { ...state, - lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta), + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta), + reasoningItems: { + ...state.reasoningItems, + [itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) }, + }, }, events, ] } -export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS] +// Some compatible gateways emit a reasoning final without streaming any +// deltas, mirroring `response.output_text.done`. Reconcile the complete text +// as a single delta unless that summary index already streamed one. +export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => { + const item = state.reasoningItems[itemID] + if (!item || typeof event.text !== "string") return [state, NO_EVENTS] + const index = event.summary_index ?? 0 + if (item.deltaIndexes.has(index)) return [state, NO_EVENTS] + return onReasoningDelta(state, { ...event, delta: event.text }, itemID) +} const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) => providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null }) @@ -855,7 +873,11 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => { lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)), reasoningItems: { ...state.reasoningItems, - [item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } }, + [item.id]: { + encryptedContent: item.encrypted_content, + summaryParts: { 0: "active" }, + deltaIndexes: new Set(), + }, }, }, events, @@ -1161,6 +1183,15 @@ export const step = (state: ParserState, event: Event) => { if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`) return Effect.succeed(onReasoningDelta(state, event, event.item_id)) } + if ( + event.type === "response.reasoning.done" || + event.type === "response.reasoning_summary_text.done" || + event.type === "response.reasoning_summary.done" || + event.type === "response.reasoning_text.done" + ) { + if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`) + return Effect.succeed(onReasoningDone(state, event, event.item_id)) + } if (event.type === "response.reasoning_summary_part.added") return event.item_id ? Effect.succeed(onReasoningSummaryPartAdded(state, event)) diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index ae2c8ca89c8..e93e6feafc0 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -166,10 +166,6 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => { return event.item_id ? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id)) : ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`) - if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done") - return event.item_id - ? Effect.succeed(OpenResponses.onReasoningDone(state, event)) - : 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) return OpenResponses.step(state, event) diff --git a/packages/ai/src/protocols/xai-responses.ts b/packages/ai/src/protocols/xai-responses.ts index 01ab748946a..9a620927c24 100644 --- a/packages/ai/src/protocols/xai-responses.ts +++ b/packages/ai/src/protocols/xai-responses.ts @@ -1,7 +1,5 @@ -import { Effect } from "effect" import { Protocol } from "../route/protocol.js" import { OpenResponses } from "./open-responses.js" -import { ProviderShared } from "./shared.js" import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js" const ADAPTER = "xai-responses" @@ -27,15 +25,9 @@ const HOSTED_TOOLS = { }, } as const satisfies ResponsesHostedTools.Definitions +// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`, +// handled by the baseline); only its hosted tool vocabulary differs. const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => { - if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta") - return event.item_id - ? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id)) - : ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`) - if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done") - return event.item_id - ? Effect.succeed(OpenResponses.onReasoningDone(state, event)) - : 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) return OpenResponses.step(state, event) diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index 859312c4f6d..22c5f8f5d31 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -173,6 +173,37 @@ describe("Open Responses-compatible route", () => { }), ) + it.effect("reconciles raw reasoning finals without streamed deltas", () => + 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: "Think it through." })).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_raw", encrypted_content: null }, + }, + // Raw reasoning finals carry no summary index; they reconcile + // into the item's first block. + { type: "response.reasoning.done", item_id: "rs_raw", text: "Raw chain of thought." }, + { + type: "response.output_item.done", + item: { type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("Raw chain of thought.") + }), + ) + 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 3587efc1f6b..9fd367a684f 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -2196,6 +2196,105 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("reconciles reasoning summaries that arrive only as finals", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLMRequest.update(request, { providerOptions: { store: false } }), + ).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 }, + // No `.delta` events at all: the gateway sends the complete + // summary text in the `.done` final. + { + type: "response.reasoning_summary_text.done", + item_id: "rs_1", + summary_index: 0, + text: "Checked the diff.", + }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 }, + { + type: "response.output_item.done", + item: { + type: "reasoning", + id: "rs_1", + summary: [{ type: "summary_text", text: "Checked the diff." }], + encrypted_content: "encrypted-state", + }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("Checked the diff.") + expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([ + { + type: "reasoning-start", + id: "rs_1:0", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, + }, + { type: "reasoning-delta", id: "rs_1:0", text: "Checked the diff.", providerMetadata: undefined }, + { + type: "reasoning-end", + id: "rs_1:0", + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + }, + ]) + + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [response.message], + providerOptions: { store: false, include: ["reasoning.encrypted_content"] }, + }), + ) + expect(prepared.body.input).toEqual([ + { + type: "reasoning", + id: "rs_1", + summary: [{ type: "summary_text", text: "Checked the diff." }], + encrypted_content: "encrypted-state", + }, + ]) + }), + ) + + it.effect("does not duplicate reasoning finals after streamed deltas", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Streamed" }, + // Repeats the complete text, as the spec allows. + { type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Streamed" }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 }, + { + type: "response.output_item.done", + item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("Streamed") + expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([ + { type: "reasoning-delta", id: "rs_1:0", text: "Streamed", providerMetadata: undefined }, + ]) + }), + ) + it.effect("closes reasoning summary parts when storage is not disabled", () => Effect.gen(function* () { const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe( diff --git a/packages/ai/test/provider/xai-responses.test.ts b/packages/ai/test/provider/xai-responses.test.ts index 5defc903623..211cace89eb 100644 --- a/packages/ai/test/provider/xai-responses.test.ts +++ b/packages/ai/test/provider/xai-responses.test.ts @@ -24,7 +24,7 @@ describe("xAI Responses route", () => { }), ) - it.effect("parses xAI reasoning text events", () => + it.effect("parses xAI reasoning summaries", () => Effect.gen(function* () { const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe( Effect.provide( @@ -34,8 +34,13 @@ describe("xAI Responses route", () => { type: "response.output_item.added", item: { type: "reasoning", id: "reasoning_1" }, }, - { type: "response.reasoning_text.delta", item_id: "reasoning_1", delta: "Considering." }, - { type: "response.reasoning_text.done", item_id: "reasoning_1" }, + // Grok streams reasoning with the standard summary event name. + { + type: "response.reasoning_summary_text.delta", + item_id: "reasoning_1", + summary_index: 0, + delta: "Considering.", + }, { type: "response.output_item.done", item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },