Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e7409f6
refactor(anthropic): share the Messages URL, version and key-auth hea…
lidge-jun Sep 25, 2026
c321dfe
feat(anthropic): build a managed native Messages request from the sou…
lidge-jun Sep 25, 2026
88f21f3
feat(claude): name why a Messages route declines the managed native lane
lidge-jun Sep 25, 2026
20cddfe
feat(claude): send eligible managed-key Messages natively
lidge-jun Sep 25, 2026
d042f7b
feat(claude): route eligible managed-key Messages to the native lane
lidge-jun Sep 25, 2026
0dca674
feat(claude): count the native Messages body in count_tokens
lidge-jun Sep 25, 2026
beda74b
feat(protocols): preview managed native Messages candidates
lidge-jun Sep 25, 2026
3cfded6
test(claude): pin the native Messages builder, decline rule and preview
lidge-jun Sep 25, 2026
2580120
test(claude): exercise managed native Messages against a fake upstream
lidge-jun Sep 25, 2026
e84b404
docs(structure): describe the managed native Messages lane
lidge-jun Sep 25, 2026
e59e396
feat(protocols): name operator policy that only the bridge applies
lidge-jun Sep 25, 2026
5f6d60d
feat(claude): tell whether translation would elide a blocked skill
lidge-jun Sep 25, 2026
822a5a1
feat(claude): keep Messages on the bridge when bridge-only policy app…
lidge-jun Sep 25, 2026
8bf884a
feat(claude): judge native Messages with the bridge's selector and Cl…
lidge-jun Sep 25, 2026
e863993
feat(claude): record why a Messages route declined the native lane
lidge-jun Sep 25, 2026
9706f83
feat(protocols): preview the bridge-only decline for Messages candidates
lidge-jun Sep 25, 2026
776e9a6
feat(claude): echo the requested model on native Messages responses
lidge-jun Sep 25, 2026
f4f943f
test(claude): expect the requested model on native Messages responses
lidge-jun Sep 25, 2026
b6fc330
test(claude): pin bridge-only-policy declines and the decline trace
lidge-jun Sep 25, 2026
53e4783
docs(structure): record bridge-only declines, the decline trace and m…
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,3 +48,6 @@ 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 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 |
5 changes: 5 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@
"anthropic-tail-guard.test.ts": "adapters/anthropic",
"anthropic-thinking-signature.test.ts": "adapters/anthropic",
"anthropic-tool-call-id.test.ts": "adapters/anthropic",
"anthropic-messages-passthrough.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 @@ -343,6 +344,8 @@
"protocol-direct-encoders-chat.test.ts": "responses",
"protocol-direct-encoders-messages.test.ts": "responses",
"chat-native-combo.test.ts": "responses",
"messages-native-eligibility.test.ts": "responses",
"messages-native-bridge-policy.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 @@ -419,6 +422,8 @@
"claude-management-api.test.ts": "claude-integration",
"claude-manual-env.test.ts": "gui",
"claude-messages-endpoint.test.ts": "claude-integration",
"messages-native.test.ts": "claude-integration",
"messages-native-decline-trace.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
49 changes: 36 additions & 13 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,39 @@ function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean {
return provider.apiKeyTransport === "bearer";
}

/** The `anthropic-version` every Messages request from this proxy pins. */
export const ANTHROPIC_API_VERSION = "2023-06-01";

/**
* The fixed headers of every Messages request this proxy builds, before credentials. Shared by
* the adapter and the managed native lane so both pin the same version and client identity.
*/
export function anthropicBaseRequestHeaders(stream: boolean | undefined): Record<string, string> {
return {
"Content-Type": "application/json",
"anthropic-version": ANTHROPIC_API_VERSION,
"Accept": stream ? "text/event-stream" : "application/json",
"User-Agent": "@anthropic-ai/sdk/0.74.0",
};
}

/** Key-auth credential placement: `x-api-key`, or a bearer when the provider asks for one. */
export function applyAnthropicKeyAuth(headers: Record<string, string>, provider: OcxProviderConfig): void {
if (typeof provider.apiKey !== "string") return;
if (anthropicKeyUsesBearer(provider)) headers["Authorization"] = `Bearer ${provider.apiKey}`;
else headers["x-api-key"] = provider.apiKey;
}

/** 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);
const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0];
if (unresolvedPlaceholder) {
throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`);
}
return url;
}

/** Map a Responses reasoning effort to an Anthropic extended-thinking budget (tokens, >= 1024). */
function reasoningBudget(effort: string): number {
switch (effort) {
Expand Down Expand Up @@ -1129,22 +1162,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
body.tool_choice = { ...settledToolChoice, disable_parallel_tool_use: true };
}

const url = anthropicMessagesUrl(provider.baseUrl);
const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0];
if (unresolvedPlaceholder) {
throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`);
}
const url = resolveAnthropicMessagesUrl(provider);
// Anthropic fast mode: `speed` is only accepted beside its beta; without it the API
// answers 400 "speed: Extra inputs are not permitted". The beta is merged below, after
// any header override, so a request never carries one without the other.
const fastSpeed = anthropicFastSpeed(parsed, provider);
if (fastSpeed) body.speed = fastSpeed.value;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
"Accept": parsed.stream ? "text/event-stream" : "application/json",
"User-Agent": "@anthropic-ai/sdk/0.74.0",
};
const headers = anthropicBaseRequestHeaders(parsed.stream);
if (isOAuth) {
headers["Authorization"] = `Bearer ${provider.apiKey}`;
headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
Expand All @@ -1155,8 +1179,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
headers["X-Claude-Code-Session-Id"] = claudeCodeSessionId(provider.apiKey);
headers["x-client-request-id"] = crypto.randomUUID();
} else {
if (anthropicKeyUsesBearer(provider)) headers["Authorization"] = `Bearer ${provider.apiKey}`;
else headers["x-api-key"] = provider.apiKey;
applyAnthropicKeyAuth(headers, provider);
}
if (provider.headers) Object.assign(headers, provider.headers);
mergeAnthropicBetaHeader(headers, fastSpeed?.betas ?? []);
Expand Down
87 changes: 87 additions & 0 deletions src/adapters/anthropic/passthrough.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Managed native Messages request builder (PF-08).
*
* The request a proxy-managed Anthropic key sends when the client already spoke Messages: the
* caller's own body, cut to a fixed field allowlist, with the wire model and the provider's
* credential. URL, `anthropic-version`, client identity and credential placement come from the
* same helpers the Anthropic adapter uses, so the two lanes cannot drift apart.
*
* Authority: only the provider's configured key is ever placed on the request. No caller header
* is read here at all — 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.
*/
import type { OcxConfig, OcxProviderConfig } from "../../types";
import { anthropicBaseRequestHeaders, applyAnthropicKeyAuth, resolveAnthropicMessagesUrl } from "../anthropic";

/**
* Top-level Messages fields the native lane forwards. Everything else is dropped: an unknown or
* beta-gated field would otherwise reach the provider unchecked. None of the dropped fields has
* a name in the protocol feature vocabulary, so no feature effect is recorded for them.
*/
export const ANTHROPIC_MESSAGES_PASSTHROUGH_FIELDS = [
"model",
"messages",
"system",
"max_tokens",
"metadata",
"stop_sequences",
"stream",
"temperature",
"top_p",
"top_k",
"tools",
"tool_choice",
"thinking",
"output_config",
"service_tier",
] as const;

const PASSTHROUGH_FIELD_SET: ReadonlySet<string> = new Set(ANTHROPIC_MESSAGES_PASSTHROUGH_FIELDS);

export interface AnthropicMessagesPassthroughRequest {
url: string;
headers: Record<string, string>;
/** The serialized wire body. */
body: string;
/** The same body before serialization, for callers that count or inspect what is sent. */
wireBody: Record<string, unknown>;
}

/** The allowlisted copy of `body` with `model` set to the wire model. Shallow: nothing is cloned. */
export function anthropicMessagesPassthroughBody(
body: Readonly<Record<string, unknown>>,
modelId: string,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(body)) {
if (PASSTHROUGH_FIELD_SET.has(key) && value !== undefined) out[key] = value;
}
out.model = modelId;
return out;
}

/**
* Build the upstream request. Throws the adapter's own errors for a missing key or a malformed
* or unresolved base URL. `config` is accepted for parity with the other passthrough builders;
* no config key changes the wire today.
*/
export function buildAnthropicMessagesPassthroughRequest(
provider: OcxProviderConfig,
modelId: string,
body: Readonly<Record<string, unknown>>,
_config?: OcxConfig,
): AnthropicMessagesPassthroughRequest {
if (provider.authMode !== undefined && provider.authMode !== "key") {
throw new Error("managed native Messages requires a key-auth anthropic provider");
}
if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") {
throw new Error("anthropic provider requires a non-empty apiKey (authMode: key)");
}
const url = resolveAnthropicMessagesUrl(provider);
const wireBody = anthropicMessagesPassthroughBody(body, modelId);
const headers = anthropicBaseRequestHeaders(wireBody.stream === true);
applyAnthropicKeyAuth(headers, provider);
// Operator-configured provider headers apply exactly as the adapter applies them.
if (provider.headers) Object.assign(headers, provider.headers);
return { url, headers, body: JSON.stringify(wireBody), wireBody };
}
22 changes: 22 additions & 0 deletions src/claude/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,28 @@ function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly str
return ids;
}

/**
* Whether translating this Messages body would elide a blocked skill bundle: a user text block
* `maybeElideSkillText` would stub, or a tool_result answering a blocked Skill call. Pure; the
* managed native Messages lane asks it so a request whose bundle the operator blocked keeps the
* translated path that applies the block.
*/
export function anthropicBodyElidesBlockedSkill(body: unknown, cc?: Pick<OcxClaudeCodeConfig, "blockedSkills">): boolean {
if (!isRec(body) || !Array.isArray(body.messages)) return false;
const names = effectiveBlockedSkillNames(cc);
if (names.length === 0) return false;
const callIds = blockedSkillCallIds(body.messages, names);
for (const msg of body.messages) {
if (!isRec(msg) || msg.role !== "user" || !Array.isArray(msg.content)) continue;

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 Keep string-form blocked skill bundles on the bridge

When a user message carries its content as a string rather than a block array, this condition skips it even though userMessageToItems applies maybeElideSkillText to that valid string form. With managedMessagesNative enabled, a large blocked-skill payload beginning with the skill marker is therefore judged eligible and forwarded intact, bypassing claudeCode.blockedSkills; check string content with the same elision predicate before continuing.

Useful? React with 👍 / 👎.

for (const block of msg.content) {
if (!isRec(block)) continue;
if (block.type === "text" && typeof block.text === "string" && maybeElideSkillText(block.text, names) !== block.text) return true;
if (block.type === "tool_result" && typeof block.tool_use_id === "string" && callIds.has(block.tool_use_id)) return true;
}
}
return false;
}

/**
* Claude Code (observed 2026-07-11, real CLI smoke) sends `role:"system"` entries in
* `messages` despite the published API having no system role. They are emitted as
Expand Down
4 changes: 3 additions & 1 deletion src/protocols/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

/** Bumped when a reason code, hop, mode, or feature disposition changes meaning. */
export const PROTOCOL_CONTRACT_VERSION = "2026-09-24.1";
export const PROTOCOL_CONTRACT_VERSION = "2026-09-25.1";

export const PROTOCOLS = ["responses", "chat", "messages"] as const;
/** A public inference API a client can speak to this proxy. */
Expand Down Expand Up @@ -72,6 +72,8 @@ export const PROTOCOL_REASON_CODES = [
"unknown-model",
"upstream-other",
"rollout-disabled",
/** Operator policy that only the bridge applies (pinned effort, skill elision, a sidecar). */
"bridge-only-policy",
] as const;
export type ProtocolReasonCode = (typeof PROTOCOL_REASON_CODES)[number];

Expand Down
54 changes: 42 additions & 12 deletions src/protocols/plan-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { captureRouteStaticPolicy, routeConcreteModel, routeModel, type RouteRes
import { getRoutingProfile, POLICY_NAMESPACE, resolvePolicyProfileId } from "../routing/profile";
import { resolveWireProtocolOverride } from "../server/adapter-resolve";
import { nativeChatDeclineReason } from "../server/chat-native-eligibility";
import { nativeMessagesDeclineReason } from "../server/messages-native-eligibility";
import { parseSyntheticRowId } from "../server/fast-row";
import type { OcxConfig } from "../types";
import { inboundWireForProtocol, type Protocol, type ProtocolReasonCode } from "./contract";
Expand Down Expand Up @@ -90,13 +91,28 @@ function chatBodyForFeatures(features: ReadonlySet<ProtocolFeature>): Record<str
};
}

/** The Messages counterpart of `chatBodyForFeatures`. */
function messagesBodyForFeatures(features: ReadonlySet<ProtocolFeature>): Record<string, unknown> {
const content = features.has("request.images")
? [{ type: "image", source: { type: "base64", media_type: "image/png", data: "AA==" } }]
: "";
return { messages: [{ role: "user", content }] };
}

interface SyntheticRows {
effortRow: boolean;
fastRow: boolean;
/** The resolved selector the bridge would parse; the effort pin reads it. */
routeSelector: string;
}

function candidateFor(
config: OcxConfig,
inbound: Protocol,
route: RouteResult,
routeKind: SettledRouteKind,
features: ReadonlySet<ProtocolFeature>,
effortRow: boolean,
rows: SyntheticRows,
): ProtocolPlanCandidateInput {
const wire = inboundWireForProtocol(inbound);
// The same two steps every ingress runs: recapture static policy for the original inbound,
Expand All @@ -108,17 +124,29 @@ function candidateFor(
const adapter = provider.adapter ?? "openai-responses";
let declineReasons: ProtocolReasonCode[] = [];
let nativeEligible = false;
// With `nativeChatCombos` on, the combo loop judges each Chat candidate as the concrete route it
// is (PF-07), so the preview must too; a policy still resolves one candidate on the bridge.
const comboChildNative = inbound === "chat" && routeKind === "combo"
&& resolveProtocolSettings(config).rollout.nativeChatCombos;
const settled: RouteResult = {
...route,
provider,
staticPolicy,
...(routeKind === "direct" || comboChildNative ? {} : { routeKind }),
};
if (inbound === "chat") {
// With `nativeChatCombos` on, the combo loop judges each candidate as the concrete route it
// is (PF-07), so the preview must too; a policy still resolves one candidate on the bridge.
const comboChildNative = routeKind === "combo" && resolveProtocolSettings(config).rollout.nativeChatCombos;
const settled: RouteResult = {
...route,
provider,
staticPolicy,
...(routeKind === "direct" || comboChildNative ? {} : { routeKind }),
};
const reason = effortRow ? "effort-row" : nativeChatDeclineReason(settled, chatBodyForFeatures(features), config);
const reason = rows.effortRow ? "effort-row" : nativeChatDeclineReason(settled, chatBodyForFeatures(features), config);
nativeEligible = reason === undefined;
if (reason) declineReasons = [reason];
} else if (inbound === "messages" && resolveProtocolSettings(config).rollout.managedMessagesNative) {
// The runtime rule itself. With the switch off nothing is judged, so the default preview
// is exactly what it was before the managed native lane existed.
// Pinned effort is judged from config and the route; blocked-skill elision and the web-search
// sidecar depend on body content no feature describes, so a preview cannot predict them.
const reason = nativeMessagesDeclineReason(settled, messagesBodyForFeatures(features), config, {
...rows,
claudeCode: config.claudeCode,
});
nativeEligible = reason === undefined;
if (reason) declineReasons = [reason];
}
Expand Down Expand Up @@ -153,6 +181,7 @@ export function buildProtocolPlanSnapshot(
const reasonCodes: ProtocolReasonCode[] = [];
let routeKey = request.model;
let effortRow = false;
let fastRow = false;
let syntheticRow = false;
try {
const parsed = parseSyntheticRowId(request.model, config);
Expand All @@ -162,6 +191,7 @@ export function buildProtocolPlanSnapshot(
syntheticRow = true;
} else if (parsed.fastRow) {
routeKey = parsed.fastRow.baseId;
fastRow = true;
syntheticRow = true;
}
} catch {
Expand All @@ -182,7 +212,7 @@ export function buildProtocolPlanSnapshot(
return {
...base,
routeKind: settled.routeKind,
candidates: settled.routes.map(route => candidateFor(config, request.inbound, route, settled.routeKind, features, effortRow)),
candidates: settled.routes.map(route => candidateFor(config, request.inbound, route, settled.routeKind, features, { effortRow, fastRow, routeSelector: routeKey })),
reasonCodes,
};
}
Expand Down
Loading
Loading