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
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,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-native-reasoning-provenance.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
80 changes: 77 additions & 3 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, nativeReasoningReplay: Map<string, 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,24 @@ 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) {
const previous = nativeReasoningReplay.get(nat.enc);
nativeReasoningReplay.set(nat.enc, previous === undefined || previous === nat.tag ? nat.tag : "");
}
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 @@ -355,6 +373,7 @@ export type ClaudeCacheKeySource = "metadata" | "system" | null;
export interface ClaudeInboundTranslation {
body: Rec;
cacheKeySource: ClaudeCacheKeySource;
nativeReasoningReplay: ReadonlyMap<string, string>;
}

/**
Expand Down Expand Up @@ -400,6 +419,7 @@ function translateAnthropicRequest(
}

const input: Rec[] = [];
const nativeReasoningReplay = new Map<string, string>();
const systemParts: string[] = [];
const topLevelSystem = systemToInstructions(raw.system);
if (topLevelSystem !== undefined) systemParts.push(topLevelSystem);
Expand All @@ -411,7 +431,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, nativeReasoningReplay);
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,7 +551,61 @@ 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"];
Comment on lines 553 to +556

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove encrypted-content include when reasoning is stripped

When the selected model has an explicitly empty effort ladder, claude-messages.ts:1158-1161 deletes internalBody.reasoning as a safety valve, but this new include remains on the raw body and is forwarded by an openai-responses route. The resulting request asks for reasoning.encrypted_content despite no longer requesting reasoning, defeating the no-reasoning-model guard and potentially causing strict Responses-compatible endpoints to reject the unsupported parameter. Delete the include alongside reasoning, or add it only after final route capability resolution.

Useful? React with 👍 / 👎.

}

return { body, cacheKeySource };
return { body, cacheKeySource, nativeReasoningReplay };
}

/**
* Remove proxy-owned native blobs before sending a Messages body to native Anthropic.
*
* Every decode, and every re-encode on top of it, is reserved against the request's translator
* budget first (eight bytes per code unit, as `decodeReasoningEnvelope` does), and every new
* signature is charged as a retained copy, so a body of near-limit envelopes cannot allocate past the per-turn bound.
* Throws TranslatorBudgetExceededError when it would; callers answer 413.
*/
export function nativeAnthropicProjection(body: Rec, budget: TranslatorBudget): Rec {
if (!Array.isArray(body.messages)) return body;
let changed = false;
const messages = body.messages.flatMap(message => {
if (!isRec(message) || message.role !== "assistant" || !Array.isArray(message.content)) return [message];
let localChanged = false;
const content = message.content.flatMap(block => {
if (!isRec(block) || block.type !== "thinking" || typeof block.signature !== "string"
|| !block.signature.startsWith(OCX_REASONING_PREFIX)) return [block];
// The decoded object stays alive while it is re-encoded, so its reservation is held until
// the new signature exists; the encode copies get their own reservation on top of it.
const decodeReservation = budget.reserveTransient(8 * block.signature.length, { kind: "reasoning" });
try {
let decoded: unknown;
try { decoded = JSON.parse(Buffer.from(block.signature.slice(OCX_REASONING_PREFIX.length), "base64").toString("utf8")); }
catch { decoded = undefined; }
if (!isRec(decoded)) { localChanged = true; return []; }
if (!Object.hasOwn(decoded, "nat")) return [block];
localChanged = true;
const { nat: _nat, ...rest } = decoded;
if (!rest.sig && !rest.red && !rest.krc && !(typeof rest.txt === "string" && rest.txt.length > 0)) return [];
// The re-encoded envelope is never larger than the decoded one (a field was removed), so a
// decode-sized reservation bounds the stringify, buffer and base64 copies before they exist.
const encodeReservation = budget.reserveTransient(8 * block.signature.length, { kind: "reasoning" });
let signature: string;
try {
signature = OCX_REASONING_PREFIX + Buffer.from(JSON.stringify(rest), "utf8").toString("base64");
} finally {
encodeReservation.release();
}
budget.chargeRetained(2 * signature.length, { kind: "reasoning" });
return [{ ...block, signature }];
} finally {
decodeReservation.release();
}
});
if (!localChanged) return [message];
changed = true;
return content.length ? [{ ...message, content }] : [];
});
return changed ? { ...body, messages } : body;
}
42 changes: 34 additions & 8 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 @@ -339,6 +339,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 All @@ -353,6 +360,7 @@ export function responsesSseToAnthropicSse(
* upstream sent no confirmed usage before the first frame. See `messageSnapshot` (#4857).
*/
inputTokenFloor?: number;
nativeReasoningTagFor?: (blob: string) => string | undefined;
},
): ReadableStream<Uint8Array> {
const translatorBudget = opts.translatorBudget;
Expand Down Expand Up @@ -441,6 +449,13 @@ export function responsesSseToAnthropicSse(
open.webSearchArgsEmitted = true;
}
if (open.kind === "thinking") {
const native = open.reasoningNative;
const tag = native && opts.nativeReasoningTagFor?.(native.enc);
if (!open.thinkingBuf && !open.reasoningSig && !tag) {
releaseThinkingBuffer(open);
open = null;
return;
}
// Delay the index and all thinking frames until closure so a matching
// done envelope can put its redacted blocks first. The existing buffer
// remains charged through signature emission, including queued frames.
Expand All @@ -455,7 +470,10 @@ export function responsesSseToAnthropicSse(
delta: { type: "thinking_delta", thinking: open.thinkingBuf },
});
}
const signature = open.reasoningSig ?? encodeReasoningEnvelope({ txt: open.thinkingBuf ?? "" }, translatorBudget);
const signature = open.reasoningSig ?? encodeReasoningEnvelope({
txt: open.thinkingBuf ?? "",
...(tag ? { nat: { ...native, model, tag } } : {}),
}, translatorBudget);
Comment on lines +473 to +476

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind native reasoning to the effective routed model

The envelope records the client-facing model, not the model that actually produced the ciphertext. For synthetic effort/Fast rows, handleClaudeMessages saves the suffixed selector in requestedModel and then replaces anthropicBody.model with the base ID before translation, so the next turn compares this stored value with the normalized ID in inbound.ts:320 and always drops otherwise-valid continuity. Conversely, a combo or policy can retain the same client selector while switching between two physical models at the same endpoint and credential, allowing the tag and model checks to pass and forwarding one model's blob to another. Carry the effective routed model identity through minting and route verification instead of using the response-display selector.

Useful? React with 👍 / 👎.

emit("content_block_delta", {
type: "content_block_delta", index: open.index,
delta: { type: "signature_delta", signature },
Expand Down Expand Up @@ -743,11 +761,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 @@ -940,7 +962,7 @@ export function responsesSseToAnthropicSse(
}

/** Non-streaming: /v1/responses JSON -> Anthropic message JSON. */
export function responsesJsonToAnthropicMessage(json: unknown, model: string, translatorBudget?: TranslatorBudget): Rec {
export function responsesJsonToAnthropicMessage(json: unknown, model: string, translatorBudget?: TranslatorBudget, nativeReasoningTagFor?: (blob: string) => string | undefined): Rec {
const body = isRec(json) ? json : {};
const output = Array.isArray(body.output) ? body.output : [];
const content: Rec[] = [];
Expand Down Expand Up @@ -976,10 +998,14 @@ 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);
const tag = native && nativeReasoningTagFor?.(native);
if (parts.length > 0 || env?.sig || tag) {
const text = parts.join("\n\n");
const nat = tag ? { enc: native, model, tag, ...(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
30 changes: 29 additions & 1 deletion src/responses/reasoning-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,32 @@ 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;
/** Route-bound HMAC for this exact ciphertext. */
tag: 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, tag } = value as { enc?: unknown; model?: unknown; id?: unknown; tag?: unknown };
if (typeof enc !== "string" || enc.length === 0 || typeof model !== "string" || model.length === 0 || typeof tag !== "string" || tag.length === 0) return undefined;
return typeof id === "string" && id.length > 0 ? { enc, model, tag, id } : { enc, model, tag };
}

export function encodeReasoningEnvelope(envelope: ReasoningEnvelope, budget?: TranslatorBudget): string {
Expand Down Expand Up @@ -116,7 +142,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
18 changes: 17 additions & 1 deletion src/responses/reasoning-replay-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,30 @@
* long-lived proxy cannot grow without limit.
*/

import { createHash, createHmac, randomBytes } from "node:crypto";
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { thoughtSignatureReplaySalt } from "./thought-signature-replay";
import type {
OcxProviderConfig,
OcxReasoningReplayIdentity,
OcxReasoningReplayScopeRef,
} from "../types";

const MAX_ENTRIES = 64;
export interface NativeReasoningOwner { destination: string; credential: string }

/** Bind exact ciphertext to the durable route that accepted it. */
export function nativeReasoningTag(owner: NativeReasoningOwner | undefined, blob: string): string | undefined {
const salt = thoughtSignatureReplaySalt();
if (!owner || !salt || salt.length < 16 || blob.length === 0) return undefined;
return createHmac("sha256", salt).update("native-reasoning\0")
.update(JSON.stringify([owner.destination, owner.credential, createHash("sha256").update(blob).digest("hex")]))
.digest("hex");
}

export function nativeReasoningTagMatches(expected: string | undefined, presented: string | undefined): boolean {
if (!expected || !presented || !/^[a-f0-9]{64}$/.test(expected) || !/^[a-f0-9]{64}$/.test(presented)) return false;
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(presented, "hex"));
}
const MAX_TOTAL_BYTES = 256 * 1024;
const TTL_MS = 60 * 60 * 1000;
const OPAQUE_BLOB_REJECTION_TTL_MS = 5 * 60 * 1000;
Expand Down
Loading
Loading