From c5ad7c1b0f7623bbfdf64dd4967fa6e99c15c01a Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:19:31 +0200 Subject: [PATCH] fix(ai): concatenate openai completions reasoning deltas (#8605) --- packages/ai/src/api/openai-completions.ts | 32 ++++++++- ...enai-completions-reasoning-details.test.ts | 67 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 08b181f23..04326ca2f 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -247,6 +247,31 @@ function parseLegacyEncryptedReasoningDetail( } } +function fillMissingCommonReasoningDetailFields( + target: OpenAIReasoningDetailBase, + source: OpenAIReasoningDetail, +): void { + target.id ??= source.id; + target.format ||= source.format; + target.index ??= source.index; +} + +function appendOpenAIReasoningDetail(details: OpenAIReasoningDetail[], detail: OpenAIReasoningDetail): void { + const lastDetail = details[details.length - 1]; + if (detail.type === "reasoning.text" && lastDetail?.type === "reasoning.text") { + lastDetail.text += detail.text; + lastDetail.signature ||= detail.signature; + fillMissingCommonReasoningDetailFields(lastDetail, detail); + return; + } + if (detail.type === "reasoning.summary" && lastDetail?.type === "reasoning.summary") { + lastDetail.summary += detail.summary; + fillMissingCommonReasoningDetailFields(lastDetail, detail); + return; + } + details.push({ ...detail }); +} + const OPENAI_COMPLETIONS_REASONING_FIELDS = ["reasoning", "reasoning_content", "reasoning_text"] as const; type OpenAICompletionsReasoningField = (typeof OPENAI_COMPLETIONS_REASONING_FIELDS)[number]; @@ -623,9 +648,10 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio if (!isOpenAIReasoningDetail(detail)) continue; const block = ensureThinkingBlock(""); const preservedDetails = parseOpenAIReasoningDetails(block.thinkingSignature) ?? []; - preservedDetails.push(detail); - // Keep provider replay data in the existing signature slot. OpenRouter - // requires the complete reasoning_details sequence in its original order. + appendOpenAIReasoningDetail(preservedDetails, detail); + // Keep provider replay data in the existing signature slot. OpenRouter streams + // reasoning_details as deltas: consecutive text/summary deltas are merged into + // logical entries, while encrypted entries remain opaque and discrete. block.thinkingSignature = JSON.stringify(preservedDetails); } } diff --git a/packages/ai/test/openai-completions-reasoning-details.test.ts b/packages/ai/test/openai-completions-reasoning-details.test.ts index 723a51b79..a76c3b1dd 100644 --- a/packages/ai/test/openai-completions-reasoning-details.test.ts +++ b/packages/ai/test/openai-completions-reasoning-details.test.ts @@ -181,4 +181,71 @@ describe("openai-completions reasoning_details streaming", () => { expect(payload?.reasoning_details).toEqual(expectedReasoningDetails); expect(payload?.reasoning).toBeUndefined(); }); + + it("merges consecutive text and summary reasoning_details deltas before replay", async () => { + const textDelta = { type: "reasoning.text", text: "The", index: 0 }; + const textDeltaWithSignature = { + type: "reasoning.text", + text: " user wants the time.", + signature: "sha256:text-signature", + format: "openai-responses-v1", + index: 0, + }; + const summaryDelta = { type: "reasoning.summary", summary: "Looked", index: 0 }; + const summaryDeltaWithFormat = { + type: "reasoning.summary", + summary: " up time.", + format: "openai-responses-v1", + index: 0, + }; + const laterSummaryDelta = { + type: "reasoning.summary", + summary: "After encrypted block.", + format: "openai-responses-v1", + index: 0, + }; + const expectedReasoningDetails = [ + { + type: "reasoning.text", + text: "The user wants the time.", + index: 0, + signature: "sha256:text-signature", + format: "openai-responses-v1", + }, + { + type: "reasoning.summary", + summary: "Looked up time.", + index: 0, + format: "openai-responses-v1", + }, + reasoningDetail, + laterSummaryDelta, + ]; + + mockState.chunkSets = [ + [ + chunk({ reasoning_details: [textDelta] }), + chunk({ reasoning_details: [textDeltaWithSignature] }), + chunk({ reasoning_details: [summaryDelta] }), + chunk({ reasoning_details: [summaryDeltaWithFormat] }), + chunk({ reasoning_details: [reasoningDetail] }), + chunk({ reasoning_details: [laterSummaryDelta] }), + toolCallChunk(), + chunk({}, "tool_calls"), + ], + [chunk({ content: "ok" }), chunk({}, "stop")], + ]; + + const assistantMessage = await runOpenAICompletionsStream(); + const thinking = assistantMessage.content.find((block) => block.type === "thinking"); + expect(thinking).toEqual({ + type: "thinking", + thinking: "", + thinkingSignature: JSON.stringify(expectedReasoningDetails), + }); + + await runOpenAICompletionsStream([assistantMessage]); + + expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails); + }); });