fix(ai): order thinking before text when a completions chunk has both

Proxies such as LiteLLM can send reasoning_content and content in one
stream delta. Reasoning was handled after content, so the message ended
up as [text, thinking]. Handle reasoning fields and reasoning_details
before content and tool calls.

fixes #10926
This commit is contained in:
David Brailovsky 2026-10-14 12:10:56 +02:00
parent d7f7e0dfee
commit 63035734f2
4 changed files with 58 additions and 30 deletions

View file

@ -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

View file

@ -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);
}
}
}
}

View file

@ -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"]);
});
});

View file

@ -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];