diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9f5a4c73d..f12e9de30 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,6 +13,7 @@ - Fixed OpenCode Go Gemini models failing with a 404 by moving OpenCode Zen and OpenCode Go to the `opencode.ai/inference` endpoints, and added Google Generative AI support to the OpenCode Go provider - Fixed OpenCode Go MiniMax M2.7 failing with "Model does not support this protocol" by using the Anthropic Messages API - Fixed automatic retry not triggering for gateway `Unable to forward upstream stream` errors ([#10920](https://github.com/earendil-works/pi/issues/10920)) +- Fixed OpenAI-compatible responses showing thinking after the answer when a proxy such as LiteLLM sends reasoning and content in the same stream chunk ([#10926](https://github.com/earendil-works/pi/issues/10926)) ## [1.2.0] - 2026-10-12 diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 96cb23ab3..5c20a4aa0 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -589,21 +589,8 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio } if (choice.delta) { - if ( - choice.delta.content !== null && - choice.delta.content !== undefined && - choice.delta.content.length > 0 - ) { - const block = ensureTextBlock(); - block.text += choice.delta.content; - stream.push({ - type: "text_delta", - contentIndex: getContentIndex(block), - delta: choice.delta.content, - partial: output, - }); - } - + // Handle reasoning before content: some proxies (e.g. LiteLLM) aggregate reasoning and + // answer into one delta, and the thinking block must precede the text block. // Some endpoints return reasoning in reasoning_content (llama.cpp), // or reasoning (other openai compatible endpoints) // Use the first non-empty reasoning field to avoid duplication @@ -637,6 +624,34 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio } } + const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details; + if (Array.isArray(reasoningDetails)) { + for (const detail of reasoningDetails) { + if (!isOpenAIReasoningDetail(detail)) continue; + ensureThinkingBlock(""); + streamedReasoningDetails ??= []; + // 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. + appendOpenAIReasoningDetail(streamedReasoningDetails, detail); + } + } + + if ( + choice.delta.content !== null && + choice.delta.content !== undefined && + choice.delta.content.length > 0 + ) { + const block = ensureTextBlock(); + block.text += choice.delta.content; + stream.push({ + type: "text_delta", + contentIndex: getContentIndex(block), + delta: choice.delta.content, + partial: output, + }); + } + if (choice?.delta?.tool_calls) { for (const toolCall of choice.delta.tool_calls as StreamingToolCallDelta[]) { const block = ensureToolCallBlock(toolCall); @@ -666,19 +681,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio }); } } - - const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details; - if (Array.isArray(reasoningDetails)) { - for (const detail of reasoningDetails) { - if (!isOpenAIReasoningDetail(detail)) continue; - ensureThinkingBlock(""); - streamedReasoningDetails ??= []; - // 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. - appendOpenAIReasoningDetail(streamedReasoningDetails, detail); - } - } } } diff --git a/packages/ai/test/openai-completions-reasoning-details.test.ts b/packages/ai/test/openai-completions-reasoning-details.test.ts index 87b2b871c..7dd3c8f9f 100644 --- a/packages/ai/test/openai-completions-reasoning-details.test.ts +++ b/packages/ai/test/openai-completions-reasoning-details.test.ts @@ -252,3 +252,28 @@ describe("openai-completions reasoning_details streaming", () => { expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails); }); }); + +describe("openai-completions reasoning and content in one delta", () => { + beforeEach(() => { + mockState.chunkSets = []; + mockState.payloads = []; + }); + + // https://github.com/earendil-works/pi/issues/10926 + it("places thinking before text when one delta carries reasoning_content and content", async () => { + mockState.chunkSets = [[chunk({ role: "assistant", reasoning_content: "thinking", content: "answer" }, "stop")]]; + + const assistantMessage = await runOpenAICompletionsStream(); + expect(assistantMessage.content).toEqual([ + { type: "thinking", thinking: "thinking", thinkingSignature: "reasoning_content" }, + { type: "text", text: "answer" }, + ]); + }); + + it("places thinking before text when one delta carries reasoning_details and content", async () => { + mockState.chunkSets = [[chunk({ reasoning_details: [reasoningDetail], content: "answer" }, "stop")]]; + + const assistantMessage = await runOpenAICompletionsStream(); + expect(assistantMessage.content.map((block) => block.type)).toEqual(["thinking", "text"]); + }); +}); diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 5ed640e1e..0794e1b18 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1106,12 +1106,12 @@ describe("openai-completions tool_choice", () => { ]); expect(response.content).toHaveLength(6); - expect(response.content[0]).toEqual({ type: "text", text: "answer 1 answer 2\n" }); - expect(response.content[1]).toEqual({ + expect(response.content[0]).toEqual({ type: "thinking", thinking: "think 1 think 2", thinkingSignature: "reasoning_content", }); + expect(response.content[1]).toEqual({ type: "text", text: "answer 1 answer 2\n" }); const readCall = response.content[2]; const grepCall = response.content[3]; const listCall = response.content[4];