mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): retain reasoning details in thinking signature
This commit is contained in:
parent
496185f6e4
commit
b7bb00b936
4 changed files with 85 additions and 91 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -88,7 +88,6 @@ type _AiAssistantMessageFieldsAccountedFor = Assert<
|
|||
| "model"
|
||||
| "responseModel"
|
||||
| "responseId"
|
||||
| "reasoningDetails"
|
||||
| "diagnostics"
|
||||
| "usage"
|
||||
| "stopReason"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue