diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4e327359f78..ca08887f933 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -375,7 +375,7 @@ "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-token-footer.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", - "claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", + "claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 53047a35941..254e6350c5b 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -281,7 +281,7 @@ function userMessageToItems(content: unknown, input: Rec[], elide: SkillElisionC pushUserMessage(input, pending); } -function assistantMessageToItems(content: unknown, input: Rec[], budget: TranslatorBudget): void { +function assistantMessageToItems(content: unknown, input: Rec[], budget: TranslatorBudget, requestedModel: string): void { if (typeof content === "string") { if (content.length > 0) input.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: content }] }); return; @@ -314,6 +314,20 @@ function assistantMessageToItems(content: unknown, input: Rec[], budget: Transla const owned = decodeReasoningEnvelope(signature, budget); if (!owned) throw new AnthropicRequestError("malformed ocxr1 reasoning signature"); if (Object.hasOwn(owned, "sig")) throw new AnthropicRequestError("OpenCodex reasoning continuity cannot be replayed as an Anthropic signature"); + if (owned.nat) { + // The provider's own blob goes back only to the model that minted it; any other + // model gets the visible text alone, as before this envelope field existed. + const nat = owned.nat.model === requestedModel ? owned.nat : undefined; + if (nat) budget.chargeRetained(2 * nat.enc.length, { kind: "reasoning" }); + if (thinking.length === 0 && !nat) break; + input.push({ + type: "reasoning", + id: nat?.id ?? `rs_${crypto.randomUUID().replace(/-/g, "")}`, + summary: thinking.length > 0 ? [{ type: "summary_text", text: thinking }] : [], + ...(nat ? { encrypted_content: nat.enc } : {}), + }); + break; + } } const encrypted = signature.length === 0 ? undefined : signature.startsWith(OCX_REASONING_PREFIX) ? signature : encodeReasoningEnvelope({ sig: signature }, budget); if (encrypted) budget.chargeRetained(2 * encrypted.length, { kind: "reasoning" }); @@ -411,7 +425,7 @@ function translateAnthropicRequest( for (const msg of raw.messages) { if (!isRec(msg)) throw new AnthropicRequestError("each message must be an object"); if (msg.role === "user") userMessageToItems(msg.content, input, elide); - else if (msg.role === "assistant") assistantMessageToItems(msg.content, input, budget); + else if (msg.role === "assistant") assistantMessageToItems(msg.content, input, budget, raw.model); else if (msg.role === "system") { const text = systemMessageText(msg.content); // Keep it where the client put it. `developer` is first-class in the Responses @@ -531,6 +545,9 @@ function translateAnthropicRequest( reasoning.effort = effortForThinkingBudget(thinking.budget_tokens); } body.reasoning = reasoning; + // store:false returns a provider's encrypted reasoning only on request. The outbound + // envelope (`nat`) carries it to the client so the next turn can hand it back. + body.include = ["reasoning.encrypted_content"]; } return { body, cacheKeySource }; diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index b4259b93e4a..86967401c66 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -20,7 +20,7 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { sseFieldOffset, sseFieldValue } from "../lib/sse-decoder"; -import { decodeReasoningEnvelope, encodeReasoningEnvelope } from "../responses/reasoning-envelope"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; type Rec = Record; @@ -280,6 +280,13 @@ interface OpenBlock { thinkingBuf?: string; thinkingBufBytes?: number; reasoningSig?: string; + /** The provider's own encrypted reasoning for this item; signed into the block on close. */ + reasoningNative?: { enc: string; id?: string }; +} + +/** A provider-minted (not ocxr1) `encrypted_content`, or `""`. */ +function nativeEncryptedReasoning(encrypted: string): string { + return encrypted.length > 0 && !encrypted.startsWith(OCX_REASONING_PREFIX) ? encrypted : ""; } /** Streaming: Responses SSE bytes -> Anthropic Messages SSE bytes. */ @@ -396,7 +403,11 @@ export function responsesSseToAnthropicSse( delta: { type: "thinking_delta", thinking: open.thinkingBuf }, }); } - const signature = open.reasoningSig ?? encodeReasoningEnvelope({ txt: open.thinkingBuf ?? "" }, translatorBudget); + const native = open.reasoningNative; + const signature = open.reasoningSig ?? encodeReasoningEnvelope({ + txt: open.thinkingBuf ?? "", + ...(native ? { nat: { ...native, model } } : {}), + }, translatorBudget); emit("content_block_delta", { type: "content_block_delta", index: open.index, delta: { type: "signature_delta", signature }, @@ -684,11 +695,15 @@ export function responsesSseToAnthropicSse( emit("content_block_start", { type: "content_block_start", index: idx, content_block: { type: "redacted_thinking", data } }); emit("content_block_stop", { type: "content_block_stop", index: idx }); } - if (env?.sig && open?.kind !== "thinking") { + // A provider's own blob is the only copy of its reasoning the client can hold, so + // it gets a thinking block even when no summary text arrived. + const native = nativeEncryptedReasoning(encrypted); + if ((env?.sig || native) && open?.kind !== "thinking") { ensureBlock("thinking"); } if (open?.kind === "thinking") { if (env?.sig) open.reasoningSig = env.sig; + if (native) open.reasoningNative = typeof item.id === "string" && item.id.length > 0 ? { enc: native, id: item.id } : { enc: native }; closeOpenBlock(); } } @@ -917,10 +932,13 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string, tr // Legacy combined envelopes place redacted blocks before the signed block, // matching the Anthropic adapter. New bridge output uses separate items. for (const data of env?.red ?? []) content.push({ type: "redacted_thinking", data }); - // env.txt may be locally hidden text. Do not expose it here or manufacture - // a new signed continuity carrier; hidden-summary replay remains limited. - if (parts.length > 0 || env?.sig) { - content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: env?.sig ?? encodeReasoningEnvelope({ txt: parts.join("\n\n") }, translatorBudget) }); + // env.txt may be locally hidden text. Do not expose it here. A provider's own blob is + // carried in the envelope so the next request can hand it back (see inbound.ts). + const native = nativeEncryptedReasoning(encrypted); + if (parts.length > 0 || env?.sig || native) { + const text = parts.join("\n\n"); + const nat = native ? { enc: native, model, ...(typeof raw.id === "string" && raw.id.length > 0 ? { id: raw.id } : {}) } : undefined; + content.push({ type: "thinking", thinking: text, signature: env?.sig ?? encodeReasoningEnvelope({ txt: text, ...(nat ? { nat } : {}) }, translatorBudget) }); } break; } diff --git a/src/responses/reasoning-envelope.ts b/src/responses/reasoning-envelope.ts index 60612864fdd..ffa95d39e78 100644 --- a/src/responses/reasoning-envelope.ts +++ b/src/responses/reasoning-envelope.ts @@ -36,6 +36,30 @@ export interface ReasoningEnvelope { * same way a signature does. */ krc?: string; + /** + * A Responses provider's own `encrypted_content` for one reasoning item, minted while serving + * `model` (the client-facing model the Claude route was asked for). The Claude route has no + * other channel to hand a provider its reasoning back: the body is `store: false` and Claude + * Code replays only the thinking block. `src/claude/inbound.ts` restores it only for a request + * naming the same model, so a model switch cannot carry it to a provider that cannot decrypt it. + */ + nat?: NativeReasoning; +} + +export interface NativeReasoning { + /** The provider's opaque `encrypted_content`, verbatim. */ + enc: string; + /** Client-facing model the item was produced for. */ + model: string; + /** The provider's reasoning item id, when it sent one. */ + id?: string; +} + +function decodeNativeReasoning(value: unknown): NativeReasoning | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const { enc, model, id } = value as { enc?: unknown; model?: unknown; id?: unknown }; + if (typeof enc !== "string" || enc.length === 0 || typeof model !== "string" || model.length === 0) return undefined; + return typeof id === "string" && id.length > 0 ? { enc, model, id } : { enc, model }; } export function encodeReasoningEnvelope(envelope: ReasoningEnvelope, budget?: TranslatorBudget): string { @@ -116,7 +140,9 @@ export function decodeReasoningEnvelope(encryptedContent: string, budget?: Trans if (hasTxt) 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 || hasTxt || envelope.krc ? envelope : null; + const nat = decodeNativeReasoning((parsed as { nat?: unknown }).nat); + if (nat) envelope.nat = nat; + return envelope.sig || envelope.red || hasTxt || envelope.krc || envelope.nat ? envelope : null; } catch { return null; } finally { diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index aa94003a72c..a9c819c7178 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -544,6 +544,8 @@ Translated tool results in `src/claude/inbound.ts` retain nonempty string `tool_ `src/claude/inbound-content-options.ts` strips Claude Code's leading `x-anthropic-billing-header:` line from a string system prompt or from the first text block of a system array before it becomes Responses `instructions`, dropping a block left empty. The line's `cch` value rotates per request, so keeping it made the translated prefix and the system-derived fallback `prompt_cache_key` change every turn (#6627). The match is anchored at the prompt start, like the Antigravity strip in `src/adapters/google.ts`; native Anthropic passthrough does not use this translation and keeps the client preamble. `tests/claude-integration/claude-inbound.test.ts` covers string and array systems, header-only blocks, later mentions and key stability. +`src/claude/outbound.ts` keeps a Responses provider's own reasoning blob (an `encrypted_content` without the `ocxr1:` prefix) in the thinking block's signature envelope as `nat` (`src/responses/reasoning-envelope.ts`), together with the client-facing model and the provider's item id, and emits that thinking block even when no summary text arrived. `src/claude/inbound.ts` restores it as the reasoning item's `encrypted_content` and `id` only when the replayed request names the same model. Any other model receives the visible text alone, so a mid-conversation model switch cannot hand a blob to a provider that cannot decrypt it. A combo that changes provider under one model name can still replay a blob to another child; `src/server/responses/core-opaque-recovery.ts` retries without it when the destination rejects it with a recognized error. Translated bodies that reason request `include: ["reasoning.encrypted_content"]`, the field Codex sends, because a `store: false` response returns no blob otherwise. Before this the routed model lost its own reasoning on every turn (#6736). Anthropic signatures (`sig`) and redacted blocks (`red`) are unchanged. `tests/claude-integration/claude-native-reasoning-continuity.test.ts` covers streamed and JSON output with and without summaries, same-model and cross-model replay, the request field and malformed envelopes. + ## Claude Code picker descriptions `src/claude/model-info.ts` gives every readable (`idStyle: "readable"`, Claude Code CLI) `/v1/models` row a `description` that Claude Code 2.1.257 and later shows under the picker entry instead of the generic "From gateway": `Routed by OpenCodex to native ` for native rows and `Routed by OpenCodex to /` for routed rows. The 1M copy keeps the base description and a Fast sibling appends ` ยท Fast`. Desktop 3P rows keep the ModelInfo shape without a description. `src/claude/gateway-cache.ts` preserves a string `description` when it refreshes and rewrites the gateway-model cache and drops any other type. `tests/claude-integration/claude-model-info.test.ts` and `tests/claude-integration/claude-gateway-cache.test.ts` cover both. diff --git a/tests/claude-integration/claude-native-reasoning-continuity.test.ts b/tests/claude-integration/claude-native-reasoning-continuity.test.ts new file mode 100644 index 00000000000..6431716d95f --- /dev/null +++ b/tests/claude-integration/claude-native-reasoning-continuity.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test"; +import { sanitizeReasoningInputContent } from "../../src/adapters/openai-responses/reasoning"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { responsesJsonToAnthropicMessage, responsesSseToAnthropicSse } from "../../src/claude/outbound"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../../src/responses/reasoning-envelope"; +import { responsesRequestSchema } from "../../src/responses/schema"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const MODEL = "ocx-claude-meta-muse--muse-spark-1.3-contributor"; +const OTHER_MODEL = "ocx-claude-native--gpt-6.1-sol"; +const BLOB = "Q-PaDg-provider-minted-reasoning-blob=="; + +function sse(name: string, data: Record): string { + return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`; +} + +function streamFrom(text: string): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (let i = 0; i < text.length; i += 7) controller.enqueue(encoder.encode(text.slice(i, i + 7))); + controller.close(); + }, + }); +} + +async function collectEvents(stream: ReadableStream): Promise<{ name: string; data: Record }[]> { + const text = await new Response(stream).text(); + const events: { name: string; data: Record }[] = []; + for (const frame of text.split("\n\n")) { + if (!frame.trim()) continue; + let name = ""; + let data = ""; + for (const line of frame.split("\n")) { + if (line.startsWith("event: ")) name = line.slice(7); + else if (line.startsWith("data: ")) data += line.slice(6); + } + events.push({ name, data: JSON.parse(data) }); + } + return events; +} + +async function streamedThinking(frames: string[]): Promise<{ thinking: string; signature: string }[]> { + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom([ + ...frames, + sse("response.completed", { response: { status: "completed", usage: {} } }), + ].join("")), MODEL, { translatorBudget: createTestTranslatorBudget() })); + const blocks: { thinking: string; signature: string }[] = []; + for (const event of events) { + if (event.name === "content_block_start" && event.data.content_block?.type === "thinking") blocks.push({ thinking: "", signature: "" }); + const delta = event.data.delta; + if (event.name !== "content_block_delta" || !delta) continue; + if (delta.type === "thinking_delta") blocks[blocks.length - 1]!.thinking += delta.thinking; + if (delta.type === "signature_delta") blocks[blocks.length - 1]!.signature += delta.signature; + } + return blocks; +} + +function replay(model: string, thinking: string, signature: string, thinkingConfig?: Record) { + return { + model, max_tokens: 1024, + ...(thinkingConfig ? { thinking: thinkingConfig } : {}), + messages: [ + { role: "user", content: "Start the task." }, + { role: "assistant", content: [{ type: "thinking", thinking, signature }, { type: "text", text: "Reading first." }] }, + { role: "user", content: "Continue." }, + ], + }; +} + +describe("Claude route carries a provider's own encrypted reasoning", () => { + test("a streamed blob with no summary still yields a signed thinking block that carries it", async () => { + const blocks = await streamedThinking([ + sse("response.output_item.done", { output_index: 0, item: { type: "reasoning", id: "rs_native", summary: [], encrypted_content: BLOB } }), + ]); + expect(blocks).toHaveLength(1); + expect(blocks[0]!.thinking).toBe(""); + expect(decodeReasoningEnvelope(blocks[0]!.signature)?.nat).toEqual({ enc: BLOB, model: MODEL, id: "rs_native" }); + }); + + test("streamed summary text and the blob share one thinking block", async () => { + const blocks = await streamedThinking([ + sse("response.output_item.added", { output_index: 0, item: { type: "reasoning", id: "rs_native" } }), + sse("response.reasoning_summary_text.delta", { item_id: "rs_native", output_index: 0, summary_index: 0, delta: "Plan: read, then edit." }), + sse("response.output_item.done", { output_index: 0, item: { type: "reasoning", id: "rs_native", encrypted_content: BLOB } }), + ]); + expect(blocks).toHaveLength(1); + expect(blocks[0]!.thinking).toBe("Plan: read, then edit."); + const envelope = decodeReasoningEnvelope(blocks[0]!.signature); + expect(envelope?.txt).toBe("Plan: read, then edit."); + expect(envelope?.nat).toEqual({ enc: BLOB, model: MODEL, id: "rs_native" }); + }); + + test("reasoning without a native blob keeps the summary-only signature", async () => { + const blocks = await streamedThinking([ + sse("response.output_item.added", { output_index: 0, item: { type: "reasoning", id: "rs_plain" } }), + sse("response.reasoning_summary_text.delta", { item_id: "rs_plain", output_index: 0, summary_index: 0, delta: "hmm" }), + sse("response.output_item.done", { output_index: 0, item: { type: "reasoning", id: "rs_plain" } }), + ]); + expect(decodeReasoningEnvelope(blocks[0]!.signature)).toEqual({ txt: "hmm" }); + }); + + test("the JSON path signs the blob into its thinking block, with or without summary text", () => { + const message = responsesJsonToAnthropicMessage({ + status: "completed", + output: [ + { type: "reasoning", id: "rs_a", summary: [], encrypted_content: BLOB }, + { type: "reasoning", id: "rs_b", summary: [{ type: "summary_text", text: "Next: edit." }], encrypted_content: `${BLOB}b` }, + { type: "message", content: [{ type: "output_text", text: "Done." }] }, + ], + }, MODEL, createTestTranslatorBudget()); + const thinking = (message.content as Record[]).filter(block => block.type === "thinking"); + expect(thinking.map(block => block.thinking)).toEqual(["", "Next: edit."]); + expect(thinking.map(block => decodeReasoningEnvelope(block.signature)?.nat)).toEqual([ + { enc: BLOB, model: MODEL, id: "rs_a" }, + { enc: `${BLOB}b`, model: MODEL, id: "rs_b" }, + ]); + }); + + test("replay to the same model restores the blob and item id, and the sanitizer forwards it", () => { + const signature = encodeReasoningEnvelope({ txt: "Plan.", nat: { enc: BLOB, model: MODEL, id: "rs_native" } }); + const body = anthropicToResponsesBody(replay(MODEL, "Plan.", signature, { type: "adaptive" })); + expect(() => responsesRequestSchema.parse(body)).not.toThrow(); + const reasoning = (body.input as Record[]).filter(item => item.type === "reasoning"); + expect(reasoning).toEqual([{ + type: "reasoning", id: "rs_native", + summary: [{ type: "summary_text", text: "Plan." }], + encrypted_content: BLOB, + }]); + const sanitized = sanitizeReasoningInputContent(body) as { input: Record[] }; + expect(sanitized.input.find(item => item.type === "reasoning")?.encrypted_content).toBe(BLOB); + }); + + test("replay to a different model drops the blob and keeps the visible text", () => { + const signature = encodeReasoningEnvelope({ txt: "Plan.", nat: { enc: BLOB, model: MODEL, id: "rs_native" } }); + const body = anthropicToResponsesBody(replay(OTHER_MODEL, "Plan.", signature)); + const reasoning = (body.input as Record[]).filter(item => item.type === "reasoning"); + expect(reasoning).toHaveLength(1); + expect(reasoning[0]).not.toHaveProperty("encrypted_content"); + expect(reasoning[0]!.summary).toEqual([{ type: "summary_text", text: "Plan." }]); + expect(JSON.stringify(body)).not.toContain(BLOB); + }); + + test("an empty thinking block for a different model leaves no reasoning item", () => { + const signature = encodeReasoningEnvelope({ txt: "", nat: { enc: BLOB, model: MODEL } }); + const body = anthropicToResponsesBody(replay(OTHER_MODEL, "", signature)); + expect((body.input as Record[]).some(item => item.type === "reasoning")).toBe(false); + }); + + test("a full turn round-trips the blob back to the provider", () => { + const message = responsesJsonToAnthropicMessage({ + status: "completed", + output: [ + { type: "reasoning", id: "rs_turn", summary: [], encrypted_content: BLOB }, + { type: "message", content: [{ type: "output_text", text: "OK" }] }, + ], + }, MODEL, createTestTranslatorBudget()); + const body = anthropicToResponsesBody({ + model: MODEL, max_tokens: 1024, thinking: { type: "adaptive" }, + messages: [ + { role: "user", content: "Remember a number privately." }, + { role: "assistant", content: message.content }, + { role: "user", content: "Which number?" }, + ], + }); + const reasoning = (body.input as Record[]).find(item => item.type === "reasoning"); + expect(reasoning).toMatchObject({ id: "rs_turn", encrypted_content: BLOB }); + }); + + test("requests that reason ask for encrypted reasoning; disabled thinking does not", () => { + const enabled = anthropicToResponsesBody({ model: MODEL, max_tokens: 64, thinking: { type: "adaptive" }, messages: [{ role: "user", content: "hi" }] }); + expect(enabled.include).toEqual(["reasoning.encrypted_content"]); + const effort = anthropicToResponsesBody({ model: MODEL, max_tokens: 64, output_config: { effort: "high" }, messages: [{ role: "user", content: "hi" }] }); + expect(effort.include).toEqual(["reasoning.encrypted_content"]); + const disabled = anthropicToResponsesBody({ model: MODEL, max_tokens: 64, thinking: { type: "disabled" }, messages: [{ role: "user", content: "hi" }] }); + expect(disabled).not.toHaveProperty("include"); + }); + + test("a malformed native field is ignored rather than trusted", () => { + const malformed = OCX_REASONING_PREFIX + Buffer.from(JSON.stringify({ txt: "t", nat: { enc: 7, model: MODEL } })).toString("base64"); + expect(decodeReasoningEnvelope(malformed)).toEqual({ txt: "t" }); + const noModel = OCX_REASONING_PREFIX + Buffer.from(JSON.stringify({ nat: { enc: BLOB } })).toString("base64"); + expect(decodeReasoningEnvelope(noModel)).toBeNull(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index af44e7b3b11..825b877cb29 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -262,7 +262,7 @@ "claude-first-party-union.test.ts": "claude-integration", "claude-desktop-system-proxy.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-token-footer.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", - "claude-inbound-tool-reference.test.ts": "claude-integration", + "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-native-reasoning-continuity.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", "claude-intercept-local-ca-files.test.ts": "claude-integration",