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
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/protocol-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ These paths still use the internal Responses bridge or are not covered:
- `previous_response_id`, `store`, `background`, and compaction stay Responses features.
- Adapters whose wire is none of the three APIs (Gemini, Kiro, Cursor, and others) are translated
through the IR with no feature claims.
- Managed native Messages to first-party Anthropic preserve a coherent observed Claude Code CLI
user agent, session UUID and allowlisted SDK headers across token refresh and account selection.
Credential placement remains proxy-owned and beta allowlisting still applies. Missing or invalid
identity uses the existing compatibility headers; compatible hosts and generated Responses retain
their current behavior. Headers alone do not authorize requests or establish official support.
- Native Chat over OAuth is not planned. Native Messages over Anthropic OAuth covers only an
unpooled account; a pooled account set stays on the bridge.
- The managed native Messages path forwards a caller's `anthropic-beta` values only from a short
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ modes, the preview, and the per-request trace.
| `protocols.unrepresentable?` | `"legacy" \| "reject"` | `"legacy"` | `legacy` sends a request whose path drops a feature and records the loss in the trace. `reject` refuses it with HTTP 400 before any send, naming only the feature keys. |
| `protocols.rollout.nativeChatCombos?` | `boolean` | `false` | Send an eligible Chat candidate inside a combo natively from its own copy of the client body. |
| `protocols.rollout.managedMessagesNative?` | `boolean` | `false` | Send Messages natively to a direct, key-authenticated Anthropic provider instead of through the internal Responses bridge. |
| `protocols.rollout.managedMessagesNativeOAuth?` | `boolean` | `false` | Native Messages for the unpooled `anthropic` OAuth provider on `api.anthropic.com`. Read as off unless `managedMessagesNative` is on; a pooled account set stays on the bridge. |
| `protocols.rollout.managedMessagesNativeOAuth?` | `boolean` | `false` | Native Messages for the unpooled `anthropic` OAuth provider on `api.anthropic.com`. Read as off unless `managedMessagesNative` is on; a pooled account set stays on the bridge. Recognized JSON-string `metadata.user_id` account UUIDs are aligned with the serving OAuth credential; device and session fields are preserved. |
| `protocols.rollout.directEncoders?` | `boolean` | `false` | Encode Chat and Messages answers from a non-Responses upstream directly from adapter events. |
| `protocols.rollout.shadowPlan?` | `boolean` | `false` | Compare each Chat or Messages request's path with the plan a preview predicts and mark a disagreement as `planMismatch` on its log row. Sends nothing extra. |

Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@
"always-on-429-failover.test.ts": "routing",
"ambiguous-resend-composition.test.ts": "lib",
"ambiguous-resend-gate.test.ts": "lib",
"anthropic-client-identity.test.ts": "adapters/anthropic",
"anthropic-account-metadata.test.ts": "adapters/anthropic",
"anthropic-account-pool.test.ts": "adapters/anthropic",
"anthropic-account-pause-outbound.test.ts": "adapters/anthropic",
"anthropic-account-pause.test.ts": "adapters/anthropic",
Expand Down
17 changes: 17 additions & 0 deletions src/adapters/anthropic/account-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/** Serving-account alignment for observed JSON-string Claude metadata. No body-wide matching. */
const PROVIDER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const MAX_USER_ID_BYTES = 4096;
type Rec = Record<string, unknown>;
const isRec = (value: unknown): value is Rec => value !== null && typeof value === "object" && !Array.isArray(value);

/** Unknown formats stay untouched; the argument is the provider UUID, never the local pool id. */
export function bindAnthropicAccountMetadata(body: Rec, providerAccountUuid: string | undefined): Rec {
if (!providerAccountUuid || !PROVIDER_UUID.test(providerAccountUuid) || !isRec(body.metadata)) return body;
const userId = body.metadata.user_id;
if (typeof userId !== "string" || Buffer.byteLength(userId) > MAX_USER_ID_BYTES) return body;
let identity: unknown;
try { identity = JSON.parse(userId); } catch { return body; }
if (!isRec(identity) || typeof identity.account_uuid !== "string" || !PROVIDER_UUID.test(identity.account_uuid)
|| identity.account_uuid === providerAccountUuid) return body;
return { ...body, metadata: { ...body.metadata, user_id: JSON.stringify({ ...identity, account_uuid: providerAccountUuid }) } };
}
69 changes: 69 additions & 0 deletions src/adapters/anthropic/client-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/** Request-local compatibility data. Client headers never grant authorization or credential authority. */
import { classifyInterceptClient } from "../../claude/intercept/client-class";

const MAX_IDENTITY_BYTES = 4096;
const MAX_VALUE_BYTES = 512;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SDK_TOKEN = /^[A-Za-z0-9._+-]{1,128}$/;
const NAMES = [
"User-Agent", "X-App", "X-Claude-Code-Session-Id", "x-client-request-id",
"X-Stainless-Lang", "X-Stainless-Runtime", "X-Stainless-Runtime-Version",
"X-Stainless-Package-Version", "X-Stainless-OS", "X-Stainless-Arch",
"X-Stainless-Retry-Count", "X-Stainless-Timeout",
] as const;
const REQUIRED = ["User-Agent", "X-App", "X-Claude-Code-Session-Id", "X-Stainless-Lang", "X-Stainless-Runtime"] as const;
declare const identityBrand: unique symbol;
/** Opaque in-memory handle: serializing it cannot publish caller headers. */
export interface AnthropicClientIdentity { readonly [identityBrand]: true }
const bundles = new WeakMap<AnthropicClientIdentity, Readonly<Record<string, string>>>();

function valid(name: typeof NAMES[number], value: string): boolean {
if (!value || Buffer.byteLength(value) > MAX_VALUE_BYTES || /[^\x20-\x7e]/.test(value)) return false;
if (name === "User-Agent") return classifyInterceptClient(value) === "cli";
if (name === "X-App") return value === "cli";
if (name === "X-Claude-Code-Session-Id" || name === "x-client-request-id") return UUID.test(value);
if (name === "X-Stainless-Lang") return value === "js";
if (name === "X-Stainless-Runtime") return value === "node";
if (name === "X-Stainless-Retry-Count" || name === "X-Stainless-Timeout") return /^\d{1,6}$/.test(value);
return SDK_TOKEN.test(value);
}

/** Select only a coherent observed CLI bundle; a UA alone does not select this compatibility lane. */
export function captureAnthropicClientIdentity(headers: Headers): AnthropicClientIdentity | undefined {
const connection = headers.get("connection") ?? "";
if (Buffer.byteLength(connection) > MAX_VALUE_BYTES) return undefined;
const hopNames = new Set(connection.toLowerCase().split(",").map(name => name.trim()));
const selected: Record<string, string> = {};
let bytes = 0;
for (const name of NAMES) {
const value = headers.get(name);
if (value === null) continue;
// Headers joins duplicate occurrences. Every scalar validator rejects joined values;
// the anchored CLI UA parser also rejects a second appended user agent.
if (hopNames.has(name.toLowerCase()) || !valid(name, value)) {
if ((REQUIRED as readonly string[]).includes(name)) return undefined;
continue;
}
bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
if (bytes > MAX_IDENTITY_BYTES) return undefined;
selected[name] = value;
}
if (REQUIRED.some(name => selected[name] === undefined)) return undefined;
const handle = Object.freeze({}) as AnthropicClientIdentity;
bundles.set(handle, Object.freeze(selected));
return handle;
}

/** Called only after the builder verifies a first-party destination. Unknown/forged handles do nothing. */
export function applyAnthropicClientIdentity(headers: Record<string, string>, identity: AnthropicClientIdentity | undefined, operatorHeaders?: Readonly<Record<string, string>>): void {
const values = identity && bundles.get(identity);
if (!values) return;
const configured = new Set(Object.keys(operatorHeaders ?? {}).map(name => name.toLowerCase()));
for (const [name, value] of Object.entries(values)) {
if (configured.has(name.toLowerCase())) continue;
for (const existing of Object.keys(headers)) {
if (existing.toLowerCase() === name.toLowerCase()) delete headers[existing];
}
headers[name] = value;
}
}
27 changes: 23 additions & 4 deletions src/adapters/anthropic/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
*
* Authority: only the provider's own credential is ever placed on the request — a configured
* key, or (PF-10) the access token of the OAuth account the lane resolved, which is sent only to
* `api.anthropic.com`. The one caller header this builder sees is `anthropic-beta`, handed over
* explicitly and reduced to `beta-allowlist.ts`; no other caller header is read here at all. The
* `api.anthropic.com`. Caller betas are reduced to `beta-allowlist.ts`; a bounded opaque
* CLI identity handle carries only observed compatibility headers for first-party destinations. The
* caller-forward passthrough in `src/server/claude-messages.ts` is the only place a caller's
* Anthropic credential may travel, and it does not come through here.
*
Expand All @@ -27,6 +27,8 @@ import {
applyAnthropicOAuthAuth,
resolveAnthropicMessagesUrl,
} from "../anthropic";
import { applyAnthropicClientIdentity, type AnthropicClientIdentity } from "./client-identity";
import { bindAnthropicAccountMetadata } from "./account-metadata";
import { allowlistAnthropicBetas } from "./beta-allowlist";

/**
Expand Down Expand Up @@ -75,6 +77,10 @@ export interface AnthropicMessagesPassthroughRequest {
export interface AnthropicMessagesPassthroughOptions {
/** The caller's `anthropic-beta` header, handed over by the ingress. */
callerAnthropicBeta?: string | null;
/** Provider UUID captured with the serving OAuth credential; never a local account slot id. */
providerAccountUuid?: string;
/** Internal request-local handle; never part of the serialized Messages body. */
clientIdentity?: AnthropicClientIdentity;
}

/** The allowlisted copy of `body` with `model` set to the wire model. Shallow: nothing is cloned. */
Expand Down Expand Up @@ -201,12 +207,25 @@ export function buildAnthropicMessagesPassthroughRequest(
: "anthropic provider requires a non-empty apiKey (authMode: key)");
}
const url = resolveAnthropicMessagesUrl(provider);
const { wireBody, strippedOpaqueState, oauthToolNames } = anthropicMessagesNativeWireBody(provider, modelId, body);
const native = anthropicMessagesNativeWireBody(provider, modelId, body);
const { strippedOpaqueState, oauthToolNames } = native;
const wireBody = oauth ? bindAnthropicAccountMetadata(native.wireBody, options.providerAccountUuid) : native.wireBody;
const headers = anthropicBaseRequestHeaders(wireBody.stream === true);
if (oauth) applyAnthropicOAuthAuth(headers, provider.apiKey);
else applyAnthropicKeyAuth(headers, provider);
// Operator-configured provider headers apply exactly as the adapter applies them.
if (provider.headers) Object.assign(headers, provider.headers);
if (provider.headers) {
const configured = new Set(Object.keys(provider.headers).map(name => name.toLowerCase()));
for (const name of Object.keys(headers)) if (configured.has(name.toLowerCase())) delete headers[name];
Object.assign(headers, provider.headers);
}
if (oauth) {
const wireHeaders = new Headers(headers);
if (wireHeaders.get("authorization") !== `Bearer ${provider.apiKey}` || wireHeaders.has("x-api-key")) {
throw new Error("native OAuth serving credential was overridden by provider headers");
}
}
if (domain?.firstPartyAnthropic) applyAnthropicClientIdentity(headers, options.clientIdentity, provider.headers);
const betas = allowlistAnthropicBetas(options.callerAnthropicBeta, domain?.firstPartyAnthropic ? "first-party" : "compatible");
mergeAnthropicBetaHeader(headers, betas.betas);
return {
Expand Down
4 changes: 3 additions & 1 deletion src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import { requestPathForLane } from "../protocols/path";
import { resolveApiSurfaceSettings, resolveProtocolSettings } from "../protocols/settings";
import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace";
import { recordProtocolShadowPlan } from "../protocols/shadow-plan";
import { captureAnthropicClientIdentity } from "../adapters/anthropic/client-identity";
import { nativeMessagesDeclineReason, type NativeMessagesSelector } from "./messages-native-eligibility";
import {
isApiAuthRequired,
Expand Down Expand Up @@ -1202,7 +1203,8 @@ async function handleClaudeMessagesWithBudget(
return await handleNativeMessages({
req, config, logCtx, ...(logIds ? { logIds } : {}),
route: nativeMessagesRoute, body: nativeBody, requestedModel, translatorBudget, selector: nativeSelector,
// The one caller header the native lane is given; the builder allowlists it.
// Compatibility identity is an opaque request-local handle, separate from credentials.
clientIdentity: captureAnthropicClientIdentity(req.headers),
callerAnthropicBeta: req.headers.get("anthropic-beta"),
});
}
Expand Down
9 changes: 7 additions & 2 deletions src/server/messages-native-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ function isRec(value: unknown): value is Rec {
export interface NativeOAuthBinding {
readonly selection: Selection;
readonly snapshot: OAuthAccessSnapshot;
/** Anthropic account UUID from the same stored generation, distinct from snapshot.accountId. */
readonly providerAccountUuid?: string;
}

/** The selection moved or became pooled while it was being resolved. Maps to a 409 retry. */
Expand Down Expand Up @@ -95,7 +97,9 @@ export async function resolveNativeOAuthBinding(config: OcxConfig): Promise<Nati
expectedCredentialGeneration: candidate.generation,
})) continue;
if (pooled(config)) break;
const binding = { selection: committed, snapshot: candidate };
const row = getAccountCredentialWithStatus(PROVIDER, candidate.accountId);
if (!row || credentialGeneration(row.credential) !== candidate.generation) continue;
const binding = { selection: committed, snapshot: candidate, providerAccountUuid: row.credential.accountId };
if (nativeOAuthBindingIsCurrent(binding)) return binding;
}
// Re-read after a lost commit or a health change during credential resolution.
Expand All @@ -114,7 +118,8 @@ export function nativeOAuthBindingIsCurrent(binding: NativeOAuthBinding): boolea
&& selected?.revision === binding.selection.revision
&& !!row && !row.paused && !row.needsReauth && row.credential.expires > Date.now()
&& !getAnthropicAccountHealthSnapshot(binding.snapshot.accountId)
&& credentialGeneration(row.credential) === binding.snapshot.generation;
&& credentialGeneration(row.credential) === binding.snapshot.generation
&& row.credential.accountId === binding.providerAccountUuid;
}

/** Map a `tool_use` block's wire name back to the caller's name; other blocks are untouched. */
Expand Down
10 changes: 7 additions & 3 deletions src/server/messages-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
* key selection, key failover and 429 replay, connection policy — so nothing the Responses
* pipeline enforces is bypassed.
*
* Authority. This lane reads no caller header. The ingress hands over one value, the caller's
* `anthropic-beta`, which the builder reduces to an allowlist. The caller-forward passthrough in
* Authority. The ingress hands over caller `anthropic-beta` for allowlisting and an opaque
* observed CLI identity handle for first-party compatibility. Neither grants credential authority. The caller-forward passthrough in
* `claude-messages.ts` (the caller's own Anthropic credential) is a different branch decided
* before this one, and nothing here can reach it or be reached from it.
*
Expand Down Expand Up @@ -67,6 +67,7 @@ import type { OcxProviderTransport } from "../providers/xai-transport";
import { preservesPhysicalComboProvider, resolveComboId } from "../combos";
import { captureRouteStaticPolicy, routeModel, type RouteResult } from "../router";
import { POLICY_NAMESPACE, resolvePolicyProfileId } from "../routing/profile";
import type { AnthropicClientIdentity } from "../adapters/anthropic/client-identity";
import type { OcxConfig, OcxProviderConfig, OcxUsage } from "../types";
import { resolveWireProtocolOverride } from "./adapter-resolve";
import {
Expand Down Expand Up @@ -145,9 +146,10 @@ export interface HandleNativeMessagesOptions {
selector?: NativeMessagesSelector;
/**
* The caller's `anthropic-beta` header, handed over by the ingress. The builder keeps only
* allowlisted values; no other caller header reaches this lane.
* allowlisted values. Client identity is a separate bounded opaque handle.
*/
callerAnthropicBeta?: string | null;
clientIdentity?: AnthropicClientIdentity;
}

type FinishLog = (status: number, message?: string, meta?: FinalRequestLogMeta) => void;
Expand Down Expand Up @@ -410,6 +412,8 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
recordAttemptCredentialSource(attempt, route.providerName, activeProvider, "anthropic");
const built = buildAnthropicMessagesPassthroughRequest(activeProvider, route.modelId, body, config, {
callerAnthropicBeta: options.callerAnthropicBeta,
providerAccountUuid: oauthBinding?.providerAccountUuid,
clientIdentity: options.clientIdentity,
});
if (built.strippedOpaqueState && rejectUnrepresentable) throw new NativeOpaqueStateRefusal();
if (built.droppedBetas) addProtocolEntryReason(logCtx, "anthropic-beta-dropped");
Expand Down
4 changes: 4 additions & 0 deletions structure/data-planes/inbound-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,3 +430,7 @@ Unicode pattern normalization uses [copy-on-write traversal](../transports/byte-
Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting).

The [compaction routing override](../transports/responses-failover.md#compaction-routing-overrides) requires original Responses ingress; translated Chat and Messages calls retain their own routing.

Managed native Anthropic OAuth metadata follows [the native Messages binding contract](protocol-paths.md#managed-native-messages): the serving credential's provider UUID replaces only recognized account metadata, with each attempt rebuilt from the source.

Managed native Messages retain a coherent observed CLI identity bundle only for first-party Anthropic; [native Messages](protocol-paths.md#managed-native-messages) owns its bounds and credential separation. Header identity never selects an account or authorizes a request.
Loading
Loading