mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
d7f7e0dfee
commit
63035734f2
4 changed files with 58 additions and 30 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue