mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): concatenate openai completions reasoning deltas (#8605)
This commit is contained in:
parent
5cd6a2a502
commit
c5ad7c1b0f
2 changed files with 96 additions and 3 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue