Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
75389ee
feat(protocols): name OAuth pools, dropped betas and stripped opaque …
lidge-jun Sep 25, 2026
4ed4a3a
feat(anthropic): allowlist caller betas for the native Messages lane
lidge-jun Sep 25, 2026
b6aa990
feat(protocols): keep opaque thinking state inside Anthropic's own do…
lidge-jun Sep 25, 2026
ee7c49a
refactor(anthropic): export the OAuth header placement the adapter ap…
lidge-jun Sep 25, 2026
153f40f
feat(anthropic): build native Messages for OAuth, allowlisted betas a…
lidge-jun Sep 25, 2026
08ea046
feat(messages): admit unpooled Anthropic OAuth routes to the native lane
lidge-jun Sep 25, 2026
4324cea
feat(messages): resolve the Anthropic OAuth account for a native send
lidge-jun Sep 25, 2026
7e3edda
feat(messages): send native Messages with OAuth, allowlisted betas an…
lidge-jun Sep 25, 2026
7fb999b
feat(messages): hand the native lane its beta header and guard opaque…
lidge-jun Sep 25, 2026
9b25a51
docs(protocols): say how the plan preview judges Anthropic OAuth
lidge-jun Sep 25, 2026
a0844df
test(anthropic): pin the native beta allowlist and the OAuth request …
lidge-jun Sep 25, 2026
eb114e6
test(protocols): pin opaque-state domains and OAuth native eligibility
lidge-jun Sep 25, 2026
527fcff
test(messages): drive the native lane with OAuth, stripped opaque sta…
lidge-jun Sep 25, 2026
2cb6931
docs(structure): describe the native Messages beta allowlist, opaque …
lidge-jun Sep 25, 2026
6af9cce
test(messages): build credential-shaped fixtures at runtime for the p…
lidge-jun Sep 25, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ Kept current by each packet that migrates something.
| Responses-only features on Chat/Messages | `previous_response_id`, `store`, `background`, compaction stay on the bridge |
| Non-public-wire adapters (`other`) | translated through the IR; no feature claims |
| OAuth native Chat | not planned in this unit |
| Messages → key-auth Anthropic | native behind `managedMessagesNative` (PF-08); bridge while off. Caller `anthropic-beta` is not forwarded (PF-10 allowlist); top-level fields outside the allowlist are dropped with no feature effect |
| Messages → Anthropic OAuth | bridge until `managedMessagesNativeOAuth` (PF-10) |
| Messages → key-auth Anthropic | native behind `managedMessagesNative` (PF-08); bridge while off. Caller `anthropic-beta` passes only through the PF-10 allowlist (`interleaved-thinking-2025-05-14` to `api.anthropic.com`, nothing to a compatible host); a dropped value is traced as `anthropic-beta-dropped`, never by value. Top-level fields outside the allowlist are dropped with no feature effect |
| Messages → Anthropic OAuth | native behind `managedMessagesNativeOAuth` (PF-10) for the unpooled `anthropic` provider on `api.anthropic.com`; bridge while off |
| Messages → pooled Anthropic OAuth | not migrated (PF-10): `anthropicAccountPool.enabled` or two usable stored accounts decline with `oauth-account-pool`, because rotation, session affinity and quota ranking live in the Responses transport |
| Opaque thinking state on the native lane | signatures and `redacted_thinking` reach `api.anthropic.com` only; elsewhere removed and traced as `opaque-state-stripped` (reason code, not a feature effect: a same-wire hop has no degraded disposition), refused before any send under `reject` |
| Messages native lane, translated-only steps | a pinned route effort, blocked-skill elision, the web-search sidecar and vision preprocessing keep the request on the bridge (`bridge-only-policy` / `vision-preprocessing`); `stabilizePromptCache` is a recorded gap — the native lane does not apply it |
6 changes: 6 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@
"anthropic-thinking-signature.test.ts": "adapters/anthropic",
"anthropic-tool-call-id.test.ts": "adapters/anthropic",
"anthropic-messages-passthrough.test.ts": "adapters/anthropic",
"anthropic-beta-allowlist.test.ts": "adapters/anthropic",
"anthropic-messages-passthrough-oauth.test.ts": "adapters/anthropic",
"anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic",
"anthropic-tool-schema.test.ts": "adapters/anthropic",
"antigravity-baseurl-override.test.ts": "adapters/google",
Expand Down Expand Up @@ -332,6 +334,7 @@
"chat-inbound-developer-position.test.ts": "responses",
"protocol-contract.test.ts": "responses",
"protocol-features.test.ts": "responses",
"protocol-opaque-state.test.ts": "responses",
"protocol-baseline.test.ts": "responses",
"protocol-dto.test.ts": "responses",
"protocol-path.test.ts": "responses",
Expand All @@ -346,6 +349,7 @@
"chat-native-combo.test.ts": "responses",
"messages-native-eligibility.test.ts": "responses",
"messages-native-bridge-policy.test.ts": "responses",
"messages-native-oauth-eligibility.test.ts": "responses",
"chat-inbound-reasoning-none.test.ts": "responses",
"chat-native-decline-reason.test.ts": "responses",
"chat-inbound-reasoning-replay.test.ts": "responses",
Expand Down Expand Up @@ -424,6 +428,8 @@
"claude-messages-endpoint.test.ts": "claude-integration",
"messages-native.test.ts": "claude-integration",
"messages-native-decline-trace.test.ts": "claude-integration",
"messages-native-oauth.test.ts": "claude-integration",
"messages-native-opaque-state.test.ts": "claude-integration",
"messages-surface-matrix.test.ts": "claude-integration",
"claude-model-info.test.ts": "claude-integration",
"claude-models-discovery.test.ts": "claude-integration",
Expand Down
29 changes: 17 additions & 12 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,21 @@ export function applyAnthropicKeyAuth(headers: Record<string, string>, provider:
else headers["x-api-key"] = provider.apiKey;
}

/**
* OAuth (Claude Pro/Max) credential placement: the bearer, the OAuth beta pair and the Claude
* Code client fingerprint. Shared by the adapter and the managed native lane.
*/
export function applyAnthropicOAuthAuth(headers: Record<string, string>, accessToken: string): void {
headers["Authorization"] = `Bearer ${accessToken}`;
headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
// Match the real Claude Code CLI request fingerprint: a valid OAuth token with an empty
// header set is a non-first-party signature. (cch billing-header signing is intentionally
// out of scope — brittle and version-coupled.)
Object.assign(headers, CLAUDE_CODE_HEADERS);
headers["X-Claude-Code-Session-Id"] = claudeCodeSessionId(accessToken);
headers["x-client-request-id"] = crypto.randomUUID();
}

/** The provider's Messages endpoint, refusing a base URL with an unresolved `{placeholder}`. */
export function resolveAnthropicMessagesUrl(provider: Pick<OcxProviderConfig, "baseUrl">): string {
const url = anthropicMessagesUrl(provider.baseUrl);
Expand Down Expand Up @@ -1169,18 +1184,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
const fastSpeed = anthropicFastSpeed(parsed, provider);
if (fastSpeed) body.speed = fastSpeed.value;
const headers = anthropicBaseRequestHeaders(parsed.stream);
if (isOAuth) {
headers["Authorization"] = `Bearer ${provider.apiKey}`;
headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
// Match the real Claude Code CLI request fingerprint: a valid OAuth token with an empty
// header set is a non-first-party signature. (cch billing-header signing is intentionally
// out of scope — brittle and version-coupled.)
Object.assign(headers, CLAUDE_CODE_HEADERS);
headers["X-Claude-Code-Session-Id"] = claudeCodeSessionId(provider.apiKey);
headers["x-client-request-id"] = crypto.randomUUID();
} else {
applyAnthropicKeyAuth(headers, provider);
}
if (isOAuth) applyAnthropicOAuthAuth(headers, provider.apiKey);
else applyAnthropicKeyAuth(headers, provider);
if (provider.headers) Object.assign(headers, provider.headers);
mergeAnthropicBetaHeader(headers, fastSpeed?.betas ?? []);

Expand Down
80 changes: 80 additions & 0 deletions src/adapters/anthropic/beta-allowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* The `anthropic-beta` values a managed native Messages request may carry from its caller (PF-10).
*
* A beta header changes what the provider accepts and bills, so the native lane never forwards
* the caller's header as written. Each value is compared, case-insensitively, against a fixed
* list for the destination's provider class; a match is re-emitted in this file's own spelling,
* so no caller byte ever reaches the wire, and everything else is dropped. The caller learns
* nothing about which value was dropped, and the trace records only that something was
* (`anthropic-beta-dropped`), never the value.
*
* Proxy-owned betas (the OAuth pair, the fast-mode beta) are not listed here: the lane sets them
* itself from the provider's own configuration, exactly as the adapter does.
*
* LEAF MODULE: no runtime import, so the builder, the lane and tests can read it freely.
*/

/** Where the native lane sends: Anthropic's own API, or an Anthropic-compatible third party. */
export type AnthropicProviderClass = "first-party" | "compatible";

/**
* Betas a caller may forward to `api.anthropic.com`. Deliberately short: only a value whose
* effect is limited to how the model uses the body it already sent, with no new body field,
* no billing tier and no retained server state, belongs here.
*/
const FIRST_PARTY_BETAS: readonly string[] = [
// Lets thinking blocks appear between tool calls in one assistant turn. No request field;
// the thinking and tool blocks it affects are already on the lane's field allowlist, and the
// proxy's own OAuth quota probe sends it (`src/providers/quota/vendor-probes-oauth.ts`).
"interleaved-thinking-2025-05-14",
];

/**
* Betas a caller may forward to an Anthropic-compatible third party. Empty: a third party's
* beta semantics are its own, an unknown value can fail the request outright, and none is
* needed for the fields the lane forwards.
*/
const COMPATIBLE_BETAS: readonly string[] = [];

const ALLOWLISTS: Readonly<Record<AnthropicProviderClass, ReadonlyMap<string, string>>> = {
"first-party": new Map(FIRST_PARTY_BETAS.map(beta => [beta.toLowerCase(), beta])),
compatible: new Map(COMPATIBLE_BETAS.map(beta => [beta.toLowerCase(), beta])),
};

/** A caller header longer than this is not parsed at all and counts as dropped. */
const MAX_CALLER_BETA_HEADER_CHARS = 2048;

export interface AllowlistedAnthropicBetas {
/** Allowlisted values in this file's spelling, first-seen order, no duplicates. */
betas: string[];
/** Whether any non-empty caller value was left out. Never says which. */
dropped: boolean;
}

/**
* Filter the caller's comma-separated `anthropic-beta` header for one provider class. An absent
* or blank header yields nothing and drops nothing.
*/
export function allowlistAnthropicBetas(
callerHeader: string | null | undefined,
providerClass: AnthropicProviderClass,
): AllowlistedAnthropicBetas {
if (typeof callerHeader !== "string" || callerHeader.trim() === "") return { betas: [], dropped: false };
if (callerHeader.length > MAX_CALLER_BETA_HEADER_CHARS) return { betas: [], dropped: true };
const allowed = ALLOWLISTS[providerClass];
const betas: string[] = [];
let dropped = false;
for (const part of callerHeader.split(",")) {
const token = part.trim().toLowerCase();
if (!token) continue;
const canonical = allowed.get(token);
if (canonical === undefined) dropped = true;
else if (!betas.includes(canonical)) betas.push(canonical);
}
return { betas, dropped };
}

/** The allowlist for one class, for documentation and tests. */
export function anthropicBetaAllowlist(providerClass: AnthropicProviderClass): readonly string[] {
return [...ALLOWLISTS[providerClass].values()];
}
Loading
Loading