From b7bb00b936dbe21b8e160b3e89efdec361846699 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 19 Aug 2026 23:35:43 +0200 Subject: [PATCH] fix(ai): retain reasoning details in thinking signature --- packages/ai/src/api/openai-completions.ts | 134 ++++++++---------- packages/ai/src/types.ts | 3 +- ...enai-completions-reasoning-details.test.ts | 38 +++-- packages/server/src/protocol.ts | 1 - 4 files changed, 85 insertions(+), 91 deletions(-) diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index a36ce4cb1..08b181f23 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -160,17 +160,6 @@ function isOpenAIReasoningDetail(detail: unknown): detail is OpenAIReasoningDeta } } -function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail { - return ( - isReasoningDetailObject(detail) && - detail.type === "reasoning.encrypted" && - typeof detail.id === "string" && - detail.id.length > 0 && - typeof detail.data === "string" && - detail.data.length > 0 - ); -} - export interface OpenAICompletionsOptions extends StreamOptions { toolChoice?: OpenAI.Chat.Completions.ChatCompletionToolChoiceOption; reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; @@ -219,7 +208,6 @@ type OpenAIReasoningSummaryDetail = OpenAIReasoningDetailBase & { type OpenAIEncryptedReasoningDetail = OpenAIReasoningDetailBase & { type: "reasoning.encrypted"; - id: string; data: string; }; @@ -231,6 +219,34 @@ type OpenAIReasoningTextDetail = OpenAIReasoningDetailBase & { type OpenAIReasoningDetail = OpenAIReasoningSummaryDetail | OpenAIEncryptedReasoningDetail | OpenAIReasoningTextDetail; +function parseOpenAIReasoningDetails(signature: string | undefined): OpenAIReasoningDetail[] | undefined { + if (!signature) return undefined; + try { + const parsed = JSON.parse(signature) as unknown; + return Array.isArray(parsed) && parsed.length > 0 && parsed.every(isOpenAIReasoningDetail) ? parsed : undefined; + } catch { + return undefined; + } +} + +function parseLegacyEncryptedReasoningDetail( + signature: string | undefined, +): OpenAIEncryptedReasoningDetail | undefined { + if (!signature) return undefined; + try { + const parsed = JSON.parse(signature) as unknown; + return isOpenAIReasoningDetail(parsed) && + parsed.type === "reasoning.encrypted" && + typeof parsed.id === "string" && + parsed.id.length > 0 && + parsed.data.length > 0 + ? parsed + : undefined; + } catch { + return undefined; + } +} + const OPENAI_COMPLETIONS_REASONING_FIELDS = ["reasoning", "reasoning_content", "reasoning_text"] as const; type OpenAICompletionsReasoningField = (typeof OPENAI_COMPLETIONS_REASONING_FIELDS)[number]; @@ -341,7 +357,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio 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 getCustomToolCallInput = (block: StreamingToolCallBlock): string => { @@ -432,16 +447,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio } 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; const name = toolCall.function?.name ?? toolCall.custom?.name ?? ""; @@ -498,7 +503,6 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio }; delete block.partialArgs; } - applyPendingReasoningDetail(block); return block; }; @@ -616,24 +620,13 @@ 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; - } - output.reasoningDetails ??= []; - // OpenRouter requires reasoning_details to be replayed unmodified and in order. - output.reasoningDetails.push(detail); - - // Keep the legacy encrypted tool-call attachment path for compatibility with - // sessions that replay encrypted details from toolCall.thoughtSignature. - if (isEncryptedReasoningDetail(detail)) { - const serializedDetail = JSON.stringify(detail); - const matchingToolCall = toolCallBlocksById.get(detail.id); - if (matchingToolCall) { - matchingToolCall.thoughtSignature = serializedDetail; - } else { - pendingReasoningDetailsByToolCallId.set(detail.id, serializedDetail); - } - } + 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. + block.thinkingSignature = JSON.stringify(preservedDetails); } } } @@ -1237,9 +1230,18 @@ export function convertMessages( ); const assistantText = assistantTextParts.map((part) => part.text).join(""); - const nonEmptyThinkingBlocks = msg.content - .filter(isThinkingContentBlock) - .filter((block) => block.thinking.trim().length > 0); + const thinkingBlocks = msg.content.filter(isThinkingContentBlock); + const toolCalls = msg.content.filter(isToolCallBlock); + const signedReasoningDetails = thinkingBlocks + .map((block) => parseOpenAIReasoningDetails(block.thinkingSignature)) + .find((details) => details !== undefined); + const legacyReasoningDetails = toolCalls + .map((toolCall) => parseLegacyEncryptedReasoningDetail(toolCall.thoughtSignature)) + .filter((detail): detail is OpenAIEncryptedReasoningDetail => detail !== undefined); + const preservedReasoningDetails = + signedReasoningDetails ?? (legacyReasoningDetails.length > 0 ? legacyReasoningDetails : undefined); + + const nonEmptyThinkingBlocks = thinkingBlocks.filter((block) => block.thinking.trim().length > 0); if (nonEmptyThinkingBlocks.length > 0) { if (compat.requiresThinkingAsText) { // Convert thinking blocks to plain text (no tags to avoid model mimicking them) @@ -1257,13 +1259,16 @@ export function convertMessages( assistantMsg.content = assistantText; } - // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) - let signature = nonEmptyThinkingBlocks[0].thinkingSignature; - if (model.provider === "opencode-go" && signature === "reasoning") { - signature = "reasoning_content"; - } - if (signature && isOpenAICompletionsReasoningField(signature)) { - assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + // reasoning_details is the structured alternative to a raw reasoning field. + if (!preservedReasoningDetails) { + // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) + let signature = nonEmptyThinkingBlocks[0].thinkingSignature; + if (model.provider === "opencode-go" && signature === "reasoning") { + signature = "reasoning_content"; + } + if (signature && isOpenAICompletionsReasoningField(signature)) { + assistantMsg[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + } } } } else if (assistantText.length > 0) { @@ -1275,15 +1280,6 @@ export function convertMessages( assistantMsg.content = assistantText; } - const preservedReasoningDetails = - msg.provider === model.provider && - msg.api === model.api && - msg.model === model.id && - msg.reasoningDetails?.length - ? msg.reasoningDetails - : undefined; - - const toolCalls = msg.content.filter(isToolCallBlock); if (toolCalls.length > 0) { assistantMsg.tool_calls = toolCalls.map((tc): ChatCompletionMessageToolCall => { const customInputProperty = options?.grammarToolInputProperties?.get(tc.name); @@ -1306,22 +1302,6 @@ export function convertMessages( }, }; }); - if (!preservedReasoningDetails) { - const reasoningDetails = toolCalls - .filter((tc) => tc.thoughtSignature) - .map((tc): OpenAIReasoningDetail | null => { - try { - const parsed = JSON.parse(tc.thoughtSignature!) as unknown; - return isOpenAIReasoningDetail(parsed) ? parsed : null; - } catch { - return null; - } - }) - .filter((detail): detail is OpenAIReasoningDetail => detail !== null); - if (reasoningDetails.length > 0) { - assistantMsg.reasoning_details = reasoningDetails; - } - } } if (preservedReasoningDetails) { assistantMsg.reasoning_details = preservedReasoningDetails; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7647226b7..d35a48b84 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -356,7 +356,7 @@ export interface TextContent { export interface ThinkingContent { type: "thinking"; thinking: string; - thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID + thinkingSignature?: string; // Provider-specific opaque or serialized reasoning replay data /** When true, the thinking content was redacted by safety filters. The opaque * encrypted payload is stored in `thinkingSignature` so it can be passed back * to the API for multi-turn continuity. */ @@ -432,7 +432,6 @@ export interface AssistantMessage { model: string; responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`) responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one - reasoningDetails?: JsonValue[]; // Provider-specific structured reasoning details to replay verbatim on later same-model turns. diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries. usage: Usage; stopReason: StopReason; diff --git a/packages/ai/test/openai-completions-reasoning-details.test.ts b/packages/ai/test/openai-completions-reasoning-details.test.ts index ae41b938f..723a51b79 100644 --- a/packages/ai/test/openai-completions-reasoning-details.test.ts +++ b/packages/ai/test/openai-completions-reasoning-details.test.ts @@ -99,9 +99,11 @@ async function runOpenAICompletionsStream(messages: AssistantMessage[] = []): Pr 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"); +function getAssistantPayload(payload: unknown): { reasoning?: unknown; reasoning_details?: unknown } | undefined { + const messages = ( + payload as { messages?: Array<{ role?: string; reasoning?: unknown; reasoning_details?: unknown }> } + ).messages; + return messages?.find((message) => message.role === "assistant"); } describe("openai-completions reasoning_details streaming", () => { @@ -110,22 +112,26 @@ describe("openai-completions reasoning_details streaming", () => { mockState.payloads = []; }); - it("preserves reasoning_details that arrive before their matching tool call", async () => { + it("preserves reasoning_details in the thinking signature", async () => { mockState.chunkSets = [ [chunk({ reasoning_details: [reasoningDetail] }), 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([reasoningDetail]), + }); const toolCall = assistantMessage.content.find((block) => block.type === "toolCall"); - expect(toolCall).toMatchObject({ + expect(toolCall).toEqual({ type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" }, - thoughtSignature: JSON.stringify(reasoningDetail), }); - expect(assistantMessage.reasoningDetails).toEqual([reasoningDetail]); await runOpenAICompletionsStream([assistantMessage]); @@ -139,7 +145,10 @@ describe("openai-completions reasoning_details streaming", () => { ]; const assistantMessage = await runOpenAICompletionsStream(); - delete assistantMessage.reasoningDetails; + assistantMessage.content = assistantMessage.content.filter((block) => block.type !== "thinking"); + const toolCall = assistantMessage.content.find((block) => block.type === "toolCall"); + if (!toolCall || toolCall.type !== "toolCall") throw new Error("Expected tool call"); + toolCall.thoughtSignature = JSON.stringify(reasoningDetail); await runOpenAICompletionsStream([assistantMessage]); @@ -149,7 +158,7 @@ describe("openai-completions reasoning_details streaming", () => { it("preserves signed text and summary reasoning_details in their original sequence", async () => { mockState.chunkSets = [ [ - chunk({ reasoning_details: [signedReasoningTextDetail] }), + chunk({ reasoning: signedReasoningTextDetail.text, reasoning_details: [signedReasoningTextDetail] }), chunk({ reasoning_details: [reasoningDetail, reasoningSummaryDetail] }), toolCallChunk(), chunk({}, "tool_calls"), @@ -159,10 +168,17 @@ describe("openai-completions reasoning_details streaming", () => { const assistantMessage = await runOpenAICompletionsStream(); const expectedReasoningDetails = [signedReasoningTextDetail, reasoningDetail, reasoningSummaryDetail]; - expect(assistantMessage.reasoningDetails).toEqual(expectedReasoningDetails); + const thinking = assistantMessage.content.find((block) => block.type === "thinking"); + expect(thinking).toEqual({ + type: "thinking", + thinking: signedReasoningTextDetail.text, + thinkingSignature: JSON.stringify(expectedReasoningDetails), + }); await runOpenAICompletionsStream([assistantMessage]); - expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails); + const payload = getAssistantPayload(mockState.payloads[1]); + expect(payload?.reasoning_details).toEqual(expectedReasoningDetails); + expect(payload?.reasoning).toBeUndefined(); }); }); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 08ebfbf32..069828e59 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -88,7 +88,6 @@ type _AiAssistantMessageFieldsAccountedFor = Assert< | "model" | "responseModel" | "responseId" - | "reasoningDetails" | "diagnostics" | "usage" | "stopReason"