Skip to content
Closed
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
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 19 additions & 2 deletions src/claude/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };
Expand Down
32 changes: 25 additions & 7 deletions src/claude/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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;
}
Expand Down
28 changes: 27 additions & 1 deletion src/responses/reasoning-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>` for native rows and `Routed by OpenCodex to <provider>/<model>` 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.
Expand Down
Loading
Loading