From 7d0497fdb752a05a9327864951ae7d6fcc4a9617 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 22 Jun 2026 10:14:59 +0200 Subject: [PATCH] fix(ai): preserve early reasoning details closes #5114 --- packages/ai/CHANGELOG.md | 4 + .../ai/src/providers/openai-completions.ts | 47 +++++-- ...enai-completions-reasoning-details.test.ts | 118 ++++++++++++++++++ 3 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 packages/ai/test/openai-completions-reasoning-details.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 586260cf..828e3433 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI-compatible streaming to preserve encrypted `reasoning_details` that arrive before matching tool call deltas ([#5114](https://github.com/earendil-works/pi/issues/5114)). + ## [0.79.9] - 2026-06-20 ### Added diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 10c8e7e6..b257f96e 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -78,6 +78,20 @@ function isImageContentBlock(block: { type: string }): block is ImageContent { return block.type === "image"; } +function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail { + if (typeof detail !== "object" || detail === null) { + return false; + } + const candidate = detail as Record; + return ( + candidate.type === "reasoning.encrypted" && + typeof candidate.id === "string" && + candidate.id.length > 0 && + typeof candidate.data === "string" && + candidate.data.length > 0 + ); +} + export interface OpenAICompletionsOptions extends StreamOptions { toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } }; reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -96,6 +110,12 @@ type ResolvedChatTemplateKwargValue = string | number | boolean | null; type ChatCompletionInstructionMessageParam = ChatCompletionDeveloperMessageParam | ChatCompletionSystemMessageParam; +type OpenAIEncryptedReasoningDetail = { + type: "reasoning.encrypted"; + id: string; + data: string; +}; + type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & { cache_control?: OpenAICompatCacheControl; }; @@ -177,6 +197,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA let hasFinishReason = false; const toolCallBlocksByIndex = new Map(); const toolCallBlocksById = new Map(); + const pendingReasoningDetailsByToolCallId = new Map(); const blocks = output.content as StreamingBlock[]; const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block); const finishBlock = (block: StreamingBlock) => { @@ -232,6 +253,16 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA } return thinkingBlock; }; + const applyPendingReasoningDetail = (block: StreamingToolCallBlock) => { + if (!block.id) { + return; + } + const pendingReasoningDetail = pendingReasoningDetailsByToolCallId.get(block.id); + if (pendingReasoningDetail) { + block.thoughtSignature = pendingReasoningDetail; + pendingReasoningDetailsByToolCallId.delete(block.id); + } + }; const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => { const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined; let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined; @@ -267,6 +298,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA if (toolCall.id) { toolCallBlocksById.set(toolCall.id, block); } + applyPendingReasoningDetail(block); return block; }; @@ -376,15 +408,16 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA } } - const reasoningDetails = (choice.delta as any).reasoning_details; - if (reasoningDetails && Array.isArray(reasoningDetails)) { + const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details; + if (Array.isArray(reasoningDetails)) { for (const detail of reasoningDetails) { - if (detail.type === "reasoning.encrypted" && detail.id && detail.data) { - const matchingToolCall = output.content.find( - (b) => b.type === "toolCall" && b.id === detail.id, - ) as ToolCall | undefined; + if (isEncryptedReasoningDetail(detail)) { + const serializedDetail = JSON.stringify(detail); + const matchingToolCall = toolCallBlocksById.get(detail.id); if (matchingToolCall) { - matchingToolCall.thoughtSignature = JSON.stringify(detail); + matchingToolCall.thoughtSignature = serializedDetail; + } else { + pendingReasoningDetailsByToolCallId.set(detail.id, serializedDetail); } } } diff --git a/packages/ai/test/openai-completions-reasoning-details.test.ts b/packages/ai/test/openai-completions-reasoning-details.test.ts new file mode 100644 index 00000000..c07b1acf --- /dev/null +++ b/packages/ai/test/openai-completions-reasoning-details.test.ts @@ -0,0 +1,118 @@ +import { Type } from "typebox"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { streamOpenAICompletions } from "../src/providers/openai-completions.ts"; +import type { AssistantMessage, Model, Tool } from "../src/types.ts"; + +const mockState = vi.hoisted(() => ({ + chunkSets: [] as unknown[][], + payloads: [] as unknown[], +})); + +vi.mock("openai", () => { + class FakeOpenAI { + chat = { + completions: { + create: (payload: unknown) => { + mockState.payloads.push(payload); + const chunks = mockState.chunkSets.shift() ?? []; + const stream = { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk; + } + }, + }; + const result = Promise.resolve(stream) as Promise & { + withResponse: () => Promise<{ data: typeof stream; response: { status: number; headers: Headers } }>; + }; + result.withResponse = async () => ({ + data: stream, + response: { status: 200, headers: new Headers() }, + }); + return result; + }, + }, + }; + } + return { default: FakeOpenAI }; +}); + +const reasoningDetail = { type: "reasoning.encrypted", id: "call_1", data: "encrypted-signature" }; +const readTool: Tool = { + name: "read", + description: "Read a file", + parameters: Type.Object({ path: Type.String() }), +}; + +function model(): Model<"openai-completions"> { + return { + id: "google/gemini-test", + name: "Gemini Test", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 100_000, + maxTokens: 4096, + }; +} + +function chunk(delta: Record, finishReason: string | null = null): unknown { + return { + id: "chatcmpl-test", + model: "google/gemini-test", + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; +} + +function toolCallChunk(): unknown { + return chunk({ + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name: "read", arguments: '{"path":"README.md"}' }, + }, + ], + }); +} + +async function runOpenAICompletionsStream(messages: AssistantMessage[] = []): Promise { + return await streamOpenAICompletions(model(), { messages, tools: [readTool] }, { apiKey: "test" }).result(); +} + +function getAssistantPayload(payload: unknown): { reasoning_details?: unknown } | undefined { + const messages = (payload as { messages?: Array<{ role?: string; reasoning_details?: unknown }> }).messages ?? []; + return messages.find((message) => message.role === "assistant"); +} + +describe("openai-completions reasoning_details streaming", () => { + beforeEach(() => { + mockState.chunkSets = []; + mockState.payloads = []; + }); + + it("preserves reasoning_details that arrive before their matching tool call", async () => { + mockState.chunkSets = [ + [chunk({ reasoning_details: [reasoningDetail] }), toolCallChunk(), chunk({}, "tool_calls")], + [chunk({ content: "ok" }), chunk({}, "stop")], + ]; + + const assistantMessage = await runOpenAICompletionsStream(); + const toolCall = assistantMessage.content.find((block) => block.type === "toolCall"); + expect(toolCall).toMatchObject({ + type: "toolCall", + id: "call_1", + name: "read", + arguments: { path: "README.md" }, + thoughtSignature: JSON.stringify(reasoningDetail), + }); + + await runOpenAICompletionsStream([assistantMessage]); + + expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual([reasoningDetail]); + }); +});