From 3fe07b1821f0d538cf2c136b5c05b1bc59c956be Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:04 +0900 Subject: [PATCH 01/13] docs(devlog): open the protocol-first-class plan unit Records the stacked work packets (PF-01..PF-12), the invariants every packet keeps, and the per-packet design so Chat Completions and Messages can reach the shared execution policy without the public Responses wire as a mandatory detour. --- .../260924_protocol_first_class/000_plan.md | 88 +++++++++++ .../010_contract_and_baseline.md | 104 +++++++++++++ .../020_engine_and_codecs.md | 136 +++++++++++++++++ .../030_gui_and_management_api.md | 142 ++++++++++++++++++ .../040_acceptance_and_rollout.md | 46 ++++++ 5 files changed, 516 insertions(+) create mode 100644 devlog/_plan/260924_protocol_first_class/000_plan.md create mode 100644 devlog/_plan/260924_protocol_first_class/010_contract_and_baseline.md create mode 100644 devlog/_plan/260924_protocol_first_class/020_engine_and_codecs.md create mode 100644 devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md create mode 100644 devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md diff --git a/devlog/_plan/260924_protocol_first_class/000_plan.md b/devlog/_plan/260924_protocol_first_class/000_plan.md new file mode 100644 index 00000000000..4c98f28292e --- /dev/null +++ b/devlog/_plan/260924_protocol_first_class/000_plan.md @@ -0,0 +1,88 @@ +# 260924 Protocol first class — plan + +Responses stays the first-class feature surface. What changes is that Chat Completions and +Anthropic Messages stop needing the public Responses JSON/SSE as a mandatory intermediate to +reach the shared execution policy. Same-wire requests keep their source representation; +cross-wire requests convert through the adapter-neutral IR; one execution owner keeps account +selection, affinity, send budget, retry, cancellation and logging. + +## Outcome + +```text +Responses / Chat / Messages request + -> source body kept + lazily parsed intent + -> shared admission, routing, execution policy + -> final provider / model / credential settled + -> per-attempt protocol plan + same wire : native builder from the source body + different wire : codec -> IR -> target builder + not migrated yet : legacy bridge (internal Responses), labelled as such + not expressible : refused before any send (when policy = reject) + -> upstream + +same wire : upstream -> safe relay + observation -> client wire +different wire: upstream -> AdapterEvent -> client encoder +``` + +## Non-goals + +- Files, Batches and Responses CRUD APIs; every beta feature; lossless behavior for arbitrary + custom providers; emulating every Responses-only feature on Chat or Messages. +- A second or third execution engine. Native lanes reuse the shared attempt, budget, cancel + and log owners; they do not copy them. +- Renaming the Responses schema and calling it a neutral IR. +- Forwarding arbitrary headers or body fields to every provider unchecked. +- Guessing native support from a provider name in the GUI. +- A shadow mode that sends two inferences. Shadow compares plans only. +- Presenting one successful connection as protocol verification. + +## Invariants every work packet keeps + +1. The planner never selects a provider. It consumes the route the router settled + (`ResolvedModelPolicy` precedence: hard-pin, explicit override, ingress-scoped registry + default, provider default) and decides only the wire within that route. +2. No native lane may bypass admission scope, send budget, affinity, key failover, cancellation, + request logging or spend accounting. A native lane that lands before its safety wiring is + not acceptable in any order. +3. Every fallback candidate builds its request from the source envelope. An earlier candidate's + deleted fields or injected headers are never the next candidate's input. +4. Plan and trace records carry only fixed vocabulary (`src/protocols/contract.ts`) and + identifiers the server already exposes. No prompt, tool argument, token, signature or key. +5. `native` (a delivery mode) and `VERIFIED` (a Lab evidence verdict) are different axes and + are never merged into one badge. +6. Every rollout switch defaults off and changes nothing while off + (`resolveProtocolSettings`, `src/protocols/settings.ts`). +7. The core request path stays free of Lab imports + (`tests/lab/core-lab-boundary.test.ts`). +8. Files at their file-size cap (`tests/fixtures/file-size-baseline.json`) do not grow; code + moves out first. `src/server/request-log.ts` sits at 1996 of a 2000-line threshold. + +## Work packets and stack order + +Each packet is one pull request, stacked on the previous one. PF numbers are work ids, not +GitHub numbers. + +| Packet | Branch | Scope | Doc | +|---|---|---|---| +| PF-01 | `feat/pf01-protocol-contract` | vocabulary, feature dispositions, 18-cell baseline, plan/trace DTOs, settings keys | [010](010_contract_and_baseline.md) | +| PF-02 | `feat/pf02-protocol-trace` | observed path trace on request/attempt rows, persisted; Logs badge, detail, filter | [030](030_gui_and_management_api.md#pf-02-observed-path-trace) | +| PF-03 | `feat/pf03-protocol-plan` | pure planner, `GET /api/protocols`, `POST /api/protocols/plan`, API page preview | [030](030_gui_and_management_api.md#pf-03-planner-and-preview) | +| PF-05 | `feat/pf05-inference-primitives` | shared execution context, attempt and delivery primitives, client-wire marker | [020](020_engine_and_codecs.md#pf-05-shared-inference-primitives) | +| PF-04 | `feat/pf04-api-surfaces` | Messages exposure split from Claude integration, settings PATCH, API cards | [030](030_gui_and_management_api.md#pf-04-api-surface-settings) | +| PF-06 | `feat/pf06-source-envelope` | source envelope, codecs, unrepresentable guard | [020](020_engine_and_codecs.md#pf-06-source-envelope-and-guard) | +| PF-09 | `feat/pf09-direct-encoders` | AdapterEvent to Chat/Messages encoders behind `directEncoders` | [020](020_engine_and_codecs.md#pf-09-direct-client-encoders) | +| PF-07 | `feat/pf07-native-chat-combos` | eligible Chat candidates in combos/policy send natively | [020](020_engine_and_codecs.md#pf-07-native-chat-candidates-in-combos) | +| PF-08 | `feat/pf08-managed-messages-native` | key-auth Anthropic targets receive `/v1/messages` natively | [020](020_engine_and_codecs.md#pf-08-managed-native-messages) | +| PF-11 | `feat/pf11-protocol-evidence-gui` | provider protocol panel, compatibility pair filters, combo guarantees, deep links | [030](030_gui_and_management_api.md#pf-11-evidence-combo-and-provider-views) | +| PF-10 | `feat/pf10-auth-opaque-state` | beta allowlist, OAuth native Messages, cross-domain opaque state guard | [020](020_engine_and_codecs.md#pf-10-auth-and-opaque-state) | +| PF-12 | `feat/pf12-protocol-rollout` | shadow plan comparison, docs, not-migrated inventory | [040](040_acceptance_and_rollout.md) | + +PF-05 lands before PF-04 because PF-06 through PF-09 build on its primitives and PF-04 does +not; the dependency order, not the id order, decides the stack. + +## Verification policy for this unit + +Each pull request records exactly what ran. Unit tests are written beside each change and +registered in the test layout; whether they were executed is stated per PR, never implied. +Default flips for rollout switches are out of scope until the acceptance scenarios in +[040](040_acceptance_and_rollout.md) have recorded evidence. diff --git a/devlog/_plan/260924_protocol_first_class/010_contract_and_baseline.md b/devlog/_plan/260924_protocol_first_class/010_contract_and_baseline.md new file mode 100644 index 00000000000..fcd3f833d4b --- /dev/null +++ b/devlog/_plan/260924_protocol_first_class/010_contract_and_baseline.md @@ -0,0 +1,104 @@ +# 010 — PF-01 contract and baseline + +## Modules + +| File | Role | Import rule | +|---|---|---| +| `src/protocols/contract.ts` | protocols, upstream wires, hops, delivery modes, reason codes, name mappings | leaf | +| `src/protocols/features.ts` | feature keys, sources, cross-wire hop dispositions, body extraction, path effects | leaf (+ type from `src/compatibility/manifest.ts`) | +| `src/protocols/baseline.ts` | 18-cell current/target matrix | leaf | +| `src/protocols/dto.ts` | `ProtocolPlanV1`, `ProtocolTraceV1`, validators, limits | leaf | +| `src/protocols/settings.ts` | only reader of `apiSurfaces` and `protocols` config | type-only config import | + +"Leaf" means importable from `gui/src/*`: no server, router, provider or Lab imports, not even +as types. `tests/responses/protocol-contract.test.ts` enforces it by reading import specifiers. + +## Vocabulary + +- **Protocol**: `responses | chat | messages` — the public API a client speaks. +- **Upstream wire**: a protocol or `other` (Gemini, Kiro, Cursor, ...). Nothing is claimed about + `other`; absent feature dispositions there mean unknown. +- **Hop**: a wire name, `ir` (`OcxParsedRequest` / `AdapterEvent`), or `responses-internal` + (Responses JSON/SSE produced only as an internal bridge). +- **Delivery mode**: `native` (same wire, source body is the wire source), `translated` + (cross-wire through the IR or the target wire only), `legacy-bridge` (path contains + `responses-internal`), `blocked` (refused before any send). +- **Fidelity**: `preserved`, `degraded`, `unknown`. + +Internal spellings keep their names and map explicitly: routing `InboundWire` "anthropic" is +`messages`; adapters `openai-responses`/`openai-chat`/`anthropic` are the three protocol wires; +Lab identities `openai-responses`/`openai-chat`/`anthropic-messages` map one to one. Persisted +rows are not rewritten. + +## Feature dispositions and their evidence + +Same-wire hops are passthrough by definition. Cross-wire claims about current code: + +| Claim | Evidence | +|---|---| +| Chat `n`, `logprobs`/`top_logprobs`, `logit_bias`, `seed`, `audio`/`modalities`, `prediction` are unsupported on `chat>responses` | `chatCompletionsToResponsesBody` in `src/chat/inbound.ts` builds the body from an explicit field list without them | +| Chat tools, sampling, stop, user, parallel tool calls, service tier, prompt cache key, metadata, reasoning effort, response format are translated on `chat>responses` | same function, explicit field copies | +| Messages `top_k` is unsupported on `messages>responses` | `src/claude/inbound.ts` header: accepted and dropped | +| Messages `thinking.budget_tokens` is degraded on `messages>responses` | maps to an effort tier, never forwarded raw | +| Messages `cache_control` is degraded on `messages>responses` | block-level cache hints are not carried into the Responses body; caching is re-derived downstream | +| Responses hosted tools are degraded on `responses>chat` and `responses>messages` | only web search and image generation have sidecar bridges | +| Responses `previous_response_id` and compaction are translated off-wire | expanded from proxy-side state before the adapter runs | +| Responses `store` is degraded and `background` unsupported off-wire | only proxy-side state exists for non-Responses upstreams | + +The `chat>messages` and `messages>chat` rows describe the target direct codecs (PF-06). Today +those pairs travel through `responses-internal`, so their effective disposition is the +composition of the two Responses hops, which `featureEffectsForPath` computes from the path. + +## Baseline (eligible single-provider route) + +| inbound → upstream | current | target | +|---|---|---| +| responses → responses | native `responses,responses` | native | +| responses → chat / messages | translated `responses,ir,X` | translated | +| chat → chat | native `chat,chat` (JSON when `stream:false`) | native | +| chat → responses | translated `chat,responses` | translated | +| chat → messages | legacy-bridge `chat,responses-internal,ir,messages` | translated `chat,ir,messages` | +| messages → messages | legacy-bridge for managed keys (native only for caller-forwarded Anthropic credentials) | native | +| messages → responses | translated `messages,responses` | translated | +| messages → chat | legacy-bridge | translated `messages,ir,chat` | + +Every routed (non-native) Chat and Messages path streams internally and folds for a +non-streaming client (`sse-folded`). The stream axis doubles the table to 18 cells in +`src/protocols/baseline.ts`. + +Out of scope for the baseline, and described per request by the planner instead: combos and +policy routes (legacy bridge today), OAuth/forward credentials, synthetic effort rows, +vision-preprocessed images, tool-result images, and Responses-only features on Chat. + +## DTOs + +`ProtocolTraceV1` is the observed record for one request (`v: 1`), persisted with the request +log row; `attempts[]` records each physical attempt's upstream, mode and request path. A blocked +request has empty paths. `ProtocolPlanV1` is the predicted record (`schemaVersion: 1`) with one +candidate per route target, `guaranteedFeatures` (preserved by every eligible candidate) and +`partialFeatures`. Both validators reject anything that is not exactly v1, oversize, or outside +the vocabulary. Limits: 6 hops, 8 reason codes, 24 feature effects, 16 candidates, 16 attempts, +200-character identifiers without control characters. + +## Settings + +```jsonc +{ + "apiSurfaces": { "messages": { "enabled": false } }, // absent => inherit claudeCode.enabled + "protocols": { + "unrepresentable": "legacy", // or "reject" + "rollout": { + "nativeChatCombos": false, + "managedMessagesNative": false, + "managedMessagesNativeOAuth": false, // effective only with the key-auth switch + "directEncoders": false, + "shadowPlan": false + } + } +} +``` + +`apiSurfaces` is kept raw by the config schema and parsed fail-closed: a present malformed value +closes the surface rather than inheriting. `protocols` is schema-validated and drops to defaults +when malformed, because every default is the conservative one. PF-01 adds the keys and the +resolver only; no request path reads them until PF-04. diff --git a/devlog/_plan/260924_protocol_first_class/020_engine_and_codecs.md b/devlog/_plan/260924_protocol_first_class/020_engine_and_codecs.md new file mode 100644 index 00000000000..4639664e6b2 --- /dev/null +++ b/devlog/_plan/260924_protocol_first_class/020_engine_and_codecs.md @@ -0,0 +1,136 @@ +# 020 — execution, codecs and encoders (PF-05 to PF-10) + +The execution owner stays single. Native lanes and the Responses pipeline share the same +primitives for budgets, attempts, cancellation, final logging and client-wire identity; the +Responses-only state (`previous_response_id`, compaction, response-state retention, Codex +specifics) stays in `src/responses/*` and `src/server/responses/*` and is reached through hooks, +not moved. + +## PF-05 shared inference primitives + +New directory `src/server/inference/` (inside the already-documented `src/server/` area). + +| File | Exports | Replaces | +|---|---|---| +| `context.ts` | `createInferenceSendBudget(req, logCtx)` — the one construction of `createRequestExecutionBudget(undefined, undefined, attachRequestSpendTracker(req, logCtx))` | the inline construction in `src/server/responses/core.ts` `handleResponses` and the native Chat equivalent | +| `final-log.ts` | `createFinalRequestLog(logIds, logCtx)` returning `{ finish(status, meta), finished() }`; finish-once | the `finalizeNativeLog` / `finishLog` / `nativeLogged` closures repeated in `chat-completions.ts`, `chat-native.ts`, `claude-messages.ts` | +| `attempt.ts` | `beginInferenceAttempt(logCtx, { provider, model, adapter })` → `{ attempt, seal(accountLabel?), finish(status, usage?) }`; wraps `beginRequestAttempt`, `activeAttempt`, `activeAttemptStartedAt`, `attempts.push`, `sealRequestAttemptIdentity`, `finishRequestAttempt` | the hand-rolled attempt opening in `chat-native.ts` and the combo child | +| `client-wire.ts` | `markClientWire(response, protocol)`, `clientWireOf(response)` (WeakMap on `Response`) | nothing yet; PF-07 and PF-09 use it so an ingress can tell a body already in its client wire from a Responses body | + +Rules: + +- Behavior-preserving. Every moved statement keeps its order relative to the sends it guards. +- `src/server/responses/core.ts` must shrink (it is at its file-size cap); PF-07 needs a few + lines of headroom there. +- Split `handleNativeChatCompletions` in `src/server/chat-native.ts` into + `runNativeChatAttempt(execution, attemptHandle)` — send loop, key failover, 429 replay, + relay, usage — and the existing wrapper that opens the attempt and owns the final log. The + split is what lets a combo child run a native attempt whose final log belongs to the parent. +- No new behavior, no new config reads. + +## PF-06 source envelope and guard + +| File | Role | +|---|---| +| `src/protocols/envelope.ts` | `createProtocolEnvelope({ inbound, body, translatorBudget })` → `{ inbound, features(), freshBody() }`. The source body is retained for the request lifetime only; `features()` is computed once lazily; `freshBody()` returns a structured clone charged to the translator budget (`request_copies`). Server-side module (may import `src/lib/translator-budget`). | +| `src/protocols/codecs/chat.ts`, `codecs/messages.ts`, `codecs/responses.ts` | thin, named entry points over the existing translators (`chatCompletionsToResponsesBody`, `anthropicToResponsesTranslation`, the Responses parser) so ingress code calls one codec surface. No translator behavior changes. | +| `src/protocols/guard.ts` | `checkRepresentable({ inbound, requestPath, features, policy })` → `{ ok: true } \| { ok: false, features, reasonCodes }` using `featureEffectsForPath` and `unrepresentableFeatures`. Pure. | + +Wiring (only when `resolveProtocolSettings(config).unrepresentable === "reject"`): + +- `src/server/chat-completions.ts`: after the route settles and before the Responses projection + is built, compute the path the request will take (native Chat path when native-eligible; + otherwise the bridge path for the settled route's adapter). Unknown-adapter hops never block. + A refusal returns HTTP 400 in Chat error shape, `type: "invalid_request_error"`, + `code: "unsupported_feature"`, message naming the feature keys only, marks the trace blocked + (`feature-unrepresentable`) and logs the request with no upstream send. +- `src/server/claude-messages.ts`: the same after route settlement, Anthropic error shape. +- Combo and policy routes are checked per candidate in PF-07; at ingress they are not refused. +- `legacy` policy: no behavior change; the would-be refusal is still reflected in the trace's + `featureEffects`. + +Native Chat's in-place effort normalization (`chatEffortSnapshots`) keeps working; PF-07 moves +combo children onto `freshBody()` so no candidate inherits another's rewrite. + +## PF-07 native Chat candidates in combos + +Behind `protocols.rollout.nativeChatCombos`. + +- `HandleResponsesOptions` (`src/server/responses/core-options.ts`) gains + `protocolSource?: { inbound: "chat"; envelope; dispatchNativeChild(input) }`, supplied only by + `src/server/chat-completions.ts` for combo routes when the switch is on. +- In `src/server/responses/core-combo.ts`, where each child is dispatched through + `requestDispatchers.handleResponses`, a child whose concrete route passes + `isNativeChatRouteEligible(targetRoute, envelope.freshBody(), config)` is dispatched through + `protocolSource.dispatchNativeChild` instead. That runs `runNativeChatAttempt` on the attempt the + combo already opened, with the combo's `targetSendBudget`, abort signal and turn lease. It + returns a Chat-wire `Response` marked with `markClientWire(response, "chat")`. +- A marked child response skips `preflightComboStreamResponse` (native Chat reports pre-stream + failures by status before any byte). Non-OK native responses go through the existing + `consumeComboFailure` path unchanged. +- `src/server/chat-completions.ts` returns a response whose `clientWireOf` is `chat` without the + Responses-to-Chat conversion, still wrapped by the deferred request log. +- Policy routes: migrate only if their child dispatch goes through the same combo loop; + otherwise record them as not migrated in [040](040_acceptance_and_rollout.md). +- `n > 1` is never emulated with multiple inferences. A candidate whose path cannot carry a + requested feature is skipped under `reject` policy with reason `feature-unrepresentable`. +- Each native child records its attempt path with `markAttemptProtocolPath` (PF-02) as native. + +## PF-08 managed native Messages + +Behind `protocols.rollout.managedMessagesNative`. + +| File | Role | +|---|---| +| `src/adapters/anthropic/passthrough.ts` | `buildAnthropicMessagesPassthroughRequest(provider, modelId, body, config)` → `{ url, headers, body }`. URL is the provider's Messages endpoint as the existing adapter (`src/adapters/anthropic.ts`) computes it; auth headers from the provider's key exactly as that adapter injects them; `anthropic-version` pinned as the adapter pins it. Body = the source Messages body with `model` replaced by the wire model and a field allowlist (`model, messages, system, max_tokens, metadata, stop_sequences, stream, temperature, top_p, top_k, tools, tool_choice, thinking, output_config, service_tier`). | +| `src/server/messages-native.ts` | `isNativeMessagesRouteEligible(route, body, config)` and `handleNativeMessages(...)`, modelled on the native Chat lane and built on PF-05 primitives: attempt, key failover and 429 replay (`src/providers/key-failover.ts`), spend reservation, SSE relay with the existing Anthropic log tap, JSON for non-streaming callers. | + +Eligibility: adapter `anthropic`, `authMode` key (OAuth is PF-10), not combo/policy, no vision +preprocessing required, no synthetic effort/fast row, switch on. + +Wiring in `src/server/claude-messages.ts`: after route settlement and after the managed-client +steps that already ran on the Anthropic body (alias/modelMap resolution, `ocx-route`, effort +directives), an eligible route goes native. The caller-forward passthrough (the caller's own +Anthropic credential) stays a separate branch with separate authority. +`handleClaudeCountTokens` counts the body the native lane would send when the route is eligible. + +## PF-09 direct client encoders + +Behind `protocols.rollout.directEncoders`. + +| File | Role | +|---|---| +| `src/protocols/encoders/chat.ts` | `encodeChatCompletionSse(events, opts)` and `foldChatCompletion(events, opts)` from `AdapterEvent` | +| `src/protocols/encoders/messages.ts` | `encodeAnthropicMessageSse(events, opts)` and `foldAnthropicMessage(events, opts)` from `AdapterEvent` | + +- `HandleResponsesOptions` gains `clientEncoder?: { protocol: "chat" \| "messages"; stream: boolean }`, + set by the two ingresses when the switch is on. +- In `src/server/responses/adapter-delivery.ts`, when `clientEncoder` is set, the guarded event + stream is encoded directly instead of through `bridgeToResponsesSSE`. Effects keep parity: the + events are also collected (charged to the translator budget) and, at the terminal, folded with + `buildResponseJSON` so `rememberResponseState`, `notifyResponseComplete`, + `commitReasoningReplayServingRoute` and the key-usage binding run exactly as before. +- The returned response is marked with `markClientWire`; the ingress passes it through. +- Encoders preserve chunk indexes, one role frame, finish/stop reasons, tool-call identity and + argument streaming, usage, error frames and cancellation. Passthrough (Responses upstream) + and native lanes are unaffected. +- The attempt's `responsePath` becomes `[upstream, "ir", client]`; the request path is still the + bridge until the codecs decode to IR directly, and the trace says so. + +## PF-10 auth and opaque state + +- `src/adapters/anthropic/beta-allowlist.ts`: the `anthropic-beta` values a managed native + Messages request may forward, per provider class (first-party vs Anthropic-compatible). Others + are dropped and recorded as a degraded feature, never forwarded blind. +- OAuth native Messages behind `managedMessagesNativeOAuth`: eligibility extends to Anthropic + OAuth accounts, credentials resolved through the existing OAuth account selection; the account + chosen is the one the router/pool already chose. No refresh or selection happens in planning. +- `src/protocols/opaque-state.ts`: thinking signatures and `redacted_thinking` blocks are + forwarded only to first-party Anthropic destinations on the native lane; for any other + destination they are removed from the fresh body and recorded as a degraded feature (blocked + under `reject`). A fallback to a different provider or credential domain rebuilds from the + envelope and applies the same rule. + +## Not migrated after this unit + +Recorded and maintained in [040](040_acceptance_and_rollout.md#not-migrated-inventory). diff --git a/devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md b/devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md new file mode 100644 index 00000000000..a159b362712 --- /dev/null +++ b/devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md @@ -0,0 +1,142 @@ +# 030 — management API and dashboard (PF-02, PF-03, PF-04, PF-11) + +No new top-level page. Each existing screen answers one question: + +| Screen | Hash | Question | +|---|---|---| +| Integrations → API / Keys | `#integrations/keys` | How do I connect, and which path would this request take? | +| Providers → detail / settings | `#providers` | Which wire does this provider receive, and who decided that? | +| Models → Compatibility | `#models/compatibility` | Which protocol pair and feature is backed by which Lab evidence? | +| Models → Combos / Routing | `#models/combos`, `#models/routing` | What does each candidate do, and what do all candidates guarantee? | +| Logs | `#logs` | What did this request actually do, attempt by attempt? | + +The GUI never re-implements protocol policy. It imports the leaf contract +(`src/protocols/contract.ts`, `features.ts`, `dto.ts`) for vocabulary and validation, and gets +every decision from the server. + +## Management routes + +| Route | Packet | Mutates | CLI | +|---|---|---|---| +| `GET /api/protocols` | PF-03 | no | deferred verb (PF-12 owns `ocx api protocols`) | +| `POST /api/protocols/plan` | PF-03 | no | deferred verb (PF-12 owns `ocx api explain`) | +| `PATCH /api/protocols/settings` | PF-04 | yes | deferred verb (PF-12 owns `ocx api policy`) | + +All three live in `src/server/management/protocol-routes.ts`, are mounted lazily from +`src/server/management-api.ts` under the `/api/protocols` namespace, and are declared in +`src/server/management/route-registry.ts` with a `deferred-verb` exemption whose `ownerDoc` is +this file. Existing `/api/providers`, `/api/logs`, `/api/request-history` and `/api/lab/*` are +reused, not duplicated. + +## PF-02 observed path trace + +Server: + +- `src/protocols/trace.ts` (server side, no GUI import): entry marks and attempt marks kept in + WeakMaps keyed by the request log context and attempt objects, so `RequestLogContext` does not + grow. `markProtocolEntry(logCtx, { inbound, lane, reasonCodes, features })` with lane + `native | bridge`; `markProtocolBlocked(logCtx, { inbound, reasonCodes, features })`; + `markAttemptProtocolPath(attempt, { mode, requestPath, responsePath? })`; + `protocolTraceForRequest(logCtx, attempts)` derives `ProtocolTraceV1` at finalize. +- Derivation without an explicit attempt mark: Responses inbound → `responses,responses` + (adapter `openai-responses`) or `responses,ir,`; Chat/Messages lane `native` → + `,`; lane `bridge` → `,responses` for a Responses upstream, otherwise + `,responses-internal,ir,`. No attempt and no native/blocked mark → no trace. +- Entry marks: `src/server/chat-completions.ts` (native vs bridge; features from the Chat body; + reason codes for why the native lane declined), `src/server/claude-messages.ts` (caller-forward + native passthrough, bridge, disabled surface and compatibility reject as blocked). The Responses + ingress needs no mark. +- `src/server/request-log.ts` is 4 lines under the 2000-line threshold. First move + `filterRequestLogs` and `filteredRequestLogCount` byte-for-byte into + `src/server/request-log-filter.ts` (re-exported from `request-log.ts`), then add + `protocolTrace?: ProtocolTraceV1` to `RequestLogEntry`, compute it in `addFinalRequestLog`, + persist it through the usage row (`src/usage/log.ts` `PersistedUsageEntry`, validated with + `parseProtocolTraceV1` on read) and hydrate it back in `requestLogEntryFromPersistedUsage`. +- `/api/logs` spreads the entry, so the DTO carries the trace with no route change. Add a + `protocolMode` query filter (`native | translated | legacy-bridge | blocked | none`) in + `request-log-filter.ts`. + +Dashboard: + +- `gui/src/components/protocols/ProtocolBadge.tsx`: compact `Chat → Chat` style path label with + the mode as text, not colour alone. Rows without a trace show nothing in the list. +- `gui/src/components/protocols/ProtocolTracePanel.tsx`: a section in the Logs detail dialog — + inbound, final mode, request/response path (internal Responses hop labelled as internal), + reason codes, feature effects, per-attempt paths. A row without a trace says "no path data" + instead of guessing. +- Logs filter: protocol mode, client-side in `gui/src/pages/logs-filter.ts` beside the existing + filters. + +## PF-03 planner and preview + +Server: + +- `src/protocols/plan.ts` (leaf, pure): `planProtocol(input): ProtocolPlanV1`. Input is a + snapshot: `inbound`, `requestedModel`, `routeKind`, `candidates[]` (provider, model, adapter, + `nativeEligible`, `declineReasons`), `features`, `surfaces`, `settings`, `policyRevision`, + `basis`. It computes each candidate's path with the same rules PF-02 uses for observed paths, + its feature effects, eligibility under the unrepresentable policy, and the + guaranteed/partial feature sets. A disabled surface yields `blocked` with `surface-disabled`. +- `src/protocols/plan-snapshot.ts` (server side): builds the snapshot from config without side + effects — `routeModel` (synchronous; no fetch, refresh or write), `captureRouteStaticPolicy`, + `resolveWireProtocolOverride` with the inbound's wire spelling, `routeConcreteModel` for each + combo target, and `isNativeChatRouteEligible` for Chat candidates. Messages caller-forward + passthrough depends on the caller's credential and is reported with + `caller-credential-required`, never assumed. Unknown models produce a plan with + `routeKind: "unknown"`, no candidates and `unknown-model`. +- `GET /api/protocols` → `{ schemaVersion: 1, contractVersion, policyRevision, surfaces, settings, features: PROTOCOL_FEATURES }`. +- `POST /api/protocols/plan` body `{ model: string, inbound: Protocol, features?: ProtocolFeature[] }` + (bounded: model ≤ 200 chars, at most 24 features, unknown keys rejected with 400) → + `ProtocolPlanV1` with `basis: "preview"`. Never reads a request body sample, never logs input. + +Dashboard (`#integrations/keys`): + +- `gui/src/protocol-api.ts`: fetch + validate with `isProtocolPlanV1`; cache key is + `apiBase + model + inbound + sorted features + policyRevision`; an older server that answers + 404 disables the panel quietly. +- `gui/src/components/protocols/ProtocolPlanPanel.tsx` and `FeatureDispositionList.tsx`: a + "Request path preview" section in `ApiKeysWorkspace` (new section anchor after the endpoints + section): model picker from the existing model list, inbound selector, feature toggles, and a + Preview button. It states that preview sends nothing and costs nothing, shows per-candidate + path, mode, fidelity, feature effects, reasons and the policy revision, and separates + "guaranteed by all candidates" from "some candidates only". +- The existing per-protocol "Test" button stays the explicit live test; its result keeps saying + that one success is a connection test, not verification. + +## PF-04 API surface settings + +- `resolveApiSurfaceSettings` becomes the only reader: `claudeInboundDisabled` in + `src/server/claude-messages.ts` (both `/v1/messages` and `/v1/messages/count_tokens`), + `buildApiAccessEndpoints` in `src/server/management/api-access.ts` (adds + `surfaces: { responses, chat, messages }` with `enabled` and `source`; keeps + `claudeCodeEnabled` for older dashboards), and the dashboard. +- `PATCH /api/protocols/settings` body `{ messagesEnabled?: boolean, unrepresentable?: "legacy" | "reject", rollout?: Partial<...> }` + through the existing config mutation path (locked, atomic). Disabling Messages writes + `apiSurfaces.messages.enabled = false` **and** `claudeCode.enabled = false` in the same save, so + an older binary after rollback cannot reopen the endpoint. Enabling writes only + `apiSurfaces.messages.enabled = true`. The `claudeCode` subtree is written through the same + helper the Claude settings route uses, respecting its hand-edit protection. +- Dashboard: the endpoints panel becomes three API cards (Responses, Chat Completions, Messages) + with state, endpoint, source ("explicit", "inherited from Claude settings", "invalid value — + closed") and, for Messages, a toggle plus a link to the Claude page. A disabled Messages card + stays visible. +- Upgrade/rollback matrix to record: absent → inherit; explicit false + old binary → closed; + explicit true + `claudeCode.enabled=false` + old binary → closed (safe direction). + +## PF-11 evidence, combo and provider views + +- `GET /api/protocols?provider=` adds `provider: { name, adapter, adapterSource, authMode, upstream, modelOverrides: [{ model, adapter, source }] }` + from the provider's resolved static policy (`adapterSource` from `ResolvedModelPolicy` + provenance; `hard-pin | operator | registry | provider-default`). Bounded to 64 overrides. +- `gui/src/components/provider-workspace/ProviderProtocolPanel.tsx` in provider settings: + labels the adapter as "upstream wire this provider receives", shows the decision source and + model overrides, and saves only through the existing `onUpdateProvider` → `PATCH /api/providers`. + It never looks like an API exposure switch. +- Compatibility matrix: inbound and upstream protocol filters in + `gui/src/pages/compatibility-matrix-shared.ts` / `CompatibilityMatrix.tsx`, mapping Lab + identities with `protocolFromLabProtocol`. Absent Lab data reads "unverified", never "failed" + or "unsupported". +- Combo detail (`gui/src/components/combo-workspace-detail-panel.tsx`): per-candidate path and + the guaranteed/partial feature split from `POST /api/protocols/plan`. +- Deep links through the existing hash route helpers: plan panel → provider settings and + compatibility; Logs trace → compatibility for that pair. diff --git a/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md b/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md new file mode 100644 index 00000000000..902a74b41f0 --- /dev/null +++ b/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md @@ -0,0 +1,46 @@ +# 040 — acceptance and rollout (PF-12) + +## Rollout order + +1. Existing execution is the default. Every `protocols.rollout.*` switch is off. +2. `shadowPlan` on: at finalize, the dispatch-basis plan for the settled route is compared with + the observed trace; a disagreement sets `planMismatch: true` on the trace. No second request + is ever sent. +3. Per-target opt-in: `nativeChatCombos`, `managedMessagesNative`, `directEncoders`, then + `managedMessagesNativeOAuth`. +4. A switch's default flips only after its scenarios below have recorded evidence on a merged + head, in a separate reviewed change. +5. `unrepresentable: "reject"` is an operator policy, not a rollout step; it stays opt-in. + +## Acceptance scenarios + +| Scenario | Accepted when | +|---|---| +| Chat → Chat, `n=2` / `logprobs` | every choice and logprobs survive; direct and combo give the same upstream body; every choice terminal is honoured | +| Chat → Responses, `n=2` | under `reject`, refused before any send with `unsupported_feature`; never reduced to one choice and never emulated with extra calls | +| Messages → Messages (managed key) | source blocks and declared options (`top_k`, `cache_control`, `thinking`) survive; caller-forward, managed key and OAuth stay separate authority cases | +| Messages → Chat | role order and tool pairing preserved; unsupported fields follow policy | +| Chat → Messages | function definitions/results map to content blocks; stop reason and usage mapped | +| Responses → Chat / Messages | continuation and compaction unchanged | +| Mixed combo failover | every attempt built from the source envelope; send budget shared; affinity kept; ineligible candidates skipped with a recorded reason; no resend after partial stream output | +| `stream: false` / `true` | correct envelope, error frames, terminal, chunk boundaries, backpressure, cancellation, timeout, memory budget | +| Unknown extension or media | never silently dropped on a translated path without a recorded effect | +| Dashboard and remote runtime | stale/unknown/unsupported distinguished; per-request trace; policy-revision mismatch visible; hash state survives Back/Forward | +| API disable migration | `/v1/messages` and `count_tokens` agree; upgrade, old-UI writes and rollback never reopen a closed surface | + +Verification runs in isolated fixtures with no access to a user's home, credentials or services. +Live provider probes happen only with a consenting operator's keys and budget and are recorded +separately from fixture results. + +## Not-migrated inventory + +Kept current by each packet that migrates something. + +| Path | State after this unit | +|---|---| +| Chat/Messages request decode | still produces a Responses-shaped body before the IR (`responses-internal`); codecs are named entry points over the existing translators | +| Policy-route children | native Chat only if dispatched through the combo child loop (PF-07 records the outcome) | +| Sidecars (web search, vision, image generation) | Responses pipeline only | +| 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 | From c1cd39b5e7886944fb179e676da3455ebad1f230 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:05 +0900 Subject: [PATCH 02/13] feat(protocols): add the shared protocol vocabulary One leaf module names protocols, upstream wires, path hops, delivery modes and a closed reason-code list, and maps the older spellings (InboundWire "anthropic", adapter ids, Lab identities) explicitly instead of renaming them in place. --- src/protocols/contract.ts | 177 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/protocols/contract.ts diff --git a/src/protocols/contract.ts b/src/protocols/contract.ts new file mode 100644 index 00000000000..19c501fada7 --- /dev/null +++ b/src/protocols/contract.ts @@ -0,0 +1,177 @@ +/** + * Shared protocol vocabulary for the three public inference APIs. + * + * LEAF MODULE. The dashboard imports this file directly (`gui/src/*` reaches `src/` for pure + * contracts), so it must not import server, provider, router, or Lab code — not even as a type. + * Internal spellings that predate this vocabulary (`InboundWire` "anthropic", adapter ids, Lab + * protocol identities) are connected through the explicit mapping functions below rather than + * renamed in place; persisted rows and existing enums keep their spelling. + */ + +/** Bumped when a reason code, hop, mode, or feature disposition changes meaning. */ +export const PROTOCOL_CONTRACT_VERSION = "2026-09-24.1"; + +export const PROTOCOLS = ["responses", "chat", "messages"] as const; +/** A public inference API a client can speak to this proxy. */ +export type Protocol = (typeof PROTOCOLS)[number]; + +export const UPSTREAM_WIRES = ["responses", "chat", "messages", "other"] as const; +/** + * The wire the final provider receives. `other` covers adapters whose upstream is none of + * the three public protocols (Gemini, Kiro, Cursor, ...); nothing is claimed about them here. + */ +export type UpstreamWire = (typeof UPSTREAM_WIRES)[number]; + +export const PROTOCOL_HOPS = ["responses", "chat", "messages", "other", "ir", "responses-internal"] as const; +/** + * One node of a request or response path. + * + * - a wire name: that wire's JSON/SSE is actually produced at this point; + * - `ir`: the adapter-neutral request (`OcxParsedRequest`) or event (`AdapterEvent`) form; + * - `responses-internal`: Responses JSON/SSE produced only as an internal bridge, never sent to + * the provider. Its presence is what makes a path `legacy-bridge`. + */ +export type ProtocolHop = (typeof PROTOCOL_HOPS)[number]; + +export const DELIVERY_MODES = ["native", "translated", "legacy-bridge", "blocked"] as const; +/** + * How one request (or one attempt) reached its upstream. + * + * - `native`: same wire end to end; the source body is the wire source. Model rewrites, auth + * injection and declared provider policy can still apply, so native is not byte-identical. + * - `translated`: cross-wire conversion whose only intermediate is the IR or the target wire. + * - `legacy-bridge`: the path contains `responses-internal`. + * - `blocked`: refused before any upstream send. + */ +export type DeliveryMode = (typeof DELIVERY_MODES)[number]; + +export const FIDELITIES = ["preserved", "degraded", "unknown"] as const; +export type Fidelity = (typeof FIDELITIES)[number]; + +/** + * Fixed reason codes. Never conversation-derived, never free text: plan and trace records carry + * only these, which is what keeps them safe to persist and to show on a remote dashboard. + */ +export const PROTOCOL_REASON_CODES = [ + "same-wire-native", + "cross-wire-codec", + "cross-wire-ir", + "combo-or-policy-route", + "responses-only-feature", + "hosted-tool", + "vision-preprocessing", + "tool-result-image", + "auth-mode-not-native", + "effort-row", + "fast-row", + "caller-credential-required", + "not-migrated", + "surface-disabled", + "feature-unrepresentable", + "compatibility-reject", + "unknown-model", + "upstream-other", + "rollout-disabled", +] as const; +export type ProtocolReasonCode = (typeof PROTOCOL_REASON_CODES)[number]; + +const PROTOCOL_SET = new Set(PROTOCOLS); +const UPSTREAM_SET = new Set(UPSTREAM_WIRES); +const HOP_SET = new Set(PROTOCOL_HOPS); +const MODE_SET = new Set(DELIVERY_MODES); +const FIDELITY_SET = new Set(FIDELITIES); +const REASON_SET = new Set(PROTOCOL_REASON_CODES); + +export function isProtocol(value: unknown): value is Protocol { + return typeof value === "string" && PROTOCOL_SET.has(value); +} +export function isUpstreamWire(value: unknown): value is UpstreamWire { + return typeof value === "string" && UPSTREAM_SET.has(value); +} +export function isProtocolHop(value: unknown): value is ProtocolHop { + return typeof value === "string" && HOP_SET.has(value); +} +export function isDeliveryMode(value: unknown): value is DeliveryMode { + return typeof value === "string" && MODE_SET.has(value); +} +export function isFidelity(value: unknown): value is Fidelity { + return typeof value === "string" && FIDELITY_SET.has(value); +} +export function isProtocolReasonCode(value: unknown): value is ProtocolReasonCode { + return typeof value === "string" && REASON_SET.has(value); +} + +/** The routing-layer spelling (`InboundWire` in `src/providers/registry/types.ts`). */ +export type InboundWireSpelling = "responses" | "chat" | "anthropic"; + +export function protocolFromInboundWire(wire: InboundWireSpelling): Protocol { + return wire === "anthropic" ? "messages" : wire; +} + +export function inboundWireForProtocol(protocol: Protocol): InboundWireSpelling { + return protocol === "messages" ? "anthropic" : protocol; +} + +/** Lab protocol identities (`src/lab/conformance/fixtures/*`) to the public vocabulary. */ +export function protocolFromLabProtocol(identity: string): Protocol | undefined { + switch (identity) { + case "openai-responses": + return "responses"; + case "openai-chat": + return "chat"; + case "anthropic-messages": + return "messages"; + default: + return undefined; + } +} + +export function labProtocolForProtocol(protocol: Protocol): string { + switch (protocol) { + case "responses": + return "openai-responses"; + case "chat": + return "openai-chat"; + case "messages": + return "anthropic-messages"; + } +} + +/** + * The upstream wire a provider adapter id speaks. Only the three adapters whose request body + * is one of the public protocols map to a protocol; every other adapter is `other`. + */ +export function upstreamWireForAdapter(adapter: string): UpstreamWire { + switch (adapter) { + case "openai-responses": + return "responses"; + case "openai-chat": + return "chat"; + case "anthropic": + return "messages"; + default: + return "other"; + } +} + +/** + * The wire-producing nodes of a path: `ir` is dropped and `responses-internal` counts as + * Responses, because a feature lost in an internal Responses body is lost all the same. + */ +export function protocolNodes(path: readonly ProtocolHop[]): UpstreamWire[] { + const nodes: UpstreamWire[] = []; + for (const hop of path) { + if (hop === "ir") continue; + const node: UpstreamWire = hop === "responses-internal" ? "responses" : hop; + if (nodes[nodes.length - 1] !== node) nodes.push(node); + } + return nodes; +} + +/** Mode implied by a request path. `blocked` is never implied; a refusal has no path. */ +export function deliveryModeForPath(path: readonly ProtocolHop[]): Exclude { + if (path.includes("responses-internal")) return "legacy-bridge"; + const first = path[0]; + const last = path[path.length - 1]; + return path.length === 2 && first === last && first !== "other" ? "native" : "translated"; +} From a277e3425153e6b8a73257e8f2ef25f5266cad0e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:05 +0900 Subject: [PATCH 03/13] feat(protocols): declare per-hop feature dispositions Which request features survive each cross-wire hop, in the compatibility-manifest vocabulary, so a path's losses (Chat n and logprobs through Responses, Messages top_k) are computed from the path instead of discovered by users. --- src/protocols/features.ts | 295 ++++++++++++++++++++++ tests/responses/protocol-features.test.ts | 129 ++++++++++ 2 files changed, 424 insertions(+) create mode 100644 src/protocols/features.ts create mode 100644 tests/responses/protocol-features.test.ts diff --git a/src/protocols/features.ts b/src/protocols/features.ts new file mode 100644 index 00000000000..37860efe733 --- /dev/null +++ b/src/protocols/features.ts @@ -0,0 +1,295 @@ +/** + * Request features whose survival depends on the protocol path, and the declared + * disposition of each feature across every cross-wire hop. + * + * LEAF MODULE (see `contract.ts`). Dispositions reuse the compatibility-manifest vocabulary + * (`passthrough | translated | degraded | unsupported`). A hop into `other` has no entry on + * purpose: nothing is claimed about adapters outside the three public protocols, and an absent + * entry is how "unknown" is spelled here — never as a fifth disposition. + * + * These are declared claims about the current code, pinned by fixtures; they are not Lab + * evidence and they never imply VERIFIED. + */ +import type { CompatibilityDisposition } from "../compatibility/manifest"; +import { protocolNodes, type Fidelity, type Protocol, type ProtocolHop, type UpstreamWire } from "./contract"; + +export type FeatureDisposition = CompatibilityDisposition; + +export const PROTOCOL_FEATURES = [ + "request.tools", + "request.hosted_tools", + "request.images", + "request.documents", + "request.multiple_choices", + "request.logprobs", + "request.logit_bias", + "request.seed", + "request.audio", + "request.prediction", + "request.response_format", + "request.reasoning", + "request.thinking_budget", + "request.top_k", + "request.cache_control", + "request.previous_response_id", + "request.store", + "request.background", + "request.compaction", +] as const; +export type ProtocolFeature = (typeof PROTOCOL_FEATURES)[number]; + +const FEATURE_SET = new Set(PROTOCOL_FEATURES); +export function isProtocolFeature(value: unknown): value is ProtocolFeature { + return typeof value === "string" && FEATURE_SET.has(value); +} + +/** Which public protocols can express each feature at all. */ +export const FEATURE_SOURCES: Readonly> = { + "request.tools": ["responses", "chat", "messages"], + "request.hosted_tools": ["responses"], + "request.images": ["responses", "chat", "messages"], + "request.documents": ["responses", "chat", "messages"], + "request.multiple_choices": ["chat"], + "request.logprobs": ["responses", "chat"], + "request.logit_bias": ["chat"], + "request.seed": ["chat"], + "request.audio": ["chat"], + "request.prediction": ["chat"], + "request.response_format": ["responses", "chat"], + "request.reasoning": ["responses", "chat", "messages"], + "request.thinking_budget": ["messages"], + "request.top_k": ["messages"], + "request.cache_control": ["messages"], + "request.previous_response_id": ["responses"], + "request.store": ["responses"], + "request.background": ["responses"], + "request.compaction": ["responses"], +}; + +type WireHop = `${Protocol}>${Protocol}`; +const T: FeatureDisposition = "translated"; +const D: FeatureDisposition = "degraded"; +const U: FeatureDisposition = "unsupported"; + +/** + * Cross-wire hop dispositions. Same-wire hops are `passthrough` by definition and never listed. + * Evidence for each current-code claim lives in `devlog/_plan/260924_protocol_first_class/010_contract_and_baseline.md`. + */ +export const FEATURE_HOP_DISPOSITIONS: Readonly>>>> = { + "request.tools": { + "chat>responses": T, "responses>chat": T, "messages>responses": T, + "responses>messages": T, "chat>messages": T, "messages>chat": T, + }, + "request.hosted_tools": { "responses>chat": D, "responses>messages": D }, + "request.images": { + "chat>responses": T, "responses>chat": T, "messages>responses": T, + "responses>messages": T, "chat>messages": T, "messages>chat": T, + }, + "request.documents": { + "chat>responses": D, "responses>chat": D, "messages>responses": T, + "responses>messages": T, "chat>messages": D, "messages>chat": D, + }, + "request.multiple_choices": { "chat>responses": U, "chat>messages": U }, + "request.logprobs": { "chat>responses": U, "chat>messages": U, "responses>chat": U, "responses>messages": U }, + "request.logit_bias": { "chat>responses": U, "chat>messages": U }, + "request.seed": { "chat>responses": U, "chat>messages": U }, + "request.audio": { "chat>responses": U, "chat>messages": U }, + "request.prediction": { "chat>responses": U, "chat>messages": U }, + "request.response_format": { "chat>responses": T, "responses>chat": T, "chat>messages": T, "responses>messages": T }, + "request.reasoning": { + "chat>responses": T, "responses>chat": T, "messages>responses": D, + "responses>messages": T, "chat>messages": T, "messages>chat": D, + }, + "request.thinking_budget": { "messages>responses": D, "messages>chat": D }, + "request.top_k": { "messages>responses": U, "messages>chat": U }, + "request.cache_control": { "messages>responses": D, "messages>chat": D }, + "request.previous_response_id": { "responses>chat": T, "responses>messages": T }, + "request.store": { "responses>chat": D, "responses>messages": D }, + "request.background": { "responses>chat": U, "responses>messages": U }, + "request.compaction": { "responses>chat": T, "responses>messages": T }, +}; + +const RANK: Readonly> = { + passthrough: 0, + translated: 1, + degraded: 2, + unsupported: 3, +}; + +/** + * Disposition of one feature over one hop between wire nodes. `undefined` means unknown: + * either end is `other`, or the pair is not declared. + */ +export function featureHopDisposition( + feature: ProtocolFeature, + from: UpstreamWire, + to: UpstreamWire, +): FeatureDisposition | undefined { + if (from === "other" || to === "other") return undefined; + if (from === to) return "passthrough"; + return FEATURE_HOP_DISPOSITIONS[feature][`${from}>${to}`]; +} + +export interface FeatureEffect { + feature: ProtocolFeature; + disposition: FeatureDisposition; +} + +export interface PathFeatureEffects { + effects: FeatureEffect[]; + /** Present features with no declared disposition on some hop. */ + unknown: ProtocolFeature[]; + fidelity: Fidelity; +} + +/** + * The worst disposition each present feature meets along a request path. A feature the + * inbound protocol cannot express is ignored rather than reported. + */ +export function featureEffectsForPath( + inbound: Protocol, + path: readonly ProtocolHop[], + present: Iterable, +): PathFeatureEffects { + const nodes = protocolNodes(path); + const effects: FeatureEffect[] = []; + const unknown: ProtocolFeature[] = []; + const seen = new Set(); + for (const feature of present) { + if (seen.has(feature) || !FEATURE_SOURCES[feature].includes(inbound)) continue; + seen.add(feature); + let worst: FeatureDisposition = "passthrough"; + let unknownHop = false; + for (let i = 1; i < nodes.length; i++) { + const hop = featureHopDisposition(feature, nodes[i - 1]!, nodes[i]!); + if (hop === undefined) unknownHop = true; + else if (RANK[hop] > RANK[worst]) worst = hop; + } + // A loss already declared on a known hop is reported even when a later hop is unknown: + // an unknown adapter cannot restore what an earlier hop dropped. + if (unknownHop && RANK[worst] < RANK.degraded) unknown.push(feature); + else effects.push({ feature, disposition: worst }); + } + effects.sort((a, b) => PROTOCOL_FEATURES.indexOf(a.feature) - PROTOCOL_FEATURES.indexOf(b.feature)); + unknown.sort((a, b) => PROTOCOL_FEATURES.indexOf(a) - PROTOCOL_FEATURES.indexOf(b)); + const degraded = effects.some(effect => RANK[effect.disposition] >= RANK.degraded); + return { + effects, + unknown, + fidelity: degraded ? "degraded" : unknown.length > 0 ? "unknown" : "preserved", + }; +} + +/** Features that would be refused under `reject-unrepresentable`. */ +export function unrepresentableFeatures(effects: readonly FeatureEffect[]): ProtocolFeature[] { + return effects.filter(effect => effect.disposition === "unsupported").map(effect => effect.feature); +} + +type Rec = Record; +function isRec(value: unknown): value is Rec { + return !!value && typeof value === "object" && !Array.isArray(value); +} +function nonEmptyArray(value: unknown): value is unknown[] { + return Array.isArray(value) && value.length > 0; +} + +const HOSTED_TOOL_TYPES = new Set([ + "web_search", "web_search_preview", "image_generation", "file_search", + "code_interpreter", "computer_use_preview", "mcp", "local_shell", +]); + +function hasPartType(messages: unknown, types: ReadonlySet): boolean { + if (!Array.isArray(messages)) return false; + for (const message of messages) { + if (!isRec(message) || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (isRec(part) && typeof part.type === "string" && types.has(part.type)) return true; + if (isRec(part) && Array.isArray(part.content) && hasPartType([part], types)) return true; + } + } + return false; +} + +const CHAT_IMAGE_PARTS = new Set(["image_url", "input_image", "image"]); +const CHAT_FILE_PARTS = new Set(["file", "input_file"]); +const MESSAGES_IMAGE_PARTS = new Set(["image"]); +const MESSAGES_DOCUMENT_PARTS = new Set(["document"]); +const RESPONSES_IMAGE_PARTS = new Set(["input_image"]); +const RESPONSES_FILE_PARTS = new Set(["input_file"]); + +function hasCacheControl(value: unknown, depth = 0): boolean { + if (depth > 4) return false; + if (Array.isArray(value)) return value.some(item => hasCacheControl(item, depth + 1)); + if (!isRec(value)) return false; + if (value.cache_control !== undefined) return true; + return hasCacheControl(value.content, depth + 1); +} + +/** + * Features present in a Chat Completions body. Reads only structure; never retains content. + * Bounded: nested content is inspected two levels deep at most. + */ +export function featuresFromChatBody(body: unknown): Set { + const out = new Set(); + if (!isRec(body)) return out; + if (nonEmptyArray(body.tools) || nonEmptyArray(body.functions)) out.add("request.tools"); + if (hasPartType(body.messages, CHAT_IMAGE_PARTS)) out.add("request.images"); + if (hasPartType(body.messages, CHAT_FILE_PARTS)) out.add("request.documents"); + if (typeof body.n === "number" && body.n > 1) out.add("request.multiple_choices"); + if (body.logprobs === true || typeof body.top_logprobs === "number") out.add("request.logprobs"); + if (isRec(body.logit_bias) && Object.keys(body.logit_bias).length > 0) out.add("request.logit_bias"); + if (typeof body.seed === "number") out.add("request.seed"); + if (body.audio !== undefined || (Array.isArray(body.modalities) && body.modalities.includes("audio"))) out.add("request.audio"); + if (body.prediction !== undefined) out.add("request.prediction"); + if (isRec(body.response_format) && body.response_format.type !== "text") out.add("request.response_format"); + if (typeof body.reasoning_effort === "string" || isRec(body.reasoning)) out.add("request.reasoning"); + return out; +} + +/** Features present in an Anthropic Messages body. */ +export function featuresFromMessagesBody(body: unknown): Set { + const out = new Set(); + if (!isRec(body)) return out; + if (nonEmptyArray(body.tools)) out.add("request.tools"); + if (hasPartType(body.messages, MESSAGES_IMAGE_PARTS)) out.add("request.images"); + if (hasPartType(body.messages, MESSAGES_DOCUMENT_PARTS)) out.add("request.documents"); + if (isRec(body.thinking) || isRec(body.output_config)) out.add("request.reasoning"); + if (isRec(body.thinking) && typeof body.thinking.budget_tokens === "number") out.add("request.thinking_budget"); + if (typeof body.top_k === "number") out.add("request.top_k"); + if (hasCacheControl(body.system) || hasCacheControl(body.messages) || hasCacheControl(body.tools)) { + out.add("request.cache_control"); + } + return out; +} + +/** Features present in a Responses body. */ +export function featuresFromResponsesBody(body: unknown): Set { + const out = new Set(); + if (!isRec(body)) return out; + if (Array.isArray(body.tools)) { + for (const tool of body.tools) { + if (!isRec(tool) || typeof tool.type !== "string") continue; + if (HOSTED_TOOL_TYPES.has(tool.type)) out.add("request.hosted_tools"); + else out.add("request.tools"); + } + } + if (hasPartType(body.input, RESPONSES_IMAGE_PARTS)) out.add("request.images"); + if (hasPartType(body.input, RESPONSES_FILE_PARTS)) out.add("request.documents"); + if (typeof body.top_logprobs === "number" + || (Array.isArray(body.include) && body.include.includes("message.output_text.logprobs"))) { + out.add("request.logprobs"); + } + if (isRec(body.text) && isRec(body.text.format) && body.text.format.type !== "text") out.add("request.response_format"); + if (isRec(body.reasoning)) out.add("request.reasoning"); + if (typeof body.previous_response_id === "string" && body.previous_response_id.length > 0) out.add("request.previous_response_id"); + if (body.store === true) out.add("request.store"); + if (body.background === true) out.add("request.background"); + if (body.compaction_trigger !== undefined) out.add("request.compaction"); + return out; +} + +export function featuresFromBody(protocol: Protocol, body: unknown): Set { + if (protocol === "chat") return featuresFromChatBody(body); + if (protocol === "messages") return featuresFromMessagesBody(body); + return featuresFromResponsesBody(body); +} diff --git a/tests/responses/protocol-features.test.ts b/tests/responses/protocol-features.test.ts new file mode 100644 index 00000000000..2cfd9af68f9 --- /dev/null +++ b/tests/responses/protocol-features.test.ts @@ -0,0 +1,129 @@ +/** + * Declared feature dispositions (src/protocols/features.ts). + * + * The current-code claims here are the ones a reader can check in the translators: + * `src/chat/inbound.ts` builds its Responses body from an explicit field list that has no + * `n`, `logprobs`, `logit_bias` or `seed`, and `src/claude/inbound.ts` documents that `top_k` + * is accepted and dropped. If a translator learns one of these fields, the claim here must + * change in the same commit. + */ +import { describe, expect, test } from "bun:test"; +import { + featureEffectsForPath, + featureHopDisposition, + featuresFromChatBody, + featuresFromMessagesBody, + featuresFromResponsesBody, + FEATURE_SOURCES, + PROTOCOL_FEATURES, + unrepresentableFeatures, +} from "../../src/protocols/features"; + +describe("feature extraction", () => { + test("Chat bodies report only structural features", () => { + const features = featuresFromChatBody({ + model: "m", + n: 2, + logprobs: true, + seed: 7, + tools: [{ type: "function", function: { name: "f" } }], + messages: [{ role: "user", content: [{ type: "text", text: "secret" }, { type: "image_url", image_url: { url: "data:" } }] }], + }); + expect([...features].sort()).toEqual([ + "request.images", + "request.logprobs", + "request.multiple_choices", + "request.seed", + "request.tools", + ]); + }); + + test("n=1 is not multiple choices and a text response_format is not structured output", () => { + const features = featuresFromChatBody({ n: 1, response_format: { type: "text" }, messages: [] }); + expect(features.size).toBe(0); + }); + + test("Messages bodies report top_k, thinking budget and cache_control", () => { + const features = featuresFromMessagesBody({ + top_k: 5, + thinking: { type: "enabled", budget_tokens: 2048 }, + system: [{ type: "text", text: "s", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: [{ type: "document", source: {} }] }], + }); + expect([...features].sort()).toEqual([ + "request.cache_control", + "request.documents", + "request.reasoning", + "request.thinking_budget", + "request.top_k", + ]); + }); + + test("Responses bodies separate hosted tools from function tools", () => { + const features = featuresFromResponsesBody({ + tools: [{ type: "web_search" }], + previous_response_id: "resp_1", + store: true, + }); + expect([...features].sort()).toEqual(["request.hosted_tools", "request.previous_response_id", "request.store"]); + }); + + test("non-object bodies report nothing", () => { + expect(featuresFromChatBody(null).size).toBe(0); + expect(featuresFromMessagesBody("x").size).toBe(0); + expect(featuresFromResponsesBody([]).size).toBe(0); + }); +}); + +describe("hop dispositions", () => { + test("same-wire hops are passthrough and hops into other are unknown", () => { + for (const feature of PROTOCOL_FEATURES) { + expect(featureHopDisposition(feature, "chat", "chat")).toBe("passthrough"); + expect(featureHopDisposition(feature, "chat", "other")).toBeUndefined(); + } + }); + + test("every feature declares a disposition for each cross-wire hop out of its sources", () => { + const wires = ["responses", "chat", "messages"] as const; + for (const feature of PROTOCOL_FEATURES) { + for (const source of FEATURE_SOURCES[feature]) { + for (const target of wires) { + if (target === source) continue; + expect(featureHopDisposition(feature, source, target)).toBeDefined(); + } + } + } + }); +}); + +describe("path effects", () => { + test("Chat n=2 survives the native path and is unsupported through Responses", () => { + const native = featureEffectsForPath("chat", ["chat", "chat"], ["request.multiple_choices"]); + expect(native.effects).toEqual([{ feature: "request.multiple_choices", disposition: "passthrough" }]); + expect(native.fidelity).toBe("preserved"); + + const bridged = featureEffectsForPath("chat", ["chat", "responses-internal", "ir", "chat"], ["request.multiple_choices"]); + expect(bridged.effects).toEqual([{ feature: "request.multiple_choices", disposition: "unsupported" }]); + expect(bridged.fidelity).toBe("degraded"); + expect(unrepresentableFeatures(bridged.effects)).toEqual(["request.multiple_choices"]); + }); + + test("a feature the inbound protocol cannot express is ignored", () => { + const effects = featureEffectsForPath("messages", ["messages", "messages"], ["request.multiple_choices"]); + expect(effects.effects).toEqual([]); + expect(effects.fidelity).toBe("preserved"); + }); + + test("an unknown adapter makes an otherwise lossless feature unknown, not preserved", () => { + const effects = featureEffectsForPath("chat", ["chat", "responses-internal", "ir", "other"], ["request.tools"]); + expect(effects.effects).toEqual([]); + expect(effects.unknown).toEqual(["request.tools"]); + expect(effects.fidelity).toBe("unknown"); + }); + + test("a loss declared before an unknown hop is still reported", () => { + const effects = featureEffectsForPath("messages", ["messages", "responses-internal", "ir", "other"], ["request.top_k"]); + expect(effects.effects).toEqual([{ feature: "request.top_k", disposition: "unsupported" }]); + expect(effects.fidelity).toBe("degraded"); + }); +}); From 3d45781ac4b62a03caf6f118fd4f5b183ecdd664 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:05 +0900 Subject: [PATCH 04/13] feat(protocols): pin the 18-cell ingress-by-upstream baseline Current and target paths for 3 ingresses x 3 upstreams x stream, so every later packet changes a named cell on purpose rather than drifting the contract. --- src/protocols/baseline.ts | 88 +++++++++++++++++++++++ tests/responses/protocol-baseline.test.ts | 69 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/protocols/baseline.ts create mode 100644 tests/responses/protocol-baseline.test.ts diff --git a/src/protocols/baseline.ts b/src/protocols/baseline.ts new file mode 100644 index 00000000000..e00eb732bef --- /dev/null +++ b/src/protocols/baseline.ts @@ -0,0 +1,88 @@ +/** + * The 3 ingress × 3 upstream × stream baseline: the path each combination takes in the + * current code for an eligible single-provider route, and the path the protocol-first-class + * work targets. + * + * LEAF MODULE (see `contract.ts`). `current` is a claim about today's code and must change in + * the same commit that changes the code it describes; `target` changes only with the plan in + * `devlog/_plan/260924_protocol_first_class/`. Combos, policy routes, OAuth credentials and + * Responses-only features are not "eligible single-provider routes" and are described by the + * planner, not by this table. + */ +import { deliveryModeForPath, PROTOCOLS, type DeliveryMode, type Protocol, type ProtocolHop } from "./contract"; + +/** How the client receives the result. `sse-folded` = streamed internally, folded to JSON. */ +export type ResponseShape = "sse" | "json" | "sse-folded"; + +export interface BaselinePath { + mode: Exclude; + requestPath: readonly ProtocolHop[]; + /** Upstream first, client last. */ + responsePath: readonly ProtocolHop[]; + responseShape: ResponseShape; +} + +export interface BaselineCell { + inbound: Protocol; + upstream: Protocol; + stream: boolean; + current: BaselinePath; + target: BaselinePath; +} + +function path(requestPath: readonly ProtocolHop[], stream: boolean, folded: boolean): BaselinePath { + const responsePath = [...requestPath].reverse(); + return { + mode: deliveryModeForPath(requestPath), + requestPath, + responsePath, + responseShape: stream ? "sse" : folded ? "sse-folded" : "json", + }; +} + +/** Current request path for an eligible single-provider route. */ +function currentRequestPath(inbound: Protocol, upstream: Protocol): readonly ProtocolHop[] { + if (inbound === upstream) { + // Native Messages exists today only for caller-forwarded Anthropic credentials; a + // proxy-managed Anthropic key still replays through Responses. + return inbound === "messages" ? ["messages", "responses-internal", "ir", "messages"] : [inbound, upstream]; + } + if (inbound === "responses") return ["responses", "ir", upstream]; + // Chat and Messages reach a Responses upstream through their Responses codec directly. + if (upstream === "responses") return [inbound, "responses"]; + return [inbound, "responses-internal", "ir", upstream]; +} + +function targetRequestPath(inbound: Protocol, upstream: Protocol): readonly ProtocolHop[] { + if (inbound === upstream) return [inbound, upstream]; + if (inbound !== "responses" && upstream === "responses") return [inbound, "responses"]; + return [inbound, "ir", upstream]; +} + +/** + * Whether the current non-stream client response is folded from an internal stream. Every + * routed (non-native) path streams internally; native Chat and Responses passthrough honour + * the caller's stream bit. + */ +function currentFolds(inbound: Protocol, upstream: Protocol): boolean { + if (inbound === "responses") return false; + return !(inbound === "chat" && upstream === "chat"); +} + +export const BASELINE_MATRIX: readonly BaselineCell[] = PROTOCOLS.flatMap(inbound => + PROTOCOLS.flatMap(upstream => + [false, true].map((stream): BaselineCell => ({ + inbound, + upstream, + stream, + current: path(currentRequestPath(inbound, upstream), stream, currentFolds(inbound, upstream)), + target: path(targetRequestPath(inbound, upstream), stream, false), + })), + ), +); + +export function baselineCell(inbound: Protocol, upstream: Protocol, stream: boolean): BaselineCell { + const cell = BASELINE_MATRIX.find(row => row.inbound === inbound && row.upstream === upstream && row.stream === stream); + if (!cell) throw new RangeError(`no baseline cell for ${inbound}>${upstream}/${stream}`); + return cell; +} diff --git a/tests/responses/protocol-baseline.test.ts b/tests/responses/protocol-baseline.test.ts new file mode 100644 index 00000000000..75b2fbf4b49 --- /dev/null +++ b/tests/responses/protocol-baseline.test.ts @@ -0,0 +1,69 @@ +/** + * The 18-cell protocol baseline (src/protocols/baseline.ts). + * + * `current` describes what src/server/chat-completions.ts, src/server/claude-messages.ts and the + * Responses pipeline do today for an eligible single-provider route; `target` is the end state of + * devlog/_plan/260924_protocol_first_class. A change to either side is a contract change and has + * to be made here on purpose. + */ +import { describe, expect, test } from "bun:test"; +import { BASELINE_MATRIX, baselineCell } from "../../src/protocols/baseline"; +import { PROTOCOLS } from "../../src/protocols/contract"; + +describe("baseline matrix shape", () => { + test("has exactly one cell per inbound × upstream × stream", () => { + expect(BASELINE_MATRIX).toHaveLength(18); + const keys = new Set(BASELINE_MATRIX.map(cell => `${cell.inbound}>${cell.upstream}/${cell.stream}`)); + expect(keys.size).toBe(18); + for (const inbound of PROTOCOLS) { + for (const upstream of PROTOCOLS) { + for (const stream of [false, true]) expect(baselineCell(inbound, upstream, stream)).toBeDefined(); + } + } + }); + + test("the target never contains the internal Responses bridge", () => { + for (const cell of BASELINE_MATRIX) { + expect(cell.target.requestPath).not.toContain("responses-internal"); + expect(cell.target.mode).not.toBe("legacy-bridge"); + } + }); + + test("every same-wire target is native", () => { + for (const cell of BASELINE_MATRIX.filter(row => row.inbound === row.upstream)) { + expect(cell.target.mode).toBe("native"); + } + }); +}); + +describe("current behaviour claims", () => { + test("Chat to Chat is native and honours the caller's stream bit", () => { + expect(baselineCell("chat", "chat", false).current).toMatchObject({ mode: "native", responseShape: "json" }); + expect(baselineCell("chat", "chat", true).current).toMatchObject({ mode: "native", responseShape: "sse" }); + }); + + test("managed Messages to Messages still replays through internal Responses", () => { + const cell = baselineCell("messages", "messages", false); + expect(cell.current.mode).toBe("legacy-bridge"); + expect(cell.current.requestPath).toEqual(["messages", "responses-internal", "ir", "messages"]); + expect(cell.current.responseShape).toBe("sse-folded"); + expect(cell.target.mode).toBe("native"); + }); + + test("Chat and Messages reach a Responses upstream through their codec, with no detour", () => { + expect(baselineCell("chat", "responses", true).current).toMatchObject({ mode: "translated", requestPath: ["chat", "responses"] }); + expect(baselineCell("messages", "responses", true).current).toMatchObject({ mode: "translated", requestPath: ["messages", "responses"] }); + }); + + test("cross-wire Chat and Messages paths are the legacy bridge today and IR translations in the target", () => { + for (const [inbound, upstream] of [["chat", "messages"], ["messages", "chat"]] as const) { + const cell = baselineCell(inbound, upstream, true); + expect(cell.current.mode).toBe("legacy-bridge"); + expect(cell.target.requestPath).toEqual([inbound, "ir", upstream]); + } + }); + + test("the response path runs from upstream to client", () => { + expect(baselineCell("chat", "messages", true).current.responsePath).toEqual(["messages", "ir", "responses-internal", "chat"]); + }); +}); From 60de45cbb57d7a225e3c5b34bc562ed25d14e89d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:05 +0900 Subject: [PATCH 05/13] feat(protocols): fix the plan and trace wire shapes ProtocolPlanV1 and ProtocolTraceV1 with bounded validators shared by the server and the dashboard, fixed before the parallel GUI and runtime packets depend on them. --- src/protocols/dto.ts | 217 +++++++++++++++++++++++++++ tests/responses/protocol-dto.test.ts | 90 +++++++++++ 2 files changed, 307 insertions(+) create mode 100644 src/protocols/dto.ts create mode 100644 tests/responses/protocol-dto.test.ts diff --git a/src/protocols/dto.ts b/src/protocols/dto.ts new file mode 100644 index 00000000000..246835bffd8 --- /dev/null +++ b/src/protocols/dto.ts @@ -0,0 +1,217 @@ +/** + * Wire shapes for protocol plans (predicted) and protocol traces (observed). + * + * LEAF MODULE (see `contract.ts`). Both the management API and the dashboard validate with + * the functions here, so a record from an older or newer server is rejected rather than + * half-rendered. Every field is drawn from a fixed vocabulary or is a bounded identifier the + * server already exposes (provider and model names); nothing is conversation-derived. + */ +import { + isDeliveryMode, + isFidelity, + isProtocol, + isProtocolHop, + isProtocolReasonCode, + isUpstreamWire, + type DeliveryMode, + type Fidelity, + type Protocol, + type ProtocolHop, + type ProtocolReasonCode, + type UpstreamWire, +} from "./contract"; +import { isProtocolFeature, type FeatureDisposition, type ProtocolFeature } from "./features"; + +export const PROTOCOL_PLAN_SCHEMA_VERSION = 1 as const; +export const PROTOCOL_TRACE_SCHEMA_VERSION = 1 as const; + +/** Upper bounds shared by producers and validators. */ +export const PROTOCOL_DTO_LIMITS = { + pathHops: 6, + reasonCodes: 8, + featureEffects: 24, + candidates: 16, + attempts: 16, + identifierLength: 200, +} as const; + +export interface ProtocolFeatureEffectV1 { + feature: ProtocolFeature; + disposition: FeatureDisposition; +} + +/** One candidate route a plan considered. A direct route has exactly one. */ +export interface ProtocolPlanCandidateV1 { + provider: string; + model: string; + adapter: string; + upstream: UpstreamWire; + mode: DeliveryMode; + requestPath: ProtocolHop[]; + responsePath: ProtocolHop[]; + fidelity: Fidelity; + reasonCodes: ProtocolReasonCode[]; + featureEffects: ProtocolFeatureEffectV1[]; + /** Present features with no declared disposition on this candidate's path. */ + unknownFeatures: ProtocolFeature[]; + /** False when this candidate would be refused under the active unrepresentable policy. */ + eligible: boolean; +} + +export interface ProtocolPlanV1 { + schemaVersion: typeof PROTOCOL_PLAN_SCHEMA_VERSION; + basis: "preview" | "dispatch"; + contractVersion: string; + /** Opaque digest of the config inputs the plan read; changes when a relevant setting changes. */ + policyRevision: string; + inbound: Protocol; + requestedModel: string; + routeKind: "direct" | "combo" | "policy" | "unknown"; + /** Mode of the first eligible candidate, or `blocked` when none is eligible. */ + mode: DeliveryMode; + reasonCodes: ProtocolReasonCode[]; + candidates: ProtocolPlanCandidateV1[]; + /** Features every eligible candidate preserves (passthrough or translated). */ + guaranteedFeatures: ProtocolFeature[]; + /** Features preserved by some, but not all, eligible candidates. */ + partialFeatures: ProtocolFeature[]; +} + +/** What one physical attempt actually did. */ +export interface ProtocolAttemptTraceV1 { + ordinal: number; + upstream: UpstreamWire; + mode: Exclude; + requestPath: ProtocolHop[]; + /** Omitted when it is the reverse of `requestPath`. */ + responsePath?: ProtocolHop[]; +} + +/** What one request actually did, recorded at the send boundary and persisted with the log row. */ +export interface ProtocolTraceV1 { + v: typeof PROTOCOL_TRACE_SCHEMA_VERSION; + inbound: Protocol; + /** Final attempt's mode, or `blocked` when refused before any send. */ + mode: DeliveryMode; + upstream?: UpstreamWire; + requestPath: ProtocolHop[]; + responsePath: ProtocolHop[]; + reasonCodes: ProtocolReasonCode[]; + featureEffects?: ProtocolFeatureEffectV1[]; + attempts?: ProtocolAttemptTraceV1[]; + /** Set only by shadow-plan comparison (`protocols.rollout.shadowPlan`) when the dispatch plan disagreed. */ + planMismatch?: true; + contractVersion: string; +} + +type Rec = Record; +function isRec(value: unknown): value is Rec { + return !!value && typeof value === "object" && !Array.isArray(value); +} +const DISPOSITIONS = new Set(["passthrough", "translated", "degraded", "unsupported"]); + +function boundedArray(value: unknown, max: number, item: (entry: unknown) => entry is T): value is T[] { + return Array.isArray(value) && value.length <= max && value.every(item); +} +function isIdentifier(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= PROTOCOL_DTO_LIMITS.identifierLength + && !/[\u0000-\u001f\u007f]/.test(value); +} +function isFeatureEffect(value: unknown): value is ProtocolFeatureEffectV1 { + return isRec(value) && isProtocolFeature(value.feature) && typeof value.disposition === "string" + && DISPOSITIONS.has(value.disposition); +} +function isPath(value: unknown): value is ProtocolHop[] { + return boundedArray(value, PROTOCOL_DTO_LIMITS.pathHops, isProtocolHop) && value.length > 0; +} +function isReasonCodes(value: unknown): value is ProtocolReasonCode[] { + return boundedArray(value, PROTOCOL_DTO_LIMITS.reasonCodes, isProtocolReasonCode); +} +function isFeatureList(value: unknown): value is ProtocolFeature[] { + return boundedArray(value, PROTOCOL_DTO_LIMITS.featureEffects, isProtocolFeature); +} + +export function isProtocolPlanCandidateV1(value: unknown): value is ProtocolPlanCandidateV1 { + return isRec(value) + && isIdentifier(value.provider) + && isIdentifier(value.model) + && isIdentifier(value.adapter) + && isUpstreamWire(value.upstream) + && isDeliveryMode(value.mode) + && (value.mode === "blocked" ? Array.isArray(value.requestPath) : isPath(value.requestPath)) + && (value.mode === "blocked" ? Array.isArray(value.responsePath) : isPath(value.responsePath)) + && isFidelity(value.fidelity) + && isReasonCodes(value.reasonCodes) + && boundedArray(value.featureEffects, PROTOCOL_DTO_LIMITS.featureEffects, isFeatureEffect) + && isFeatureList(value.unknownFeatures) + && typeof value.eligible === "boolean"; +} + +export function isProtocolPlanV1(value: unknown): value is ProtocolPlanV1 { + return isRec(value) + && value.schemaVersion === PROTOCOL_PLAN_SCHEMA_VERSION + && (value.basis === "preview" || value.basis === "dispatch") + && typeof value.contractVersion === "string" + && typeof value.policyRevision === "string" + && isProtocol(value.inbound) + && isIdentifier(value.requestedModel) + && (value.routeKind === "direct" || value.routeKind === "combo" || value.routeKind === "policy" || value.routeKind === "unknown") + && isDeliveryMode(value.mode) + && isReasonCodes(value.reasonCodes) + && boundedArray(value.candidates, PROTOCOL_DTO_LIMITS.candidates, isProtocolPlanCandidateV1) + && isFeatureList(value.guaranteedFeatures) + && isFeatureList(value.partialFeatures); +} + +function isAttemptTrace(value: unknown): value is ProtocolAttemptTraceV1 { + return isRec(value) + && typeof value.ordinal === "number" && Number.isInteger(value.ordinal) && value.ordinal > 0 + && isUpstreamWire(value.upstream) + && isDeliveryMode(value.mode) && value.mode !== "blocked" + && isPath(value.requestPath) + && (value.responsePath === undefined || isPath(value.responsePath)); +} + +export function isProtocolTraceV1(value: unknown): value is ProtocolTraceV1 { + if (!isRec(value) || value.v !== PROTOCOL_TRACE_SCHEMA_VERSION) return false; + if (!isProtocol(value.inbound) || !isDeliveryMode(value.mode)) return false; + if (value.upstream !== undefined && !isUpstreamWire(value.upstream)) return false; + const pathsOk = value.mode === "blocked" + ? Array.isArray(value.requestPath) && value.requestPath.length === 0 + && Array.isArray(value.responsePath) && value.responsePath.length === 0 + : isPath(value.requestPath) && isPath(value.responsePath); + if (!pathsOk || !isReasonCodes(value.reasonCodes) || typeof value.contractVersion !== "string") return false; + if (value.featureEffects !== undefined + && !boundedArray(value.featureEffects, PROTOCOL_DTO_LIMITS.featureEffects, isFeatureEffect)) return false; + if (value.attempts !== undefined && !boundedArray(value.attempts, PROTOCOL_DTO_LIMITS.attempts, isAttemptTrace)) return false; + if (value.planMismatch !== undefined && value.planMismatch !== true) return false; + return true; +} + +/** + * Parse a persisted or received trace into a detached copy, or `undefined` when it is not a + * valid v1 trace. Old rows without a trace stay `undefined`; callers render "no path data" + * instead of guessing. + */ +export function parseProtocolTraceV1(value: unknown): ProtocolTraceV1 | undefined { + if (!isProtocolTraceV1(value)) return undefined; + return { + v: PROTOCOL_TRACE_SCHEMA_VERSION, + inbound: value.inbound, + mode: value.mode, + ...(value.upstream !== undefined ? { upstream: value.upstream } : {}), + requestPath: [...value.requestPath], + responsePath: [...value.responsePath], + reasonCodes: [...value.reasonCodes], + ...(value.featureEffects ? { featureEffects: value.featureEffects.map(effect => ({ ...effect })) } : {}), + ...(value.attempts ? { + attempts: value.attempts.map(attempt => ({ + ...attempt, + requestPath: [...attempt.requestPath], + ...(attempt.responsePath ? { responsePath: [...attempt.responsePath] } : {}), + })), + } : {}), + ...(value.planMismatch ? { planMismatch: true as const } : {}), + contractVersion: value.contractVersion, + }; +} diff --git a/tests/responses/protocol-dto.test.ts b/tests/responses/protocol-dto.test.ts new file mode 100644 index 00000000000..9e2f3342f10 --- /dev/null +++ b/tests/responses/protocol-dto.test.ts @@ -0,0 +1,90 @@ +/** + * Plan and trace wire shapes (src/protocols/dto.ts). A record that is not exactly v1 is + * rejected whole: the dashboard renders "no path data" rather than a half-valid path. + */ +import { describe, expect, test } from "bun:test"; +import { + isProtocolPlanV1, + isProtocolTraceV1, + parseProtocolTraceV1, + PROTOCOL_DTO_LIMITS, + type ProtocolPlanV1, + type ProtocolTraceV1, +} from "../../src/protocols/dto"; +import { PROTOCOL_CONTRACT_VERSION } from "../../src/protocols/contract"; + +const trace: ProtocolTraceV1 = { + v: 1, + inbound: "chat", + mode: "legacy-bridge", + upstream: "chat", + requestPath: ["chat", "responses-internal", "ir", "chat"], + responsePath: ["chat", "ir", "responses-internal", "chat"], + reasonCodes: ["combo-or-policy-route"], + featureEffects: [{ feature: "request.multiple_choices", disposition: "unsupported" }], + attempts: [{ ordinal: 1, upstream: "chat", mode: "legacy-bridge", requestPath: ["chat", "responses-internal", "ir", "chat"] }], + contractVersion: PROTOCOL_CONTRACT_VERSION, +}; + +const plan: ProtocolPlanV1 = { + schemaVersion: 1, + basis: "preview", + contractVersion: PROTOCOL_CONTRACT_VERSION, + policyRevision: "p1-00000000", + inbound: "chat", + requestedModel: "demo", + routeKind: "direct", + mode: "native", + reasonCodes: ["same-wire-native"], + candidates: [{ + provider: "p", + model: "m", + adapter: "openai-chat", + upstream: "chat", + mode: "native", + requestPath: ["chat", "chat"], + responsePath: ["chat", "chat"], + fidelity: "preserved", + reasonCodes: ["same-wire-native"], + featureEffects: [], + unknownFeatures: [], + eligible: true, + }], + guaranteedFeatures: [], + partialFeatures: [], +}; + +describe("trace validation", () => { + test("accepts a v1 trace and returns a detached copy", () => { + expect(isProtocolTraceV1(trace)).toBe(true); + const parsed = parseProtocolTraceV1(trace)!; + expect(parsed).toEqual(trace); + expect(parsed.requestPath).not.toBe(trace.requestPath); + }); + + test("a blocked trace has empty paths", () => { + const blocked = { ...trace, mode: "blocked", upstream: undefined, requestPath: [], responsePath: [], attempts: undefined }; + expect(isProtocolTraceV1(blocked)).toBe(true); + expect(isProtocolTraceV1({ ...blocked, requestPath: ["chat"] })).toBe(false); + }); + + test("rejects unknown vocabulary, free text, other versions and oversize arrays", () => { + expect(isProtocolTraceV1({ ...trace, v: 2 })).toBe(false); + expect(isProtocolTraceV1({ ...trace, mode: "fast" })).toBe(false); + expect(isProtocolTraceV1({ ...trace, reasonCodes: ["prompt said so"] })).toBe(false); + expect(isProtocolTraceV1({ ...trace, requestPath: Array(PROTOCOL_DTO_LIMITS.pathHops + 1).fill("chat") })).toBe(false); + expect(parseProtocolTraceV1(undefined)).toBeUndefined(); + }); +}); + +describe("plan validation", () => { + test("accepts a v1 plan", () => { + expect(isProtocolPlanV1(plan)).toBe(true); + }); + + test("rejects control characters in identifiers and unknown route kinds", () => { + expect(isProtocolPlanV1({ ...plan, requestedModel: "a\nb" })).toBe(false); + expect(isProtocolPlanV1({ ...plan, routeKind: "magic" })).toBe(false); + expect(isProtocolPlanV1({ ...plan, candidates: [{ ...plan.candidates[0], eligible: "yes" }] })).toBe(false); + }); +}); From 131e4e9dd9f1d3bcf0b086d2a04b0c69578fa802 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:05 +0900 Subject: [PATCH 06/13] test(protocols): pin vocabulary mappings and the leaf-module boundary The dashboard imports these modules directly; the boundary test fails on any import that would drag server, router or Lab code into the GUI. --- tests/responses/protocol-contract.test.ts | 86 +++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/responses/protocol-contract.test.ts diff --git a/tests/responses/protocol-contract.test.ts b/tests/responses/protocol-contract.test.ts new file mode 100644 index 00000000000..04078031584 --- /dev/null +++ b/tests/responses/protocol-contract.test.ts @@ -0,0 +1,86 @@ +/** + * The shared protocol vocabulary (src/protocols/contract.ts) and its leaf-module boundary. + * + * The dashboard imports these files directly, so an import of server, router, provider or Lab + * code would drag the runtime into the GUI bundle and its typecheck. The boundary case reads + * the import specifiers as text rather than trusting a reviewer to notice. + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { + deliveryModeForPath, + inboundWireForProtocol, + isDeliveryMode, + isProtocolReasonCode, + labProtocolForProtocol, + protocolFromInboundWire, + protocolFromLabProtocol, + protocolNodes, + PROTOCOLS, + upstreamWireForAdapter, +} from "../../src/protocols/contract"; +import { repoPath } from "../helpers/repo-root"; + +describe("protocol vocabulary mapping", () => { + test("inbound wire spelling round-trips through the public name", () => { + expect(protocolFromInboundWire("anthropic")).toBe("messages"); + expect(protocolFromInboundWire("chat")).toBe("chat"); + expect(protocolFromInboundWire("responses")).toBe("responses"); + for (const protocol of PROTOCOLS) { + expect(protocolFromInboundWire(inboundWireForProtocol(protocol))).toBe(protocol); + } + }); + + test("Lab protocol identities map both ways and reject unknown identities", () => { + for (const protocol of PROTOCOLS) { + expect(protocolFromLabProtocol(labProtocolForProtocol(protocol))).toBe(protocol); + } + expect(protocolFromLabProtocol("anthropic")).toBeUndefined(); + expect(protocolFromLabProtocol("gemini")).toBeUndefined(); + }); + + test("only the three public-wire adapters map to a protocol", () => { + expect(upstreamWireForAdapter("openai-responses")).toBe("responses"); + expect(upstreamWireForAdapter("openai-chat")).toBe("chat"); + expect(upstreamWireForAdapter("anthropic")).toBe("messages"); + expect(upstreamWireForAdapter("google")).toBe("other"); + expect(upstreamWireForAdapter("kiro")).toBe("other"); + expect(upstreamWireForAdapter("")).toBe("other"); + }); + + test("guards accept exactly the declared vocabulary", () => { + expect(isDeliveryMode("native")).toBe(true); + expect(isDeliveryMode("passthrough")).toBe(false); + expect(isProtocolReasonCode("not-migrated")).toBe(true); + expect(isProtocolReasonCode("because I said so")).toBe(false); + }); +}); + +describe("path semantics", () => { + test("protocolNodes drops ir and folds the internal Responses bridge into Responses", () => { + expect(protocolNodes(["chat", "responses-internal", "ir", "chat"])).toEqual(["chat", "responses", "chat"]); + expect(protocolNodes(["responses", "ir", "chat"])).toEqual(["responses", "chat"]); + expect(protocolNodes(["chat", "chat"])).toEqual(["chat"]); + }); + + test("mode follows the path: internal Responses means legacy-bridge", () => { + expect(deliveryModeForPath(["chat", "chat"])).toBe("native"); + expect(deliveryModeForPath(["chat", "responses"])).toBe("translated"); + expect(deliveryModeForPath(["responses", "ir", "messages"])).toBe("translated"); + expect(deliveryModeForPath(["messages", "responses-internal", "ir", "messages"])).toBe("legacy-bridge"); + expect(deliveryModeForPath(["chat", "ir", "other"])).toBe("translated"); + }); +}); + +describe("leaf-module boundary", () => { + const LEAVES = ["contract.ts", "features.ts", "baseline.ts", "dto.ts"]; + const ALLOWED = new Set(["./contract", "./features", "../compatibility/manifest"]); + + for (const file of LEAVES) { + test(`src/protocols/${file} imports only protocol leaves`, () => { + const source = readFileSync(repoPath("src", "protocols", file), "utf8"); + const specifiers = [...source.matchAll(/from\s+"([^"]+)"/g)].map(match => match[1]); + for (const specifier of specifiers) expect(ALLOWED.has(specifier!)).toBe(true); + }); + } +}); From e36645974d996cd79d7bef9c7901d0e09a853d9a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:11 +0900 Subject: [PATCH 07/13] feat(config): declare apiSurfaces and protocols keys apiSurfaces stays raw in the schema so a mistyped enabled can never degrade to "inherit" and reopen a surface; protocols degrades to absence because every default is the conservative one. No request path reads either key yet. --- src/config/schema/config-schema.ts | 16 ++++++++++++ src/types.ts | 2 ++ src/types/config.ts | 39 ++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 5c67a8c3368..c881a269fc1 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -79,6 +79,22 @@ export const configSchema = z.object({ privacy: z.object({ maskEmails: z.boolean().optional() }).strict().optional().catch(undefined), // Malformed hand edits disable this opt-in exporter. Live writes reject them in diagnostics.ts. metricsExport: z.object({ enabled: z.boolean().optional() }).strict().optional().catch(undefined), + // Kept raw on purpose: `.catch(undefined)` would turn a mistyped `enabled` into "inherit", + // which can reopen a surface the operator meant to close. src/protocols/settings.ts parses it + // and fails closed instead. + apiSurfaces: z.unknown().optional(), + // Every protocol default is the conservative one (legacy policy, rollout off), so a malformed + // block dropping to undefined cannot widen behavior. + protocols: z.object({ + unrepresentable: z.enum(["legacy", "reject"]).optional(), + rollout: z.object({ + nativeChatCombos: z.boolean().optional(), + managedMessagesNative: z.boolean().optional(), + managedMessagesNativeOAuth: z.boolean().optional(), + directEncoders: z.boolean().optional(), + shadowPlan: z.boolean().optional(), + }).strict().optional(), + }).strict().optional().catch(undefined), // A malformed present client block must remain diagnosable from raw config and // fail closed through src/client/state.ts; unrelated provider state still loads. client: clientConnectionSchema.optional().catch(undefined), diff --git a/src/types.ts b/src/types.ts index 2d1322996c0..ff961d813e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,8 @@ export type { } from "./types/request"; export type { + OcxApiSurfacesConfig, + OcxProtocolsConfig, OcxClaudeCodeConfig, OcxClaudeDesktopFamily, OcxClaudeDesktopAssignment, diff --git a/src/types/config.ts b/src/types/config.ts index 2a53a326712..b1b4de40146 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,6 +1,37 @@ import type { OcxProviderConfig } from "./provider"; import type { CodexAccount } from "./accounts"; +/** Public inference API exposure. Responses and Chat Completions are always served. */ +export interface OcxApiSurfacesConfig { + /** + * `/v1/messages` and `/v1/messages/count_tokens`. Absent means "inherit + * `claudeCode.enabled !== false`"; a present non-boolean value disables the surface. + */ + messages?: { enabled?: boolean }; +} + +/** Protocol delivery policy (devlog/_plan/260924_protocol_first_class). */ +export interface OcxProtocolsConfig { + /** + * What happens when the final upstream wire cannot express a requested feature. + * `legacy` (default) keeps today's behavior; `reject` refuses before any upstream send. + */ + unrepresentable?: "legacy" | "reject"; + /** Staged rollout switches. Every switch defaults off and changes no semantics while off. */ + rollout?: { + /** Eligible Chat candidates inside combos and policy routes send natively. */ + nativeChatCombos?: boolean; + /** Proxy-managed key-auth Anthropic targets receive `/v1/messages` natively. */ + managedMessagesNative?: boolean; + /** Extends managed native Messages to Anthropic OAuth accounts. Requires the switch above. */ + managedMessagesNativeOAuth?: boolean; + /** Chat and Messages clients are encoded directly from adapter events. */ + directEncoders?: boolean; + /** Compare the dispatch plan with the observed path; never sends a second request. */ + shadowPlan?: boolean; + }; +} + /** * Claude Code inbound settings (devlog/260711_claude_inbound). Consumed by the * /v1/messages surface, the `ocx claude` launcher, and the GUI Claude page. @@ -527,6 +558,14 @@ export interface OcxConfig { googleAntigravityStaticCatalogVersion?: 1 | 2; /** Claude Code inbound + launcher settings. */ claudeCode?: OcxClaudeCodeConfig; + /** + * Which public inference APIs this proxy serves. Read only through + * `resolveApiSurfaceSettings` in `src/protocols/settings.ts`, which fails closed on a + * malformed value and inherits `claudeCode.enabled` while no explicit value exists. + */ + apiSurfaces?: OcxApiSurfacesConfig; + /** Protocol delivery policy and rollout switches; see `resolveProtocolSettings`. */ + protocols?: OcxProtocolsConfig; /** * Per-client durable intent. This phase owns only `codex`; later phases extend * one key at a time rather than widening a shared union. From 77ab79b4a7c30fde91d669d58121986dcdaf90dd Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:12 +0900 Subject: [PATCH 08/13] feat(protocols): resolve surface and protocol settings fail-closed One reader for apiSurfaces/protocols: a malformed Messages value closes the surface, an absent one inherits claudeCode.enabled, and every rollout switch defaults off. --- src/protocols/settings.ts | 118 +++++++++++++++++++++++++ tests/config/protocol-settings.test.ts | 79 +++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/protocols/settings.ts create mode 100644 tests/config/protocol-settings.test.ts diff --git a/src/protocols/settings.ts b/src/protocols/settings.ts new file mode 100644 index 00000000000..84d9ffc5be7 --- /dev/null +++ b/src/protocols/settings.ts @@ -0,0 +1,118 @@ +/** + * The single reader of `apiSurfaces` and `protocols` config. + * + * Nothing else reads those keys directly: endpoint admission, `count_tokens`, the endpoint + * metadata DTO and the dashboard all resolve through here, so they cannot disagree about + * whether an API is open. Type-only config import keeps this module free of runtime edges. + */ +import type { OcxConfig } from "../types"; +import type { Protocol } from "./contract"; + +export type ApiSurfaceSource = + /** Responses and Chat Completions: always served. */ + | "fixed" + /** An explicit boolean in `apiSurfaces`. */ + | "api-surfaces" + /** No explicit value; inherited from `claudeCode.enabled`. */ + | "claude-code-legacy" + /** A present but malformed value; the surface is closed. */ + | "invalid"; + +export interface ApiSurfaceSetting { + enabled: boolean; + source: ApiSurfaceSource; +} + +export type ApiSurfaceSettings = Readonly>>; + +type Rec = Record; +function isRec(value: unknown): value is Rec { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function resolveMessagesSurface(config: Pick): ApiSurfaceSetting { + const raw: unknown = config.apiSurfaces; + if (raw !== undefined) { + if (!isRec(raw)) return { enabled: false, source: "invalid" }; + const messages = raw.messages; + if (messages !== undefined) { + if (!isRec(messages)) return { enabled: false, source: "invalid" }; + if (Object.hasOwn(messages, "enabled")) { + return typeof messages.enabled === "boolean" + ? { enabled: messages.enabled, source: "api-surfaces" } + : { enabled: false, source: "invalid" }; + } + } + } + return { enabled: config.claudeCode?.enabled !== false, source: "claude-code-legacy" }; +} + +export function resolveApiSurfaceSettings(config: Pick): ApiSurfaceSettings { + return Object.freeze({ + responses: Object.freeze({ enabled: true, source: "fixed" as const }), + chat: Object.freeze({ enabled: true, source: "fixed" as const }), + messages: Object.freeze(resolveMessagesSurface(config)), + }); +} + +export type UnrepresentablePolicy = "legacy" | "reject"; + +export interface ProtocolRolloutSettings { + nativeChatCombos: boolean; + managedMessagesNative: boolean; + managedMessagesNativeOAuth: boolean; + directEncoders: boolean; + shadowPlan: boolean; +} + +export interface ProtocolSettings { + unrepresentable: UnrepresentablePolicy; + rollout: Readonly; +} + +/** Resolve protocol policy with conservative defaults for every absent or malformed field. */ +export function resolveProtocolSettings(config: Pick): Readonly { + const raw: unknown = config.protocols; + const protocols = isRec(raw) ? raw : {}; + const rollout = isRec(protocols.rollout) ? protocols.rollout : {}; + const on = (key: keyof ProtocolRolloutSettings): boolean => rollout[key] === true; + const managedMessagesNative = on("managedMessagesNative"); + return Object.freeze({ + unrepresentable: protocols.unrepresentable === "reject" ? "reject" : "legacy", + rollout: Object.freeze({ + nativeChatCombos: on("nativeChatCombos"), + managedMessagesNative, + // OAuth extension is meaningless without the key-auth path it extends. + managedMessagesNativeOAuth: managedMessagesNative && on("managedMessagesNativeOAuth"), + directEncoders: on("directEncoders"), + shadowPlan: on("shadowPlan"), + }), + }); +} + +/** + * Short, stable digest of every config input a protocol plan reads. Plans carry it so the + * dashboard can tell a preview computed under an older policy from a current one. Not a + * security boundary; FNV-1a over a canonical JSON projection. + */ +export function protocolPolicyRevision(config: Pick): string { + const surfaces = resolveApiSurfaceSettings(config); + const settings = resolveProtocolSettings(config); + const canonical = JSON.stringify({ + messages: [surfaces.messages.enabled, surfaces.messages.source], + unrepresentable: settings.unrepresentable, + rollout: [ + settings.rollout.nativeChatCombos, + settings.rollout.managedMessagesNative, + settings.rollout.managedMessagesNativeOAuth, + settings.rollout.directEncoders, + settings.rollout.shadowPlan, + ], + }); + let hash = 0x811c9dc5; + for (let i = 0; i < canonical.length; i++) { + hash ^= canonical.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return `p1-${hash.toString(16).padStart(8, "0")}`; +} diff --git a/tests/config/protocol-settings.test.ts b/tests/config/protocol-settings.test.ts new file mode 100644 index 00000000000..d1ce74fd40c --- /dev/null +++ b/tests/config/protocol-settings.test.ts @@ -0,0 +1,79 @@ +/** + * `apiSurfaces` and `protocols` resolution (src/protocols/settings.ts). + * + * The Messages surface must never open because a value was malformed: a mistyped explicit value + * closes it, and only an absent value inherits the legacy `claudeCode.enabled` meaning. Every + * protocol rollout switch defaults off. + */ +import { describe, expect, test } from "bun:test"; +import { + protocolPolicyRevision, + resolveApiSurfaceSettings, + resolveProtocolSettings, +} from "../../src/protocols/settings"; +import type { OcxConfig } from "../../src/types"; + +function cfg(extra: Record): OcxConfig { + return extra as unknown as OcxConfig; +} + +describe("API surface resolution", () => { + test("Responses and Chat are fixed on", () => { + const surfaces = resolveApiSurfaceSettings(cfg({ apiSurfaces: { messages: { enabled: false } } })); + expect(surfaces.responses).toEqual({ enabled: true, source: "fixed" }); + expect(surfaces.chat).toEqual({ enabled: true, source: "fixed" }); + }); + + test("absent value inherits claudeCode.enabled", () => { + expect(resolveApiSurfaceSettings(cfg({})).messages).toEqual({ enabled: true, source: "claude-code-legacy" }); + expect(resolveApiSurfaceSettings(cfg({ claudeCode: { enabled: false } })).messages) + .toEqual({ enabled: false, source: "claude-code-legacy" }); + expect(resolveApiSurfaceSettings(cfg({ apiSurfaces: {} })).messages.source).toBe("claude-code-legacy"); + expect(resolveApiSurfaceSettings(cfg({ apiSurfaces: { messages: {} } })).messages.source).toBe("claude-code-legacy"); + }); + + test("an explicit boolean wins over the legacy key in both directions", () => { + expect(resolveApiSurfaceSettings(cfg({ apiSurfaces: { messages: { enabled: true } }, claudeCode: { enabled: false } })).messages) + .toEqual({ enabled: true, source: "api-surfaces" }); + expect(resolveApiSurfaceSettings(cfg({ apiSurfaces: { messages: { enabled: false } } })).messages) + .toEqual({ enabled: false, source: "api-surfaces" }); + }); + + test("malformed values close the surface instead of inheriting", () => { + for (const apiSurfaces of ["on", [], { messages: "yes" }, { messages: { enabled: "false" } }, { messages: { enabled: null } }]) { + expect(resolveApiSurfaceSettings(cfg({ apiSurfaces })).messages).toEqual({ enabled: false, source: "invalid" }); + } + }); +}); + +describe("protocol settings resolution", () => { + test("defaults are legacy policy with every rollout switch off", () => { + expect(resolveProtocolSettings(cfg({}))).toEqual({ + unrepresentable: "legacy", + rollout: { + nativeChatCombos: false, + managedMessagesNative: false, + managedMessagesNativeOAuth: false, + directEncoders: false, + shadowPlan: false, + }, + }); + }); + + test("only literal true enables a switch and OAuth requires the key-auth switch", () => { + const settings = resolveProtocolSettings(cfg({ + protocols: { unrepresentable: "reject", rollout: { directEncoders: "true", managedMessagesNativeOAuth: true } }, + })); + expect(settings.unrepresentable).toBe("reject"); + expect(settings.rollout.directEncoders).toBe(false); + expect(settings.rollout.managedMessagesNativeOAuth).toBe(false); + }); + + test("policy revision changes with a relevant setting and ignores unrelated config", () => { + const base = protocolPolicyRevision(cfg({})); + expect(protocolPolicyRevision(cfg({ port: 1234 }))).toBe(base); + expect(protocolPolicyRevision(cfg({ protocols: { unrepresentable: "reject" } }))).not.toBe(base); + expect(protocolPolicyRevision(cfg({ claudeCode: { enabled: false } }))).not.toBe(base); + expect(base).toMatch(/^p1-[0-9a-f]{8}$/); + }); +}); From 61a688e29469ca9b8d90d173a50304ac3b155bfe Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:12 +0900 Subject: [PATCH 09/13] test: register the protocol contract tests in the layout --- scripts/test-layout/layout.json | 5 +++++ tests/fixtures/test-layout-expected.json | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b1dbc20861f..1877fcb3bc1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -324,6 +324,10 @@ "chat-completions-pool-mode.test.ts": "responses", "chat-conversation-affinity.test.ts": "responses", "chat-inbound-developer-position.test.ts": "responses", + "protocol-contract.test.ts": "responses", + "protocol-features.test.ts": "responses", + "protocol-baseline.test.ts": "responses", + "protocol-dto.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", "chat-inline-document-bytes.test.ts": "responses", @@ -670,6 +674,7 @@ "config-catalog-auto-refresh.test.ts": "config", "config-commandcode-claude-pin.test.ts": "config", "config-load-degrade.test.ts": "config", + "protocol-settings.test.ts": "config", "config-mutation-lock.test.ts": "config", "config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 10b04d90c0c..c54aaa5cc93 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -150,6 +150,10 @@ "chat-completions-pool-mode.test.ts": "responses", "chat-conversation-affinity.test.ts": "responses", "chat-inbound-developer-position.test.ts": "responses", + "protocol-contract.test.ts": "responses", + "protocol-features.test.ts": "responses", + "protocol-baseline.test.ts": "responses", + "protocol-dto.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", "chat-inline-document-bytes.test.ts": "responses", @@ -496,6 +500,7 @@ "config-catalog-auto-refresh.test.ts": "config", "config-commandcode-claude-pin.test.ts": "config", "config-load-degrade.test.ts": "config", + "protocol-settings.test.ts": "config", "config-mutation-lock.test.ts": "config", "config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", From 004dcd110f169652b99f6829d84fc138540b7356 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:12 +0900 Subject: [PATCH 10/13] docs(structure): add Protocol Paths and claim src/protocols The new area needs an owner doc; inbound-compat and config link to it instead of restating the vocabulary. --- structure/INDEX.md | 2 + structure/config.md | 2 + structure/data-planes/inbound-compat.md | 3 + structure/data-planes/protocol-paths.md | 78 +++++++++++++++++++++++++ structure/manifest.json | 9 +++ 5 files changed, 94 insertions(+) create mode 100644 structure/data-planes/protocol-paths.md diff --git a/structure/INDEX.md b/structure/INDEX.md index c37afc48b5a..baeb049f01a 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -46,6 +46,7 @@ The wire surfaces a client actually talks to. | [`data-planes/images.md`](data-planes/images.md) | Standalone image generation and edit relay. | | [`data-planes/search.md`](data-planes/search.md) | Hosted search relay and exact account selectors. | | [`data-planes/inbound-compat.md`](data-planes/inbound-compat.md) | Chat Completions inbound, Anthropic-shaped clients, and JSON-upstream streaming clients. | +| [`data-planes/protocol-paths.md`](data-planes/protocol-paths.md) | Shared protocol vocabulary, declared feature dispositions, the ingress-by-upstream baseline, plan/trace shapes, and protocol settings. | | [`remote-workspace.md`](remote-workspace.md) | Opt-in workspace identity, executor grants, runtime adapters, management and dashboard integration. | ### Tier 4 — Providers and adapters @@ -131,6 +132,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/lab/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/lib/` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-spend.md`](transports/responses-spend.md)
[`transports/inventory.md`](transports/inventory.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/integrations.md`](clients/integrations.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/oauth/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | +| `src/protocols/` | [`data-planes/protocol-paths.md`](data-planes/protocol-paths.md) | | `src/providers/` | [`runtime.md`](runtime.md)
[`subagents.md`](subagents.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | | `src/quota/` | [`dashboard-and-usage.md`](dashboard-and-usage.md) | | `src/reasoning-effort.ts` | [`runtime.md`](runtime.md) | diff --git a/structure/config.md b/structure/config.md index d05cd9b6e68..35d172e6672 100644 --- a/structure/config.md +++ b/structure/config.md @@ -592,6 +592,8 @@ so wrong types and unknown nested fields are rejected rather than silently saved when the server process creates its serve options and therefore requires restart; it adds no setting to the live `/api/settings` mutation surface. +`apiSurfaces` and `protocols` on `src/types/config.ts` are parsed by `src/protocols/settings.ts` only; [Protocol Paths](data-planes/protocol-paths.md#settings) owns their schema handling and meaning. + Stored Direct substitution follows the [credential identity contract](providers/openai-accounts.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. Proxy activation and credential-safe CLI output follow [Proxy Configuration](config-proxy.md). diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 349f568477b..2cb7be89adb 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,5 +1,8 @@ # Inbound Compatibility Surfaces +The names used for these paths (native, translated, legacy bridge) and the declared per-feature +dispositions are owned by [Protocol Paths](protocol-paths.md). + Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](../transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md new file mode 100644 index 00000000000..0ffb0eec9b5 --- /dev/null +++ b/structure/data-planes/protocol-paths.md @@ -0,0 +1,78 @@ +# 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](inbound-compat.md) +and [Responses transport](../transports/responses.md); 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. + +`contract.ts`, `src/protocols/features.ts`, `src/protocols/baseline.ts` and +`src/protocols/dto.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. + +## 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. + +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`. +`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. + +## 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; the OAuth native-Messages switch is effective only with +the key-auth one. No request path reads these settings yet. + +`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. diff --git a/structure/manifest.json b/structure/manifest.json index ffd83a2a114..1135db7bc88 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -257,6 +257,15 @@ "src/server/" ] }, + { + "path": "data-planes/protocol-paths.md", + "tier": 3, + "title": "Protocol Paths", + "scope": "Shared protocol vocabulary, declared feature dispositions, the ingress-by-upstream baseline, plan/trace shapes, and protocol settings.", + "documents": [ + "src/protocols/" + ] + }, { "path": "providers-and-adapters.md", "tier": 4, From 090c23249de3dbde9ad7ffe0bf0455b34d97be31 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:47:52 +0900 Subject: [PATCH 11/13] refactor(chat): name the reason native Chat declines a route Moves the native-lane eligibility rules into chat-native-eligibility.ts unchanged and returns the deciding rule as a protocol reason code, so plans and traces report the same reason the lane actually used. chat-native keeps re-exporting the old names. --- scripts/test-layout/layout.json | 1 + src/server/chat-native-eligibility.ts | 74 +++++++++++++++++++ src/server/chat-native.ts | 43 +---------- tests/fixtures/test-layout-expected.json | 1 + .../chat-native-decline-reason.test.ts | 51 +++++++++++++ 5 files changed, 130 insertions(+), 40 deletions(-) create mode 100644 src/server/chat-native-eligibility.ts create mode 100644 tests/responses/chat-native-decline-reason.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1877fcb3bc1..5578d4420f9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -329,6 +329,7 @@ "protocol-baseline.test.ts": "responses", "protocol-dto.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", + "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", "chat-inline-document-bytes.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", diff --git a/src/server/chat-native-eligibility.ts b/src/server/chat-native-eligibility.ts new file mode 100644 index 00000000000..3cbb1f8716a --- /dev/null +++ b/src/server/chat-native-eligibility.ts @@ -0,0 +1,74 @@ +import { chatBodyCarriesImage, chatBodyCarriesToolResultImage } from "../chat/image-parts"; +import type { ProtocolReasonCode } from "../protocols/contract"; +import type { RouteResult } from "../router"; +import type { OcxConfig } from "../types"; +import { isModelTextOnly, requiresVisionPreprocessing } from "../vision"; + +type Rec = Record; + +function isRec(value: unknown): value is Rec { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Why the native Chat lane declines a route, as a protocol reason code. */ +export type NativeChatDeclineReason = Extract< + ProtocolReasonCode, + | "cross-wire-ir" + | "auth-mode-not-native" + | "combo-or-policy-route" + | "responses-only-feature" + | "tool-result-image" + | "vision-preprocessing" + | "hosted-tool" +>; + +/** + * The first rule that keeps a Chat request off the native Chat lane, or `undefined` when the + * route is eligible. The order is the order the checks always ran in, so the reason reported is + * the one that actually decided. + */ +export function nativeChatDeclineReason( + route: RouteResult, + rawBody: Rec, + config?: OcxConfig, +): NativeChatDeclineReason | undefined { + const provider = route.provider; + if (provider.adapter !== "openai-chat") return "cross-wire-ir"; + if (provider.authMode !== undefined && provider.authMode !== "key" && provider.authMode !== "local") return "auth-mode-not-native"; + // Combo and policy execution own multi-candidate retries in the Responses pipeline. + if (route.combo || route.routeKind === "combo" || route.routeKind === "policy") return "combo-or-policy-route"; + if (rawBody.store === true || rawBody.background === true) return "responses-only-feature"; + if (typeof rawBody.previous_response_id === "string" && rawBody.previous_response_id.length > 0) return "responses-only-feature"; + if (rawBody.compaction_trigger !== undefined) return "responses-only-feature"; + // A standard Chat tool message accepts a string or text parts, not image_url, so + // normalizing a Pi/Anthropic tool image into image_url is not enough on its own — + // the part is still inside a tool message. The translated adapter already places + // tool-result images in a following user carrier after the complete paired batch + // (flushToolResultImages), so divert these requests there. Ordinary user images and + // text-only tool results keep the native fast path. + if (chatBodyCarriesToolResultImage(rawBody)) return "tool-result-image"; + // Vision sidecar coverage (roadmap 180): a text-only routed model with an + // image-bearing body must go through the Responses pipeline, whose plan + // site describes or strips the image. The native fast path has no vision + // handling, so letting it keep such a request forwards raw pixels to a + // model the operator declared blind. + if (chatBodyCarriesImage(rawBody)) { + const needsVision = config + ? requiresVisionPreprocessing(config, provider, route.modelId, route.providerName) + : isModelTextOnly(provider, route.modelId); + if (needsVision) return "vision-preprocessing"; + } + if (Array.isArray(rawBody.tools)) { + for (const tool of rawBody.tools) { + if (!isRec(tool)) continue; + if (tool.type === "web_search" || tool.type === "web_search_preview" || tool.type === "image_generation") { + return "hosted-tool"; + } + } + } + return undefined; +} + +export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec, config?: OcxConfig): boolean { + return nativeChatDeclineReason(route, rawBody, config) === undefined; +} diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index a1401176ab0..4906432a47f 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -1,6 +1,6 @@ import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter } from "../adapters/openai-chat"; -import { chatBodyCarriesImage, chatBodyCarriesToolResultImage } from "../chat/image-parts"; import type { AdapterRequest, ProviderAdapter } from "../adapters/base"; +import { isNativeChatRouteEligible } from "./chat-native-eligibility"; import { chatCompletionsErrorBody, chatCompletionsErrorResponse, @@ -20,7 +20,6 @@ import type { AdmissionLease } from "../lib/admission"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; -import { isModelTextOnly, requiresVisionPreprocessing } from "../vision"; import { applyUpstreamRecoveryInit, fetchWithResetRetry, @@ -73,6 +72,8 @@ import { registerTurn, unregisterTurn } from "./lifecycle"; import { attachRequestSpendTracker } from "./responses/request-spend"; import { workflowRefusalResponse } from "./workflow-refusal"; +export { isNativeChatRouteEligible, nativeChatDeclineReason } from "./chat-native-eligibility"; + type Rec = Record; const MAX_NATIVE_CHAT_JSON_BYTES = 32 * 1024 * 1024; @@ -154,44 +155,6 @@ function isRec(value: unknown): value is Rec { return value !== null && typeof value === "object" && !Array.isArray(value); } -export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec, config?: OcxConfig): boolean { - const provider = route.provider; - if (provider.adapter !== "openai-chat") return false; - if (provider.authMode !== undefined && provider.authMode !== "key" && provider.authMode !== "local") return false; - // Combo and policy execution own multi-candidate retries in the Responses pipeline. - if (route.combo || route.routeKind === "combo" || route.routeKind === "policy") return false; - if (rawBody.store === true || rawBody.background === true) return false; - if (typeof rawBody.previous_response_id === "string" && rawBody.previous_response_id.length > 0) return false; - if (rawBody.compaction_trigger !== undefined) return false; - // A standard Chat tool message accepts a string or text parts, not image_url, so - // normalizing a Pi/Anthropic tool image into image_url is not enough on its own — - // the part is still inside a tool message. The translated adapter already places - // tool-result images in a following user carrier after the complete paired batch - // (flushToolResultImages), so divert these requests there. Ordinary user images and - // text-only tool results keep the native fast path. - if (chatBodyCarriesToolResultImage(rawBody)) return false; - // Vision sidecar coverage (roadmap 180): a text-only routed model with an - // image-bearing body must go through the Responses pipeline, whose plan - // site describes or strips the image. The native fast path has no vision - // handling, so letting it keep such a request forwards raw pixels to a - // model the operator declared blind. - if (chatBodyCarriesImage(rawBody)) { - const needsVision = config - ? requiresVisionPreprocessing(config, provider, route.modelId, route.providerName) - : isModelTextOnly(provider, route.modelId); - if (needsVision) return false; - } - if (Array.isArray(rawBody.tools)) { - for (const tool of rawBody.tools) { - if (!isRec(tool)) continue; - if (tool.type === "web_search" || tool.type === "web_search_preview" || tool.type === "image_generation") { - return false; - } - } - } - return true; -} - function chatCompletionJson(value: unknown): Rec | null { if (!isRec(value) || !Array.isArray(value.choices) || value.choices.length === 0) return null; return value; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c54aaa5cc93..284cc16607a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -155,6 +155,7 @@ "protocol-baseline.test.ts": "responses", "protocol-dto.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", + "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", "chat-inline-document-bytes.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", diff --git a/tests/responses/chat-native-decline-reason.test.ts b/tests/responses/chat-native-decline-reason.test.ts new file mode 100644 index 00000000000..3b2ea95ff96 --- /dev/null +++ b/tests/responses/chat-native-decline-reason.test.ts @@ -0,0 +1,51 @@ +/** + * `nativeChatDeclineReason` (src/server/chat-native-eligibility.ts) names the rule that keeps a + * Chat request off the native lane. `isNativeChatRouteEligible` is defined as "no reason", so the + * two can never disagree; these cases pin which reason wins when several apply. + */ +import { describe, expect, test } from "bun:test"; +import { isNativeChatRouteEligible, nativeChatDeclineReason } from "../../src/server/chat-native"; +import type { RouteResult } from "../../src/router"; + +function route(overrides: Partial & { adapter?: string; authMode?: string } = {}): RouteResult { + const { adapter = "openai-chat", authMode, ...rest } = overrides; + return { + providerName: "p", + modelId: "m", + routeKind: "direct", + routeReason: "test", + provider: { adapter, baseUrl: "https://example.invalid/v1", ...(authMode ? { authMode } : {}) }, + ...rest, + } as unknown as RouteResult; +} + +describe("native Chat decline reasons", () => { + test("an eligible route has no reason", () => { + expect(nativeChatDeclineReason(route(), { messages: [] })).toBeUndefined(); + expect(isNativeChatRouteEligible(route(), { messages: [] })).toBe(true); + }); + + test("a non-Chat adapter is a cross-wire route", () => { + expect(nativeChatDeclineReason(route({ adapter: "anthropic" }), {})).toBe("cross-wire-ir"); + }); + + test("OAuth credentials are not native-eligible", () => { + expect(nativeChatDeclineReason(route({ authMode: "oauth" }), {})).toBe("auth-mode-not-native"); + }); + + test("combo and policy routes stay on the Responses pipeline", () => { + expect(nativeChatDeclineReason(route({ routeKind: "policy" } as Partial), {})).toBe("combo-or-policy-route"); + }); + + test("Responses-only features and hosted tools are named", () => { + expect(nativeChatDeclineReason(route(), { store: true })).toBe("responses-only-feature"); + expect(nativeChatDeclineReason(route(), { previous_response_id: "resp_1" })).toBe("responses-only-feature"); + expect(nativeChatDeclineReason(route(), { tools: [{ type: "web_search" }] })).toBe("hosted-tool"); + }); + + test("the first failing rule wins", () => { + expect(nativeChatDeclineReason(route({ authMode: "oauth", routeKind: "policy" } as Partial), { store: true })) + .toBe("auth-mode-not-native"); + expect(isNativeChatRouteEligible(route(), { store: true })).toBe(false); + }); +}); From 887edb514cbdb5bf8d78ab488cb90a2aaeee3b5a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:47:53 +0900 Subject: [PATCH 12/13] docs(structure): note the native Chat decline reasons --- structure/data-planes/protocol-paths.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 0ffb0eec9b5..897f6a5e808 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -20,6 +20,11 @@ Existing spellings keep their names and map through explicit functions in the sa `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. + `contract.ts`, `src/protocols/features.ts`, `src/protocols/baseline.ts` and `src/protocols/dto.ts` are leaf modules: the dashboard imports them directly, so they import nothing but each other and the type-only compatibility vocabulary in From a5751d9b0c0033e0954de719443ebce550b2914f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:12:28 +0900 Subject: [PATCH 13/13] feat(protocols): derive request paths from the ingress lane The observed trace and the planner need the same rule for which hops a settled route takes. Keeping it in one leaf module means a preview and the log of the same request cannot disagree on it. --- scripts/test-layout/layout.json | 1 + src/protocols/path.ts | 39 +++++++++++++++++++++++ structure/data-planes/protocol-paths.md | 8 +++-- tests/fixtures/test-layout-expected.json | 1 + tests/responses/protocol-contract.test.ts | 2 +- tests/responses/protocol-path.test.ts | 36 +++++++++++++++++++++ 6 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 src/protocols/path.ts create mode 100644 tests/responses/protocol-path.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5578d4420f9..7345d5bfdcb 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -328,6 +328,7 @@ "protocol-features.test.ts": "responses", "protocol-baseline.test.ts": "responses", "protocol-dto.test.ts": "responses", + "protocol-path.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", diff --git a/src/protocols/path.ts b/src/protocols/path.ts new file mode 100644 index 00000000000..6884134f4c6 --- /dev/null +++ b/src/protocols/path.ts @@ -0,0 +1,39 @@ +/** + * The request path a settled route takes, derived from the lane the ingress chose and the + * upstream wire of the final adapter. The observed trace (PF-02) and the planner (PF-03) both + * call this, so a preview and the log of the same request can never disagree on the rule. + * + * LEAF MODULE (see `contract.ts`). + */ +import { deliveryModeForPath, type DeliveryMode, type Protocol, type ProtocolHop, type UpstreamWire } from "./contract"; + +/** + * `native`: the ingress sent its own wire to a same-wire upstream. + * `bridge`: the ingress projected the request into the Responses pipeline. + */ +export type ProtocolLane = "native" | "bridge"; + +export function requestPathForLane(inbound: Protocol, lane: ProtocolLane, upstream: UpstreamWire): ProtocolHop[] { + if (inbound === "responses") { + return upstream === "responses" ? ["responses", "responses"] : ["responses", "ir", upstream]; + } + if (lane === "native") return [inbound, inbound]; + if (upstream === "responses") return [inbound, "responses"]; + return [inbound, "responses-internal", "ir", upstream]; +} + +/** Upstream first, client last; the internal Responses hop is not replayed on the way back. */ +export function responsePathForLane(inbound: Protocol, lane: ProtocolLane, upstream: UpstreamWire): ProtocolHop[] { + if (inbound === "responses") return upstream === "responses" ? ["responses", "responses"] : [upstream, "ir", "responses"]; + if (lane === "native") return [inbound, inbound]; + if (upstream === "responses") return ["responses", inbound]; + return [upstream, "ir", "responses-internal", inbound]; +} + +export function deliveryModeForLane( + inbound: Protocol, + lane: ProtocolLane, + upstream: UpstreamWire, +): Exclude { + return deliveryModeForPath(requestPathForLane(inbound, lane, upstream)); +} diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 897f6a5e808..a76bf182802 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -25,12 +25,16 @@ 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. -`contract.ts`, `src/protocols/features.ts`, `src/protocols/baseline.ts` and -`src/protocols/dto.ts` are leaf modules: the dashboard imports them directly, so they import +`contract.ts`, `src/protocols/features.ts`, `src/protocols/baseline.ts`, +`src/protocols/path.ts` and `src/protocols/dto.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. + ## Feature dispositions `src/protocols/features.ts` lists the request features whose survival depends on the path, the diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 284cc16607a..f39dc31acdd 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -154,6 +154,7 @@ "protocol-features.test.ts": "responses", "protocol-baseline.test.ts": "responses", "protocol-dto.test.ts": "responses", + "protocol-path.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", diff --git a/tests/responses/protocol-contract.test.ts b/tests/responses/protocol-contract.test.ts index 04078031584..841a277b32b 100644 --- a/tests/responses/protocol-contract.test.ts +++ b/tests/responses/protocol-contract.test.ts @@ -73,7 +73,7 @@ describe("path semantics", () => { }); describe("leaf-module boundary", () => { - const LEAVES = ["contract.ts", "features.ts", "baseline.ts", "dto.ts"]; + const LEAVES = ["contract.ts", "features.ts", "baseline.ts", "dto.ts", "path.ts"]; const ALLOWED = new Set(["./contract", "./features", "../compatibility/manifest"]); for (const file of LEAVES) { diff --git a/tests/responses/protocol-path.test.ts b/tests/responses/protocol-path.test.ts new file mode 100644 index 00000000000..48bac5c2686 --- /dev/null +++ b/tests/responses/protocol-path.test.ts @@ -0,0 +1,36 @@ +/** + * The lane-to-path rule shared by the observed trace and the planner (src/protocols/path.ts). + */ +import { describe, expect, test } from "bun:test"; +import { deliveryModeForLane, requestPathForLane, responsePathForLane } from "../../src/protocols/path"; + +describe("requestPathForLane", () => { + test("Responses ingress passes through or translates through the IR", () => { + expect(requestPathForLane("responses", "bridge", "responses")).toEqual(["responses", "responses"]); + expect(requestPathForLane("responses", "bridge", "chat")).toEqual(["responses", "ir", "chat"]); + expect(deliveryModeForLane("responses", "bridge", "responses")).toBe("native"); + expect(deliveryModeForLane("responses", "bridge", "messages")).toBe("translated"); + }); + + test("a native lane keeps the ingress wire end to end", () => { + expect(requestPathForLane("chat", "native", "chat")).toEqual(["chat", "chat"]); + expect(deliveryModeForLane("messages", "native", "messages")).toBe("native"); + }); + + test("a bridge lane to a Responses upstream is a direct codec, not a legacy bridge", () => { + expect(requestPathForLane("chat", "bridge", "responses")).toEqual(["chat", "responses"]); + expect(deliveryModeForLane("chat", "bridge", "responses")).toBe("translated"); + }); + + test("any other bridge lane goes through the internal Responses body", () => { + expect(requestPathForLane("messages", "bridge", "messages")).toEqual(["messages", "responses-internal", "ir", "messages"]); + expect(deliveryModeForLane("chat", "bridge", "chat")).toBe("legacy-bridge"); + expect(deliveryModeForLane("chat", "bridge", "other")).toBe("legacy-bridge"); + }); + + test("response paths run upstream first and end at the client wire", () => { + expect(responsePathForLane("chat", "bridge", "other")).toEqual(["other", "ir", "responses-internal", "chat"]); + expect(responsePathForLane("messages", "bridge", "responses")).toEqual(["responses", "messages"]); + expect(responsePathForLane("responses", "bridge", "chat")).toEqual(["chat", "ir", "responses"]); + }); +});