Skip to content

Commit 8d3d975

Browse files
committed
fix: preserve valid completions reasoning replay
1 parent 02275e8 commit 8d3d975

2 files changed

Lines changed: 157 additions & 43 deletions

File tree

src/agents/openai-transport-stream.test.ts

Lines changed: 100 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5327,17 +5327,8 @@ describe("openai transport stream", () => {
53275327
});
53285328
});
53295329

5330-
describe("buildOpenAICompletionsParams strips response-only reasoning fields", () => {
5331-
// OpenRouter and other OpenAI-Completions providers echo `reasoning_details`
5332-
// (array) and `reasoning_content` (string) in response choices to expose the
5333-
// model's chain of thought. If those fields leak back into a follow-up
5334-
// request, OpenRouter rejects the call with HTTP 500 ("Internal Server
5335-
// Error"). buildOpenAICompletionsParams must scrub them before the wire.
5336-
// Repro recipe (verified against live OpenRouter):
5337-
// POST /chat/completions with messages[*].reasoning_details: "..." => 500
5338-
// Same body without that key => 200
5339-
5340-
const baseModel = {
5330+
describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () => {
5331+
const openRouterModel = {
53415332
id: "deepseek/deepseek-v4-flash",
53425333
name: "DeepSeek v4 Flash",
53435334
api: "openai-completions",
@@ -5350,6 +5341,19 @@ describe("buildOpenAICompletionsParams strips response-only reasoning fields", (
53505341
maxTokens: 8192,
53515342
} satisfies Model<"openai-completions">;
53525343

5344+
const openAIModel = {
5345+
id: "gpt-5.4-mini",
5346+
name: "GPT-5.4 Mini",
5347+
api: "openai-completions",
5348+
provider: "openai",
5349+
baseUrl: "https://api.openai.com/v1",
5350+
reasoning: true,
5351+
input: ["text"],
5352+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
5353+
contextWindow: 200_000,
5354+
maxTokens: 8192,
5355+
} satisfies Model<"openai-completions">;
5356+
53535357
function getAssistantMessage(params: { messages: unknown }) {
53545358
expect(Array.isArray(params.messages)).toBe(true);
53555359
const list = params.messages as Array<Record<string, unknown>>;
@@ -5358,13 +5362,80 @@ describe("buildOpenAICompletionsParams strips response-only reasoning fields", (
53585362
return assistant as Record<string, unknown>;
53595363
}
53605364

5361-
it("removes reasoning_details, reasoning_content, and reasoning from assistant replay", () => {
5365+
function buildReplayParams(model: Model<"openai-completions">, thinkingSignature: string) {
5366+
return buildOpenAICompletionsParams(
5367+
model,
5368+
{
5369+
systemPrompt: "system",
5370+
messages: [
5371+
{ role: "user", content: "hello" },
5372+
{
5373+
role: "assistant",
5374+
provider: model.provider,
5375+
api: model.api,
5376+
model: model.id,
5377+
stopReason: "stop",
5378+
timestamp: 0,
5379+
content: [
5380+
{
5381+
type: "thinking",
5382+
thinking: "Need to answer politely.",
5383+
thinkingSignature,
5384+
},
5385+
{ type: "text", text: "Hello!" },
5386+
],
5387+
},
5388+
{ role: "user", content: "again" },
5389+
],
5390+
tools: [],
5391+
} as never,
5392+
undefined,
5393+
) as { messages: unknown };
5394+
}
5395+
5396+
it.each(["reasoning_details", "reasoning_content", "reasoning", "reasoning_text"])(
5397+
"strips %s from stock OpenAI Chat Completions assistant replay",
5398+
(thinkingSignature) => {
5399+
const assistant = getAssistantMessage(buildReplayParams(openAIModel, thinkingSignature));
5400+
5401+
expect(assistant).not.toHaveProperty("reasoning_details");
5402+
expect(assistant).not.toHaveProperty("reasoning_content");
5403+
expect(assistant).not.toHaveProperty("reasoning");
5404+
expect(assistant).not.toHaveProperty("reasoning_text");
5405+
},
5406+
);
5407+
5408+
it("normalizes OpenRouter string reasoning_details to reasoning", () => {
5409+
const assistant = getAssistantMessage(buildReplayParams(openRouterModel, "reasoning_details"));
5410+
5411+
expect(assistant).not.toHaveProperty("reasoning_details");
5412+
expect(assistant.reasoning).toBe("Need to answer politely.");
5413+
});
5414+
5415+
it.each(["reasoning", "reasoning_content"])(
5416+
"preserves OpenRouter %s string reasoning replay",
5417+
(thinkingSignature) => {
5418+
const assistant = getAssistantMessage(buildReplayParams(openRouterModel, thinkingSignature));
5419+
5420+
expect(assistant[thinkingSignature]).toBe("Need to answer politely.");
5421+
},
5422+
);
5423+
5424+
it("normalizes OpenRouter reasoning_text to reasoning", () => {
5425+
const assistant = getAssistantMessage(buildReplayParams(openRouterModel, "reasoning_text"));
5426+
5427+
expect(assistant).not.toHaveProperty("reasoning_text");
5428+
expect(assistant.reasoning).toBe("Need to answer politely.");
5429+
});
5430+
5431+
it("preserves OpenRouter array reasoning_details from tool-call signatures", () => {
5432+
const reasoningDetail = { type: "reasoning.encrypted", id: "rs_1", data: "ciphertext" };
53625433
const params = buildOpenAICompletionsParams(
5363-
baseModel,
5434+
openRouterModel,
53645435
{
53655436
systemPrompt: "system",
53665437
messages: [
5367-
{ role: "user", content: "你好" },
5438+
{ role: "user", content: "lookup" },
53685439
{
53695440
role: "assistant",
53705441
provider: "openrouter",
@@ -5374,23 +5445,30 @@ describe("buildOpenAICompletionsParams strips response-only reasoning fields", (
53745445
timestamp: 0,
53755446
content: [
53765447
{
5377-
type: "thinking",
5378-
thinking: "User said hi, I should respond politely.",
5379-
thinkingSignature: "considering",
5448+
type: "toolCall",
5449+
id: "call_1",
5450+
name: "lookup",
5451+
arguments: { query: "weather" },
5452+
thoughtSignature: JSON.stringify(reasoningDetail),
53805453
},
5381-
{ type: "text", text: "Hello!" },
53825454
],
53835455
},
5384-
{ role: "user", content: "再来一个" },
5456+
{
5457+
role: "toolResult",
5458+
toolCallId: "call_1",
5459+
toolName: "lookup",
5460+
content: [{ type: "text", text: "sunny" }],
5461+
isError: false,
5462+
timestamp: 1,
5463+
},
5464+
{ role: "user", content: "answer" },
53855465
],
53865466
tools: [],
53875467
} as never,
53885468
undefined,
53895469
) as { messages: unknown };
53905470

53915471
const assistant = getAssistantMessage(params);
5392-
expect(assistant).not.toHaveProperty("reasoning_details");
5393-
expect(assistant).not.toHaveProperty("reasoning_content");
5394-
expect(assistant).not.toHaveProperty("reasoning");
5472+
expect(assistant.reasoning_details).toEqual([reasoningDetail]);
53955473
});
53965474
});

src/agents/openai-transport-stream.ts

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2376,27 +2376,61 @@ function injectToolCallThoughtSignatures(
23762376
}
23772377
}
23782378

2379-
// OpenRouter / many OpenAI-Completions providers ECHO `reasoning_details`
2380-
// (array) and `reasoning_content` (string) inside response choices to surface
2381-
// the model's chain of thought. Some downstream message builders inadvertently
2382-
// preserve those fields when persisting an assistant turn, so they get
2383-
// replayed verbatim on the next request. The Chat Completions request schema
2384-
// does NOT accept either field on input messages — and worse, OpenRouter
2385-
// rejects the call with a generic HTTP 500 ("Internal Server Error") instead
2386-
// of a 4xx with a parse error, which makes the failure look like an upstream
2387-
// outage. Always strip them from outgoing messages before we hit the wire.
2388-
//
2389-
// This is a defensive guard at the last possible point in the pipeline; the
2390-
// underlying serializer (pi-ai's `convertMessages`) is the one that ought to
2391-
// drop these, but until that is fixed upstream we cannot let a single stale
2392-
// reasoning echo break every subsequent turn for the session.
2393-
const COMPLETIONS_RESPONSE_ONLY_MESSAGE_FIELDS = [
2379+
const COMPLETIONS_REASONING_REPLAY_FIELDS = [
23942380
"reasoning_details",
23952381
"reasoning_content",
23962382
"reasoning",
2383+
"reasoning_text",
23972384
] as const;
23982385

2399-
function stripCompletionsResponseOnlyFields(messages: unknown): void {
2386+
function stripCompletionsReasoningReplayFields(record: Record<string, unknown>): void {
2387+
for (const field of COMPLETIONS_REASONING_REPLAY_FIELDS) {
2388+
if (field in record) {
2389+
delete record[field];
2390+
}
2391+
}
2392+
}
2393+
2394+
function sanitizeOpenRouterReasoningReplayFields(record: Record<string, unknown>): void {
2395+
const reasoningDetails = record.reasoning_details;
2396+
if (typeof reasoningDetails === "string") {
2397+
if (reasoningDetails.length > 0 && typeof record.reasoning !== "string") {
2398+
record.reasoning = reasoningDetails;
2399+
}
2400+
delete record.reasoning_details;
2401+
} else if (reasoningDetails !== undefined && !Array.isArray(reasoningDetails)) {
2402+
delete record.reasoning_details;
2403+
}
2404+
2405+
if ("reasoning" in record && typeof record.reasoning !== "string") {
2406+
delete record.reasoning;
2407+
}
2408+
if ("reasoning_content" in record && typeof record.reasoning_content !== "string") {
2409+
delete record.reasoning_content;
2410+
}
2411+
2412+
const reasoningText = record.reasoning_text;
2413+
if (
2414+
typeof reasoningText === "string" &&
2415+
reasoningText.length > 0 &&
2416+
typeof record.reasoning !== "string" &&
2417+
typeof record.reasoning_content !== "string"
2418+
) {
2419+
record.reasoning = reasoningText;
2420+
}
2421+
if ("reasoning_text" in record) {
2422+
delete record.reasoning_text;
2423+
}
2424+
}
2425+
2426+
// OpenAI Chat Completions assistant-message input does not define reasoning
2427+
// replay fields, while OpenRouter documents a compatible pass-back contract.
2428+
// Keep OpenRouter's valid shapes, but normalize leaked string reasoning_details
2429+
// from pi-ai thinking signatures before a follow-up request hits the wire.
2430+
function sanitizeCompletionsReasoningReplayFields(
2431+
messages: unknown,
2432+
options: { preserveOpenRouterReasoning: boolean },
2433+
): void {
24002434
if (!Array.isArray(messages)) {
24012435
return;
24022436
}
@@ -2408,10 +2442,10 @@ function stripCompletionsResponseOnlyFields(messages: unknown): void {
24082442
if (record.role !== "assistant") {
24092443
continue;
24102444
}
2411-
for (const field of COMPLETIONS_RESPONSE_ONLY_MESSAGE_FIELDS) {
2412-
if (field in record) {
2413-
delete record[field];
2414-
}
2445+
if (options.preserveOpenRouterReasoning) {
2446+
sanitizeOpenRouterReasoningReplayFields(record);
2447+
} else {
2448+
stripCompletionsReasoningReplayFields(record);
24152449
}
24162450
}
24172451
}
@@ -2431,7 +2465,9 @@ export function buildOpenAICompletionsParams(
24312465
: context;
24322466
let messages = convertMessages(model as never, completionsContext, compat as never);
24332467
injectToolCallThoughtSignatures(messages as unknown[], context, model);
2434-
stripCompletionsResponseOnlyFields(messages);
2468+
sanitizeCompletionsReasoningReplayFields(messages, {
2469+
preserveOpenRouterReasoning: compat.thinkingFormat === "openrouter",
2470+
});
24352471
if (compat.strictMessageKeys) {
24362472
messages = stripCompletionMessagesToRoleContent(messages) as typeof messages;
24372473
}

0 commit comments

Comments
 (0)