From 56752d7c56fdf13512957aa38a6b312559636886 Mon Sep 17 00:00:00 2001 From: Alin Jiang Date: Tue, 18 Aug 2026 01:48:32 -0700 Subject: [PATCH 1/4] fix(responses): route raw reasoning through the expandable summary channel Codex renders the expandable reasoning trace from the Responses reasoning item summary[] channel only. Chat-completions providers (DeepSeek-style) deliver thinking as raw reasoning_content, which the bridge and the native Responses passthrough both shaped as summary:[] + content:[reasoning_text], so routed turns showed the timer with nothing to expand (issue #45). Route reasoning_raw_delta through the same summary path as thinking_delta in the streaming and buffered bridges, and add a payload rewrite for the native-Responses passthrough (DeepSeek /responses) that converts reasoning_text.delta/done and reasoning item content into the summary channel. Internal replay-cache handoff and hideThinkingSummary suppression are preserved. Tests: bridge summary-channel shape + hide parity, rewrite unit tests, replay-cache regressions all pass; tsc clean. --- src/bridge.ts | 25 ++- .../responses-reasoning-summary-rewrite.ts | 129 ++++++++++++++++ src/server/responses/core.ts | 7 + tests/bridge.test.ts | 54 ++++++- ...esponses-reasoning-summary-rewrite.test.ts | 143 ++++++++++++++++++ 5 files changed, 345 insertions(+), 13 deletions(-) create mode 100644 src/server/responses-reasoning-summary-rewrite.ts create mode 100644 tests/responses-reasoning-summary-rewrite.test.ts diff --git a/src/bridge.ts b/src/bridge.ts index ebbf2c7cb9..0258593039 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -576,9 +576,16 @@ export function bridgeToResponsesSSE( const closeCurrentRawReasoning = () => { if (!currentRawReasoning) return; rawReasoningForNextToolCall = currentRawReasoning.text; + emit("response.reasoning_summary_text.done", { + item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, text: currentRawReasoning.text, + }); + emit("response.reasoning_summary_part.done", { + item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, + part: { type: "summary_text", text: currentRawReasoning.text }, + }); const item = { - type: "reasoning", id: currentRawReasoning.itemId, summary: [], - content: [{ type: "reasoning_text", text: currentRawReasoning.text }], + type: "reasoning", id: currentRawReasoning.itemId, + summary: [{ type: "summary_text", text: currentRawReasoning.text }], }; emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item }); retainFinishedItem(item as OutputItem, currentRawReasoning.textBytes, "reasoning"); @@ -977,8 +984,12 @@ export function bridgeToResponsesSSE( if (currentToolCall) closeCurrentToolCall(); if (!currentRawReasoning) { const itemId = `rs_${uuid()}`; - const item = { type: "reasoning", id: itemId, summary: [] as never[], content: [] as { type: string; text: string }[] }; + const item = { type: "reasoning", id: itemId, summary: [] as { type: string; text: string }[] }; emit("response.output_item.added", { output_index: outputIndex, item }); + emit("response.reasoning_summary_part.added", { + item_id: itemId, output_index: outputIndex, summary_index: 0, + part: { type: "summary_text", text: "" }, + }); currentRawReasoning = { itemId, outputIndex, text: "", textBytes: 0 }; } ({ value: currentRawReasoning.text, bytes: currentRawReasoning.textBytes } = appendString( @@ -987,9 +998,9 @@ export function bridgeToResponsesSSE( event.text, "reasoning", )); - emit("response.reasoning_text.delta", { + emit("response.reasoning_summary_text.delta", { item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, - content_index: 0, delta: event.text, + summary_index: 0, delta: event.text, }); break; } @@ -1582,8 +1593,8 @@ function buildResponseJSONWithBudget( return; } pushOutput({ - type: "reasoning", id: `rs_${uuid()}`, summary: [], - content: [{ type: "reasoning_text", text: currentRawReasoning }], + type: "reasoning", id: `rs_${uuid()}`, + summary: [{ type: "summary_text", text: currentRawReasoning }], }, currentRawReasoningBytes, "reasoning"); currentRawReasoning = ""; currentRawReasoningBytes = 0; diff --git a/src/server/responses-reasoning-summary-rewrite.ts b/src/server/responses-reasoning-summary-rewrite.ts new file mode 100644 index 0000000000..8b46f60322 --- /dev/null +++ b/src/server/responses-reasoning-summary-rewrite.ts @@ -0,0 +1,129 @@ +import type { SsePayloadRewrite } from "./sse-payload-rewrite"; + +/** + * Route content-channel reasoning from native-Responses upstreams through the + * expandable summary channel (issue #45). + * + * Codex renders the expandable reasoning trace from the Responses reasoning + * item's `summary[]` channel. DeepSeek's native `/responses` endpoint emits + * raw thinking on the content channel instead (`response.reasoning_text.delta` + * plus items with `content: [{type: "reasoning_text", text}]` and an empty + * `summary`), so routed DeepSeek turns showed the "Worked for Xs" timer with + * nothing to expand. Native OpenAI upstreams already emit summary-channel + * events; this rewrite is a no-op for them (no reasoning_text events to + * rewrite) and only engages when the upstream produces content-channel + * reasoning. + * + * Replay compatibility: Codex echoes the reasoning item it received back into + * the next request's input. DeepSeek's Responses API accepts summary-shaped + * reasoning input items (verified live), so the rewrite round-trips. + */ + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function reasoningTextOf(item: Record): string { + if (!Array.isArray(item.content)) return ""; + return item.content + .filter((part): part is Record => isPlainObject(part) && part.type === "reasoning_text") + .map(part => (typeof part.text === "string" ? part.text : "")) + .join(""); +} + +/** Move a reasoning item's content channel into the summary channel. */ +function reasoningItemToSummaryShape(item: Record): Record { + if (item.type !== "reasoning") return item; + const text = reasoningTextOf(item); + const next: Record = { ...item }; + delete next.content; + next.summary = text.length > 0 ? [{ type: "summary_text", text }] : []; + return next; +} + +/** + * Rewrite one parsed SSE payload in place of the content channel, or return + * `null` when nothing changed (caller keeps the original payload). + */ +function rewritePayload(payload: Record): Record | null { + switch (payload.type) { + case "response.reasoning_text.delta": { + const next: Record = { + type: "response.reasoning_summary_text.delta", + item_id: payload.item_id, + output_index: payload.output_index, + summary_index: 0, + delta: payload.delta, + }; + if (payload.sequence_number !== undefined) next.sequence_number = payload.sequence_number; + return next; + } + case "response.reasoning_text.done": { + const next: Record = { + type: "response.reasoning_summary_text.done", + item_id: payload.item_id, + output_index: payload.output_index, + summary_index: 0, + text: payload.text, + }; + if (payload.sequence_number !== undefined) next.sequence_number = payload.sequence_number; + return next; + } + default: { + let changed = false; + const next: Record = { ...payload }; + if (isPlainObject(next.item) && next.item.type === "reasoning") { + const rewritten = reasoningItemToSummaryShape(next.item); + if (rewritten !== next.item) { + next.item = rewritten; + changed = true; + } + } + const response = isPlainObject(next.response) ? { ...next.response } : null; + if (response && Array.isArray(response.output)) { + const output = response.output.map(item => { + if (!isPlainObject(item) || item.type !== "reasoning") return item; + const rewritten = reasoningItemToSummaryShape(item); + if (rewritten !== item) changed = true; + return rewritten; + }); + if (changed) { + response.output = output; + next.response = response; + } + } + return changed ? next : null; + } + } +} + +/** Payload rewrite for passthrough relays whose upstream emits content-channel reasoning. */ +export function createReasoningSummaryChannelPayloadRewrite(): SsePayloadRewrite { + return (payload: string): string => { + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return payload; + } + if (!isPlainObject(parsed)) return payload; + const rewritten = rewritePayload(parsed); + return rewritten !== null ? JSON.stringify(rewritten) : payload; + }; +} + +/** + * True when a routed native-Responses provider emits content-channel reasoning + * (raw `reasoning_text`) instead of the summary channel. DeepSeek's + * `/responses` endpoint is the current example: it ships raw thinking with an + * empty `summary` and keeps `preserveReasoningContentModels` so multi-turn + * replays round-trip. + */ +export function routeUsesContentChannelReasoning( + provider: { statelessResponses?: boolean; preserveReasoningContentModels?: string[] }, + modelId: string, +): boolean { + if (provider.statelessResponses === true) return true; + const preserved = provider.preserveReasoningContentModels; + return Array.isArray(preserved) && preserved.some(id => id === modelId || id === modelId.toLowerCase()); +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 978916063e..d75e07ca4c 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -221,6 +221,10 @@ import { hasResponsesItemIdRepair, repairResponsesJsonItemIds, } from "../responses-item-id-repair"; +import { + createReasoningSummaryChannelPayloadRewrite, + routeUsesContentChannelReasoning, +} from "../responses-reasoning-summary-rewrite"; import { createImageGenCallRestoreRewrite, imageGenToolCallAliases, @@ -2830,6 +2834,9 @@ async function handleResponsesInner( ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) : undefined, responseModelRewrite, + routeUsesContentChannelReasoning(route.provider, route.modelId) + ? createReasoningSummaryChannelPayloadRewrite() + : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); // #893: sparse-snapshot gateways get field backfills AND lifecycle event // injection at the block level, after payload rewrites. Defaults come diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index b28a5c68cd..a35417f717 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -85,22 +85,27 @@ describe("Responses bridge reasoning and usage parity", () => { expect(firstOutputs).toBe(1); }); - test("streaming raw reasoning emits reasoning_text deltas and final raw content", async () => { + test("streaming raw reasoning is routed through the expandable summary channel", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "reasoning_raw_delta", text: "raw detail" }, { type: "done", usage: { inputTokens: 10, outputTokens: 5, cachedInputTokens: 3, reasoningOutputTokens: 2 } }, ]), "routed/model")); - const delta = frames.find(f => f.event === "response.reasoning_text.delta")?.data; - expect(delta).toMatchObject({ content_index: 0, delta: "raw detail" }); + // Chat-completions providers (DeepSeek-style) deliver thinking as raw + // reasoning_content. Codex renders the expandable reasoning trace from the + // Responses summary channel only, so raw reasoning is routed through the + // summary channel (issue #45) instead of the content channel. + expect(frames.find(f => f.event === "response.reasoning_summary_text.delta")?.data) + .toMatchObject({ summary_index: 0, delta: "raw detail" }); + expect(frames.some(f => f.event === "response.reasoning_text.delta")).toBe(false); const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; const output = completed.output as Record[]; expect(output[0]).toMatchObject({ type: "reasoning", - summary: [], - content: [{ type: "reasoning_text", text: "raw detail" }], + summary: [{ type: "summary_text", text: "raw detail" }], }); + expect((output[0] as { content?: unknown }).content).toBeUndefined(); expect(completed.usage).toMatchObject({ input_tokens: 10, input_tokens_details: { cached_tokens: 3 }, @@ -495,8 +500,9 @@ describe("Responses bridge reasoning and usage parity", () => { const output = json.output as Record[]; expect(output.map(item => item.type)).toEqual(["reasoning", "message"]); expect(output[0]).toMatchObject({ - content: [{ type: "reasoning_text", text: "raw json" }], + summary: [{ type: "summary_text", text: "raw json" }], }); + expect((output[0] as { content?: unknown }).content).toBeUndefined(); expect(json.usage).toMatchObject({ input_tokens: 6, input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, @@ -725,6 +731,42 @@ describe("Responses bridge reasoning and usage parity", () => { expect(output.map(item => item.type)).toEqual(["message"]); }); + test("streaming hideThinkingSummary suppresses raw reasoning", async () => { + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "reasoning_raw_delta", text: "hidden raw thought" }, + { type: "text_delta", text: "visible" }, + { type: "done" }, + ]), "model", undefined, undefined, undefined, undefined, undefined, { hideThinkingSummary: true })); + + expect(frames.some(f => f.event === "response.reasoning_summary_text.delta")).toBe(false); + expect(frames.some(f => f.event === "response.reasoning_text.delta")).toBe(false); + const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; + const output = completed.output as Record[]; + // Raw reasoning stays hidden: the text round-trips only in an ocxr1 envelope, + // never as visible summary or content. + expect(output.map(item => item.type)).toEqual(["reasoning", "message"]); + expect(output[0]).toMatchObject({ + type: "reasoning", + summary: [], + }); + expect((output[0] as { encrypted_content?: string }).encrypted_content).toStartWith("ocxr1:"); + expect((output[0] as { content?: unknown }).content).toBeUndefined(); + }); + + test("non-streaming hideThinkingSummary suppresses raw reasoning", () => { + const json = buildResponseJSON([ + { type: "reasoning_raw_delta", text: "hidden" }, + { type: "text_delta", text: "visible" }, + { type: "done" }, + ], "model", { hideThinkingSummary: true }); + + const output = json.output as Record[]; + expect(output.map(item => item.type)).toEqual(["reasoning", "message"]); + expect(output[0]).toMatchObject({ type: "reasoning", summary: [] }); + expect((output[0] as { encrypted_content?: string }).encrypted_content).toStartWith("ocxr1:"); + expect((output[0] as { content?: unknown }).content).toBeUndefined(); + }); + test("heartbeat events reset the stall watchdog and emit no protocol frame", async () => { // Regression for the Cursor parallel-tool-call stall: while the upstream silently assembles tool // calls, the adapter emits `heartbeat` events. They must keep the stall watchdog alive (no diff --git a/tests/responses-reasoning-summary-rewrite.test.ts b/tests/responses-reasoning-summary-rewrite.test.ts new file mode 100644 index 0000000000..e3e61bd12c --- /dev/null +++ b/tests/responses-reasoning-summary-rewrite.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { + createReasoningSummaryChannelPayloadRewrite, + routeUsesContentChannelReasoning, +} from "../src/server/responses-reasoning-summary-rewrite"; + +const rewrite = createReasoningSummaryChannelPayloadRewrite(); + +function apply(payload: unknown): unknown { + return JSON.parse(rewrite(JSON.stringify(payload))); +} + +describe("responses reasoning summary channel rewrite", () => { + test("routes reasoning_text.delta through the summary channel", () => { + expect(apply({ + type: "response.reasoning_text.delta", + content_index: 0, + delta: "think", + item_id: "rs_1", + output_index: 0, + sequence_number: 4, + })).toEqual({ + type: "response.reasoning_summary_text.delta", + summary_index: 0, + delta: "think", + item_id: "rs_1", + output_index: 0, + sequence_number: 4, + }); + }); + + test("routes reasoning_text.done through the summary channel", () => { + expect(apply({ + type: "response.reasoning_text.done", + content_index: 0, + text: "full thinking", + item_id: "rs_1", + output_index: 0, + })).toEqual({ + type: "response.reasoning_summary_text.done", + summary_index: 0, + text: "full thinking", + item_id: "rs_1", + output_index: 0, + }); + }); + + test("moves reasoning item content into summary on output_item.done", () => { + expect(apply({ + type: "response.output_item.done", + output_index: 0, + item: { + type: "reasoning", + id: "rs_1", + status: "completed", + content: [{ type: "reasoning_text", text: "thinking" }], + summary: [], + }, + })).toEqual({ + type: "response.output_item.done", + output_index: 0, + item: { + type: "reasoning", + id: "rs_1", + status: "completed", + summary: [{ type: "summary_text", text: "thinking" }], + }, + }); + }); + + test("moves reasoning item content into summary inside response.completed", () => { + const payload = { + type: "response.completed", + response: { + id: "resp_1", + status: "completed", + output: [ + { + type: "reasoning", + id: "rs_1", + status: "completed", + content: [{ type: "reasoning_text", text: "thinking" }], + summary: [], + }, + { type: "message", id: "msg_1", status: "completed", content: [{ type: "output_text", text: "OK" }] }, + ], + }, + }; + const result = apply(payload) as { response: { output: Record[] } }; + expect(result.response.output[0]).toEqual({ + type: "reasoning", + id: "rs_1", + status: "completed", + summary: [{ type: "summary_text", text: "thinking" }], + }); + expect(result.response.output[1]).toEqual(payload.response.output[1]); + }); + + test("leaves summary-channel and message events untouched", () => { + const untouched = [ + { type: "response.reasoning_summary_text.delta", summary_index: 0, delta: "s", item_id: "rs_1", output_index: 0 }, + { type: "response.output_text.delta", content_index: 0, delta: "OK", item_id: "msg_1", output_index: 1 }, + { type: "response.output_item.added", output_index: 1, item: { type: "message", id: "msg_1", status: "in_progress", content: [] } }, + ]; + for (const payload of untouched) { + expect(apply(payload)).toEqual(payload); + } + }); + + test("keeps an empty reasoning item without inventing a summary", () => { + expect(apply({ + type: "response.output_item.done", + output_index: 0, + item: { type: "reasoning", id: "rs_1", status: "completed", content: [], summary: [] }, + })).toEqual({ + type: "response.output_item.done", + output_index: 0, + item: { type: "reasoning", id: "rs_1", status: "completed", summary: [] }, + }); + }); + + test("malformed payloads pass through unchanged", () => { + expect(rewrite("not json")).toBe("not json"); + expect(rewrite("[1,2]")).toBe("[1,2]"); + }); +}); + +describe("routeUsesContentChannelReasoning", () => { + test("statelessResponses providers use the content channel", () => { + expect(routeUsesContentChannelReasoning({ statelessResponses: true }, "deepseek-v4-flash")).toBe(true); + }); + + test("preserveReasoningContentModels lists qualify", () => { + expect(routeUsesContentChannelReasoning( + { preserveReasoningContentModels: ["deepseek-v4-flash"] }, + "deepseek-v4-flash", + )).toBe(true); + }); + + test("other providers do not", () => { + expect(routeUsesContentChannelReasoning({}, "gpt-5.5")).toBe(false); + }); +}); From 96c2c04bc1310b9fe624c0620675ea7c52314703 Mon Sep 17 00:00:00 2001 From: Alin Jiang Date: Tue, 18 Aug 2026 01:53:15 -0700 Subject: [PATCH 2/4] fix(responses): preserve existing summary when content channel is empty --- .../responses-reasoning-summary-rewrite.ts | 7 +++++- ...esponses-reasoning-summary-rewrite.test.ts | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/server/responses-reasoning-summary-rewrite.ts b/src/server/responses-reasoning-summary-rewrite.ts index 8b46f60322..663187928c 100644 --- a/src/server/responses-reasoning-summary-rewrite.ts +++ b/src/server/responses-reasoning-summary-rewrite.ts @@ -37,7 +37,12 @@ function reasoningItemToSummaryShape(item: Record): Record = { ...item }; delete next.content; - next.summary = text.length > 0 ? [{ type: "summary_text", text }] : []; + // Preserve an existing summary when the item carries no content-channel text + // (a future upstream may emit both channels); only synthesize the summary + // from content when content is actually present. + next.summary = text.length > 0 + ? [{ type: "summary_text", text }] + : (Array.isArray(next.summary) ? next.summary : []); return next; } diff --git a/tests/responses-reasoning-summary-rewrite.test.ts b/tests/responses-reasoning-summary-rewrite.test.ts index e3e61bd12c..f045ce5fad 100644 --- a/tests/responses-reasoning-summary-rewrite.test.ts +++ b/tests/responses-reasoning-summary-rewrite.test.ts @@ -119,6 +119,29 @@ describe("responses reasoning summary channel rewrite", () => { }); }); + test("preserves an existing summary when content is empty", () => { + expect(apply({ + type: "response.output_item.done", + output_index: 0, + item: { + type: "reasoning", + id: "rs_1", + status: "completed", + content: [], + summary: [{ type: "summary_text", text: "already summarized" }], + }, + })).toEqual({ + type: "response.output_item.done", + output_index: 0, + item: { + type: "reasoning", + id: "rs_1", + status: "completed", + summary: [{ type: "summary_text", text: "already summarized" }], + }, + }); + }); + test("malformed payloads pass through unchanged", () => { expect(rewrite("not json")).toBe("not json"); expect(rewrite("[1,2]")).toBe("[1,2]"); From 2d5dc2a68628a3dd0481dc4756581078db3f372e Mon Sep 17 00:00:00 2001 From: Alin Jiang Date: Tue, 18 Aug 2026 01:59:56 -0700 Subject: [PATCH 3/4] fix(responses): cover non-streaming passthrough and case-insensitive model gate CodeRabbit follow-ups: - Apply the summary-channel rewrite to the bounded-JSON passthrough path too (plain JSON answers and forced JSON-to-SSE reframing both build from clientJson), handling both the SSE completed-event shape and the bare response document shape DeepSeek returns for stream:false. - Return the original reasoning item untouched when it carries no reasoning_text content, so summary-channel items are never cleared. - Normalize both sides of the preserveReasoningContentModels match so mixed-case configured ids still gate the rewrite. --- .../responses-reasoning-summary-rewrite.ts | 51 ++++++++++-- src/server/responses/core.ts | 9 ++- ...esponses-reasoning-summary-rewrite.test.ts | 80 ++++++++++++++++++- 3 files changed, 129 insertions(+), 11 deletions(-) diff --git a/src/server/responses-reasoning-summary-rewrite.ts b/src/server/responses-reasoning-summary-rewrite.ts index 663187928c..21a8b6a5bf 100644 --- a/src/server/responses-reasoning-summary-rewrite.ts +++ b/src/server/responses-reasoning-summary-rewrite.ts @@ -35,14 +35,12 @@ function reasoningTextOf(item: Record): string { function reasoningItemToSummaryShape(item: Record): Record { if (item.type !== "reasoning") return item; const text = reasoningTextOf(item); + // Items that already use the summary channel (or carry no content text at + // all) are left untouched: rewriting them could clear a valid summary. + if (text.length === 0) return item; const next: Record = { ...item }; delete next.content; - // Preserve an existing summary when the item carries no content-channel text - // (a future upstream may emit both channels); only synthesize the summary - // from content when content is actually present. - next.summary = text.length > 0 - ? [{ type: "summary_text", text }] - : (Array.isArray(next.summary) ? next.summary : []); + next.summary = [{ type: "summary_text", text }]; return next; } @@ -84,6 +82,7 @@ function rewritePayload(payload: Record): Record { @@ -97,6 +96,17 @@ function rewritePayload(payload: Record): Record { + if (!isPlainObject(item) || item.type !== "reasoning") return item; + const rewritten = reasoningItemToSummaryShape(item); + if (rewritten !== item) changed = true; + return rewritten; + }); + if (changed) next.output = output; + } return changed ? next : null; } } @@ -117,6 +127,31 @@ export function createReasoningSummaryChannelPayloadRewrite(): SsePayloadRewrite }; } +/** + * Object-level variant for the non-streaming passthrough: the bounded-JSON + * relay bypasses the SSE payload rewrite, so reasoning items inside a full + * Responses JSON document need the same normalization before plain JSON + * serialization or forced JSON-to-SSE reframing. Returns the same reference + * when nothing changed. + */ +export function rewriteReasoningSummaryInJson(value: unknown): unknown { + if (!isPlainObject(value)) return value; + const rewritten = rewritePayload(value); + return rewritten !== null ? rewritten : value; +} + +/** String-level variant of {@link rewriteReasoningSummaryInJson}. */ +export function rewriteReasoningSummaryInJsonString(json: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return json; + } + const rewritten = rewriteReasoningSummaryInJson(parsed); + return rewritten === parsed ? json : JSON.stringify(rewritten); +} + /** * True when a routed native-Responses provider emits content-channel reasoning * (raw `reasoning_text`) instead of the summary channel. DeepSeek's @@ -130,5 +165,7 @@ export function routeUsesContentChannelReasoning( ): boolean { if (provider.statelessResponses === true) return true; const preserved = provider.preserveReasoningContentModels; - return Array.isArray(preserved) && preserved.some(id => id === modelId || id === modelId.toLowerCase()); + const normalizedModelId = modelId.toLowerCase(); + return Array.isArray(preserved) + && preserved.some(id => id.toLowerCase() === normalizedModelId); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index d75e07ca4c..aecb550bbe 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -223,6 +223,7 @@ import { } from "../responses-item-id-repair"; import { createReasoningSummaryChannelPayloadRewrite, + rewriteReasoningSummaryInJsonString, routeUsesContentChannelReasoning, } from "../responses-reasoning-summary-rewrite"; import { @@ -3048,9 +3049,15 @@ async function handleResponsesInner( const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) ? repairResponsesSnapshotJson(restored, outboundRequestBody) : restored; - return parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId + const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId ? rewriteResponsesModelJson(repaired, parsed._responseModelId) : repaired; + // The bounded-JSON answer bypasses the SSE payload rewrite, so content- + // channel reasoning needs the same normalization here for the plain + // JSON answer and every reframed-SSE variant built from clientJson. + return routeUsesContentChannelReasoning(route.provider, route.modelId) + ? rewriteReasoningSummaryInJsonString(modelRewritten) + : modelRewritten; })(); // #1700: same fail-closed policy as the SSE relay above. Both the plain JSON answer and // the reframed-SSE branch below are built from this body, so one check covers them. This diff --git a/tests/responses-reasoning-summary-rewrite.test.ts b/tests/responses-reasoning-summary-rewrite.test.ts index f045ce5fad..09b8e1bad7 100644 --- a/tests/responses-reasoning-summary-rewrite.test.ts +++ b/tests/responses-reasoning-summary-rewrite.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"; import { createReasoningSummaryChannelPayloadRewrite, routeUsesContentChannelReasoning, + rewriteReasoningSummaryInJson, + rewriteReasoningSummaryInJsonString, } from "../src/server/responses-reasoning-summary-rewrite"; const rewrite = createReasoningSummaryChannelPayloadRewrite(); @@ -107,7 +109,7 @@ describe("responses reasoning summary channel rewrite", () => { } }); - test("keeps an empty reasoning item without inventing a summary", () => { + test("leaves a reasoning item without content text untouched", () => { expect(apply({ type: "response.output_item.done", output_index: 0, @@ -115,11 +117,11 @@ describe("responses reasoning summary channel rewrite", () => { })).toEqual({ type: "response.output_item.done", output_index: 0, - item: { type: "reasoning", id: "rs_1", status: "completed", summary: [] }, + item: { type: "reasoning", id: "rs_1", status: "completed", content: [], summary: [] }, }); }); - test("preserves an existing summary when content is empty", () => { + test("preserves a summary-channel reasoning item as-is", () => { expect(apply({ type: "response.output_item.done", output_index: 0, @@ -137,11 +139,72 @@ describe("responses reasoning summary channel rewrite", () => { type: "reasoning", id: "rs_1", status: "completed", + content: [], summary: [{ type: "summary_text", text: "already summarized" }], }, }); }); + test("rewrites reasoning items inside a bare completed response document", () => { + const doc = { + id: "resp_1", + object: "response", + status: "completed", + output: [ + { + type: "reasoning", + id: "rs_1", + status: "completed", + content: [{ type: "reasoning_text", text: "thinking" }], + summary: [], + }, + { type: "message", id: "msg_1", status: "completed", content: [{ type: "output_text", text: "OK" }] }, + ], + }; + const result = rewriteReasoningSummaryInJson(doc) as { output: Record[] }; + expect(result.output[0]).toEqual({ + type: "reasoning", + id: "rs_1", + status: "completed", + summary: [{ type: "summary_text", text: "thinking" }], + }); + expect(result.output[1]).toEqual(doc.output[1]); + }); + + test("rewrites reasoning items inside an SSE completed event document", () => { + const doc = { + type: "response.completed", + response: { + id: "resp_1", + status: "completed", + output: [ + { + type: "reasoning", + id: "rs_1", + status: "completed", + content: [{ type: "reasoning_text", text: "thinking" }], + summary: [], + }, + ], + }, + }; + const result = rewriteReasoningSummaryInJson(doc) as { response: { output: Record[] } }; + expect(result.response.output[0]).toEqual({ + type: "reasoning", + id: "rs_1", + status: "completed", + summary: [{ type: "summary_text", text: "thinking" }], + }); + }); + + test("string-level rewrite leaves summary-channel documents untouched", () => { + const doc = JSON.stringify({ + id: "resp_1", + output: [{ type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "already summarized" }] }], + }); + expect(rewriteReasoningSummaryInJsonString(doc)).toBe(doc); + }); + test("malformed payloads pass through unchanged", () => { expect(rewrite("not json")).toBe("not json"); expect(rewrite("[1,2]")).toBe("[1,2]"); @@ -160,6 +223,17 @@ describe("routeUsesContentChannelReasoning", () => { )).toBe(true); }); + test("model matching is case-insensitive on both sides", () => { + expect(routeUsesContentChannelReasoning( + { preserveReasoningContentModels: ["DeepSeek-V4-Flash"] }, + "deepseek-v4-flash", + )).toBe(true); + expect(routeUsesContentChannelReasoning( + { preserveReasoningContentModels: ["deepseek-v4-flash"] }, + "DeepSeek-V4-Flash", + )).toBe(true); + }); + test("other providers do not", () => { expect(routeUsesContentChannelReasoning({}, "gpt-5.5")).toBe(false); }); From 6f8396336731aab061d6704d8ab8f92aa15d22ca Mon Sep 17 00:00:00 2001 From: Alin Jiang Date: Tue, 18 Aug 2026 02:12:19 -0700 Subject: [PATCH 4/4] fix(responses): keep hideThinkingSummary effective for passthrough rewrites CodeRabbit follow-up: when the client asked for hidden thinking (no reasoning.summary in the request), the passthrough summary-channel rewrite must not surface upstream reasoning as visible summary output. Gate both the SSE payload rewrite and the bounded-JSON rewrite on parsed.options.hideThinkingSummary !== true, and cover the four hidden/visible x SSE/JSON combinations with handleResponses integration tests. --- src/server/responses/core.ts | 6 +- ...nses-reasoning-summary-passthrough.test.ts | 121 ++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 tests/responses-reasoning-summary-passthrough.test.ts diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index aecb550bbe..f2405dcc79 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2835,7 +2835,8 @@ async function handleResponsesInner( ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) : undefined, responseModelRewrite, - routeUsesContentChannelReasoning(route.provider, route.modelId) + parsed.options.hideThinkingSummary !== true + && routeUsesContentChannelReasoning(route.provider, route.modelId) ? createReasoningSummaryChannelPayloadRewrite() : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); @@ -3055,7 +3056,8 @@ async function handleResponsesInner( // The bounded-JSON answer bypasses the SSE payload rewrite, so content- // channel reasoning needs the same normalization here for the plain // JSON answer and every reframed-SSE variant built from clientJson. - return routeUsesContentChannelReasoning(route.provider, route.modelId) + return parsed.options.hideThinkingSummary !== true + && routeUsesContentChannelReasoning(route.provider, route.modelId) ? rewriteReasoningSummaryInJsonString(modelRewritten) : modelRewritten; })(); diff --git a/tests/responses-reasoning-summary-passthrough.test.ts b/tests/responses-reasoning-summary-passthrough.test.ts new file mode 100644 index 0000000000..1327e0f759 --- /dev/null +++ b/tests/responses-reasoning-summary-passthrough.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { handleResponses } from "../src/server/responses/core"; +import type { OcxConfig } from "../src/types"; + +/** + * The passthrough relay for DeepSeek's native /responses endpoint emits + * content-channel reasoning (reasoning_text.delta + content items). The + * summary-channel rewrite must engage only when the client did NOT ask for + * hidden thinking (hideThinkingSummary) - otherwise a client that asked to + * hide reasoning would get it surfaced as visible summary output. + */ + +function deepseekSeed() { + return { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" }; +} + +const SSE_UPSTREAM_FRAMES = [ + `data: ${JSON.stringify({ type: "response.created", response: { id: "resp_1", status: "in_progress", output: [] } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: { type: "reasoning", id: "rs_1", status: "in_progress", content: [], summary: [] } })}\n\n`, + `data: ${JSON.stringify({ type: "response.reasoning_text.delta", content_index: 0, delta: "think", item_id: "rs_1", output_index: 0 })}\n\n`, + `data: ${JSON.stringify({ type: "response.reasoning_text.done", content_index: 0, text: "think", item_id: "rs_1", output_index: 0 })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "rs_1", status: "completed", content: [{ type: "reasoning_text", text: "think" }], summary: [] } })}\n\n`, + `data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_1", status: "completed", output: [{ type: "reasoning", id: "rs_1", status: "completed", content: [{ type: "reasoning_text", text: "think" }], summary: [] }] } })}\n\n`, +]; + +const JSON_UPSTREAM = { + id: "resp_1", + status: "completed", + output: [ + { + type: "reasoning", + id: "rs_1", + status: "completed", + content: [{ type: "reasoning_text", text: "think" }], + summary: [], + }, + { type: "message", id: "msg_1", status: "completed", content: [{ type: "output_text", text: "OK", annotations: [] }] }, + ], +}; + +async function runHandleResponses(body: Record, upstreamBody: unknown, contentType: string) { + const encoder = new TextEncoder(); + const payload = typeof upstreamBody === "string" + ? upstreamBody + : JSON.stringify(upstreamBody); + globalThis.fetch = (async () => new Response( + contentType.includes("event-stream") + ? new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(payload)); + controller.close(); + }, + }) + : payload, + { status: 200, headers: { "content-type": contentType } }, + )) as typeof fetch; + const config = { providers: { deepseek: deepseekSeed() } } as unknown as OcxConfig; + return handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + config, + { model: "", provider: "" }, + { abortSignal: AbortSignal.timeout(5_000) }, + ); +} + +describe("passthrough reasoning summary rewrite honors hideThinkingSummary", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + test("SSE: hidden thinking stays on the content channel", async () => { + // No reasoning.summary in the request -> parseRequest sets hideThinkingSummary. + const response = await runHandleResponses( + { model: "deepseek-v4-flash", input: "ping", stream: true }, + SSE_UPSTREAM_FRAMES.join(""), + "text/event-stream", + ); + const text = await response.text(); + expect(text).toContain("response.reasoning_text.delta"); + expect(text).not.toContain("response.reasoning_summary_text.delta"); + expect(text).toContain('"content":[{"type":"reasoning_text","text":"think"}]'); + }); + + test("SSE: requested summary routes raw reasoning through the summary channel", async () => { + const response = await runHandleResponses( + { model: "deepseek-v4-flash", input: "ping", stream: true, reasoning: { effort: "max", summary: "detailed" } }, + SSE_UPSTREAM_FRAMES.join(""), + "text/event-stream", + ); + const text = await response.text(); + expect(text).toContain("response.reasoning_summary_text.delta"); + expect(text).toContain('"summary":[{"type":"summary_text","text":"think"}]'); + }); + + test("bounded JSON: hidden thinking keeps the content shape", async () => { + const response = await runHandleResponses( + { model: "deepseek-v4-flash", input: "ping", stream: false }, + JSON_UPSTREAM, + "application/json", + ); + const text = await response.text(); + expect(text).toContain('"content":[{"type":"reasoning_text","text":"think"}]'); + expect(text).not.toContain('"summary":[{"type":"summary_text"'); + }); + + test("bounded JSON: requested summary moves item content into summary", async () => { + const response = await runHandleResponses( + { model: "deepseek-v4-flash", input: "ping", stream: false, reasoning: { effort: "max", summary: "detailed" } }, + JSON_UPSTREAM, + "application/json", + ); + const text = await response.text(); + expect(text).toContain('"summary":[{"type":"summary_text","text":"think"}]'); + expect(text).not.toContain('"content":[{"type":"reasoning_text","text":"think"}]'); + }); +});