Skip to content

Latest commit

 

History

History
600 lines (531 loc) · 45.9 KB

protocol-paths.md

File metadata and controls

600 lines (531 loc) · 45.9 KB

Protocol Paths

How a request on one public inference API reaches an upstream wire, in shared vocabulary. The transport behavior of each ingress stays owned by Inbound Compatibility Surfaces and Responses transport; this doc owns the names, the declared feature dispositions and the baseline those docs are measured against.

Vocabulary and leaf modules

src/protocols/contract.ts defines the three public protocols (responses, chat, messages), the upstream wires (the protocols plus other for every adapter whose body is none of them), path hops (ir for OcxParsedRequest/AdapterEvent, responses-internal for Responses JSON/SSE produced only as an internal bridge), the delivery modes and a closed list of reason codes. A path containing responses-internal is legacy-bridge; a two-hop path with the same wire at both ends is native; any other path is translated; blocked means refused before any send and has no path.

Existing spellings keep their names and map through explicit functions in the same file: routing InboundWire "anthropic" is messages, adapters openai-responses / openai-chat / anthropic are the three protocol wires, and Lab identities openai-responses / openai-chat / anthropic-messages map one to one. Persisted rows are not rewritten.

nativeChatDeclineReason in src/server/chat-native-eligibility.ts names, as one of these reason codes, the first rule that keeps a Chat request off the native Chat lane; isNativeChatRouteEligible is defined as "no reason", so the lane decision and the reason a plan or trace reports cannot disagree. nativeMessagesDeclineReason in src/server/messages-native-eligibility.ts does the same for the managed native Messages lane (below).

contract.ts, src/protocols/features.ts, src/protocols/baseline.ts, src/protocols/path.ts, src/protocols/dto.ts, src/protocols/plan.ts, src/protocols/guard.ts and src/protocols/shadow.ts are leaf modules: the dashboard imports them directly, so they import nothing but each other and the type-only compatibility vocabulary in src/compatibility/manifest.ts. tests/responses/protocol-contract.test.ts reads their import specifiers and fails on anything else.

src/protocols/path.ts turns an ingress lane (native or bridge) and the final adapter's upstream wire into the request and response paths. The observed trace and the planner both call it, so a preview and the log of the same request apply one rule. A third lane, direct, is the Antigravity direct lane: a Chat or Messages ingress toward an other upstream gets [inbound, ir, other] and [other, ir, inbound]. Toward any other wire direct falls back to the bridge rule, so an inconsistent mark cannot claim a path that does not exist.

Feature dispositions

src/protocols/features.ts lists the request features whose survival depends on the path, the protocols that can express each, and a declared disposition for every cross-wire hop, reusing the compatibility-manifest vocabulary (passthrough, translated, degraded, unsupported). Same-wire hops are passthrough. A hop into other has no entry, and an absent entry is how unknown is spelled — never as a fifth disposition. featureEffectsForPath reports, for the features a request carries, the worst disposition met along its path, keeping a loss declared on a known hop even when a later hop is unknown. On the direct path [chat|messages, ir, other] the decode hop reads the <inbound>>responses row: the direct codecs are held to parseRequest of the Responses projection, so they keep and drop exactly what that projection does. The direct path therefore reports the same effects as the bridge path, and the reject guard refuses the same features on both.

The current-code claims follow the translators: chatCompletionsToResponsesBody in src/chat/inbound.ts copies an explicit field list without n, logprobs, logit_bias, seed, audio or prediction, and src/claude/inbound.ts drops top_k and maps a thinking budget to an effort tier. tests/responses/protocol-features.test.ts pins them. These are declared claims, not Lab evidence, and never imply a verified verdict.

Baseline

src/protocols/baseline.ts holds eighteen cells — three ingresses, three protocol upstreams, streaming on and off — each with the path an eligible single-provider route takes today and the path the protocol-first-class work targets. Today Chat to Chat and Responses to Responses are native, Chat and Messages reach a Responses upstream through their codec directly, and every other Chat or Messages pair (including Messages to a proxy-managed Anthropic key) travels through responses-internal; every routed Chat or Messages path streams internally and folds for a non-streaming client. No target cell contains responses-internal. current describes the default configuration, with every rollout switch off. tests/responses/protocol-baseline.test.ts pins both sides.

Plan and trace shapes

src/protocols/dto.ts defines ProtocolPlanV1 (a prediction: one candidate per route target, features guaranteed by every eligible candidate, features only some preserve) and ProtocolTraceV1 (an observation: final mode and paths, reason codes, feature effects, one entry per physical attempt). Both carry only the closed vocabulary and identifiers the server already exposes, within fixed limits, and both validators reject anything that is not exactly version 1.

Observed trace

src/protocols/trace.ts is server side and is not a leaf. The Chat Completions ingress (src/server/chat-completions.ts) marks the lane it chose, the reason code that declined the native lane, and the request's features; the Messages ingress (src/server/claude-messages.ts) marks caller-forward passthrough and the managed native lane as native, the translated path as the bridge, and a disabled surface or a compatibility reject as blocked. The Responses ingress needs no mark: its path follows the final adapter's wire. Marks live in WeakMaps keyed by the request log context and the live attempt objects, and no mark function throws into the request.

addFinalRequestLog derives one ProtocolTraceV1 through path.ts: a blocked mark wins with empty paths; otherwise each attempt gets the lane-derived path (or an explicit attempt mark), the final attempt sets the row's mode and paths, a reason implied by the path is appended to the entry's reasons, and feature effects come from featureEffectsForPath. No attempt and no native or blocked mark yields no trace; nothing is guessed. The usage row persists the trace and every read re-validates it with parseProtocolTraceV1, so an older or corrupt row hydrates without one. /api/logs spreads the entry and accepts protocolMode (native | translated | legacy-bridge | blocked | none) in src/server/request-log-filter.ts; an unknown value matches nothing. The dashboard renders it with gui/src/components/protocols/ (a row badge and a detail-dialog section) and filters by mode client-side in gui/src/pages/logs-filter.ts. tests/responses/protocol-trace.test.ts and tests/usage/request-log-protocol-trace.test.ts pin derivation, persistence and the filter.

Planner and preview

planProtocol in src/protocols/plan.ts is pure: given a snapshot of the settled route (inbound, selector, route kind, candidates with their final adapter and whether the ingress would take its native lane, requested features, surfaces, settings, policy revision) it computes each candidate's paths through path.ts, its feature effects, and whether reject would refuse it (feature-unrepresentable). Features preserved by every eligible candidate are guaranteed; those preserved by only some are partial. A disabled surface blocks every candidate with surface-disabled; an unroutable selector has no candidates and reports unknown-model. The planner never selects a provider. tests/responses/protocol-plan.test.ts covers it.

buildProtocolPlanSnapshot in src/protocols/plan-snapshot.ts builds that snapshot from config without side effects. Combo and policy selectors are expanded from their configured targets through routeConcreteModel rather than routeModel, which would advance round-robin state or run the policy evaluator; every other selector goes through routeModel's deterministic branches. Each candidate's wire is settled the way the ingress settles it (captureRouteStaticPolicy for the original inbound, then resolveWireProtocolOverride), and a Chat candidate's native lane is judged by nativeChatDeclineReason against a structural body built from the requested features. With nativeChatCombos on, a combo's candidates are judged as the concrete routes they are, as the combo loop judges them; policy candidates keep combo-or-policy-route. With protocols.rollout.managedMessagesNative on, a Messages candidate is judged the same way by nativeMessagesDeclineReason; with it off Messages candidates carry no decline reason, exactly as before the lane existed. Messages caller-forward passthrough depends on the caller's own credential, so it is reported as caller-credential-required and never assumed. The OpenCode Go session-lane transport is not modelled. tests/responses/protocol-plan-snapshot.test.ts pins the no-side-effect property against combo selection state.

Shadow plan

Behind protocols.rollout.shadowPlan (default off). The Chat and Messages ingresses call recordProtocolShadowPlan (src/protocols/shadow-plan.ts, server side) right after their entry mark: Chat after its one mark, Messages after the caller-forward passthrough mark and after the bridge mark every other request reaches. With the switch off it returns after reading the setting. With it on it calls buildProtocolPlanSnapshot with basis dispatch, the selector the client sent and the features the entry mark already collected (protocolMarkFeatures), and stores the result with markProtocolShadowPlanInput in a WeakMap beside the marks. The snapshot is the preview's own side-effect-free builder, so recording sends nothing, fetches nothing and advances no combo state; the stored input is fixed vocabulary plus provider and model names.

At finalize protocolTraceForRequest derives the observed trace first, then, only when an input was recorded, runs planProtocol on it and shadowPlanMismatch (src/protocols/shadow.ts, leaf, pure) on the plan and the trace. The compared candidate is the one matching the context's final provider and model, else the first eligible one; mode, upstream wire and request path must match. The response path is not compared, because directEncoders changes it and the planner does not model that switch. A blocked trace agrees with a blocked plan; a compatibility reject and a caller-forward Messages passthrough (the plan reports caller-credential-required) are not compared. A disagreement adds planMismatch: true, which isProtocolTraceV1 accepts only as true, so rows without it stay valid. Any throw in recording or comparison is swallowed and the observed trace is returned unchanged. The Responses ingress records no input and is not compared, and the dashboard does not render the field. tests/responses/protocol-shadow-plan.test.ts pins match, mismatch, switch-off and throw safety.

Provider wire summary

buildProtocolProviderSummary in src/protocols/provider-summary.ts (server side, side-effect free) answers the provider block of GET /api/protocols?provider=<name>: the adapter the provider receives, its upstream wire (upstreamWireForAdapter), the resolved auth mode, and the models whose wire was decided apart from the provider. Everything comes from captureRouteStaticPolicy, the same resolver every ingress uses, so there is no second copy of the adapter rules. The resolver's provenance collapses onto four public sources in protocolAdapterSource: hard-pin, operator (including operator-capability), registry, and provider-default for everything else.

Model overrides are resolved for a Responses client. The candidates are the explicit modelAdapters keys, the registry's modelWireDefaults keys, the exact-id hard pins (captureWireAdapterHardPins), the default model and the listed models, at most 1024 of them; a model is listed when its source is not provider-default or its adapter differs from the provider's. The list is sorted, capped at PROTOCOL_PROVIDER_OVERRIDE_LIMIT (64) and marked modelOverridesTruncated past it. Prefix pins cannot be enumerated, so only a listed model they match appears. No credential, base URL or header leaves the module. The shape and its validator, isProtocolProviderSummaryV1, live in the leaf dto.ts. tests/server/protocol-provider-summary.test.ts covers the source mapping, the cap, the 404 and the parameter bounds.

Source envelope, codecs and guard

src/protocols/envelope.ts (server side; it charges the translator budget) wraps the body an ingress parsed. It keeps that body by reference for the request only, scans its features on the first features() call and caches them, and hands out freshBody() copies, each a structuredClone charged under request_copies, so a consumer that rewrites its body cannot leak the rewrite into another consumer's input.

src/protocols/codecs/{chat,messages,responses}.ts are named entry points over the existing translators — chatToResponsesBody is chatCompletionsToResponsesBody, messagesToResponsesTranslation is anthropicToResponsesTranslation, responsesToIr is parseRequest — plus each protocol's feature scanner. They add no behavior; the Chat and Messages ingresses call the bridge through them.

checkRepresentable in src/protocols/guard.ts judges a request path the caller computed with path.ts: under the legacy policy it always passes; under reject it refuses the features featureEffectsForPath finds unsupported, with reason feature-unrepresentable. A hop into other has no disposition and never refuses by itself, but a loss declared on an earlier known hop (the internal Responses body) still does.

Only when resolveProtocolSettings(config).unrepresentable === "reject" do the Chat and Messages ingresses build an envelope and run the guard, after the route and its wire settle and before the request is sent: Chat on the native path when the native lane was chosen, otherwise on the bridge path to the settled adapter's wire; Messages on the native path when the managed native lane was chosen, otherwise on the bridge path. Combo and policy routes and an unroutable model are not judged at ingress; with nativeChatCombos on, a Chat combo's candidates are judged one by one inside the combo loop (below). A refusal answers 400 in the ingress's own error shape (Chat invalid_request_error / unsupported_feature; Anthropic invalid_request_error) naming feature keys only, marks the trace blocked, and writes the final log row with no upstream send. Under the default legacy policy nothing is built and the would-be loss appears only as the trace's featureEffects. The Messages envelope's features are fixed at the bridge entry mark, before an effort override rewrites thinking. tests/responses/protocol-envelope.test.ts, tests/responses/protocol-guard.test.ts and tests/responses/protocol-ingress-guard.test.ts pin them.

Direct client encoders

src/protocols/encoders/ turns AdapterEvent streams into the Chat Completions and Anthropic Messages wires without the internal Responses SSE. It is server side and not a leaf. adapter-events.ts is the driver: it ports the item state machine of bridgeToResponsesSSE (item boundaries, signature grouping, hidden and redacted reasoning envelopes, tool naming and argument gating, the integral-float repair, every terminal with its usage and durability rule, the wire-silence heartbeat and stall watchdog, pull-based stepping, cancellation) and calls one ClientWireWriter method wherever the bridge would emit a frame a client converter reads. chat.ts (encodeChatCompletionSse, foldChatCompletion) and messages.ts (encodeAnthropicMessageSse, foldAnthropicMessage) are the writers. They reuse the converters' own helpers, exported from src/chat/outbound.ts and src/claude/outbound.ts (ids, chunk and frame builders, usage mapping, chatCompletionsStreamErrorPayload, chatCompletionsFailedResponse, chatCompletionsIncompleteOutcome, anthropicIncompleteOutcome, anthropicFailedStatus, the message snapshot, the web-search pair), so the frames a client receives are the converters' frames. A fold is the encoded stream read by the existing collector.

Deliberate equivalences, pinned by the parity tests: a Chat function call is delivered as one complete tool-call chunk when it completes, as the converter always did; Messages streams input_json_delta fragments; custom and tool-search calls and server-side search activity have no Chat representation; a Messages thinking block is buffered until its item closes; a wire-silence heartbeat reaches Chat as the converter's : opencodex heartbeat SSE comment and Messages as its ping, neither counted as output, first output, usage or a relayed event (heartbeat contract). The one behavior that differs is backpressure: the Messages converter read the bridge eagerly, while the encoder steps one event per pull.

The server side is src/server/inference/client-encoder-delivery.ts, described with the Responses transport. A directly encoded attempt is traced with markAttemptProtocolPath: the request path is still the bridge path ([inbound, "responses-internal", "ir", upstream], mode legacy-bridge) because the request side still decodes through the Responses projection, and the response path is [upstream, "ir", inbound]. When the encoder option carries requestLane: "direct" (the Antigravity direct lane) the request path is [inbound, "ir", upstream] and the mode translated.

Antigravity direct lane

Behind protocols.rollout.antigravityDirect, the one rollout switch that defaults on: an absent value takes the lane, only an explicit false turns it off, and a non-boolean value or a malformed protocols container fails closed. Every request the eligibility rule declines falls back to the bridge. A Chat or Messages request whose settled route is one concrete Google provider in Cloud Code Assist mode (Antigravity) skips the internal Responses body in both directions. Antigravity's google adapter has the other wire, so neither native lane can take it, and on the bridge its Chat traffic was decoded twice (Chat body -> Responses items -> IR) and encoded twice unless directEncoders was on.

antigravityDirectDeclineReason in src/server/antigravity-direct-eligibility.ts names the first rule that keeps a route off the lane: rollout-disabled; not-migrated (not Google in Cloud Code Assist mode, judged with effectiveGoogleMode so a saved provider without googleMode gets the registry's); combo-or-policy-route; hosted-tool (a web_search tool the sidecar reads from the parsed Responses body); vision-preprocessing (an image for a model requiresVisionPreprocessing declares unable to read it); bridge-only-policy (the shadow-call intercept could rewrite the model before parsing; a <skills_instructions> block in a system or developer message, which the per-session skills snapshot rewrites on the bridge body; a generated image link in assistant history, whose hosted display path the bridge body redacts; or, as a runtime fact only the ingress supplies, an observing cache diagnostic recorder, which records the body the core receives). The Chat ingress and the planner both ask it; the planner judges a standalone route only and cannot see body content no Chat feature describes (a hosted tool, the rewritten text). A declined Antigravity route adds its reason to the bridge entry mark; rollout-disabled and not-migrated add nothing.

On the lane the ingress validates the Chat body by decoding it, with chatConversationToIr (src/protocols/codecs/chat-ir.ts), and sends the core chatSettingsToResponsesBody: the bridge body's settings (model, stream, store, tool choice, limits, sampling, penalties, stop, user, tier, cache key, metadata, reasoning, text format) with input: [] and no instructions or tools. Both bodies are built by one applyChatSettings, so the settings and their validation cannot drift. Everything the core decides from settings keeps its owner: routing, Antigravity account rotation and failover, effort pins and caps, tier, spend, usage and the request log. The conversation travels as HandleResponsesOptions.directRequest:

  • decode runs at the parse seam in prepareResponsesRequest, inside the same try as parseRequest, and replaces the settings-only parse's empty context with the decoded one. It decodes again if the core settled a different model id, because identity naming (#5217) and the assistant placeholder carry it, and it re-checks a named tool choice against the decoded catalog, which the settings-only parse could not see.
  • evidence replaces evidenceFromBody(parsed._rawBody) for policy routing, with the values the bridge body would have given (a non-empty catalog; an image in a user message, not in a tool result, which that scan never read).
  • bridgeConversation builds the bridge's input, instructions and tools. When a route assignment (the initial resolution or a subagent fallback, checked by leaveDirectLaneIfRerouted right after each, and once more before final-route normalization) is not the Antigravity route the lane was decided for, or the body reaches the combo dispatcher, restoreDirectRequestConversation puts them back on the raw body before canonical sanitization, ciphertext stripping or injected guidance can read or write it, re-marks the entry as the bridge with not-migrated, and drops the encoder's direct request lane. The decoded IR stays, because it equals what the parser builds from the restored body.
  • Ambiguous-resend replay (adapter-dispatch.ts) judges self-containment on the settings body merged with bridgeConversation(), built only if a reset asks, so its answer is the bridge's.

The contract that makes this safe is parity. For every Chat body the bridge accepts, the lane's parsed request equals parseRequest(chatCompletionsToResponsesBody(body)) apart from _rawBody and timestamps; every body the bridge refuses is refused with the same message. The decoder mirrors the Chat translator's walk (leading-system hoist, held mid-batch instructions, tool-call naming, legacy function pairing) and the parser's assembly for the item kinds that walk produces (pending reasoning and its merge, the assistant placeholder, reasoning after its call #950, tool-result naming, the unsigned-reasoning placeholder signature, tool deduplication, visualization directives). The Google adapter reads only the IR (never _rawBody), so it compiles the same wire. _rawBody readers that matter only to other wires, Codex surfaces or the hosted tools the lane declines see an empty conversation, which is why the lane is limited to this route.

The ingress asks for the direct encoder (requestLane: "direct") whatever directEncoders says, so the response half is [other, ir, chat] as well.

Messages on the lane

The Messages ingress routes after translating, so planMessagesAntigravityDirect (src/server/messages-antigravity-direct.ts) plans first on anthropicSettingsTranslation: the bridge body's settings with input: [], completed by the same applyAnthropicSettings, so the resolved model, prompt-cache key, tool choice and reasoning are the bridge's. It previews the route on that model, asks antigravityMessagesDeclineReason (the Chat rules, spelled for Messages: web_search_* tools or a forced WebSearch choice, images in user turns or tool results, the intercept on the resolved model, system and assistant text the core rewrites), then decodes with messagesConversationToIr (src/protocols/codecs/messages-ir.ts). Routing uses messagesRouteEvidence; a settled route that is not the planned eligible one gets the full translation from leaveDirectPlan before route shaping. A settings error or an undecodable ocxr1 envelope (retried by the bridge through nativeAnthropicProjection) yields no plan, so it refuses exactly as before. The decoder reproduces blocked-skill elision, the stabilizePromptCache relocation, tool-result errors, images, documents and tool_reference markers, and thinking envelopes (encoded and charged as the translator does, decoded as the parser does) plus the native replay map. The Google adapter still sends no thinking text on either lane, only call-bound thoughtSignatures.

tests/responses/protocol-chat-ir-parity.test.ts holds the decoder to the bridge over a fixture corpus, refusals included, and pins routing evidence and feature effects; tests/responses/protocol-antigravity-direct-eligibility.test.ts pins the rule and the plan; tests/responses/protocol-antigravity-direct-chat.test.ts sends the same Chat request on both lanes to a fake Cloud Code Assist upstream and requires the same wire body, the same answer and the chat -> ir -> other trace. tests/responses/protocol-messages-ir-parity.test.ts and protocol-antigravity-direct-messages.test.ts do the same for Messages.

Native Chat candidates in combos

With protocols.rollout.nativeChatCombos on, the Chat ingress hands a combo route its source envelope, and src/server/responses/core-combo-native.ts sends each candidate that passes isNativeChatRouteEligible on the native Chat lane from its own freshBody() copy, marking that attempt native with markAttemptProtocolPath; other candidates keep the bridge and its lane-derived path. The request's entry mark still says bridge with combo-or-policy-route, because that is the lane the ingress chose; the final mode and paths follow the last attempt. Under reject a candidate whose path cannot carry a requested feature is skipped before any send and feature-unrepresentable is added to the entry mark; if every enabled candidate is skipped the combo returns the ingress refusal and a blocked trace. With the switch off, combos are not judged per candidate. Policy routes select a single candidate in the router and stay on the bridge. The transport side (send budget, failover, logging) is in Responses transport.

Managed native Messages

Native key-pool credit-balance refusals follow the same Anthropic API-key billing contract as translated Responses: immutable outgoing-key ownership, bounded pre-output inspection, a one-hour billing key cooldown and a shared physical-send ceiling, including under concurrency pacing. Cancellation and emitted text/tools never authorize another billing send.

Behind protocols.rollout.managedMessagesNative (explicit policy defaults off). An enabled Anthropic pool supplies absent native flags for its settled provider under the pool native preference; explicit false/malformed flags remain off. A Messages request whose settled route is a direct, key-auth anthropic provider is sent as Messages instead of replaying through Responses; with managedMessagesNativeOAuth also on, so is an Anthropic OAuth account, including pooled accounts (below). nativeMessagesDeclineReason names the first rule that keeps a route off the lane: rollout-disabled, cross-wire-ir (another adapter), auth-mode-not-native (forward, or an OAuth route the OAuth rule does not admit), combo-or-policy-route, effort-row / fast-row (synthetic rows need the adapter's wire rewrite), vision-preprocessing (an image for a model declared unable to read it), and bridge-only-policy when operator policy that only the translated path applies would engage: a pinned reasoning effort for the route (resolvePinnedEffort, read with the translated body's model id as the bridge reads it), a blocked-skill bundle the translator would elide (anthropicBodyElidesBlockedSkill in src/claude/inbound.ts), or a web_search* server tool the web-search sidecar could serve (not excluded by tool_choice, sidecar not disabled in the Claude replay config; backend credentials are decided at dispatch, so this errs toward the bridge). The ingress, count_tokens and the planner all ask it. The planner can judge only the config-and-route parts: skill elision and web search depend on body content no feature describes.

With the switch on, a declined route re-marks its bridge entry with the decline reason (after any effort-row / fast-row), so the trace says why the bridge was taken; with it off nothing is added. claudeCode.stabilizePromptCache is not a decline rule: it is a Claude-app cache optimization rather than routing policy, applies only on the translated path, and is a recorded gap of the native lane.

src/server/claude-messages.ts decides the lane after the route and its wire settle and after the managed-client steps already applied to the body (alias/modelMap resolution, ocx-route, effort directives). The caller-forward passthrough is decided earlier, on the caller's own credential, and returns before this point; the two branches share no credential and no header. The body sent is envelope.freshBody() when a source envelope exists, otherwise the ingress's own body. src/server/messages-native.ts is imported lazily, only for an eligible route.

Configured admission-key model scope also applies to separately billed Advisor models before managed native key or OAuth dispatch. src/server/messages-native-scope.ts resolves active top-level and system-message tool declarations, including replacement, removal and re-offer; deferred definitions remain scoped because tool search can load them. Missing Advisor model identity cannot satisfy a model list. Anthropic rejects tool changes in a turn-scoped system message (clear_at other than never); the scope check does not rely on that refusal. Additions in such a message are always checked, and only a permanent removal withdraws a declaration. Ordinary custom schemas, arguments and historical tool results remain opaque. Provider-only and unrestricted admissions retain their existing scope. tests/claude-integration/messages-native-scope.test.ts covers refusal with zero upstream sends and byte-preserving allowed tool definitions through both credential lanes. Temporary declarations append to checker-owned lists instead of copying the accumulated prefix, so collection work is linear in the declarations visited. Permanent replacement, withdrawal and denied-shadow checks remain unchanged; this is not a request-size or wall-time guarantee.

buildAnthropicMessagesPassthroughRequest in src/adapters/anthropic/passthrough.ts builds the request from that body: the top-level allowlist (model, messages, system, max_tokens, metadata, stop_sequences, stream, temperature, top_p, top_k, tools, tool_choice, thinking, output_config, service_tier), the wire model, and the URL, anthropic-version, client identity and credential placement the Anthropic adapter uses (resolveAnthropicMessagesUrl, anthropicBaseRequestHeaders, applyAnthropicKeyAuth / applyAnthropicOAuthAuth), plus the provider's configured headers. The builder never mutates its input. A dropped field has no name in the feature vocabulary, so it records no feature effect.

Caller betas. The ingress hands over anthropic-beta explicitly; caller Authorization and x-api-key never reach the managed provider. src/adapters/anthropic/beta-allowlist.ts keeps a value only when it is on the list for the destination's class and re-emits it in the list's own spelling: interleaved-thinking-2025-05-14 for generic callers to api.anthropic.com, plus the native-client allowance described below for recognized native clients; nothing for an Anthropic-compatible host. Independently of client recognition, a caller-requested inline-tools-2026-09-15 is retained when outgoing system-message content directly contains typed inline definitions or tool references in additions/removals. Arguments, schemas and nested/unknown containers do not activate that feature allowance; no beta is injected. Proxy-owned betas (the OAuth pair) are set by the builder, and an operator's configured beta is merged, not replaced. Any dropped value adds anthropic-beta-dropped to the trace; the trace never records the value. The proxy log names dropped values once per distinct set (anthropic-beta-dropped=<names>), and only values on the file's closed list of known beta names are named; any other value is a count.

Opaque state and credential domains. src/protocols/opaque-state.ts defines a credential domain as the provider's base host plus its credential class (key, oauth, ...); first-party means HTTPS to api.anthropic.com on the default port. Thinking signatures and redacted_thinking blocks are sent only to first-party Anthropic. For any other or unknown destination the builder sends a copy without them (the signature field dropped, the thinking text kept, a redacted block dropped, a message left empty dropped) and the trace gains opaque-state-stripped. Under unrepresentable: "reject" the ingress refuses such a request before any send (blocked, feature-unrepresentable + opaque-state-stripped), and a rebuild that would strip mid-request fails closed. Because the source body is never mutated, each build — including one after a key re-selection moves to another domain — decides from the full envelope copy the lane was given.

Client identity. src/adapters/anthropic/client-identity.ts captures a bounded observed CLI or Desktop Code bundle on an opaque request-local handle: recognized CLI/SDK/Desktop Code UA plus valid session UUID, X-App: cli, JS and Node SDK markers are required. Optional allowlisted SDK and request-id fields retain valid scalars. The TypeScript Agent SDK's sdk-ts entrypoint is accepted alongside cli, sdk-cli and sdk, with the same required headers and bounds, so its first-party native feature betas are retained. Duplicates, oversized/invalid values and headers named by Connection cannot gain forwarding authority. The handle stores its headers privately in a WeakMap and serializes without them. The native builder applies it only for first-party Anthropic, independently of bearer/UUID selection. Operator provider.headers names take precedence case-insensitively over the observed identity bundle without duplicate spelling; unconfigured names retain the observed client values. Caller credentials, proxy/hop headers, arbitrary SDK names and betas are never part of the bundle. These are compatibility observations, not authorization or proof of client provenance. Missing identity, generated Responses, caller-forward and compatible destinations keep their contracts. The accepted native first-party behavior preserves one genuine Claude Code session id and its metadata device/session components across token refresh and an eligible account switch, matching a genuine client on a manual account switch. Consequently, accounts serving that session are linkable upstream. Pooled accounts use the native builder and shared Anthropic routing policy. Traffic without a genuine client identity retains per-credential synthesized session ids. tests/adapters/anthropic/anthropic-client-identity.test.ts and tests/claude-integration/messages-native-oauth.test.ts cover header continuity through refresh and account switch, destination isolation, bounded parsing and credential exclusion.

OAuth. Behind managedMessagesNativeOAuth, which resolveProtocolSettings treats as off unless managedMessagesNative is on. The two builtin Anthropic OAuth instances each use their own store namespace, and their OAuth tokens are sent only to api.anthropic.com (the builder refuses any other host for an OAuth token). Native dispatch uses shared Anthropic strategy, model-route restrictions and session affinity. A shared Desktop system cache cohort never supplies affinity. src/server/messages-native-oauth.ts resolves and commits an exact credential generation and rechecks the current route and binding before each physical send. Route changes during asynchronous preparation re-enter bounded admission; pause, manual selection, credential replacement and provider UUID changes cannot authorize a stale send. Planning and count_tokens select, refresh and write no accounts. The body retains the native preamble when recognized, otherwise gets the Claude Code identity block, with declared OAuth tool names. src/adapters/anthropic/account-metadata.ts copy-on-write aligns a valid JSON-string metadata.user_id.account_uuid with the provider UUID captured alongside the native binding. The local pool id is never used; malformed, absent and unknown metadata stays unchanged. Every rebuild starts from the source body; the binding also checks UUID equality before send. Conflicting provider credential headers fail before dispatch on every OAuth build, including builds without a provider UUID.

Pool 2 requires explicit configured ownership; endpoint equality cannot establish it. A marked endpoint override receives the same native eligibility decision as the primary pool. Ingress and protocol preview both exclude qualified Pool 2 selectors from caller-forward; bare Claude selection retains its existing meaning. The instance runtime contract owns per-pool state and credential/target fencing. Native OAuth Messages collect top-level and typed inline tool declarations before rewriting declared client names in tool choices, uses, references, additions and removals, including typed tool-result content. Typed built-in names stay fixed; ambiguous original or wire-name collisions are refused. The copy-on-write traversal leaves input schemas, tool arguments, unknown containers and cache markers (including lifetimes) opaque. Nested inline support is structural; it does not assert upstream acceptance. Key-auth and caller-forward requests retain their metadata. The answer's tool_use names are mapped back for exactly those names. A 401 remains terminal. Classified pre-output 429 and account-entitlement 403 responses use shared bearer-fenced recovery within account-failover and physical-send limits. Refused response bodies are disposed before rebinding. Already-tried alternate accounts are excluded; the shared permitted same-account throttle retry remains available. Each physical response records quota against its sending credential, and family revalidation admission precedes send accounting. No account recovery occurs after output consumption.

Observed native CLI and Desktop Code compatibility bundles preserve an existing bounded billing and known identity preamble through src/adapters/anthropic/native-client-preamble.ts; the proxy does not generate or sign billing text. Counting uses the same observed bundle. The first-party native beta allowance retains the source-listed inline-tool, per-turn control, thinking display, advisor, scoped-cache and context-management schemas. context-management-2025-06-27 travels with the caller's top-level context_management field: the builder forwards that field only when the beta survives the allowlist, so header and body never disagree, and under OAuth rewrites each edit's exclude_tools to the prefixed wire names of declared client tools. Advisor may perform sub-inference already requested by the caller; no feature or beta-gated body is injected. Generic managed callers keep the narrower allowance, and compatible destinations receive no first-party allowance. These handles grant no credential authority.

Managed native Messages record validated caller controls as requestedEffort on the final row and physical attempts. Existing annotations win; otherwise a recognized output_config.effort wins, followed by disabled thinking (none) or an enabled positive safe-integer budget (budget:<tokens>). Adaptive thinking without an explicit effort stays absent. This metadata comes from the body handed to the lane; it does not rewrite the wire or confirm upstream effort.

handleNativeMessages mirrors native Chat on the shared pieces: beginInferenceAttempt, createFinalRequestLog, the request spend tracker charged per physical send, proactive key selection, 401 and 429 key-pool rotation, same-target 429 replay, the reset/transient retry policy and sendWithConnectionPolicy. Before sending it runs the image normalizer, the image guard and the tool-call-id repair the caller-forward passthrough runs. A streaming caller gets the upstream SSE relayed through tapAnthropicSseForLog, which records usage and the terminal and applies the body stall and size guards; a non-streaming caller gets the upstream JSON (or a folded stream). Either way model is rewritten to the selector the client sent, as the translated lane answers (message_start.message.model on a stream, found within the first 64 KiB; everything else is relayed as is). Upstream errors answer in Anthropic shape with the translated lane's status policy (transient 5xx as 529, replay refusals kept non-retryable). count_tokens estimates the body the builder would send when the route is eligible, and sends nothing. tests/adapters/anthropic/anthropic-messages-passthrough.test.ts, tests/adapters/anthropic/anthropic-beta-allowlist.test.ts, tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts, tests/responses/protocol-opaque-state.test.ts, tests/responses/messages-native-oauth-eligibility.test.ts, tests/claude-integration/messages-native-oauth.test.ts, tests/claude-integration/messages-native-opaque-state.test.ts, tests/responses/messages-native-eligibility.test.ts, tests/responses/messages-native-bridge-policy.test.ts, tests/claude-integration/messages-native.test.ts and tests/claude-integration/messages-native-decline-trace.test.ts pin the builder, the rule, the lane, the decline trace, the beta allowlist, opaque state and OAuth.

Settings

resolveApiSurfaceSettings and resolveProtocolSettings in src/protocols/settings.ts are the only readers of the apiSurfaces and protocols config keys. Responses and Chat Completions are always served. The Messages surface uses an explicit apiSurfaces.messages.enabled boolean when present, closes when that value is present but malformed, and otherwise inherits claudeCode.enabled !== false. The unrepresentable policy defaults to legacy and every protocols.rollout switch defaults off except antigravityDirect; the OAuth native-Messages switch is effective only with the key-auth one. The Chat and Messages ingresses read the unrepresentable policy (above); directEncodersApply reads directEncoders on both; antigravityDirectDeclineReason reads antigravityDirect for the Chat ingress and the planner, and antigravityMessagesDeclineReason for the Messages ingress and the planner; the Chat ingress reads nativeChatCombos for combo routes (above); managedMessagesNative and managedMessagesNativeOAuth are read through nativeMessagesDeclineReason by the Messages ingress, count_tokens and the planner (above); shadowPlan is read by recordProtocolShadowPlan (Shadow plan). Every rollout switch now has a reader.

claudeInboundDisabled in src/server/claude-messages.ts is the Messages ingress reader: both /v1/messages and /v1/messages/count_tokens call it, so the two routes cannot disagree, and a closed surface answers 403 before the body is read. buildApiAccessEndpoints (src/server/management/api-access.ts) reports the resolved surfaces in the keys payload and keeps claudeCodeEnabled for older dashboards, set from the resolved Messages state rather than from claudeCode.enabled.

PATCH /api/protocols/settings is the one writer. src/server/management/protocol-settings-patch.ts validates the body strictly and applies it in memory; the route persists through saveConfigPreservingClaudeCode and restores the pre-patch snapshot when the save throws, so the live config never serves a state the file does not hold. Closing Messages writes apiSurfaces.messages.enabled = false and claudeCode.enabled = false in one save, through commitClaudeCodeBlock (src/claude/claude-code-block.ts, shared with the Claude settings routes and responsible for the auth-mode migration sentinel); a binary older than apiSurfaces reads only claudeCode.enabled, so a downgrade after a close stays closed. Opening writes only the explicit surface value, so after a downgrade the older reader decides, and it errs closed. The resulting upgrade/rollback matrix is tests/claude-integration/messages-surface-matrix.test.ts; the route contract is tests/server/protocol-settings-route.test.ts.

src/config/schema/config-schema.ts keeps apiSurfaces raw on purpose: degrading a mistyped enabled to absence would turn it into "inherit" and could reopen a surface, so the resolver fails closed instead. protocols is a strict optional object that degrades to absence when malformed, which is safe because each of its defaults is the conservative one. tests/config/protocol-settings.test.ts covers both.

CLI

src/cli/api-protocols.ts is the CLI client of the three protocol routes, through the shared runtimeRequest in src/cli/runtime-api.ts: ocx api protocols [--provider <name>] reads GET /api/protocols, ocx api explain --model --inbound [--feature ...] posts POST /api/protocols/plan, and ocx api policy reads the same GET when given no setting flag and sends one PATCH /api/protocols/settings built from --messages, --unrepresentable and repeated --rollout <switch>=<on|off> otherwise. The CLI rejects only malformed argv (exit 2, nothing sent) and checks feature names and the inbound against the leaf vocabulary; switch names and the OAuth dependency are validated by the route, so the two cannot drift. Nothing invokes ocx api policy implicitly. The three are declared as api capabilities in src/cli/capabilities.ts, so the routes carry no exemption in src/server/management/route-registry.ts; the capability mutation check reads the registry's mutates, so the read-only plan POST is not a write. tests/cli/cli-api-protocols.test.ts pins the requests, the usage errors and that every protocol route is verbed.