fix(ai): retain reasoning details in thinking signature

This commit is contained in:
Armin Ronacher 2026-08-19 23:35:43 +02:00
parent 496185f6e4
commit b7bb00b936
4 changed files with 85 additions and 91 deletions

View file

@ -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<number, StreamingToolCallBlock>();
const toolCallBlocksById = new Map<string, StreamingToolCallBlock>();
const pendingReasoningDetailsByToolCallId = new Map<string, string>();
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;

View file

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

View file

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

View file

@ -88,7 +88,6 @@ type _AiAssistantMessageFieldsAccountedFor = Assert<
| "model"
| "responseModel"
| "responseId"
| "reasoningDetails"
| "diagnostics"
| "usage"
| "stopReason"