Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
171 changes: 171 additions & 0 deletions src/server/responses-reasoning-summary-rewrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
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<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}

function reasoningTextOf(item: Record<string, unknown>): string {
if (!Array.isArray(item.content)) return "";
return item.content
.filter((part): part is Record<string, unknown> => 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<string, unknown>): Record<string, unknown> {
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<string, unknown> = { ...item };
delete next.content;
next.summary = [{ type: "summary_text", text }];
return next;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* 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<string, unknown>): Record<string, unknown> | null {
switch (payload.type) {
case "response.reasoning_text.delta": {
const next: Record<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = { ...payload };
if (isPlainObject(next.item) && next.item.type === "reasoning") {
const rewritten = reasoningItemToSummaryShape(next.item);
if (rewritten !== next.item) {
next.item = rewritten;
changed = true;
}
}
// SSE event shape: {type: "response.completed", response: {output}}.
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;
}
}
// Bare response document shape (non-streaming passthrough):
// {object: "response", output: [...]}.
if (Array.isArray(next.output)) {
const output = next.output.map(item => {
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;
}
}
}

/** 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;
};
}

/**
* 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
* `/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;
const normalizedModelId = modelId.toLowerCase();
return Array.isArray(preserved)
&& preserved.some(id => id.toLowerCase() === normalizedModelId);
}
18 changes: 17 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ import {
hasResponsesItemIdRepair,
repairResponsesJsonItemIds,
} from "../responses-item-id-repair";
import {
createReasoningSummaryChannelPayloadRewrite,
rewriteReasoningSummaryInJsonString,
routeUsesContentChannelReasoning,
} from "../responses-reasoning-summary-rewrite";
import {
createImageGenCallRestoreRewrite,
imageGenToolCallAliases,
Expand Down Expand Up @@ -2830,6 +2835,10 @@ async function handleResponsesInner(
? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget)
: undefined,
responseModelRewrite,
parsed.options.hideThinkingSummary !== true
&& routeUsesContentChannelReasoning(route.provider, route.modelId)
? createReasoningSummaryChannelPayloadRewrite()
: undefined,
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
// #893: sparse-snapshot gateways get field backfills AND lifecycle event
// injection at the block level, after payload rewrites. Defaults come
Expand Down Expand Up @@ -3041,9 +3050,16 @@ 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 parsed.options.hideThinkingSummary !== true
&& 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
Expand Down
54 changes: 48 additions & 6 deletions tests/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const output = completed.output as Record<string, unknown>[];
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 },
Expand Down Expand Up @@ -495,8 +500,9 @@ describe("Responses bridge reasoning and usage parity", () => {
const output = json.output as Record<string, unknown>[];
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 },
Expand Down Expand Up @@ -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<string, unknown>;
const output = completed.output as Record<string, unknown>[];
// 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<string, unknown>[];
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
Expand Down
Loading
Loading