Skip to content
Draft
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
55 changes: 11 additions & 44 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,22 +388,18 @@ export function bridgeToResponsesSSE(
let currentRawReasoning: { itemId: string; outputIndex: number; text: string; textBytes: number } | null = null;
// Anthropic extended-thinking round-trip state: the signature signs the CURRENT thinking
// block; redacted blocks are opaque payloads replayed verbatim. Attached to the reasoning
// item as an ocxr1 encrypted_content envelope on close. hiddenThinkingText collects the
// suppressed text under hideThinkingSummary so the signed text still round-trips.
// item as an ocxr1 encrypted_content envelope on close. Suppressed thinking text is never
// included because this envelope is only an encoding, not encryption.
let pendingSignature: string | undefined;
let pendingSignatureBytes = 0;
let pendingRedacted: string[] = [];
let hiddenThinkingText = "";
let hiddenThinkingBytes = 0;
const takeReasoningEnvelope = (hiddenText?: string): string | undefined => {
const takeReasoningEnvelope = (): string | undefined => {
if (!pendingSignature && pendingRedacted.length === 0) return undefined;
const envelope: ReasoningEnvelope = {};
if (pendingSignature) envelope.sig = pendingSignature;
if (pendingRedacted.length > 0) envelope.red = pendingRedacted;
if (hiddenText) envelope.txt = hiddenText;
const previousBytes = pendingSignatureBytes
+ pendingRedacted.reduce((sum, value) => sum + bytesOf(value), 0)
+ (hiddenText ? hiddenThinkingBytes : 0);
+ pendingRedacted.reduce((sum, value) => sum + bytesOf(value), 0);
const encoded = encodeReasoningEnvelope(envelope);
const reservation = budget?.reserveTransient(bytesOf(encoded), { kind: "reasoning" });
pendingSignature = undefined;
Expand All @@ -413,12 +409,9 @@ export function bridgeToResponsesSSE(
budget?.releaseRetained(previousBytes, { kind: "reasoning" });
return encoded;
};
// hideThinkingSummary path: no visible reasoning item exists, but a signed thinking block
// must still round-trip — emit an envelope-only reasoning item (empty summary, no text leak).
// Preserve opaque provider state for hidden reasoning without exposing the suppressed text.
const flushHiddenReasoningEnvelope = () => {
const encrypted = takeReasoningEnvelope(hiddenThinkingText || undefined);
hiddenThinkingText = "";
hiddenThinkingBytes = 0;
const encrypted = takeReasoningEnvelope();
if (!encrypted) return;
const itemId = `rs_${uuid()}`;
const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
Expand All @@ -427,35 +420,20 @@ export function bridgeToResponsesSSE(
retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
outputIndex++;
};
// hideThinkingSummary for RAW reasoning (openai-chat reasoning_content, kiro tags): no
// visible reasoning item is emitted — the app renders nothing, so tool cells keep grouping
// like native models — but the text still round-trips in a txt-only ocxr1 envelope so
// preserveReasoningContentModels replay (GLM interleaved thinking) keeps working. Direct
// encodeReasoningEnvelope: takeReasoningEnvelope's sig/red guard would drop txt-only.
let hiddenRawReasoningText = "";
let hiddenRawReasoningBytes = 0;
// Raw reasoning text flushed most recently, waiting for the tool call it
// preceded. Recorded into the replay cache on tool_call_start so a later
// continuation can re-attach it when history lost the reasoning item
// (issue #950). Kept until new reasoning/text arrives: parallel tool
// calls share the same preceding reasoning block.
let hiddenRawReasoningText = "";
let hiddenRawReasoningBytes = 0;
let rawReasoningForNextToolCall = "";
const flushHiddenRawReasoning = () => {
if (!hiddenRawReasoningText) return;
rawReasoningForNextToolCall = hiddenRawReasoningText;
const previousBytes = hiddenRawReasoningBytes;
const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText });
const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" });
budget?.releaseRetained(hiddenRawReasoningBytes, { kind: "reasoning" });
hiddenRawReasoningText = "";
hiddenRawReasoningBytes = 0;
reservation?.commitRetained();
budget?.releaseRetained(previousBytes, { kind: "reasoning" });
const itemId = `rs_${uuid()}`;
const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
emit("response.output_item.added", { output_index: outputIndex, item });
emit("response.output_item.done", { output_index: outputIndex, item });
retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
outputIndex++;
};
// Kiro reasoning round-trip. Kiro sends its encrypted blob at the END of a turn, while the
// assistant message is still open, so this CANNOT emit on arrival: the open message still
Expand Down Expand Up @@ -869,12 +847,6 @@ export function bridgeToResponsesSSE(
// recorded for a LATER tool call (CodeRabbit on #971).
flushHiddenRawReasoning();
rawReasoningForNextToolCall = "";
({ value: hiddenThinkingText, bytes: hiddenThinkingBytes } = appendString(
hiddenThinkingText,
hiddenThinkingBytes,
event.thinking,
"reasoning",
));
break;
}
if (currentMsg) closeCurrentMessage("commentary");
Expand Down Expand Up @@ -1470,8 +1442,7 @@ function buildResponseJSONWithBudget(
if (batchSignature) envelope.sig = batchSignature;
if (batchRedacted.length > 0) envelope.red = batchRedacted;
const hidden = options?.hideThinkingSummary === true;
if (hidden && currentSummaryReasoning && (envelope.sig || envelope.red)) envelope.txt = currentSummaryReasoning;
const encrypted = envelope.sig || envelope.red || envelope.txt ? encodeReasoningEnvelope(envelope) : undefined;
const encrypted = envelope.sig || envelope.red ? encodeReasoningEnvelope(envelope) : undefined;
const sourceBytes = currentSummaryReasoningBytes + batchSignatureBytes + batchRedactedBytes;
batchSignature = undefined;
batchSignatureBytes = 0;
Expand All @@ -1496,11 +1467,7 @@ function buildResponseJSONWithBudget(
if (!currentRawReasoning) return;
rawReasoningForNextToolCall = currentRawReasoning;
if (options?.hideThinkingSummary === true) {
// Same contract as the streaming path: no visible reasoning, txt-only envelope round-trip.
pushOutput({
type: "reasoning", id: `rs_${uuid()}`, summary: [],
encrypted_content: encodeReasoningEnvelope({ txt: currentRawReasoning }),
}, currentRawReasoningBytes, "reasoning");
budget?.releaseRetained(currentRawReasoningBytes, { kind: "reasoning" });
currentRawReasoning = "";
currentRawReasoningBytes = 0;
return;
Expand Down
2 changes: 1 addition & 1 deletion src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
const envelope = typeof reasoning.encrypted_content === "string"
? decodeReasoningEnvelope(reasoning.encrypted_content)
: null;
const thinkingText = envelope?.txt || text;
const thinkingText = text;

// Kiro reasoning round-trip: a krc-only item carries nothing renderable — it is provider
// state for the assistant turn that ALREADY closed, because Kiro emits its
Expand Down
9 changes: 1 addition & 8 deletions src/responses/reasoning-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,6 @@ export interface ReasoningEnvelope {
sig?: string;
/** Raw redacted_thinking block data payloads, order preserved. */
red?: string[];
/**
* Hidden thinking text (hideThinkingSummary providers): the signature signs this exact text,
* so replay needs it even though the visible summary was suppressed.
*/
txt?: string;
/**
* Kiro `reasoningContentEvent.redactedContent`: a KMS-encrypted reasoning blob that is opaque to
* the proxy. Kiro's own CLI replays it on the matching `assistantResponseMessage` to preserve
Expand All @@ -49,11 +44,9 @@ export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnve
const red = obj.red.filter((r): r is string => typeof r === "string");
if (red.length > 0) envelope.red = red;
}
const txt = (parsed as { txt?: unknown }).txt;
if (typeof txt === "string" && txt.length > 0) envelope.txt = txt;
const krc = (parsed as { krc?: unknown }).krc;
if (typeof krc === "string" && krc.length > 0) envelope.krc = krc;
return envelope.sig || envelope.red || envelope.txt || envelope.krc ? envelope : null;
return envelope.sig || envelope.red || envelope.krc ? envelope : null;
} catch {
return null;
}
Expand Down
19 changes: 10 additions & 9 deletions tests/anthropic-thinking-signature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe("bridge ocxr1 envelope emission", () => {
expect(env?.sig).toBe("RealSig1234567890==");
});

test("SSE hideThinkingSummary: envelope-only reasoning item, no text leak", async () => {
test("SSE hideThinkingSummary: envelope preserves signature without hidden text", async () => {
async function* gen() { yield* baseEvents; }
const sse = await drainSse(bridgeToResponsesSSE(gen(), "claude-x", undefined, undefined, undefined, undefined, 2000, { hideThinkingSummary: true }));
const reasoning = sseItems(sse).find(i => i.type === "reasoning");
Expand All @@ -118,7 +118,7 @@ describe("bridge ocxr1 envelope emission", () => {
expect(sse.split("reasoning_summary_text.delta").length).toBe(1); // no summary deltas emitted
const env = decodeReasoningEnvelope(reasoning!.encrypted_content as string);
expect(env?.sig).toBe("RealSig1234567890==");
expect(env?.txt).toBe("hidden chain"); // signed text survives inside the envelope only
expect(Buffer.from((reasoning!.encrypted_content as string).slice(OCX_REASONING_PREFIX.length), "base64").toString()).not.toContain("hidden chain");
});

test("JSON: reasoning item carries envelope; redacted blocks included", async () => {
Expand Down Expand Up @@ -199,8 +199,11 @@ describe("parser ocxr1 decode + anthropic replay", () => {
expect((assistant as { kiroRedactedReasoning?: string }).kiroRedactedReasoning).toBeUndefined();
});

test("hidden signed text (txt) is restored as the thinking body", async () => {
const encrypted = encodeReasoningEnvelope({ sig: "RealSig1234567890==", txt: "the hidden signed text" });
test("legacy plaintext envelope text is not restored as thinking", async () => {
const encrypted = OCX_REASONING_PREFIX + Buffer.from(JSON.stringify({
sig: "RealSig1234567890==",
txt: "the hidden signed text",
})).toString("base64");
const parsed = parseRequest({
model: "anthropic/claude-x",
input: [
Expand All @@ -211,7 +214,7 @@ describe("parser ocxr1 decode + anthropic replay", () => {
});
const assistant = parsed.context.messages.find(m => m.role === "assistant");
const thinking = (assistant as unknown as { content: OcxThinkingContent[] }).content.find(p => p.type === "thinking");
expect(thinking?.thinking).toBe("the hidden signed text");
expect(thinking).toBeUndefined();
});

test("native (non-ocxr1) encrypted_content keeps the placeholder signature", async () => {
Expand Down Expand Up @@ -261,17 +264,15 @@ describe("parser ocxr1 decode + anthropic replay", () => {
const adapter = createAnthropicAdapter(provider);
const firstEnvelope = encodeReasoningEnvelope({
sig: "FirstRealSignature123456==",
txt: "first signed chain",
});
const secondEnvelope = encodeReasoningEnvelope({
sig: "SecondRealSignature123456==",
txt: "second signed chain",
});
const parsed = parseRequest({
model: "anthropic/claude-x",
input: [
{ type: "reasoning", id: "rs_first", summary: [], encrypted_content: firstEnvelope },
{ type: "reasoning", id: "rs_second", summary: [], encrypted_content: secondEnvelope },
{ type: "reasoning", id: "rs_first", summary: [{ type: "summary_text", text: "first signed chain" }], encrypted_content: firstEnvelope },
{ type: "reasoning", id: "rs_second", summary: [{ type: "summary_text", text: "second signed chain" }], encrypted_content: secondEnvelope },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] },
{ type: "message", role: "user", content: [{ type: "input_text", text: "next" }] },
],
Expand Down
47 changes: 6 additions & 41 deletions tests/bridge-raw-reasoning-hidden.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { bridgeToResponsesSSE, buildResponseJSON } from "../src/bridge";
import { decodeReasoningEnvelope } from "../src/responses/reasoning-envelope";
import {
clearReasoningReplayCacheForTests,
peekReasoningForCall,
} from "../src/responses/reasoning-replay-cache";
import { parseRequest } from "../src/responses/parser";
import { createOpenAIChatAdapter } from "../src/adapters/openai-chat";
import type { AdapterEvent } from "../src/types";

async function* replay(events: AdapterEvent[]): AsyncGenerator<AdapterEvent> {
Expand Down Expand Up @@ -43,7 +40,7 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del
clearReasoningReplayCacheForTests();
});

test("streamed hidden: no reasoning_text deltas, envelope-only item, tool calls untouched", async () => {
test("streamed hidden: no reasoning output, tool calls untouched", async () => {
const frames = await collectSse(bridgeToResponsesSSE(replay([
{ type: "reasoning_raw_delta", text: "chain " },
{ type: "reasoning_raw_delta", text: "of thought" },
Expand All @@ -57,11 +54,7 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del
const completed = frames.find(f => f.event === "response.completed")?.data.response as Record<string, unknown>;
const output = completed.output as Record<string, unknown>[];
const reasoning = output.filter(o => o.type === "reasoning");
expect(reasoning).toHaveLength(1);
expect(reasoning[0].content).toBeUndefined();
expect(reasoning[0].summary).toEqual([]);
const envelope = decodeReasoningEnvelope(reasoning[0].encrypted_content as string);
expect(envelope?.txt).toBe("chain of thought");
expect(reasoning).toHaveLength(0);
const fc = output.find(o => o.type === "function_call") as Record<string, unknown>;
expect(fc).toMatchObject({ call_id: "call_1", name: "read_file" });
});
Expand All @@ -80,7 +73,7 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del
});
});

test("streamed hidden: thrown upstream still flushes the envelope before response.failed", async () => {
test("streamed hidden: thrown upstream does not expose reasoning before response.failed", async () => {
async function* throwing(): AsyncGenerator<AdapterEvent> {
yield { type: "reasoning_raw_delta", text: "doomed thought" };
throw new Error("upstream exploded");
Expand All @@ -91,19 +84,16 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del
const added = frames.filter(f => f.event === "response.output_item.added")
.map(f => f.data.item as Record<string, unknown>)
.filter(i => i.type === "reasoning");
expect(added).toHaveLength(1);
expect(decodeReasoningEnvelope(added[0].encrypted_content as string)?.txt).toBe("doomed thought");
expect(added).toHaveLength(0);
});

test("non-streaming hidden: envelope-only item instead of raw content", () => {
test("non-streaming hidden: no raw reasoning item is emitted", () => {
const json = buildResponseJSON([
{ type: "reasoning_raw_delta", text: "quiet" },
{ type: "done" },
], "routed/model", { hideThinkingSummary: true });
const output = (json as { output: Record<string, unknown>[] }).output;
const reasoning = output.find(o => o.type === "reasoning") as Record<string, unknown>;
expect(reasoning.content).toBeUndefined();
expect(decodeReasoningEnvelope(reasoning.encrypted_content as string)?.txt).toBe("quiet");
expect(output.find(o => o.type === "reasoning")).toBeUndefined();
});

test("non-streaming visible: raw shape unchanged", () => {
Expand All @@ -117,31 +107,6 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del
});
});

test("replay: envelope-only item round-trips into reasoning_content for preserve-listed models", () => {
const json = buildResponseJSON([
{ type: "reasoning_raw_delta", text: "replay me" },
{ type: "done" },
], "routed/model", { hideThinkingSummary: true });
const reasoningItem = (json as { output: Record<string, unknown>[] }).output.find(o => o.type === "reasoning");
const parsed = parseRequest({
model: "glm-5.2",
stream: false,
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] },
reasoningItem,
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] },
{ type: "message", role: "user", content: [{ type: "input_text", text: "next" }] },
],
});
const adapter = createOpenAIChatAdapter({
adapter: "openai-chat", baseUrl: "https://api.z.ai/api/coding/paas/v4", apiKey: "k",
preserveReasoningContentModels: ["glm-5.2"],
});
const body = JSON.parse(adapter.buildRequest(parsed).body) as { messages: Record<string, unknown>[] };
const assistant = body.messages.find(m => m.role === "assistant" && m.reasoning_content !== undefined);
expect(assistant?.reasoning_content).toBe("replay me");
});

test("streamed hidden: raw reasoning is recorded in the replay cache for the following tool call", async () => {
await collectSse(bridgeToResponsesSSE(replay([
{ type: "reasoning_raw_delta", text: "chain " },
Expand Down
Loading