fix(ai): preserve early reasoning details

closes #5114
This commit is contained in:
Vegard Stikbakke
2026-06-22 10:14:59 +02:00
parent 084574048b
commit 7d0497fdb7
3 changed files with 162 additions and 7 deletions
@@ -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<string, unknown>;
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<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 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);
}
}
}