From 013cbb9983eff966436e45f6c7e15afc81e64e55 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:04 +0900 Subject: [PATCH 001/173] 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 c2174224ed2ba4239439a8143994fce2532721b0 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:05 +0900 Subject: [PATCH 002/173] 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 361430b025c376384eb51aac3be55babc31d01e6 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:05 +0900 Subject: [PATCH 003/173] 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 43bb741cd8a783bd046cfce2b0c83c054c6bf818 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:05 +0900 Subject: [PATCH 004/173] 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 9d884f01e7ce7beda8b4022f0bafbb70819db262 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:05 +0900 Subject: [PATCH 005/173] 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 258648e63adccf59899f5a39f2e31820b35ec05d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:05 +0900 Subject: [PATCH 006/173] 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 abc03a39738b64a3dd47684f106b11051d832da4 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:11 +0900 Subject: [PATCH 007/173] 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 a35e43b99ef..1bed11973d0 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. @@ -534,6 +565,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 575b73f7be35db0f5b19122de6057671609c4dc6 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:12 +0900 Subject: [PATCH 008/173] 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 47f79bdf187701d1f84e9ae822163a2ea220af5a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:12 +0900 Subject: [PATCH 009/173] 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 d40c4e97b89..bf53f7b272b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -329,6 +329,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", @@ -675,6 +679,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 dd77e58701e..222df260550 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -155,6 +155,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", @@ -501,6 +505,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 31a2af1bfaa9c97875f185bf89aa06bfaeeda6de Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:46:12 +0900 Subject: [PATCH 010/173] 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 fd9982860f7..79cff1af5d1 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. | | [`remote-link.md`](remote-link.md) | SSH machine-link building blocks: OpenSSH argument policy, ssh_config candidates, tunnel lifecycle reducer and the private link store. | @@ -133,6 +134,7 @@ A source area can be described by more than one doc, because these docs are orga | `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/link/` | [`remote-link.md`](remote-link.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 b147deb8995..7a2a5aba118 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 256008a0dd0..a4349938e8e 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 caef0124b437ca929d24d2b8ff821555a5c51145 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:47:52 +0900 Subject: [PATCH 011/173] 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 bf53f7b272b..d76b885594c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -334,6 +334,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 222df260550..24d441bb54c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -160,6 +160,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 7cee784bc91f907607060cf85e38292ed482ca6a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 02:47:53 +0900 Subject: [PATCH 012/173] 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 d64b8b94d7010988fca446588893d654d10a96fe Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:12:28 +0900 Subject: [PATCH 013/173] 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 d76b885594c..23cb14420b1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -333,6 +333,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 24d441bb54c..2ae6a628a0c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -159,6 +159,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"]); + }); +}); From 0de990cd57920c93ffb4859a703fb2e528ebf9b3 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:16:17 +0900 Subject: [PATCH 014/173] refactor(request-log): move the /api/logs filters into request-log-filter.ts request-log.ts sits a few lines under its file-size threshold and the protocol trace needs room there. filterRequestLogs and filteredRequestLogCount move unchanged, together with the ring capacity they bound tail and limit by, and request-log.ts re-exports them. --- src/server/request-log-filter.ts | 76 ++++++++++++++++++++++++++++++++ src/server/request-log.ts | 71 +---------------------------- 2 files changed, 78 insertions(+), 69 deletions(-) create mode 100644 src/server/request-log-filter.ts diff --git a/src/server/request-log-filter.ts b/src/server/request-log-filter.ts new file mode 100644 index 00000000000..d92bf9379c8 --- /dev/null +++ b/src/server/request-log-filter.ts @@ -0,0 +1,76 @@ +/** + * `/api/logs` query filters over the in-memory request log. Split out of `request-log.ts` + * (which sits at its file-size threshold) so query clauses can grow here. + */ +import type { RequestLogEntry } from "./request-log"; +import { matchesLogConversationId } from "./request-log-conversation"; + +/** Capacity of the in-memory request log ring; also the upper bound of `tail` and `limit`. */ +export const MAX_LOG_SIZE = 2000; + +export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchParams): RequestLogEntry[] { + let filtered = logs; + const provider = params.get("provider")?.trim(); + if (provider) { + filtered = filtered.filter(entry => entry.provider === provider + || entry.attempts?.some(attempt => attempt.provider === provider)); + } + const conversationId = params.get("conversationId")?.trim() || params.get("conversation")?.trim(); + if (conversationId) { + filtered = filtered.filter(entry => matchesLogConversationId(entry.conversationId, conversationId)); + } + // #2704: there was no `model` clause at all, so `?model=x` was ACCEPTED and silently + // ignored -- worse than an error, because it yields wrong conclusions from output that + // looks correct. Attempts are matched for the same reason `provider` matches them: a + // request that failed over should be findable by the model that actually served it. + const model = params.get("model")?.trim(); + if (model) { + filtered = filtered.filter(entry => entry.model === model + || entry.attempts?.some(attempt => attempt.model === model)); + } + // #4057: "which account served this request" is the first question asked when one provider + // holds several accounts, and until now the only way to answer it was to grep usage.jsonl by + // hand. Attempts are matched for the same reason `provider` and `model` match them: when a + // request failed over between pool accounts, a search for the account that finally served it + // has to find that request, not only the account that first refused it. + const account = params.get("account")?.trim(); + if (account) { + filtered = filtered.filter(entry => entry.accountLogLabel === account + || entry.attempts?.some(attempt => attempt.accountLogLabel === account)); + } + const status = params.get("status")?.trim().toLowerCase(); + if (status) { + filtered = /^[1-5]xx$/.test(status) + ? filtered.filter(entry => Math.floor(entry.status / 100) === Number(status[0])) + : filtered.filter(entry => String(entry.status) === status); + } + const tailRaw = params.get("tail")?.trim(); + if (tailRaw) { + const tail = Number.parseInt(tailRaw, 10); + if (Number.isFinite(tail) && tail > 0) filtered = filtered.slice(-Math.min(tail, MAX_LOG_SIZE)); + } + const offsetRaw = params.get("offset")?.trim(); + const limitRaw = params.get("limit")?.trim(); + if (limitRaw) { + const limit = Number.parseInt(limitRaw, 10); + const offset = offsetRaw ? Number.parseInt(offsetRaw, 10) : 0; + if (Number.isFinite(limit) && limit > 0) { + const capped = Math.min(limit, MAX_LOG_SIZE); + const startOffset = Number.isFinite(offset) && offset > 0 ? offset : 0; + const end = filtered.length - startOffset; + if (end <= 0) filtered = []; + else { + const begin = Math.max(0, end - capped); + filtered = filtered.slice(begin, end); + } + } + } + return filtered; +} + +export function filteredRequestLogCount(logs: RequestLogEntry[], params: URLSearchParams): number { + const withoutPagination = new URLSearchParams(params); + withoutPagination.delete("limit"); + withoutPagination.delete("offset"); + return filterRequestLogs(logs, withoutPagination).length; +} diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 5e94238a997..951608f2ee9 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -70,7 +70,8 @@ import { USAGE_DEBUG_BODY_SAMPLE_BYTES, type UsageDebugBodyKind, } from "../usage/debug"; -import { matchesLogConversationId } from "./request-log-conversation"; +import { MAX_LOG_SIZE } from "./request-log-filter"; +export { filterRequestLogs, filteredRequestLogCount } from "./request-log-filter"; import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory"; import { capEstimateAtContextWindow } from "../lib/token-estimate"; import { inferCursorContextWindow } from "../adapters/cursor/discovery"; @@ -362,7 +363,6 @@ export interface RequestLogEntry { const requestLog: RequestLogEntry[] = []; const requestLogObserversForTests = new Set<(entry: RequestLogEntry) => void>(); -const MAX_LOG_SIZE = 2000; const requestLogEntryBytes = new WeakMap(); let requestLogBytes = 0; /** True after hydrateRequestLogsFromDisk ran once in this process. */ @@ -1583,73 +1583,6 @@ export function addFinalRequestLog( } } -export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchParams): RequestLogEntry[] { - let filtered = logs; - const provider = params.get("provider")?.trim(); - if (provider) { - filtered = filtered.filter(entry => entry.provider === provider - || entry.attempts?.some(attempt => attempt.provider === provider)); - } - const conversationId = params.get("conversationId")?.trim() || params.get("conversation")?.trim(); - if (conversationId) { - filtered = filtered.filter(entry => matchesLogConversationId(entry.conversationId, conversationId)); - } - // #2704: there was no `model` clause at all, so `?model=x` was ACCEPTED and silently - // ignored -- worse than an error, because it yields wrong conclusions from output that - // looks correct. Attempts are matched for the same reason `provider` matches them: a - // request that failed over should be findable by the model that actually served it. - const model = params.get("model")?.trim(); - if (model) { - filtered = filtered.filter(entry => entry.model === model - || entry.attempts?.some(attempt => attempt.model === model)); - } - // #4057: "which account served this request" is the first question asked when one provider - // holds several accounts, and until now the only way to answer it was to grep usage.jsonl by - // hand. Attempts are matched for the same reason `provider` and `model` match them: when a - // request failed over between pool accounts, a search for the account that finally served it - // has to find that request, not only the account that first refused it. - const account = params.get("account")?.trim(); - if (account) { - filtered = filtered.filter(entry => entry.accountLogLabel === account - || entry.attempts?.some(attempt => attempt.accountLogLabel === account)); - } - const status = params.get("status")?.trim().toLowerCase(); - if (status) { - filtered = /^[1-5]xx$/.test(status) - ? filtered.filter(entry => Math.floor(entry.status / 100) === Number(status[0])) - : filtered.filter(entry => String(entry.status) === status); - } - const tailRaw = params.get("tail")?.trim(); - if (tailRaw) { - const tail = Number.parseInt(tailRaw, 10); - if (Number.isFinite(tail) && tail > 0) filtered = filtered.slice(-Math.min(tail, MAX_LOG_SIZE)); - } - const offsetRaw = params.get("offset")?.trim(); - const limitRaw = params.get("limit")?.trim(); - if (limitRaw) { - const limit = Number.parseInt(limitRaw, 10); - const offset = offsetRaw ? Number.parseInt(offsetRaw, 10) : 0; - if (Number.isFinite(limit) && limit > 0) { - const capped = Math.min(limit, MAX_LOG_SIZE); - const startOffset = Number.isFinite(offset) && offset > 0 ? offset : 0; - const end = filtered.length - startOffset; - if (end <= 0) filtered = []; - else { - const begin = Math.max(0, end - capped); - filtered = filtered.slice(begin, end); - } - } - } - return filtered; -} - -export function filteredRequestLogCount(logs: RequestLogEntry[], params: URLSearchParams): number { - const withoutPagination = new URLSearchParams(params); - withoutPagination.delete("limit"); - withoutPagination.delete("offset"); - return filterRequestLogs(logs, withoutPagination).length; -} - interface FinalizedUsageResult { usage?: OcxUsage; status: UsageStatus; From b140d5576971a820fd96889364e7f17ce2046922 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:17:49 +0900 Subject: [PATCH 015/173] feat(protocols): derive the observed protocol trace from entry and attempt marks Ingresses record which lane they chose, and send sites may record the path an attempt took; the trace is derived once at finalize through path.ts, so the log and the planner use one rule. Marks sit in WeakMaps so RequestLogContext and attempt rows do not grow, and nothing here can throw into the request it describes. --- src/protocols/trace.ts | 259 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 src/protocols/trace.ts diff --git a/src/protocols/trace.ts b/src/protocols/trace.ts new file mode 100644 index 00000000000..02a6cf4be35 --- /dev/null +++ b/src/protocols/trace.ts @@ -0,0 +1,259 @@ +/** + * Observed protocol path for one request (PF-02). + * + * SERVER SIDE: the dashboard never imports this file; it reads the finished `ProtocolTraceV1` + * through `dto.ts`. Marks live in WeakMaps keyed by the request log context and by the live + * attempt objects, so neither `RequestLogContext` nor the persisted attempt row grows a field, + * and a context that is never finalized takes its marks with it. + * + * Every function here is side-effect free apart from its own WeakMap and never throws into the + * request path: a trace is diagnostics, and a request must not fail because its trace could not + * be recorded. Nothing conversation-derived is kept — only fixed vocabulary. + */ +import { + PROTOCOL_CONTRACT_VERSION, + isProtocolReasonCode, + upstreamWireForAdapter, + type DeliveryMode, + type Protocol, + type ProtocolHop, + type ProtocolReasonCode, + type UpstreamWire, +} from "./contract"; +import { PROTOCOL_DTO_LIMITS, PROTOCOL_TRACE_SCHEMA_VERSION, type ProtocolAttemptTraceV1, type ProtocolTraceV1 } from "./dto"; +import { featureEffectsForPath, isProtocolFeature, type ProtocolFeature } from "./features"; +import { deliveryModeForLane, requestPathForLane, responsePathForLane, type ProtocolLane } from "./path"; + +/** Features are computed inside the mark, so a thrown feature scan is contained there too. */ +export type ProtocolFeatureSource = Iterable | (() => Iterable); + +interface EntryMark { + kind: "entry"; + inbound: Protocol; + lane: ProtocolLane; + reasonCodes: ProtocolReasonCode[]; + features: ProtocolFeature[]; +} + +interface BlockedMark { + kind: "blocked"; + inbound: Protocol; + reasonCodes: ProtocolReasonCode[]; + features: ProtocolFeature[]; +} + +interface AttemptMark { + mode: Exclude; + requestPath: ProtocolHop[]; + responsePath?: ProtocolHop[]; +} + +/** The attempt fields the trace reads. `PersistedUsageAttempt` satisfies it. */ +export interface ProtocolTraceAttempt { + ordinal: number; + adapter: string; +} + +const requestMarks = new WeakMap(); +const attemptMarks = new WeakMap(); + +function boundedReasons(codes: Iterable): ProtocolReasonCode[] { + const out: ProtocolReasonCode[] = []; + for (const code of codes) { + if (!isProtocolReasonCode(code) || out.includes(code)) continue; + out.push(code); + if (out.length >= PROTOCOL_DTO_LIMITS.reasonCodes) break; + } + return out; +} + +function collectFeatures(source: ProtocolFeatureSource | undefined): ProtocolFeature[] { + if (source === undefined) return []; + const iterable = typeof source === "function" ? source() : source; + const out: ProtocolFeature[] = []; + for (const feature of iterable) { + if (isProtocolFeature(feature) && !out.includes(feature)) out.push(feature); + if (out.length >= PROTOCOL_DTO_LIMITS.featureEffects) break; + } + return out; +} + +/** + * Record which lane a Chat or Messages ingress chose. A later mark replaces an earlier one: + * the last decision before the send is the one that describes it. + */ +export function markProtocolEntry( + logCtx: object, + mark: { inbound: Protocol; lane: ProtocolLane; reasonCodes?: Iterable; features?: ProtocolFeatureSource }, +): void { + try { + requestMarks.set(logCtx, { + kind: "entry", + inbound: mark.inbound, + lane: mark.lane, + reasonCodes: boundedReasons(mark.reasonCodes ?? []), + features: collectFeatures(mark.features), + }); + } catch { + /* a trace must never fail the request it describes */ + } +} + +/** Record a refusal made before any upstream send. */ +export function markProtocolBlocked( + logCtx: object, + mark: { inbound: Protocol; reasonCodes: Iterable; features?: ProtocolFeatureSource }, +): void { + try { + requestMarks.set(logCtx, { + kind: "blocked", + inbound: mark.inbound, + reasonCodes: boundedReasons(mark.reasonCodes), + features: collectFeatures(mark.features), + }); + } catch { + /* a trace must never fail the request it describes */ + } +} + +/** + * Record the path one physical attempt actually took, overriding the lane-derived one. For a + * send site that knows its path better than the ingress lane does. + */ +export function markAttemptProtocolPath( + attempt: object, + mark: { mode: Exclude; requestPath: readonly ProtocolHop[]; responsePath?: readonly ProtocolHop[] }, +): void { + try { + if (mark.requestPath.length === 0 || mark.requestPath.length > PROTOCOL_DTO_LIMITS.pathHops) return; + const responsePath = mark.responsePath && mark.responsePath.length > 0 + && mark.responsePath.length <= PROTOCOL_DTO_LIMITS.pathHops + ? [...mark.responsePath] + : undefined; + attemptMarks.set(attempt, { + mode: mark.mode, + requestPath: [...mark.requestPath], + ...(responsePath ? { responsePath } : {}), + }); + } catch { + /* a trace must never fail the request it describes */ + } +} + +function isReverse(a: readonly ProtocolHop[], b: readonly ProtocolHop[]): boolean { + return a.length === b.length && a.every((hop, index) => hop === b[b.length - 1 - index]); +} + +interface ResolvedPath { + upstream: UpstreamWire; + mode: Exclude; + requestPath: ProtocolHop[]; + responsePath: ProtocolHop[]; +} + +function pathFor(inbound: Protocol, lane: ProtocolLane, upstream: UpstreamWire): ResolvedPath { + return { + upstream, + mode: deliveryModeForLane(inbound, lane, upstream), + requestPath: requestPathForLane(inbound, lane, upstream), + responsePath: responsePathForLane(inbound, lane, upstream), + }; +} + +function attemptPath(inbound: Protocol, lane: ProtocolLane, attempt: ProtocolTraceAttempt): ResolvedPath { + // A native Chat or Messages lane sends the ingress wire itself, whatever the adapter id says; + // Responses has no lane and follows its adapter. + const upstream = lane === "native" && inbound !== "responses" ? inbound : upstreamWireForAdapter(attempt.adapter); + const derived = pathFor(inbound, lane, upstream); + const mark = attemptMarks.get(attempt); + if (!mark) return derived; + return { + upstream, + mode: mark.mode, + requestPath: [...mark.requestPath], + responsePath: mark.responsePath ? [...mark.responsePath] : [...mark.requestPath].reverse(), + }; +} + +/** A reason code the path itself implies, so a trace is never reason-less. */ +function pathReason(path: ResolvedPath): ProtocolReasonCode { + if (path.upstream === "other") return "upstream-other"; + if (path.mode === "native") return "same-wire-native"; + if (path.mode === "legacy-bridge") return "not-migrated"; + return path.requestPath.includes("ir") ? "cross-wire-ir" : "cross-wire-codec"; +} + +/** + * Derive the observed trace at finalize. `attempts` are the live attempt objects (the ones + * `markAttemptProtocolPath` was keyed by), not detached copies. + * + * - blocked mark: `blocked`, empty paths. + * - Responses inbound without a mark: the final adapter's wire decides the path. + * - Chat or Messages: the entry lane decides, through `path.ts`. + * - no attempt and no native or blocked mark: `undefined`; nothing is guessed. + */ +export function protocolTraceForRequest( + logCtx: object & { inboundProtocol?: Protocol }, + attempts: readonly ProtocolTraceAttempt[] | undefined, +): ProtocolTraceV1 | undefined { + try { + const mark = requestMarks.get(logCtx); + if (mark?.kind === "blocked") { + return { + v: PROTOCOL_TRACE_SCHEMA_VERSION, + inbound: mark.inbound, + mode: "blocked", + requestPath: [], + responsePath: [], + reasonCodes: mark.reasonCodes, + contractVersion: PROTOCOL_CONTRACT_VERSION, + }; + } + let inbound: Protocol; + let lane: ProtocolLane; + if (mark) { + inbound = mark.inbound; + lane = mark.lane; + } else if (logCtx.inboundProtocol === "responses") { + inbound = "responses"; + lane = "native"; + } else { + return undefined; + } + const live = (attempts ?? []).filter(attempt => Number.isInteger(attempt.ordinal) && attempt.ordinal > 0); + const kept = live.slice(-PROTOCOL_DTO_LIMITS.attempts); + const attemptPaths = kept.map(attempt => ({ attempt, path: attemptPath(inbound, lane, attempt) })); + let final: ResolvedPath; + const last = attemptPaths.at(-1); + if (last) final = last.path; + else if (mark && lane === "native") final = pathFor(inbound, lane, inbound); + else return undefined; + + const reasonCodes = boundedReasons([...(mark?.reasonCodes ?? []), pathReason(final)]); + const features = mark?.features ?? []; + const effects = features.length > 0 + ? featureEffectsForPath(inbound, final.requestPath, features).effects.slice(0, PROTOCOL_DTO_LIMITS.featureEffects) + : []; + const attemptTraces: ProtocolAttemptTraceV1[] = attemptPaths.map(({ attempt, path }) => ({ + ordinal: attempt.ordinal, + upstream: path.upstream, + mode: path.mode, + requestPath: path.requestPath, + ...(isReverse(path.requestPath, path.responsePath) ? {} : { responsePath: path.responsePath }), + })); + return { + v: PROTOCOL_TRACE_SCHEMA_VERSION, + inbound, + mode: final.mode, + upstream: final.upstream, + requestPath: final.requestPath, + responsePath: final.responsePath, + reasonCodes, + ...(effects.length > 0 ? { featureEffects: effects.map(effect => ({ ...effect })) } : {}), + ...(attemptTraces.length > 0 ? { attempts: attemptTraces } : {}), + contractVersion: PROTOCOL_CONTRACT_VERSION, + }; + } catch { + return undefined; + } +} From a8a0baeaa9bc9ff7f48f13b37cfaa8a3fd235b93 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:17:49 +0900 Subject: [PATCH 016/173] test(protocols): cover trace derivation and register it in the layout Pins the Responses, native, bridge, combo, explicit-mark and blocked derivations, the no-guess cases that yield no trace, and the DTO limits. --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + tests/responses/protocol-trace.test.ts | 153 +++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 tests/responses/protocol-trace.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 23cb14420b1..4229a59e612 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -334,6 +334,7 @@ "protocol-baseline.test.ts": "responses", "protocol-dto.test.ts": "responses", "protocol-path.test.ts": "responses", + "protocol-trace.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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2ae6a628a0c..2a5f57164f7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -160,6 +160,7 @@ "protocol-baseline.test.ts": "responses", "protocol-dto.test.ts": "responses", "protocol-path.test.ts": "responses", + "protocol-trace.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-trace.test.ts b/tests/responses/protocol-trace.test.ts new file mode 100644 index 00000000000..5a264eb6695 --- /dev/null +++ b/tests/responses/protocol-trace.test.ts @@ -0,0 +1,153 @@ +/** + * Observed protocol trace derivation (src/protocols/trace.ts, PF-02). + */ +import { describe, expect, test } from "bun:test"; +import { PROTOCOL_CONTRACT_VERSION } from "../../src/protocols/contract"; +import { isProtocolTraceV1, PROTOCOL_DTO_LIMITS } from "../../src/protocols/dto"; +import { + markAttemptProtocolPath, + markProtocolBlocked, + markProtocolEntry, + protocolTraceForRequest, +} from "../../src/protocols/trace"; + +const attempt = (ordinal: number, adapter: string) => ({ ordinal, adapter }); + +describe("protocolTraceForRequest", () => { + test("Responses ingress needs no mark and follows the final adapter", () => { + const ctx = { inboundProtocol: "responses" as const }; + const trace = protocolTraceForRequest(ctx, [attempt(1, "openai-responses")]); + expect(trace).toMatchObject({ + v: 1, + inbound: "responses", + mode: "native", + upstream: "responses", + requestPath: ["responses", "responses"], + responsePath: ["responses", "responses"], + reasonCodes: ["same-wire-native"], + contractVersion: PROTOCOL_CONTRACT_VERSION, + }); + expect(isProtocolTraceV1(trace)).toBe(true); + + const translated = protocolTraceForRequest(ctx, [attempt(1, "anthropic")]); + expect(translated?.requestPath).toEqual(["responses", "ir", "messages"]); + expect(translated?.mode).toBe("translated"); + expect(translated?.reasonCodes).toEqual(["cross-wire-ir"]); + }); + + test("no attempt and no native or blocked mark means no trace", () => { + expect(protocolTraceForRequest({ inboundProtocol: "responses" }, [])).toBeUndefined(); + expect(protocolTraceForRequest({ inboundProtocol: "chat" }, [attempt(1, "openai-chat")])).toBeUndefined(); + const bridged = {}; + markProtocolEntry(bridged, { inbound: "chat", lane: "bridge" }); + expect(protocolTraceForRequest(bridged, undefined)).toBeUndefined(); + expect(protocolTraceForRequest({}, undefined)).toBeUndefined(); + }); + + test("a native Chat lane is chat to chat, with features passed through", () => { + const ctx = {}; + markProtocolEntry(ctx, { inbound: "chat", lane: "native", features: () => ["request.tools", "request.seed"] }); + const trace = protocolTraceForRequest(ctx, [attempt(1, "openai-chat")]); + expect(trace).toMatchObject({ + inbound: "chat", + mode: "native", + upstream: "chat", + requestPath: ["chat", "chat"], + featureEffects: [ + { feature: "request.tools", disposition: "passthrough" }, + { feature: "request.seed", disposition: "passthrough" }, + ], + attempts: [{ ordinal: 1, upstream: "chat", mode: "native", requestPath: ["chat", "chat"] }], + }); + expect(trace?.attempts?.[0]).not.toHaveProperty("responsePath"); + expect(isProtocolTraceV1(trace)).toBe(true); + }); + + test("a native Messages passthrough without an attempt still has a path", () => { + const ctx = {}; + markProtocolEntry(ctx, { inbound: "messages", lane: "native", reasonCodes: ["same-wire-native"] }); + const trace = protocolTraceForRequest(ctx, undefined); + expect(trace).toMatchObject({ mode: "native", upstream: "messages", requestPath: ["messages", "messages"] }); + expect(trace?.reasonCodes).toEqual(["same-wire-native"]); + expect(trace).not.toHaveProperty("attempts"); + }); + + test("a bridge lane reports the internal Responses hop and the declined reason", () => { + const ctx = {}; + markProtocolEntry(ctx, { + inbound: "chat", + lane: "bridge", + reasonCodes: ["cross-wire-ir"], + features: ["request.seed", "request.tools"], + }); + const trace = protocolTraceForRequest(ctx, [attempt(1, "anthropic")]); + expect(trace).toMatchObject({ + mode: "legacy-bridge", + upstream: "messages", + requestPath: ["chat", "responses-internal", "ir", "messages"], + responsePath: ["messages", "ir", "responses-internal", "chat"], + reasonCodes: ["cross-wire-ir", "not-migrated"], + featureEffects: [ + { feature: "request.tools", disposition: "translated" }, + { feature: "request.seed", disposition: "unsupported" }, + ], + }); + const codec = {}; + markProtocolEntry(codec, { inbound: "messages", lane: "bridge" }); + expect(protocolTraceForRequest(codec, [attempt(1, "openai-responses")])).toMatchObject({ + mode: "translated", + requestPath: ["messages", "responses"], + reasonCodes: ["cross-wire-codec"], + }); + }); + + test("combo attempts each keep their own path and the final one decides", () => { + const ctx = {}; + markProtocolEntry(ctx, { inbound: "chat", lane: "bridge" }); + const trace = protocolTraceForRequest(ctx, [attempt(1, "cursor"), attempt(2, "openai-responses")]); + expect(trace?.mode).toBe("translated"); + expect(trace?.attempts?.map(a => [a.ordinal, a.upstream, a.mode])).toEqual([ + [1, "other", "legacy-bridge"], + [2, "responses", "translated"], + ]); + }); + + test("an explicit attempt mark overrides the lane-derived path", () => { + const ctx = {}; + const live = attempt(1, "anthropic"); + markProtocolEntry(ctx, { inbound: "messages", lane: "bridge" }); + markAttemptProtocolPath(live, { mode: "translated", requestPath: ["messages", "ir", "messages"], responsePath: ["messages", "messages"] }); + const trace = protocolTraceForRequest(ctx, [live]); + expect(trace?.mode).toBe("translated"); + expect(trace?.attempts?.[0]?.responsePath).toEqual(["messages", "messages"]); + expect(isProtocolTraceV1(trace)).toBe(true); + }); + + test("a blocked mark wins and carries no path", () => { + const ctx = {}; + markProtocolBlocked(ctx, { inbound: "messages", reasonCodes: ["surface-disabled"] }); + const trace = protocolTraceForRequest(ctx, [attempt(1, "anthropic")]); + expect(trace).toMatchObject({ mode: "blocked", requestPath: [], responsePath: [], reasonCodes: ["surface-disabled"] }); + expect(trace).not.toHaveProperty("upstream"); + expect(isProtocolTraceV1(trace)).toBe(true); + }); + + test("limits hold and a throwing feature scan is contained", () => { + const ctx = {}; + markProtocolEntry(ctx, { + inbound: "chat", + lane: "bridge", + features: () => { throw new Error("boom"); }, + }); + // The mark is dropped, not half-written; the request carries on. + expect(protocolTraceForRequest(ctx, [attempt(1, "anthropic")])).toBeUndefined(); + + const many = {}; + markProtocolEntry(many, { inbound: "responses", lane: "bridge" }); + const attempts = Array.from({ length: PROTOCOL_DTO_LIMITS.attempts + 4 }, (_, i) => attempt(i + 1, "openai-responses")); + const trace = protocolTraceForRequest(many, attempts); + expect(trace?.attempts).toHaveLength(PROTOCOL_DTO_LIMITS.attempts); + expect(trace?.attempts?.at(-1)?.ordinal).toBe(PROTOCOL_DTO_LIMITS.attempts + 4); + expect(isProtocolTraceV1(trace)).toBe(true); + }); +}); From a28afbf06b75fa3f91057c9f31e53d5ce005119c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:18:38 +0900 Subject: [PATCH 017/173] feat(request-log): record and persist the observed protocol trace addFinalRequestLog derives the trace from the live context and attempts, and the usage row carries it so the Logs detail survives a restart. Reads re-validate it with parseProtocolTraceV1, so older rows and corrupt ones hydrate with no trace rather than a guessed one. --- src/server/request-log.ts | 10 ++++++++++ src/usage/log.ts | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 951608f2ee9..89480a910c7 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -22,6 +22,8 @@ import type { CodexAffinityMove, CodexAffinityReason } from "../codex/routing"; import { readCodexCatalogPath } from "../codex/catalog"; import type { AttemptTierOutcome, OcxProviderConfig, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; +import { parseProtocolTraceV1, type ProtocolTraceV1 } from "../protocols/dto"; +import { protocolTraceForRequest } from "../protocols/trace"; import type { AdapterRequest } from "../adapters/base"; import type { RequestSpendSettlement } from "./responses/request-spend"; import type { AdapterTierMetadata } from "../providers/fastwire"; @@ -359,6 +361,8 @@ export interface RequestLogEntry { */ failureStage?: RequestFailureStage; failureCause?: RequestFailureCause; + /** Observed protocol path (PF-02, `src/protocols/trace.ts`); absent when nothing was observed. */ + protocolTrace?: ProtocolTraceV1; } const requestLog: RequestLogEntry[] = []; @@ -429,6 +433,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R const routeDecision = normalizeRouteDecisionTraceForLog(entry.routeDecision); const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); const spend = normalizeRequestSpend(entry.spend); + const protocolTrace = parseProtocolTraceV1(entry.protocolTrace); return { requestId: entry.requestId, ...(isLogicalRequestId(entry.logicalRequestId) ? { logicalRequestId: entry.logicalRequestId } : {}), @@ -484,6 +489,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ? { conversationStateScrub: "account-change" } : {}), ...normalizeRequestFailureAttribution(entry), + ...(protocolTrace ? { protocolTrace } : {}), }; } @@ -662,6 +668,7 @@ export function addRequestLog(entry: RequestLogEntry) { ...normalizeRequestFailureAttribution(entry), ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}), ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}), + ...(entry.protocolTrace ? { protocolTrace: entry.protocolTrace } : {}), ...(entry.conversationStateScrub === "account-change" ? { conversationStateScrub: "account-change" } : {}), @@ -1504,6 +1511,8 @@ export function addFinalRequestLog( // the in-memory /api/logs row matches what usage.jsonl already stores. const shadowCallRewrittenFrom = sanitizeLogMetadataString(logCtx.shadowCallRewrittenFrom); const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(logCtx.claudeCompatibility); + // Keyed by the live attempt objects, not the detached copies above. + const protocolTrace = protocolTraceForRequest(logCtx, logCtx.attempts); addLog({ requestId, ...(isLogicalRequestId(logicalRequestId) ? { logicalRequestId } : {}), @@ -1564,6 +1573,7 @@ export function addFinalRequestLog( ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}), ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}), ...(claudeCompatibility ? { claudeCompatibility } : {}), + ...(protocolTrace ? { protocolTrace } : {}), ...attribution, }); // Formatted from the finalized snapshot, so the ring shows exactly what the ledger holds. diff --git a/src/usage/log.ts b/src/usage/log.ts index 3cb1296bf0a..8871f64fe7f 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -16,6 +16,7 @@ import { } from "./request-outcome"; import type { AttemptTierOutcome, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; +import { parseProtocolTraceV1, type ProtocolTraceV1 } from "../protocols/dto"; import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; import { claudeCompatibilityReason, normalizeClaudeFeatureCodes, type ClaudeFeatureCode } from "../claude/compatibility"; import type { CodexWsStageRecord } from "../server/responses/codex-ws-wire"; @@ -386,6 +387,11 @@ export interface PersistedUsageEntry { routeDecision?: RouteDecisionTraceV1; /** Closed Claude protocol codes only; absent on older rows. */ claudeCompatibility?: PersistedClaudeCompatibilityLog; + /** + * Observed protocol path (PF-02): fixed vocabulary only. Re-validated on every read; older + * rows and rows that fail validation carry none, and are never back-filled by guessing. + */ + protocolTrace?: ProtocolTraceV1; /** * How far this request got and why it failed (#2366). Projected from the attempt that ended * the request so every surface reads the answer off the same row. Absent on a completed @@ -943,6 +949,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ? normalizeRouteDecisionTrace(entry.routeDecision) : undefined; const spend = normalizeRequestSpend(entry.spend); + const protocolTrace = parseProtocolTraceV1(entry.protocolTrace); return { requestId: entry.requestId, ...(isLogicalRequestId(entry.logicalRequestId) ? { logicalRequestId: entry.logicalRequestId } : {}), @@ -1031,6 +1038,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}), ...(routeDecision ? { routeDecision } : {}), ...(claudeCompatibility ? { claudeCompatibility } : {}), + ...(protocolTrace ? { protocolTrace } : {}), ...normalizeRequestFailureAttribution(entry), }; } From b8101cedac07db5c5211a73807437e8c50eb0c6b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:19:28 +0900 Subject: [PATCH 018/173] feat(request-log): filter /api/logs by protocolMode Lets the Logs page and API ask which requests took a native, translated, legacy-bridge or blocked path, or carry no trace at all. An unknown mode matches nothing instead of being silently ignored, for the reason #2704 gave for model. --- src/server/request-log-filter.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/server/request-log-filter.ts b/src/server/request-log-filter.ts index d92bf9379c8..9b45191f4d9 100644 --- a/src/server/request-log-filter.ts +++ b/src/server/request-log-filter.ts @@ -4,10 +4,24 @@ */ import type { RequestLogEntry } from "./request-log"; import { matchesLogConversationId } from "./request-log-conversation"; +import { isDeliveryMode } from "../protocols/contract"; /** Capacity of the in-memory request log ring; also the upper bound of `tail` and `limit`. */ export const MAX_LOG_SIZE = 2000; +/** `protocolMode=none` selects rows with no observed protocol trace. */ +export const REQUEST_LOG_PROTOCOL_MODE_NONE = "none"; + +/** + * Whether a row matches a `protocolMode` query value: the final trace mode, or `none` for a row + * without a trace. An unrecognised value matches nothing rather than being ignored, for the + * reason #2704 gives for `model`. + */ +export function matchesProtocolMode(entry: Pick, mode: string): boolean { + if (mode === REQUEST_LOG_PROTOCOL_MODE_NONE) return entry.protocolTrace === undefined; + return isDeliveryMode(mode) && entry.protocolTrace?.mode === mode; +} + export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchParams): RequestLogEntry[] { let filtered = logs; const provider = params.get("provider")?.trim(); @@ -38,6 +52,8 @@ export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchPara filtered = filtered.filter(entry => entry.accountLogLabel === account || entry.attempts?.some(attempt => attempt.accountLogLabel === account)); } + const protocolMode = params.get("protocolMode")?.trim().toLowerCase(); + if (protocolMode) filtered = filtered.filter(entry => matchesProtocolMode(entry, protocolMode)); const status = params.get("status")?.trim().toLowerCase(); if (status) { filtered = /^[1-5]xx$/.test(status) From 359de56a7c8025f980e5a769b963c99c2c7da5ec Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:19:28 +0900 Subject: [PATCH 019/173] test(request-log): cover protocol trace persistence and the protocolMode filter Pins the finalize-time trace, the usage-row round trip, old and corrupt rows hydrating with no trace, and each protocolMode value including none and an unknown one. --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + .../usage/request-log-protocol-trace.test.ts | 128 ++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 tests/usage/request-log-protocol-trace.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4229a59e612..5cba486adbe 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1401,6 +1401,7 @@ "request-log-conversation.test.ts": "usage", "request-log-estimate-cap.test.ts": "usage", "request-log-nonstream.test.ts": "usage", + "request-log-protocol-trace.test.ts": "usage", "request-log-served-model.test.ts": "usage", "request-log.test.ts": "usage", "request-outcome-agreement.test.ts": "usage", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2a5f57164f7..ed747c91ea0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1227,6 +1227,7 @@ "request-log-conversation.test.ts": "usage", "request-log-estimate-cap.test.ts": "usage", "request-log-nonstream.test.ts": "usage", + "request-log-protocol-trace.test.ts": "usage", "request-log-served-model.test.ts": "usage", "request-log.test.ts": "usage", "request-outcome-agreement.test.ts": "usage", diff --git a/tests/usage/request-log-protocol-trace.test.ts b/tests/usage/request-log-protocol-trace.test.ts new file mode 100644 index 00000000000..e9fb0592d1b --- /dev/null +++ b/tests/usage/request-log-protocol-trace.test.ts @@ -0,0 +1,128 @@ +/** + * The observed protocol trace on request-log rows (PF-02): computed at finalize, carried + * through the persisted usage row, re-validated on read, and filterable by `protocolMode`. + */ +import { describe, expect, test } from "bun:test"; +import type { ProtocolTraceV1 } from "../../src/protocols/dto"; +import { markProtocolBlocked, markProtocolEntry } from "../../src/protocols/trace"; +import { + addFinalRequestLog, + beginRequestAttempt, + filterRequestLogs, + filteredRequestLogCount, + requestLogEntryFromPersistedUsage, + type RequestLogContext, + type RequestLogEntry, +} from "../../src/server/request-log"; +import { normalizeUsageEntryForTest, type PersistedUsageEntry } from "../../src/usage/log"; + +function finalize(logCtx: RequestLogContext, status = 200): RequestLogEntry { + let captured: RequestLogEntry | undefined; + addFinalRequestLog("req-trace", Date.now() - 10, logCtx, status, { closeReason: "terminal" }, entry => { + captured = entry; + }); + if (!captured) throw new Error("no row finalized"); + return captured; +} + +const baseRow: PersistedUsageEntry = { + requestId: "ocx-row", + timestamp: 1_700_000_000_000, + provider: "p", + model: "m", + status: 200, + durationMs: 5, + usageStatus: "reported", +}; + +const bridgeTrace: ProtocolTraceV1 = { + v: 1, + inbound: "chat", + mode: "legacy-bridge", + upstream: "messages", + requestPath: ["chat", "responses-internal", "ir", "messages"], + responsePath: ["messages", "ir", "responses-internal", "chat"], + reasonCodes: ["cross-wire-ir", "not-migrated"], + featureEffects: [{ feature: "request.seed", disposition: "unsupported" }], + attempts: [{ ordinal: 1, upstream: "messages", mode: "legacy-bridge", requestPath: ["chat", "responses-internal", "ir", "messages"] }], + contractVersion: "2026-09-24.1", +}; + +describe("addFinalRequestLog protocol trace", () => { + test("a Chat bridge row carries the lane-derived trace", () => { + const attempt = beginRequestAttempt(1, "p", "m", "anthropic"); + const logCtx: RequestLogContext = { model: "m", provider: "p", inboundProtocol: "chat", attempts: [attempt] }; + markProtocolEntry(logCtx, { inbound: "chat", lane: "bridge", reasonCodes: ["cross-wire-ir"], features: ["request.seed"] }); + const row = finalize(logCtx); + expect(row.protocolTrace).toMatchObject({ + mode: "legacy-bridge", + requestPath: ["chat", "responses-internal", "ir", "messages"], + featureEffects: [{ feature: "request.seed", disposition: "unsupported" }], + }); + }); + + test("a blocked Messages row carries a blocked trace; an unmarked non-Responses row carries none", () => { + const blocked: RequestLogContext = { model: "unknown", provider: "unknown", inboundProtocol: "messages" }; + markProtocolBlocked(blocked, { inbound: "messages", reasonCodes: ["surface-disabled"] }); + expect(finalize(blocked, 403).protocolTrace).toMatchObject({ mode: "blocked", reasonCodes: ["surface-disabled"] }); + + const unmarked: RequestLogContext = { model: "m", provider: "p", attempts: [beginRequestAttempt(1, "p", "m", "openai-chat")] }; + expect(finalize(unmarked).protocolTrace).toBeUndefined(); + }); +}); + +describe("persisted protocol trace", () => { + test("round-trips through the usage row and hydrates back", () => { + const normalized = normalizeUsageEntryForTest({ ...baseRow, protocolTrace: bridgeTrace }); + expect(normalized.protocolTrace).toEqual(bridgeTrace); + const hydrated = requestLogEntryFromPersistedUsage(JSON.parse(JSON.stringify(normalized)) as PersistedUsageEntry); + expect(hydrated.protocolTrace).toEqual(bridgeTrace); + }); + + test("an old row without a trace hydrates with none", () => { + expect(normalizeUsageEntryForTest(baseRow)).not.toHaveProperty("protocolTrace"); + expect(requestLogEntryFromPersistedUsage(baseRow)).not.toHaveProperty("protocolTrace"); + }); + + test("a hand-edited trace that fails validation is dropped, not forwarded", () => { + const corrupt = { ...baseRow, protocolTrace: { ...bridgeTrace, reasonCodes: ["free text"] } } as unknown as PersistedUsageEntry; + expect(normalizeUsageEntryForTest(corrupt)).not.toHaveProperty("protocolTrace"); + expect(requestLogEntryFromPersistedUsage(corrupt)).not.toHaveProperty("protocolTrace"); + const future = { ...baseRow, protocolTrace: { ...bridgeTrace, v: 2 } } as unknown as PersistedUsageEntry; + expect(requestLogEntryFromPersistedUsage(future)).not.toHaveProperty("protocolTrace"); + }); +}); + +describe("protocolMode filter", () => { + const row = (requestId: string, protocolTrace?: ProtocolTraceV1): RequestLogEntry => ({ + requestId, + timestamp: 1, + model: "m", + provider: "p", + status: 200, + durationMs: 1, + usageStatus: "reported", + ...(protocolTrace ? { protocolTrace } : {}), + }); + const logs = [ + row("bridge", bridgeTrace), + row("native", { ...bridgeTrace, mode: "native", requestPath: ["chat", "chat"], responsePath: ["chat", "chat"] }), + row("blocked", { ...bridgeTrace, mode: "blocked", requestPath: [], responsePath: [] }), + row("old"), + ]; + const ids = (query: string) => filterRequestLogs(logs, new URLSearchParams(query)).map(entry => entry.requestId); + + test("selects by final mode, and none selects rows without a trace", () => { + expect(ids("protocolMode=legacy-bridge")).toEqual(["bridge"]); + expect(ids("protocolMode=native")).toEqual(["native"]); + expect(ids("protocolMode=blocked")).toEqual(["blocked"]); + expect(ids("protocolMode=translated")).toEqual([]); + expect(ids("protocolMode=none")).toEqual(["old"]); + expect(filteredRequestLogCount(logs, new URLSearchParams("protocolMode=native&limit=1"))).toBe(1); + }); + + test("an unrecognised mode matches nothing instead of being ignored", () => { + expect(ids("protocolMode=verified")).toEqual([]); + expect(ids("")).toEqual(["bridge", "native", "blocked", "old"]); + }); +}); From 070e16c3535cef4ec5750cd7a27682a933fe4842 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:19:59 +0900 Subject: [PATCH 020/173] feat(chat): mark the protocol lane a Chat Completions request takes The ingress already decides between the native Chat lane and the Responses bridge; it now records that decision, the rule that declined the native lane, and the request's features, so the trace reports the reason the lane actually used. The decision itself is unchanged. --- src/server/chat-completions.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index f8c273de0a1..182e739e2c7 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -69,7 +69,10 @@ import { isTranslatorBudgetExceededError, type TranslatorBudget, } from "../lib/translator-budget"; -import { handleNativeChatCompletions, isNativeChatRouteEligible } from "./chat-native"; +import { handleNativeChatCompletions, nativeChatDeclineReason } from "./chat-native"; +import type { ProtocolReasonCode } from "../protocols/contract"; +import { featuresFromChatBody } from "../protocols/features"; +import { markProtocolEntry } from "../protocols/trace"; import { jsonCompletionSse } from "./chat-native-sse"; import { parseRequestEffortRowId } from "./effort-row"; import { parseSyntheticRowId } from "./fast-row"; @@ -163,6 +166,8 @@ async function handleChatCompletionsWithBudget( let routeMayChangeCredentialDomain = false; let settledRoute: ReturnType | null = null; let chatNativeRoute: ReturnType | null = null; + // Why the native Chat lane was not taken; stays `unknown-model` when routing threw. + let nativeDecline: ProtocolReasonCode | undefined = "unknown-model"; try { const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody)); // The native Chat lane sends without re-entering the Responses path, so it @@ -199,7 +204,10 @@ async function handleChatCompletionsWithBudget( } // Combos must enter the Responses routing path so child selection, forced default // effort, failover, and per-attempt telemetry run before any native Chat send. - if (!route.combo && !effortRow && isNativeChatRouteEligible(route, chatBody, config)) { + nativeDecline = route.combo ? "combo-or-policy-route" + : effortRow ? "effort-row" + : nativeChatDeclineReason(route, chatBody, config); + if (nativeDecline === undefined) { chatNativeRoute = route; // Reserve an input estimate for spend without recording it as usage: native Chat attempts // keep the provider-reported counts, as they did before the reservation existed. @@ -232,6 +240,12 @@ async function handleChatCompletionsWithBudget( /* unknown model: let handleResponses shape the 404 */ } + markProtocolEntry(logCtx, { + inbound: "chat", + lane: chatNativeRoute ? "native" : "bridge", + reasonCodes: !chatNativeRoute && nativeDecline ? [nativeDecline] : [], + features: () => featuresFromChatBody(chatBody), + }); if (chatNativeRoute) { return handleNativeChatCompletions({ req, From 688f38bc1576e2a50c2107fd3bd918c5b52252ea Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:20:17 +0900 Subject: [PATCH 021/173] feat(claude): mark the protocol lane a Messages request takes Caller-forward passthrough is the native Messages lane, the translated path is the bridge, and a disabled surface or a compatibility reject is a refusal before any send. Recording each at the point it is decided lets the Logs trace say which one happened without guessing. --- src/server/claude-messages.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index e8444ffc161..e7c25b322ce 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -59,6 +59,8 @@ import { } from "./request-log-conversation"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; +import { featuresFromMessagesBody } from "../protocols/features"; +import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace"; import { isApiAuthRequired, isDataPlaneAdmissionSecret, @@ -718,6 +720,7 @@ async function handleClaudeMessagesWithBudget( logCtx.surface = "claude"; const disabled = claudeInboundDisabled(config); if (disabled) { + markProtocolBlocked(logCtx, { inbound: "messages", reasonCodes: ["surface-disabled"] }); if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" }); return disabled; } @@ -797,7 +800,10 @@ async function handleClaudeMessagesWithBudget( // caller's body with the caller's credential and never runs the Anthropic adapter, so the // proxy-owned `speed` + beta (anthropic-speed wire) and its usage.speed observation would be // silently skipped. Translation reaches the adapter, which owns both. + const messagesBody = anthropicBody; + const messagesFeatures = () => featuresFromMessagesBody(messagesBody); if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model, cc)) { + markProtocolEntry(logCtx, { inbound: "messages", lane: "native", features: messagesFeatures }); return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); } // Capture source semantics before effort rewriting or translation drops fields. @@ -814,6 +820,7 @@ async function handleClaudeMessagesWithBudget( anthropicBeta: req.headers.get("anthropic-beta") ?? undefined, }); if (compatibility.decision === "reject") { + markProtocolBlocked(logCtx, { inbound: "messages", reasonCodes: ["compatibility-reject"], features: messagesFeatures }); logCtx.errorCode = "claude_compatibility_unsupported"; if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" }); return anthropicErrorResponse(400, compatibility.reason!, "invalid_request_error"); @@ -826,6 +833,13 @@ async function handleClaudeMessagesWithBudget( }; } } + // Features are read here, before an effort override rewrites the thinking settings. + markProtocolEntry(logCtx, { + inbound: "messages", + lane: "bridge", + reasonCodes: effortRow ? ["effort-row"] : fastRow ? ["fast-row"] : [], + features: messagesFeatures, + }); if (isRec(anthropicBody) && effortOverride) { anthropicBody.output_config = { ...(isRec(anthropicBody.output_config) ? anthropicBody.output_config : {}), From db7ed1321f4676a35b4e944c3794df2be6bdfa12 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:24:52 +0900 Subject: [PATCH 022/173] feat(gui): add protocol path copy to every locale The Logs protocol badge, detail section and filter need labels for the closed mode, hop and disposition vocabulary. The short wire names and the IR acronym stay English in French and Taiwanese Mandarin, as the other protocol names already do, and are allowlisted there. --- gui/src/i18n/de.ts | 29 +++++++++++++++++++++++++++++ gui/src/i18n/en.ts | 29 +++++++++++++++++++++++++++++ gui/src/i18n/fr.ts | 29 +++++++++++++++++++++++++++++ gui/src/i18n/ja.ts | 29 +++++++++++++++++++++++++++++ gui/src/i18n/ko.ts | 29 +++++++++++++++++++++++++++++ gui/src/i18n/ru.ts | 29 +++++++++++++++++++++++++++++ gui/src/i18n/tr.ts | 29 +++++++++++++++++++++++++++++ gui/src/i18n/vi.ts | 29 +++++++++++++++++++++++++++++ gui/src/i18n/zh-TW.ts | 29 +++++++++++++++++++++++++++++ gui/src/i18n/zh.ts | 29 +++++++++++++++++++++++++++++ gui/tests/fr-localization.test.ts | 5 +++++ gui/tests/locale-parity.test.ts | 5 +++++ 12 files changed, 300 insertions(+) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 37308e18859..a3ebd511747 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -877,6 +877,35 @@ export const de: Record = { "logs.detail.route.selected": "Ausgewählt", "logs.detail.route.candidates": "Kandidaten", "logs.detail.route.unknown": "Für diese Anfrage wurde keine Route-Entscheidung aufgezeichnet (Zeile vor dem Trace).", + "logs.filter.protocol.label": "Protokollpfad", + "logs.filter.protocol.all": "Alle Pfade", + "logs.filter.protocol.none": "Keine Pfaddaten", + "logs.protocol.mode.native": "Nativ", + "logs.protocol.mode.translated": "Übersetzt", + "logs.protocol.mode.legacyBridge": "Legacy-Brücke", + "logs.protocol.mode.blocked": "Blockiert", + "logs.protocol.disposition.passthrough": "Durchgereicht", + "logs.protocol.disposition.translated": "Übersetzt", + "logs.protocol.disposition.degraded": "Eingeschränkt", + "logs.protocol.disposition.unsupported": "Verworfen", + "logs.protocol.wire.responses": "Responses", + "logs.protocol.wire.chat": "Chat", + "logs.protocol.wire.messages": "Messages", + "logs.protocol.wire.other": "Anderer Adapter", + "logs.protocol.hop.ir": "IR", + "logs.protocol.hop.internal": "Responses (intern)", + "logs.protocol.badgeTitle": "Protokollpfad: {path} ({mode})", + "logs.detail.protocol.section": "Protokollpfad", + "logs.detail.protocol.inbound": "Client-API", + "logs.detail.protocol.mode": "Zustellmodus", + "logs.detail.protocol.upstream": "Upstream-Format", + "logs.detail.protocol.requestPath": "Anfragepfad", + "logs.detail.protocol.responsePath": "Antwortpfad", + "logs.detail.protocol.reasons": "Gründe", + "logs.detail.protocol.features": "Auswirkungen auf Funktionen", + "logs.detail.protocol.attempts": "Pfade der Versuche", + "logs.detail.protocol.attempt": "Versuch {ordinal}", + "logs.detail.protocol.none": "Für diese Anfrage wurden keine Pfaddaten aufgezeichnet.", "logs.detail.section.performance": "Leistung", "logs.detail.section.cost": "API-Listenpreis-Äquivalent", "logs.detail.section.attempts": "Combo-Versuche", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 86f303e0d73..54cd3520144 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -926,6 +926,35 @@ export const en = { "logs.detail.route.selected": "Selected", "logs.detail.route.candidates": "Candidates", "logs.detail.route.unknown": "No route trace recorded for this request (pre-trace row).", + "logs.filter.protocol.label": "Protocol path", + "logs.filter.protocol.all": "All paths", + "logs.filter.protocol.none": "No path data", + "logs.protocol.mode.native": "Native", + "logs.protocol.mode.translated": "Translated", + "logs.protocol.mode.legacyBridge": "Legacy bridge", + "logs.protocol.mode.blocked": "Blocked", + "logs.protocol.disposition.passthrough": "Passed through", + "logs.protocol.disposition.translated": "Translated", + "logs.protocol.disposition.degraded": "Degraded", + "logs.protocol.disposition.unsupported": "Dropped", + "logs.protocol.wire.responses": "Responses", + "logs.protocol.wire.chat": "Chat", + "logs.protocol.wire.messages": "Messages", + "logs.protocol.wire.other": "Other adapter", + "logs.protocol.hop.ir": "IR", + "logs.protocol.hop.internal": "Responses (internal)", + "logs.protocol.badgeTitle": "Protocol path: {path} ({mode})", + "logs.detail.protocol.section": "Protocol path", + "logs.detail.protocol.inbound": "Client API", + "logs.detail.protocol.mode": "Delivery mode", + "logs.detail.protocol.upstream": "Upstream wire", + "logs.detail.protocol.requestPath": "Request path", + "logs.detail.protocol.responsePath": "Response path", + "logs.detail.protocol.reasons": "Reasons", + "logs.detail.protocol.features": "Feature effects", + "logs.detail.protocol.attempts": "Attempt paths", + "logs.detail.protocol.attempt": "Attempt {ordinal}", + "logs.detail.protocol.none": "No path data recorded for this request.", "logs.detail.section.performance": "Performance", "logs.detail.section.cost": "API list-price equivalent", "logs.detail.section.attempts": "Combo attempts", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 77269ffce75..b239c710629 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -907,6 +907,35 @@ export const fr: Record = { "logs.detail.route.selected": "Sélection", "logs.detail.route.candidates": "Candidats", "logs.detail.route.unknown": "Aucune trace de routage enregistrée pour cette requête (ligne antérieure à la traçabilité).", + "logs.filter.protocol.label": "Chemin de protocole", + "logs.filter.protocol.all": "Tous les chemins", + "logs.filter.protocol.none": "Aucune donnée de chemin", + "logs.protocol.mode.native": "Natif", + "logs.protocol.mode.translated": "Traduit", + "logs.protocol.mode.legacyBridge": "Pont hérité", + "logs.protocol.mode.blocked": "Bloqué", + "logs.protocol.disposition.passthrough": "Transmis tel quel", + "logs.protocol.disposition.translated": "Traduit", + "logs.protocol.disposition.degraded": "Dégradé", + "logs.protocol.disposition.unsupported": "Abandonné", + "logs.protocol.wire.responses": "Responses", + "logs.protocol.wire.chat": "Chat", + "logs.protocol.wire.messages": "Messages", + "logs.protocol.wire.other": "Autre adaptateur", + "logs.protocol.hop.ir": "IR", + "logs.protocol.hop.internal": "Responses (interne)", + "logs.protocol.badgeTitle": "Chemin de protocole : {path} ({mode})", + "logs.detail.protocol.section": "Chemin de protocole", + "logs.detail.protocol.inbound": "API client", + "logs.detail.protocol.mode": "Mode de livraison", + "logs.detail.protocol.upstream": "Format amont", + "logs.detail.protocol.requestPath": "Chemin de la requête", + "logs.detail.protocol.responsePath": "Chemin de la réponse", + "logs.detail.protocol.reasons": "Raisons", + "logs.detail.protocol.features": "Effets sur les fonctionnalités", + "logs.detail.protocol.attempts": "Chemins des tentatives", + "logs.detail.protocol.attempt": "Tentative {ordinal}", + "logs.detail.protocol.none": "Aucune donnée de chemin enregistrée pour cette requête.", "logs.detail.section.performance": "Performances", "logs.detail.section.cost": "Équivalent au tarif catalogue de l’API", "logs.detail.section.attempts": "Tentatives de combinaison", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3e433860df0..4fb724b1d69 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -837,6 +837,35 @@ export const ja: Record = { "logs.detail.route.selected": "選択済み", "logs.detail.route.candidates": "候補", "logs.detail.route.unknown": "このリクエストにはルートトレースが記録されていません(トレース前の行)。", + "logs.filter.protocol.label": "プロトコル経路", + "logs.filter.protocol.all": "すべての経路", + "logs.filter.protocol.none": "経路データなし", + "logs.protocol.mode.native": "ネイティブ", + "logs.protocol.mode.translated": "変換", + "logs.protocol.mode.legacyBridge": "レガシーブリッジ", + "logs.protocol.mode.blocked": "ブロック", + "logs.protocol.disposition.passthrough": "そのまま転送", + "logs.protocol.disposition.translated": "変換", + "logs.protocol.disposition.degraded": "劣化", + "logs.protocol.disposition.unsupported": "破棄", + "logs.protocol.wire.responses": "Responses", + "logs.protocol.wire.chat": "Chat", + "logs.protocol.wire.messages": "Messages", + "logs.protocol.wire.other": "その他のアダプター", + "logs.protocol.hop.ir": "IR", + "logs.protocol.hop.internal": "Responses(内部)", + "logs.protocol.badgeTitle": "プロトコル経路: {path}({mode})", + "logs.detail.protocol.section": "プロトコル経路", + "logs.detail.protocol.inbound": "クライアント API", + "logs.detail.protocol.mode": "配信モード", + "logs.detail.protocol.upstream": "アップストリーム形式", + "logs.detail.protocol.requestPath": "リクエスト経路", + "logs.detail.protocol.responsePath": "レスポンス経路", + "logs.detail.protocol.reasons": "理由", + "logs.detail.protocol.features": "機能への影響", + "logs.detail.protocol.attempts": "試行ごとの経路", + "logs.detail.protocol.attempt": "試行 {ordinal}", + "logs.detail.protocol.none": "このリクエストには経路データが記録されていません。", "logs.detail.section.performance": "パフォーマンス", "logs.detail.section.cost": "API 定価相当額", "logs.detail.section.attempts": "コンボの試行", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7073e04b491..856faf63f42 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -908,6 +908,35 @@ export const ko: Record = { "logs.detail.route.selected": "선택됨", "logs.detail.route.candidates": "후보", "logs.detail.route.unknown": "이 요청에 대한 라우팅 추적이 기록되지 않았습니다(추적 이전 행).", + "logs.filter.protocol.label": "프로토콜 경로", + "logs.filter.protocol.all": "모든 경로", + "logs.filter.protocol.none": "경로 데이터 없음", + "logs.protocol.mode.native": "네이티브", + "logs.protocol.mode.translated": "변환됨", + "logs.protocol.mode.legacyBridge": "레거시 브리지", + "logs.protocol.mode.blocked": "차단됨", + "logs.protocol.disposition.passthrough": "그대로 전달", + "logs.protocol.disposition.translated": "변환됨", + "logs.protocol.disposition.degraded": "저하됨", + "logs.protocol.disposition.unsupported": "삭제됨", + "logs.protocol.wire.responses": "Responses", + "logs.protocol.wire.chat": "Chat", + "logs.protocol.wire.messages": "Messages", + "logs.protocol.wire.other": "기타 어댑터", + "logs.protocol.hop.ir": "IR", + "logs.protocol.hop.internal": "Responses (내부)", + "logs.protocol.badgeTitle": "프로토콜 경로: {path} ({mode})", + "logs.detail.protocol.section": "프로토콜 경로", + "logs.detail.protocol.inbound": "클라이언트 API", + "logs.detail.protocol.mode": "전달 모드", + "logs.detail.protocol.upstream": "업스트림 형식", + "logs.detail.protocol.requestPath": "요청 경로", + "logs.detail.protocol.responsePath": "응답 경로", + "logs.detail.protocol.reasons": "사유", + "logs.detail.protocol.features": "기능 영향", + "logs.detail.protocol.attempts": "시도별 경로", + "logs.detail.protocol.attempt": "시도 {ordinal}", + "logs.detail.protocol.none": "이 요청에는 기록된 경로 데이터가 없습니다.", "logs.detail.section.performance": "성능", "logs.detail.section.cost": "API 정가 환산치", "logs.detail.section.attempts": "Combo 시도", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 1776e8f4936..89c62a66e18 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -894,6 +894,35 @@ export const ru: Record = { "logs.detail.route.selected": "Выбрано", "logs.detail.route.candidates": "Кандидаты", "logs.detail.route.unknown": "Для этого запроса трасса маршрута не записана (строка до трассировки).", + "logs.filter.protocol.label": "Путь протокола", + "logs.filter.protocol.all": "Все пути", + "logs.filter.protocol.none": "Нет данных о пути", + "logs.protocol.mode.native": "Нативный", + "logs.protocol.mode.translated": "Преобразованный", + "logs.protocol.mode.legacyBridge": "Устаревший мост", + "logs.protocol.mode.blocked": "Заблокирован", + "logs.protocol.disposition.passthrough": "Передано без изменений", + "logs.protocol.disposition.translated": "Преобразовано", + "logs.protocol.disposition.degraded": "Ухудшено", + "logs.protocol.disposition.unsupported": "Отброшено", + "logs.protocol.wire.responses": "Responses", + "logs.protocol.wire.chat": "Chat", + "logs.protocol.wire.messages": "Messages", + "logs.protocol.wire.other": "Другой адаптер", + "logs.protocol.hop.ir": "IR", + "logs.protocol.hop.internal": "Responses (внутренний)", + "logs.protocol.badgeTitle": "Путь протокола: {path} ({mode})", + "logs.detail.protocol.section": "Путь протокола", + "logs.detail.protocol.inbound": "Клиентский API", + "logs.detail.protocol.mode": "Режим доставки", + "logs.detail.protocol.upstream": "Формат upstream", + "logs.detail.protocol.requestPath": "Путь запроса", + "logs.detail.protocol.responsePath": "Путь ответа", + "logs.detail.protocol.reasons": "Причины", + "logs.detail.protocol.features": "Влияние на функции", + "logs.detail.protocol.attempts": "Пути попыток", + "logs.detail.protocol.attempt": "Попытка {ordinal}", + "logs.detail.protocol.none": "Для этого запроса данные о пути не записаны.", "logs.detail.section.performance": "Производительность", "logs.detail.section.cost": "Эквивалент стоимости по прайс-листу API", "logs.detail.section.attempts": "Попытки комбо", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1747fff72a7..9d496db366b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -913,6 +913,35 @@ export const tr: Record = { "logs.detail.route.selected": "Seçilen", "logs.detail.route.candidates": "Adaylar", "logs.detail.route.unknown": "Kayıtlı yönlendirme izi yok.", + "logs.filter.protocol.label": "Protokol yolu", + "logs.filter.protocol.all": "Tüm yollar", + "logs.filter.protocol.none": "Yol verisi yok", + "logs.protocol.mode.native": "Yerel", + "logs.protocol.mode.translated": "Çevrilmiş", + "logs.protocol.mode.legacyBridge": "Eski köprü", + "logs.protocol.mode.blocked": "Engellendi", + "logs.protocol.disposition.passthrough": "Olduğu gibi iletildi", + "logs.protocol.disposition.translated": "Çevrildi", + "logs.protocol.disposition.degraded": "Kısıtlandı", + "logs.protocol.disposition.unsupported": "Düşürüldü", + "logs.protocol.wire.responses": "Responses", + "logs.protocol.wire.chat": "Chat", + "logs.protocol.wire.messages": "Messages", + "logs.protocol.wire.other": "Diğer bağdaştırıcı", + "logs.protocol.hop.ir": "IR", + "logs.protocol.hop.internal": "Responses (dahili)", + "logs.protocol.badgeTitle": "Protokol yolu: {path} ({mode})", + "logs.detail.protocol.section": "Protokol yolu", + "logs.detail.protocol.inbound": "İstemci API'si", + "logs.detail.protocol.mode": "Teslim modu", + "logs.detail.protocol.upstream": "Upstream biçimi", + "logs.detail.protocol.requestPath": "İstek yolu", + "logs.detail.protocol.responsePath": "Yanıt yolu", + "logs.detail.protocol.reasons": "Nedenler", + "logs.detail.protocol.features": "Özellik etkileri", + "logs.detail.protocol.attempts": "Deneme yolları", + "logs.detail.protocol.attempt": "Deneme {ordinal}", + "logs.detail.protocol.none": "Bu istek için yol verisi kaydedilmedi.", "logs.detail.section.performance": "Performans", "logs.detail.section.cost": "Tahmini maliyet", "logs.detail.section.attempts": "Kombo denemeleri", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 90111c0154f..2f2c8ba128f 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -906,6 +906,35 @@ export const vi: Record = { "logs.detail.route.selected": "Đã chọn", "logs.detail.route.candidates": "Ứng viên (Candidates)", "logs.detail.route.unknown": "Không có dấu vết định tuyến (route trace) nào được ghi lại cho request này (pre-trace row).", + "logs.filter.protocol.label": "Đường giao thức", + "logs.filter.protocol.all": "Tất cả đường", + "logs.filter.protocol.none": "Không có dữ liệu đường", + "logs.protocol.mode.native": "Gốc", + "logs.protocol.mode.translated": "Đã chuyển đổi", + "logs.protocol.mode.legacyBridge": "Cầu nối cũ", + "logs.protocol.mode.blocked": "Bị chặn", + "logs.protocol.disposition.passthrough": "Chuyển tiếp nguyên trạng", + "logs.protocol.disposition.translated": "Đã chuyển đổi", + "logs.protocol.disposition.degraded": "Bị suy giảm", + "logs.protocol.disposition.unsupported": "Bị loại bỏ", + "logs.protocol.wire.responses": "Responses", + "logs.protocol.wire.chat": "Chat", + "logs.protocol.wire.messages": "Messages", + "logs.protocol.wire.other": "Bộ chuyển đổi khác", + "logs.protocol.hop.ir": "IR", + "logs.protocol.hop.internal": "Responses (nội bộ)", + "logs.protocol.badgeTitle": "Đường giao thức: {path} ({mode})", + "logs.detail.protocol.section": "Đường giao thức", + "logs.detail.protocol.inbound": "API phía client", + "logs.detail.protocol.mode": "Chế độ phân phối", + "logs.detail.protocol.upstream": "Định dạng upstream", + "logs.detail.protocol.requestPath": "Đường request", + "logs.detail.protocol.responsePath": "Đường response", + "logs.detail.protocol.reasons": "Lý do", + "logs.detail.protocol.features": "Ảnh hưởng tới tính năng", + "logs.detail.protocol.attempts": "Đường của từng lần thử", + "logs.detail.protocol.attempt": "Lần thử {ordinal}", + "logs.detail.protocol.none": "Không có dữ liệu đường nào được ghi lại cho request này.", "logs.detail.section.performance": "Hiệu suất (Performance)", "logs.detail.section.cost": "Mức tương đương giá niêm yết API", "logs.detail.section.attempts": "Lượt thử kết hợp (Combo attempts)", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 56182f2aa19..ec111153d28 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2477,6 +2477,35 @@ export const zhTW: Record = { "logs.detail.route.selected": "已選取", "logs.detail.route.candidates": "候選", "logs.detail.route.unknown": "此請求沒有記錄路由追蹤(追蹤前的列)。", + "logs.filter.protocol.label": "協定路徑", + "logs.filter.protocol.all": "所有路徑", + "logs.filter.protocol.none": "無路徑資料", + "logs.protocol.mode.native": "原生", + "logs.protocol.mode.translated": "已轉換", + "logs.protocol.mode.legacyBridge": "舊版橋接", + "logs.protocol.mode.blocked": "已封鎖", + "logs.protocol.disposition.passthrough": "原樣傳遞", + "logs.protocol.disposition.translated": "已轉換", + "logs.protocol.disposition.degraded": "已降級", + "logs.protocol.disposition.unsupported": "已捨棄", + "logs.protocol.wire.responses": "Responses", + "logs.protocol.wire.chat": "Chat", + "logs.protocol.wire.messages": "Messages", + "logs.protocol.wire.other": "其他轉接器", + "logs.protocol.hop.ir": "IR", + "logs.protocol.hop.internal": "Responses(內部)", + "logs.protocol.badgeTitle": "協定路徑:{path}({mode})", + "logs.detail.protocol.section": "協定路徑", + "logs.detail.protocol.inbound": "用戶端 API", + "logs.detail.protocol.mode": "傳遞模式", + "logs.detail.protocol.upstream": "上游格式", + "logs.detail.protocol.requestPath": "請求路徑", + "logs.detail.protocol.responsePath": "回應路徑", + "logs.detail.protocol.reasons": "原因", + "logs.detail.protocol.features": "功能影響", + "logs.detail.protocol.attempts": "各次嘗試的路徑", + "logs.detail.protocol.attempt": "嘗試 {ordinal}", + "logs.detail.protocol.none": "此請求沒有記錄路徑資料。", "logs.detail.source.user": "供應商設定的價格覆蓋", "logs.detail.attempt.recovery.transient5xx": "暫時性 5xx", "logs.detail.attempt.recovery.connectionReset": "連線重設", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3883ef907b6..e631dd8e776 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -889,6 +889,35 @@ export const zh: Record = { "logs.detail.route.selected": "已选择", "logs.detail.route.candidates": "候选", "logs.detail.route.unknown": "此请求未记录路由跟踪(跟踪之前的行)。", + "logs.filter.protocol.label": "协议路径", + "logs.filter.protocol.all": "所有路径", + "logs.filter.protocol.none": "无路径数据", + "logs.protocol.mode.native": "原生", + "logs.protocol.mode.translated": "已转换", + "logs.protocol.mode.legacyBridge": "旧版桥接", + "logs.protocol.mode.blocked": "已阻止", + "logs.protocol.disposition.passthrough": "原样透传", + "logs.protocol.disposition.translated": "已转换", + "logs.protocol.disposition.degraded": "已降级", + "logs.protocol.disposition.unsupported": "已丢弃", + "logs.protocol.wire.responses": "Responses", + "logs.protocol.wire.chat": "Chat", + "logs.protocol.wire.messages": "Messages", + "logs.protocol.wire.other": "其他适配器", + "logs.protocol.hop.ir": "IR", + "logs.protocol.hop.internal": "Responses(内部)", + "logs.protocol.badgeTitle": "协议路径:{path}({mode})", + "logs.detail.protocol.section": "协议路径", + "logs.detail.protocol.inbound": "客户端 API", + "logs.detail.protocol.mode": "投递模式", + "logs.detail.protocol.upstream": "上游格式", + "logs.detail.protocol.requestPath": "请求路径", + "logs.detail.protocol.responsePath": "响应路径", + "logs.detail.protocol.reasons": "原因", + "logs.detail.protocol.features": "功能影响", + "logs.detail.protocol.attempts": "各次尝试的路径", + "logs.detail.protocol.attempt": "尝试 {ordinal}", + "logs.detail.protocol.none": "此请求未记录路径数据。", "logs.detail.section.performance": "性能", "logs.detail.section.cost": "API 标价折算", "logs.detail.section.attempts": "Combo 尝试", diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 7b96f3c7a31..7dfb4c07b17 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -210,6 +210,11 @@ const INTENTIONAL_ENGLISH = new Set([ // untranslated `~$`); the templates are pure placeholders on purpose. "logs.cost.approximate", "logs.cost.lowerBound", + // Protocol wire names on the Logs protocol path, and the IR acronym beside them. + "logs.protocol.wire.responses", + "logs.protocol.wire.chat", + "logs.protocol.wire.messages", + "logs.protocol.hop.ir", ]); function placeholders(value: string): string[] { diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index b71dccfc804..44508dd9dd0 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -49,6 +49,11 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "api.modelsEndpoint", "api.protocolChatCompletions", "api.protocolMessages", + // Short wire names on the Logs protocol path, and the IR acronym beside them. + "logs.protocol.wire.responses", + "logs.protocol.wire.chat", + "logs.protocol.wire.messages", + "logs.protocol.hop.ir", "api.protocolResponses", "api.responsesEndpoint", // Provider proper nouns (Taiwan keeps the English brand; "火山方舟" is Mainland usage) From 836fd8316efb3aa327bb13049e79c4bd20e19d73 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:24:53 +0900 Subject: [PATCH 023/173] feat(gui): add the protocol path badge and trace panel The badge gives a Logs row a compact client-to-upstream label with the mode spelled out, and the panel lists what the request actually did attempt by attempt. Both validate with parseProtocolTraceV1 and render nothing or "no path data" rather than guessing for old rows. --- .../components/protocols/ProtocolBadge.tsx | 25 +++++++ .../protocols/ProtocolTracePanel.tsx | 69 +++++++++++++++++++ .../components/protocols/protocol-labels.ts | 53 ++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 gui/src/components/protocols/ProtocolBadge.tsx create mode 100644 gui/src/components/protocols/ProtocolTracePanel.tsx create mode 100644 gui/src/components/protocols/protocol-labels.ts diff --git a/gui/src/components/protocols/ProtocolBadge.tsx b/gui/src/components/protocols/ProtocolBadge.tsx new file mode 100644 index 00000000000..40ce8e892c0 --- /dev/null +++ b/gui/src/components/protocols/ProtocolBadge.tsx @@ -0,0 +1,25 @@ +import { parseProtocolTraceV1 } from "../../../../src/protocols/dto"; +import type { TFn } from "../../i18n/shared"; +import { PROTOCOL_MODE_KEYS, protocolCompactLabel } from "./protocol-labels"; + +/** + * Compact protocol path for a Logs row, e.g. "Chat → Chat · Native". The mode is written out, + * never conveyed by colour alone. A row without a valid trace renders nothing: an old row is + * not guessed at. + */ +export function ProtocolBadge({ trace, t }: { trace: unknown; t: TFn }) { + const parsed = parseProtocolTraceV1(trace); + if (!parsed) return null; + const path = protocolCompactLabel(parsed, t); + const mode = t(PROTOCOL_MODE_KEYS[parsed.mode]); + return ( + + {path} · {mode} + + ); +} diff --git a/gui/src/components/protocols/ProtocolTracePanel.tsx b/gui/src/components/protocols/ProtocolTracePanel.tsx new file mode 100644 index 00000000000..9975105737a --- /dev/null +++ b/gui/src/components/protocols/ProtocolTracePanel.tsx @@ -0,0 +1,69 @@ +import { parseProtocolTraceV1 } from "../../../../src/protocols/dto"; +import type { TFn } from "../../i18n/shared"; +import { FEATURE_DISPOSITION_KEYS, PROTOCOL_MODE_KEYS, protocolHopLabel, protocolPathLabel } from "./protocol-labels"; + +/** + * The protocol section of the Logs detail dialog: what this request actually did, attempt by + * attempt. Reason codes and feature ids are fixed machine identifiers and render as code; the + * internal Responses hop is labelled as internal. A row without a valid trace says so instead + * of inferring a path from other fields. + */ +export function ProtocolTracePanel({ trace, t }: { trace: unknown; t: TFn }) { + const parsed = parseProtocolTraceV1(trace); + return ( +
+

{t("logs.detail.protocol.section")}

+ {parsed ? ( +
+ {t("logs.detail.protocol.inbound")} + {protocolHopLabel(parsed.inbound, t)} + {t("logs.detail.protocol.mode")} + {t(PROTOCOL_MODE_KEYS[parsed.mode])} + {parsed.upstream && ( + <> + {t("logs.detail.protocol.upstream")} + {protocolHopLabel(parsed.upstream, t)} + + )} + {parsed.requestPath.length > 0 && ( + <> + {t("logs.detail.protocol.requestPath")} + {protocolPathLabel(parsed.requestPath, t)} + {t("logs.detail.protocol.responsePath")} + {protocolPathLabel(parsed.responsePath, t)} + + )} + {t("logs.detail.protocol.reasons")} + {parsed.reasonCodes.join(", ") || "–"} + {parsed.featureEffects && parsed.featureEffects.length > 0 && ( + <> + {t("logs.detail.protocol.features")} + + {parsed.featureEffects.map(effect => ( + + {effect.feature}: {t(FEATURE_DISPOSITION_KEYS[effect.disposition])} + + ))} + + + )} + {parsed.attempts && parsed.attempts.length > 0 && ( + <> + {t("logs.detail.protocol.attempts")} + + {parsed.attempts.map(attempt => ( + + {t("logs.detail.protocol.attempt", { ordinal: attempt.ordinal })}:{" "} + {protocolPathLabel(attempt.requestPath, t)} · {t(PROTOCOL_MODE_KEYS[attempt.mode])} + + ))} + + + )} +
+ ) : ( +

{t("logs.detail.protocol.none")}

+ )} +
+ ); +} diff --git a/gui/src/components/protocols/protocol-labels.ts b/gui/src/components/protocols/protocol-labels.ts new file mode 100644 index 00000000000..4c73f03e561 --- /dev/null +++ b/gui/src/components/protocols/protocol-labels.ts @@ -0,0 +1,53 @@ +import type { DeliveryMode, ProtocolHop } from "../../../../src/protocols/contract"; +import type { ProtocolTraceV1 } from "../../../../src/protocols/dto"; +import type { FeatureDisposition } from "../../../../src/protocols/features"; +import type { TFn, TKey } from "../../i18n/shared"; + +/** + * Localized labels for the closed protocol vocabulary. The records are exhaustive over the + * contract's unions, so a new mode, hop or disposition fails the dashboard typecheck instead of + * rendering a raw identifier. + */ +export const PROTOCOL_MODE_KEYS: Record = { + native: "logs.protocol.mode.native", + translated: "logs.protocol.mode.translated", + "legacy-bridge": "logs.protocol.mode.legacyBridge", + blocked: "logs.protocol.mode.blocked", +}; + +export const FEATURE_DISPOSITION_KEYS: Record = { + passthrough: "logs.protocol.disposition.passthrough", + translated: "logs.protocol.disposition.translated", + degraded: "logs.protocol.disposition.degraded", + unsupported: "logs.protocol.disposition.unsupported", +}; + +const HOP_KEYS: Record = { + responses: "logs.protocol.wire.responses", + chat: "logs.protocol.wire.chat", + messages: "logs.protocol.wire.messages", + other: "logs.protocol.wire.other", + ir: "logs.protocol.hop.ir", + "responses-internal": "logs.protocol.hop.internal", +}; + +const ARROW = " → "; + +export function protocolHopLabel(hop: ProtocolHop, t: TFn): string { + return t(HOP_KEYS[hop]); +} + +export function protocolPathLabel(path: readonly ProtocolHop[], t: TFn): string { + return path.map(hop => protocolHopLabel(hop, t)).join(ARROW); +} + +/** + * The compact list label: the client wire and the upstream wire, without the intermediate + * hops the detail panel spells out. A refusal has no upstream, so it shows the client wire only. + */ +export function protocolCompactLabel(trace: ProtocolTraceV1, t: TFn): string { + const inbound = protocolHopLabel(trace.inbound, t); + if (trace.mode === "blocked") return inbound; + const upstream = trace.upstream ?? trace.requestPath[trace.requestPath.length - 1]; + return upstream ? `${inbound}${ARROW}${protocolHopLabel(upstream, t)}` : inbound; +} From c874390857ff3cfc5c1451b22e734c934effa364 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:24:53 +0900 Subject: [PATCH 024/173] feat(gui): filter Logs by protocol delivery mode Adds a protocol path select beside the status filter that selects rows by their final delivery mode, or rows with no path data. The field is optional in the filter state so earlier state shapes keep working. --- gui/src/pages/logs-filter-bar.tsx | 18 ++++++++++++++++-- gui/src/pages/logs-filter.ts | 20 +++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/gui/src/pages/logs-filter-bar.tsx b/gui/src/pages/logs-filter-bar.tsx index ae478bf61b4..df8a67d027d 100644 --- a/gui/src/pages/logs-filter-bar.tsx +++ b/gui/src/pages/logs-filter-bar.tsx @@ -1,8 +1,9 @@ import { useRef } from "react"; -import type { TFn } from "../i18n/shared"; +import type { TFn, TKey } from "../i18n/shared"; import { IconX } from "../icons"; import { formatProviderDisplayName } from "../provider-icons"; -import type { LogFilterState, LogStatusFilter, LogTimeWindow } from "./logs-filter"; +import { PROTOCOL_MODE_KEYS } from "../components/protocols/protocol-labels"; +import { LOG_PROTOCOL_MODE_FILTERS, type LogFilterState, type LogProtocolModeFilter, type LogStatusFilter, type LogTimeWindow } from "./logs-filter"; import { logsSurfaceKeyDown } from "./logs-surface-keydown"; interface LogsFilterBarProps { @@ -114,6 +115,13 @@ export function LogsFilterBar({ + +
@@ -132,3 +140,9 @@ export function LogsFilterBar({
); } + +function protocolModeFilterKey(mode: LogProtocolModeFilter): TKey { + if (mode === "all") return "logs.filter.protocol.all"; + if (mode === "none") return "logs.filter.protocol.none"; + return PROTOCOL_MODE_KEYS[mode]; +} diff --git a/gui/src/pages/logs-filter.ts b/gui/src/pages/logs-filter.ts index 3dffa145eba..c129d3acdde 100644 --- a/gui/src/pages/logs-filter.ts +++ b/gui/src/pages/logs-filter.ts @@ -1,9 +1,16 @@ import { matchesLogConversationId } from "../log-conversation-id"; +import type { DeliveryMode } from "../../../src/protocols/contract"; +import { parseProtocolTraceV1 } from "../../../src/protocols/dto"; import type { LogSurface, LogSurfaceFilter } from "./logs-surface-filter"; import { logMatchesSurface } from "./logs-surface-filter"; export type LogTimeWindow = "all" | "15m" | "1h" | "24h"; export type LogStatusFilter = "all" | "success" | "errors"; +/** Final protocol delivery mode, or `none` for rows without an observed path (PF-02). */ +export type LogProtocolModeFilter = "all" | DeliveryMode | "none"; +export const LOG_PROTOCOL_MODE_FILTERS: readonly LogProtocolModeFilter[] = [ + "all", "native", "translated", "legacy-bridge", "blocked", "none", +]; export interface LogFilterState { surface: LogSurfaceFilter; @@ -16,6 +23,8 @@ export interface LogFilterState { interceptedOnly: boolean; conversationId: string; conversationQueryHash?: string; + /** Absent means "all", so filter states saved before this field existed stay valid. */ + protocolMode?: LogProtocolModeFilter; } export const DEFAULT_LOG_FILTER_STATE: LogFilterState = { @@ -44,6 +53,7 @@ export interface FilterableLogEntry { conversationId?: string; shadowCallRewrittenFrom?: unknown; attempts?: unknown; + protocolTrace?: unknown; displayMetrics?: { tokPerSecond?: { kind: "value"; value: number } | { kind: "unavailable" }; }; @@ -59,7 +69,8 @@ export function hasActiveLogFilters(filters: LogFilterState): boolean { || filters.minTokPerSec !== undefined || filters.maxTokPerSec !== undefined || filters.interceptedOnly - || filters.conversationId.trim() !== ""; + || filters.conversationId.trim() !== "" + || (filters.protocolMode ?? "all") !== "all"; } /** Safely retain only object-shaped failover attempts from untrusted log data. */ @@ -95,10 +106,17 @@ export function filterLogs( const providerQuery = filters.provider.trim().toLowerCase(); const conversationQuery = filters.conversationId.trim(); const since = timeThreshold(filters.timeWindow, now); + const protocolMode = filters.protocolMode ?? "all"; return logs.filter(log => { if (!logMatchesSurface(log, filters.surface)) return false; if (filters.interceptedOnly && typeof log.shadowCallRewrittenFrom !== "string") return false; + if (protocolMode !== "all") { + // Validated like the detail panel, so a row the panel reports as "no path data" is the + // row the `none` filter selects. + const traceMode = parseProtocolTraceV1(log.protocolTrace)?.mode; + if (protocolMode === "none" ? traceMode !== undefined : traceMode !== protocolMode) return false; + } if (conversationQuery && !matchesLogConversationId( log.conversationId, conversationQuery, From 97b1015438b1fcfb864ba5b4fe50b1c075e7626b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:24:53 +0900 Subject: [PATCH 025/173] feat(gui): show the protocol path on Logs rows and in the detail dialog Wires the badge into the model cell beside the existing row badges and adds the protocol section after the route decision in the detail dialog. --- gui/src/pages/Logs.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index d0bee23273b..85ae5570463 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -13,6 +13,8 @@ import { DataSurfaceSkeleton } from "../components/data-surface"; import { EmptyState, Notice } from "../ui"; import Debug from "./Debug"; import { LogsFilterBar } from "./logs-filter-bar"; +import { ProtocolBadge } from "../components/protocols/ProtocolBadge"; +import { ProtocolTracePanel } from "../components/protocols/ProtocolTracePanel"; import { logsClockAnchor, logsClockNow, type LogsClockAnchor } from "./logs-clock"; import { DEFAULT_LOG_FILTER_STATE, extractLogFilterOptions, filterLogs, hasActiveLogFilters, type LogFilterState } from "./logs-filter"; @@ -208,6 +210,8 @@ export interface LogEntry extends LogFailureAttribution { selected?: { provider?: string; model?: string; reason?: string }; candidates?: Array<{ provider?: string; model?: string; eligible?: boolean; exclusions?: Array<{ code?: string }> }>; }; + /** Observed protocol path (PF-02). Untrusted JSON; rendered only after `parseProtocolTraceV1`. */ + protocolTrace?: unknown; } function validCachedLogs(cached: LogEntry[] | null): LogEntry[] | null { @@ -979,6 +983,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { )} {log.surface === "grok" && {t("logs.badge.grok")}} {speedLabel(log) && {speedLabel(log)}} + {/* The wire field (reasoning_effort=high) stays in the title and the detail @@ -1200,6 +1205,8 @@ function LogDetailDialog({ )} + +

{t("logs.detail.section.performance")}

From 58d5f5c1b0866c5efe8c6dcbf9315145a3f603af Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:25:45 +0900 Subject: [PATCH 026/173] test(gui): cover the protocol badge, trace panel and mode filter Pins the text-not-colour mode label, the internal hop label, the no-path-data fallback for old and invalid rows, and that the none filter selects exactly the rows the panel reports as having no path data. --- gui/tests/logs-protocol-trace.test.tsx | 87 ++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 gui/tests/logs-protocol-trace.test.tsx diff --git a/gui/tests/logs-protocol-trace.test.tsx b/gui/tests/logs-protocol-trace.test.tsx new file mode 100644 index 00000000000..9b942b63c56 --- /dev/null +++ b/gui/tests/logs-protocol-trace.test.tsx @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ProtocolTraceV1 } from "../../src/protocols/dto"; +import { ProtocolBadge } from "../src/components/protocols/ProtocolBadge"; +import { ProtocolTracePanel } from "../src/components/protocols/ProtocolTracePanel"; +import { DICTS, interpolate, type TFn } from "../src/i18n/shared"; +import { DEFAULT_LOG_FILTER_STATE, filterLogs, hasActiveLogFilters } from "../src/pages/logs-filter"; + +const t: TFn = (key, vars) => interpolate(DICTS.en[key], vars); + +const bridge: ProtocolTraceV1 = { + v: 1, + inbound: "chat", + mode: "legacy-bridge", + upstream: "messages", + requestPath: ["chat", "responses-internal", "ir", "messages"], + responsePath: ["messages", "ir", "responses-internal", "chat"], + reasonCodes: ["cross-wire-ir", "not-migrated"], + featureEffects: [{ feature: "request.seed", disposition: "unsupported" }], + attempts: [{ ordinal: 1, upstream: "messages", mode: "legacy-bridge", requestPath: ["chat", "responses-internal", "ir", "messages"] }], + contractVersion: "2026-09-24.1", +}; +const native: ProtocolTraceV1 = { + ...bridge, + mode: "native", + upstream: "chat", + requestPath: ["chat", "chat"], + responsePath: ["chat", "chat"], + reasonCodes: ["same-wire-native"], + featureEffects: undefined, + attempts: undefined, +}; + +describe("ProtocolBadge", () => { + test("writes the path and the mode as text", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("Chat → Chat · Native"); + expect(html).toContain('data-protocol-mode="native"'); + }); + + test("renders nothing for a row without a valid trace", () => { + expect(renderToStaticMarkup()).toBe(""); + expect(renderToStaticMarkup()).toBe(""); + }); +}); + +describe("ProtocolTracePanel", () => { + test("labels the internal Responses hop and lists reasons, features and attempts", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("Chat → Responses (internal) → IR → Messages"); + expect(html).toContain("Legacy bridge"); + expect(html).toContain("cross-wire-ir, not-migrated"); + expect(html).toContain("request.seed: Dropped"); + expect(html).toContain("Attempt 1"); + }); + + test("says there is no path data instead of guessing", () => { + const html = renderToStaticMarkup(); + expect(html).toContain(DICTS.en["logs.detail.protocol.none"]); + }); +}); + +describe("protocol mode filter", () => { + const logs = [ + { id: "bridge", protocolTrace: bridge }, + { id: "native", protocolTrace: native }, + { id: "old" }, + { id: "corrupt", protocolTrace: { mode: "native" } }, + ]; + const ids = (protocolMode: typeof DEFAULT_LOG_FILTER_STATE.protocolMode) => + filterLogs(logs, { ...DEFAULT_LOG_FILTER_STATE, protocolMode }).map(log => log.id); + + test("selects by final mode, and none selects rows the panel reports as no path data", () => { + expect(ids(undefined)).toEqual(["bridge", "native", "old", "corrupt"]); + expect(ids("all")).toEqual(["bridge", "native", "old", "corrupt"]); + expect(ids("native")).toEqual(["native"]); + expect(ids("legacy-bridge")).toEqual(["bridge"]); + expect(ids("blocked")).toEqual([]); + expect(ids("none")).toEqual(["old", "corrupt"]); + }); + + test("counts as an active filter only when narrowed", () => { + expect(hasActiveLogFilters(DEFAULT_LOG_FILTER_STATE)).toBe(false); + expect(hasActiveLogFilters({ ...DEFAULT_LOG_FILTER_STATE, protocolMode: "all" })).toBe(false); + expect(hasActiveLogFilters({ ...DEFAULT_LOG_FILTER_STATE, protocolMode: "none" })).toBe(true); + }); +}); From 0fb43a2a967aa171a802d9866e9f511bb200d49b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:26:09 +0900 Subject: [PATCH 027/173] docs(structure): document the observed protocol trace Protocol Paths now owns how ingress marks become a persisted trace and the protocolMode filter; the dashboard doc points there and names the new request-log-filter owner. --- structure/dashboard-and-usage.md | 5 +++++ structure/data-planes/protocol-paths.md | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 5a6024c90fe..1df21acbe49 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -508,6 +508,11 @@ An absent upstream model stays absent; the tooltip retains all available model identities. Historical Codex `openai`, `chatgpt` and `openai-multi` main labels collapse for reporting; configured provider names ending in `-main` remain separate. +Rows also carry the observed protocol path (`protocolTrace`), persisted in `usage.jsonl` and +re-validated on read; the Logs list shows it as a text badge, the detail dialog as a section, and +`src/server/request-log-filter.ts` owns the `/api/logs` query filters including `protocolMode`. +[Protocol Paths](data-planes/protocol-paths.md) owns its derivation. + Request-history selectors longer than 130 characters persist as a prefix plus a digest of the complete selector; exact-match filtering uses the same idempotent encoding. The derived index rebuilds when its projection version changes and encodes older raw-selector rows from canonical JSONL so exact filters diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index a76bf182802..e1121b0e40a 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -70,6 +70,29 @@ features guaranteed by every eligible candidate, features only some preserve) an per physical attempt). Both carry only the closed vocabulary and identifiers the server already exposes, within fixed limits, and both validators reject anything that is not exactly version 1. +## Observed trace + +`src/protocols/trace.ts` is server side and is not a leaf. The Chat Completions ingress +(`src/server/chat-completions.ts`) marks the lane it chose, the reason code that declined the +native lane, and the request's features; the Messages ingress (`src/server/claude-messages.ts`) +marks caller-forward passthrough as the native lane, the translated path as the bridge, and a +disabled surface or a compatibility reject as blocked. The Responses ingress needs no mark: its +path follows the final adapter's wire. Marks live in WeakMaps keyed by the request log context +and the live attempt objects, and no mark function throws into the request. + +`addFinalRequestLog` derives one `ProtocolTraceV1` through `path.ts`: a blocked mark wins with +empty paths; otherwise each attempt gets the lane-derived path (or an explicit attempt mark), +the final attempt sets the row's mode and paths, a reason implied by the path is appended to the +entry's reasons, and feature effects come from `featureEffectsForPath`. No attempt and no native +or blocked mark yields no trace; nothing is guessed. The usage row persists the trace and every +read re-validates it with `parseProtocolTraceV1`, so an older or corrupt row hydrates without +one. `/api/logs` spreads the entry and accepts `protocolMode` +(`native | translated | legacy-bridge | blocked | none`) in `src/server/request-log-filter.ts`; +an unknown value matches nothing. The dashboard renders it with +`gui/src/components/protocols/` (a row badge and a detail-dialog section) and filters by mode +client-side in `gui/src/pages/logs-filter.ts`. `tests/responses/protocol-trace.test.ts` and +`tests/usage/request-log-protocol-trace.test.ts` pin derivation, persistence and the filter. + ## Settings `resolveApiSurfaceSettings` and `resolveProtocolSettings` in `src/protocols/settings.ts` are the From c4677d2b611410c7d3d3c64892c1442e2f9497b2 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:17:49 +0900 Subject: [PATCH 028/173] feat(protocols): add the pure protocol planner planProtocol turns a settled-route snapshot into a ProtocolPlanV1: each candidate's path from path.ts, its feature effects, and whether reject-unrepresentable would refuse it, plus the features every eligible candidate keeps versus only some. It reads no config so the dashboard and the server can share it as a leaf. --- scripts/test-layout/layout.json | 1 + src/protocols/plan.ts | 188 ++++++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/responses/protocol-contract.test.ts | 4 +- tests/responses/protocol-plan.test.ts | 146 +++++++++++++++++ 5 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 src/protocols/plan.ts create mode 100644 tests/responses/protocol-plan.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5cba486adbe..07de9d83cb5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -335,6 +335,7 @@ "protocol-dto.test.ts": "responses", "protocol-path.test.ts": "responses", "protocol-trace.test.ts": "responses", + "protocol-plan.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/plan.ts b/src/protocols/plan.ts new file mode 100644 index 00000000000..931abab96a9 --- /dev/null +++ b/src/protocols/plan.ts @@ -0,0 +1,188 @@ +/** + * The protocol planner: given a snapshot of what the router settled, predict the path each + * candidate would take, what it does to the request's features, and whether it would be + * refused under the unrepresentable policy. + * + * LEAF MODULE (see `contract.ts`) and PURE. The planner never selects a provider and never + * reads config; `plan-snapshot.ts` builds its input on the server. Paths come from + * `path.ts`, the same rule the observed trace uses, so a preview and the log of the same + * request cannot disagree about how a lane maps to hops. Settings and surfaces are taken + * structurally rather than imported from `settings.ts`, which reads config types and is + * therefore not a leaf. + */ +import { PROTOCOL_CONTRACT_VERSION, upstreamWireForAdapter, type Protocol, type ProtocolReasonCode } from "./contract"; +import { + PROTOCOL_DTO_LIMITS, + PROTOCOL_PLAN_SCHEMA_VERSION, + type ProtocolPlanCandidateV1, + type ProtocolPlanV1, +} from "./dto"; +import { featureEffectsForPath, PROTOCOL_FEATURES, unrepresentableFeatures, type ProtocolFeature } from "./features"; +import { deliveryModeForLane, requestPathForLane, responsePathForLane, type ProtocolLane } from "./path"; + +/** One settled route target, as the server resolved it. */ +export interface ProtocolPlanCandidateInput { + provider: string; + model: string; + /** Final adapter id after the wire override for this inbound. */ + adapter: string; + /** True when the ingress would send its own wire to this candidate. */ + nativeEligible: boolean; + /** Why the ingress declined its native lane, when it did. */ + declineReasons: readonly ProtocolReasonCode[]; +} + +export interface ProtocolPlanInput { + inbound: Protocol; + requestedModel: string; + routeKind: ProtocolPlanV1["routeKind"]; + candidates: readonly ProtocolPlanCandidateInput[]; + features: readonly ProtocolFeature[]; + surfaces: Readonly>; + settings: { readonly unrepresentable: "legacy" | "reject" }; + policyRevision: string; + basis: ProtocolPlanV1["basis"]; + /** Plan-level reasons the snapshot observed, such as `caller-credential-required`. */ + reasonCodes?: readonly ProtocolReasonCode[]; +} + +function bounded(codes: Iterable): ProtocolReasonCode[] { + return [...new Set(codes)].slice(0, PROTOCOL_DTO_LIMITS.reasonCodes); +} + +function orderedFeatures(features: Iterable): ProtocolFeature[] { + const present = new Set(features); + return PROTOCOL_FEATURES.filter(feature => present.has(feature)); +} + +function blockedCandidate(candidate: ProtocolPlanCandidateInput, reason: ProtocolReasonCode): ProtocolPlanCandidateV1 { + return { + provider: candidate.provider, + model: candidate.model, + adapter: candidate.adapter, + upstream: upstreamWireForAdapter(candidate.adapter), + mode: "blocked", + requestPath: [], + responsePath: [], + fidelity: "unknown", + reasonCodes: [reason], + featureEffects: [], + unknownFeatures: [], + eligible: false, + }; +} + +function planCandidate( + input: ProtocolPlanInput, + candidate: ProtocolPlanCandidateInput, + features: readonly ProtocolFeature[], +): ProtocolPlanCandidateV1 { + const upstream = upstreamWireForAdapter(candidate.adapter); + // A native lane only exists toward the ingress's own wire; an inconsistent snapshot must + // not produce a path that claims the source body reached a different wire untouched. + const lane: ProtocolLane = candidate.nativeEligible && upstream === input.inbound ? "native" : "bridge"; + const requestPath = requestPathForLane(input.inbound, lane, upstream); + const responsePath = responsePathForLane(input.inbound, lane, upstream); + const mode = deliveryModeForLane(input.inbound, lane, upstream); + const effects = featureEffectsForPath(input.inbound, requestPath, features); + const eligible = input.settings.unrepresentable !== "reject" || unrepresentableFeatures(effects.effects).length === 0; + + const reasons: ProtocolReasonCode[] = []; + if (mode === "native") reasons.push("same-wire-native"); + else if (mode === "translated") reasons.push(requestPath.length === 2 ? "cross-wire-codec" : "cross-wire-ir"); + else reasons.push("not-migrated"); + if (upstream === "other") reasons.push("upstream-other"); + if (lane === "bridge" && input.inbound !== "responses") reasons.push(...candidate.declineReasons); + if (!eligible) reasons.push("feature-unrepresentable"); + + return { + provider: candidate.provider, + model: candidate.model, + adapter: candidate.adapter, + upstream, + mode, + requestPath, + responsePath, + fidelity: effects.fidelity, + reasonCodes: bounded(reasons), + featureEffects: effects.effects.map(effect => ({ feature: effect.feature, disposition: effect.disposition })), + unknownFeatures: effects.unknown, + eligible, + }; +} + +/** Features preserved (passthrough or translated) by every / by only some eligible candidates. */ +function featureSplit( + eligible: readonly ProtocolPlanCandidateV1[], + features: readonly ProtocolFeature[], +): { guaranteed: ProtocolFeature[]; partial: ProtocolFeature[] } { + const guaranteed: ProtocolFeature[] = []; + const partial: ProtocolFeature[] = []; + if (eligible.length === 0) return { guaranteed, partial }; + for (const feature of features) { + let preserved = 0; + for (const candidate of eligible) { + const effect = candidate.featureEffects.find(entry => entry.feature === feature); + if (effect && (effect.disposition === "passthrough" || effect.disposition === "translated")) preserved++; + } + if (preserved === eligible.length) guaranteed.push(feature); + else if (preserved > 0) partial.push(feature); + } + return { guaranteed, partial }; +} + +export function planProtocol(input: ProtocolPlanInput): ProtocolPlanV1 { + const features = orderedFeatures(input.features); + const snapshotCandidates = input.candidates.slice(0, PROTOCOL_DTO_LIMITS.candidates); + const base = { + schemaVersion: PROTOCOL_PLAN_SCHEMA_VERSION, + basis: input.basis, + contractVersion: PROTOCOL_CONTRACT_VERSION, + policyRevision: input.policyRevision, + inbound: input.inbound, + requestedModel: input.requestedModel, + routeKind: input.routeKind, + } as const; + + if (!input.surfaces[input.inbound].enabled) { + return { + ...base, + mode: "blocked", + reasonCodes: ["surface-disabled"], + candidates: snapshotCandidates.map(candidate => blockedCandidate(candidate, "surface-disabled")), + guaranteedFeatures: [], + partialFeatures: [], + }; + } + + if (input.routeKind === "unknown" || snapshotCandidates.length === 0) { + return { + ...base, + mode: "blocked", + reasonCodes: bounded([...(input.reasonCodes ?? []), "unknown-model"]), + candidates: [], + guaranteedFeatures: [], + partialFeatures: [], + }; + } + + const candidates = snapshotCandidates.map(candidate => planCandidate(input, candidate, features)); + const eligible = candidates.filter(candidate => candidate.eligible); + const first = eligible[0]; + const { guaranteed, partial } = featureSplit(eligible, features); + const routeReasons: ProtocolReasonCode[] = input.routeKind === "combo" || input.routeKind === "policy" + ? ["combo-or-policy-route"] + : []; + return { + ...base, + mode: first ? first.mode : "blocked", + reasonCodes: bounded([ + ...(input.reasonCodes ?? []), + ...routeReasons, + ...(first ? first.reasonCodes : ["feature-unrepresentable" as const]), + ]), + candidates, + guaranteedFeatures: guaranteed, + partialFeatures: partial, + }; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ed747c91ea0..2eec9b04dfa 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -161,6 +161,7 @@ "protocol-dto.test.ts": "responses", "protocol-path.test.ts": "responses", "protocol-trace.test.ts": "responses", + "protocol-plan.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 841a277b32b..bfbefea9968 100644 --- a/tests/responses/protocol-contract.test.ts +++ b/tests/responses/protocol-contract.test.ts @@ -73,8 +73,8 @@ describe("path semantics", () => { }); describe("leaf-module boundary", () => { - const LEAVES = ["contract.ts", "features.ts", "baseline.ts", "dto.ts", "path.ts"]; - const ALLOWED = new Set(["./contract", "./features", "../compatibility/manifest"]); + const LEAVES = ["contract.ts", "features.ts", "baseline.ts", "dto.ts", "path.ts", "plan.ts"]; + const ALLOWED = new Set(["./contract", "./features", "./dto", "./path", "../compatibility/manifest"]); for (const file of LEAVES) { test(`src/protocols/${file} imports only protocol leaves`, () => { diff --git a/tests/responses/protocol-plan.test.ts b/tests/responses/protocol-plan.test.ts new file mode 100644 index 00000000000..6e21d9c6200 --- /dev/null +++ b/tests/responses/protocol-plan.test.ts @@ -0,0 +1,146 @@ +/** + * The pure protocol planner (src/protocols/plan.ts): paths come from the lane rule, feature + * effects from the declared dispositions, and eligibility from the unrepresentable policy. + */ +import { describe, expect, test } from "bun:test"; +import { isProtocolPlanV1 } from "../../src/protocols/dto"; +import { planProtocol, type ProtocolPlanCandidateInput, type ProtocolPlanInput } from "../../src/protocols/plan"; + +const OPEN = { responses: { enabled: true }, chat: { enabled: true }, messages: { enabled: true } } as const; + +function candidate(adapter: string, overrides: Partial = {}): ProtocolPlanCandidateInput { + return { provider: `p-${adapter}`, model: "m", adapter, nativeEligible: false, declineReasons: [], ...overrides }; +} + +function input(overrides: Partial = {}): ProtocolPlanInput { + return { + inbound: "chat", + requestedModel: "some-model", + routeKind: "direct", + candidates: [candidate("openai-chat", { nativeEligible: true })], + features: [], + surfaces: OPEN, + settings: { unrepresentable: "legacy" }, + policyRevision: "p1-00000000", + basis: "preview", + ...overrides, + }; +} + +describe("planProtocol", () => { + test("an eligible native Chat route is native and preserves every Chat feature", () => { + const plan = planProtocol(input({ features: ["request.multiple_choices", "request.tools"] })); + expect(isProtocolPlanV1(plan)).toBe(true); + expect(plan.mode).toBe("native"); + expect(plan.candidates[0]).toMatchObject({ + upstream: "chat", + requestPath: ["chat", "chat"], + responsePath: ["chat", "chat"], + fidelity: "preserved", + eligible: true, + reasonCodes: ["same-wire-native"], + }); + expect(plan.guaranteedFeatures).toEqual(["request.tools", "request.multiple_choices"]); + expect(plan.partialFeatures).toEqual([]); + }); + + test("a declined Chat lane bridges and carries the decline reason", () => { + const plan = planProtocol(input({ + candidates: [candidate("openai-chat", { declineReasons: ["vision-preprocessing"] })], + })); + expect(plan.mode).toBe("legacy-bridge"); + expect(plan.candidates[0]!.requestPath).toEqual(["chat", "responses-internal", "ir", "chat"]); + expect(plan.candidates[0]!.reasonCodes).toEqual(["not-migrated", "vision-preprocessing"]); + }); + + test("n=2 on Chat to a Responses upstream is refused under reject and kept under legacy", () => { + const features = ["request.multiple_choices"] as const; + const legacy = planProtocol(input({ candidates: [candidate("openai-responses")], features: [...features] })); + expect(legacy.mode).toBe("translated"); + expect(legacy.candidates[0]!.requestPath).toEqual(["chat", "responses"]); + expect(legacy.candidates[0]!.reasonCodes).toEqual(["cross-wire-codec"]); + expect(legacy.candidates[0]!.eligible).toBe(true); + expect(legacy.candidates[0]!.fidelity).toBe("degraded"); + expect(legacy.guaranteedFeatures).toEqual([]); + + const reject = planProtocol(input({ + candidates: [candidate("openai-responses")], + features: [...features], + settings: { unrepresentable: "reject" }, + })); + expect(isProtocolPlanV1(reject)).toBe(true); + expect(reject.mode).toBe("blocked"); + expect(reject.candidates[0]!.eligible).toBe(false); + expect(reject.candidates[0]!.reasonCodes).toContain("feature-unrepresentable"); + expect(reject.reasonCodes).toContain("feature-unrepresentable"); + expect(reject.candidates[0]!.featureEffects).toEqual([{ feature: "request.multiple_choices", disposition: "unsupported" }]); + }); + + test("a disabled surface blocks every candidate with surface-disabled", () => { + const plan = planProtocol(input({ + inbound: "messages", + candidates: [candidate("anthropic")], + surfaces: { ...OPEN, messages: { enabled: false } }, + })); + expect(isProtocolPlanV1(plan)).toBe(true); + expect(plan.mode).toBe("blocked"); + expect(plan.reasonCodes).toEqual(["surface-disabled"]); + expect(plan.candidates[0]).toMatchObject({ mode: "blocked", eligible: false, requestPath: [], reasonCodes: ["surface-disabled"] }); + }); + + test("an unknown model yields no candidates and unknown-model", () => { + const plan = planProtocol(input({ routeKind: "unknown", candidates: [] })); + expect(isProtocolPlanV1(plan)).toBe(true); + expect(plan).toMatchObject({ routeKind: "unknown", mode: "blocked", candidates: [], reasonCodes: ["unknown-model"] }); + }); + + test("a combo splits guaranteed from partial features across eligible candidates", () => { + const plan = planProtocol(input({ + inbound: "responses", + routeKind: "combo", + candidates: [candidate("openai-responses"), candidate("openai-chat")], + features: ["request.tools", "request.background", "request.store"], + })); + expect(isProtocolPlanV1(plan)).toBe(true); + expect(plan.candidates.map(c => c.mode)).toEqual(["native", "translated"]); + expect(plan.candidates[1]!.requestPath).toEqual(["responses", "ir", "chat"]); + expect(plan.guaranteedFeatures).toEqual(["request.tools"]); + // Responses keeps both; Chat drops background and degrades store. + expect(plan.partialFeatures).toEqual(["request.store", "request.background"]); + expect(plan.reasonCodes[0]).toBe("combo-or-policy-route"); + }); + + test("under reject only eligible candidates count toward the guarantee", () => { + const plan = planProtocol(input({ + inbound: "responses", + routeKind: "combo", + candidates: [candidate("openai-chat"), candidate("openai-responses")], + features: ["request.tools", "request.background"], + settings: { unrepresentable: "reject" }, + })); + expect(plan.candidates.map(c => c.eligible)).toEqual([false, true]); + expect(plan.mode).toBe("native"); + expect(plan.guaranteedFeatures).toEqual(["request.tools", "request.background"]); + expect(plan.partialFeatures).toEqual([]); + }); + + test("an adapter outside the three protocols is upstream-other with unknown fidelity", () => { + const plan = planProtocol(input({ inbound: "responses", candidates: [candidate("gemini")], features: ["request.tools"] })); + expect(plan.candidates[0]).toMatchObject({ upstream: "other", mode: "translated", fidelity: "unknown", unknownFeatures: ["request.tools"] }); + expect(plan.candidates[0]!.reasonCodes).toEqual(["cross-wire-ir", "upstream-other"]); + }); + + test("a native flag toward a different wire never yields a native path", () => { + const plan = planProtocol(input({ candidates: [candidate("anthropic", { nativeEligible: true })] })); + expect(plan.candidates[0]!.mode).toBe("legacy-bridge"); + }); + + test("snapshot reason codes lead the plan reasons", () => { + const plan = planProtocol(input({ + inbound: "messages", + candidates: [candidate("anthropic")], + reasonCodes: ["caller-credential-required"], + })); + expect(plan.reasonCodes).toEqual(["caller-credential-required", "not-migrated"]); + }); +}); From 23d9bef65fc4cbc1357e1f8cbd443d390b56a46b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:20:38 +0900 Subject: [PATCH 029/173] feat(protocols): build plan snapshots from config without side effects The preview needs the route a request would settle on, but routeModel picks combo targets and runs the policy evaluator. Combos and policies are expanded from their configured targets through routeConcreteModel instead, so a preview never advances round-robin state. Messages caller-forward is reported as caller-credential-required because a preview has no caller credential to judge. --- scripts/test-layout/layout.json | 1 + src/protocols/plan-snapshot.ts | 190 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + .../responses/protocol-plan-snapshot.test.ts | 160 +++++++++++++++ 4 files changed, 352 insertions(+) create mode 100644 src/protocols/plan-snapshot.ts create mode 100644 tests/responses/protocol-plan-snapshot.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 07de9d83cb5..7d9c1101bd1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -336,6 +336,7 @@ "protocol-path.test.ts": "responses", "protocol-trace.test.ts": "responses", "protocol-plan.test.ts": "responses", + "protocol-plan-snapshot.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/plan-snapshot.ts b/src/protocols/plan-snapshot.ts new file mode 100644 index 00000000000..75eb207ef20 --- /dev/null +++ b/src/protocols/plan-snapshot.ts @@ -0,0 +1,190 @@ +/** + * Builds the planner's input from live config, for a preview that sends nothing. + * + * SERVER SIDE (not a leaf): this reads the router and the ingress eligibility rules so the + * snapshot is the route the request would actually settle on. It must stay free of side + * effects, which is why it does not simply call `routeModel` for every selector: + * + * - a combo selector would go through `tryPickComboModel`, which advances round-robin state. + * Combos are expanded here from their configured targets, each resolved with + * `routeConcreteModel` (no combo or policy lookup, no pick); + * - a policy selector would run the evaluator, whose clock-dependent pick is not what a + * preview describes. Policies are expanded from their configured candidates the same way; + * - every other selector takes `routeModel`'s deterministic branches, which read config and + * the last-known model cache and never fetch, refresh or write. + * + * Messages caller-forward passthrough depends on the caller's own Anthropic credential, which + * a preview does not have; it is reported as `caller-credential-required`, never assumed. + */ +import { resolveInboundModel } from "../claude/inbound-model-options"; +import { getCombo, preservesPhysicalComboProvider, resolveComboId } from "../combos"; +import { captureRouteStaticPolicy, routeConcreteModel, routeModel, type RouteResult } from "../router"; +import { getRoutingProfile, POLICY_NAMESPACE, resolvePolicyProfileId } from "../routing/profile"; +import { resolveWireProtocolOverride } from "../server/adapter-resolve"; +import { nativeChatDeclineReason } from "../server/chat-native-eligibility"; +import { parseSyntheticRowId } from "../server/fast-row"; +import type { OcxConfig } from "../types"; +import { inboundWireForProtocol, type Protocol, type ProtocolReasonCode } from "./contract"; +import type { ProtocolPlanV1 } from "./dto"; +import type { ProtocolFeature } from "./features"; +import { planProtocol, type ProtocolPlanCandidateInput, type ProtocolPlanInput } from "./plan"; +import { protocolPolicyRevision, resolveApiSurfaceSettings, resolveProtocolSettings } from "./settings"; + +export interface ProtocolPlanRequest { + model: string; + inbound: Protocol; + features: readonly ProtocolFeature[]; +} + +type SettledRouteKind = Exclude; + +interface SettledTargets { + routeKind: SettledRouteKind; + routes: RouteResult[]; +} + +function concreteRoutes(config: OcxConfig, targets: readonly { provider: string; model: string }[]): RouteResult[] { + const routes: RouteResult[] = []; + for (const target of targets) { + try { + routes.push(routeConcreteModel(config, `${target.provider}/${target.model}`)); + } catch { + // An unconfigured or disabled target is skipped at dispatch time as well. + } + } + return routes; +} + +/** The route targets a selector settles on, or `undefined` when nothing routes it. */ +function settleTargets(config: OcxConfig, modelId: string): SettledTargets | undefined { + const policyId = resolvePolicyProfileId(config, modelId); + if (policyId !== null || modelId.startsWith(`${POLICY_NAMESPACE}/`)) { + const profile = policyId ? getRoutingProfile(config, policyId) : undefined; + return profile ? { routeKind: "policy", routes: concreteRoutes(config, profile.candidates) } : undefined; + } + if (!preservesPhysicalComboProvider(config)) { + const comboId = resolveComboId(config, modelId); + if (comboId !== null) { + const combo = getCombo(config, comboId); + return combo ? { routeKind: "combo", routes: concreteRoutes(config, combo.targets) } : undefined; + } + } + try { + return { routeKind: "direct", routes: [routeModel(config, modelId)] }; + } catch { + return undefined; + } +} + +/** + * A structural stand-in for a Chat body carrying the requested features, for the + * eligibility rules that inspect the body. Never derived from a real request. + */ +function chatBodyForFeatures(features: ReadonlySet): Record { + const content = features.has("request.images") + ? [{ type: "image_url", image_url: { url: "data:image/png;base64,AA==" } }] + : ""; + return { + messages: [{ role: "user", content }], + ...(features.has("request.tools") ? { tools: [{ type: "function", function: { name: "preview" } }] } : {}), + }; +} + +function candidateFor( + config: OcxConfig, + inbound: Protocol, + route: RouteResult, + routeKind: SettledRouteKind, + features: ReadonlySet, + effortRow: boolean, +): ProtocolPlanCandidateInput { + const wire = inboundWireForProtocol(inbound); + // The same two steps every ingress runs: recapture static policy for the original inbound, + // then settle the wire from it. + const staticPolicy = captureRouteStaticPolicy( + route.providerName, route.modelId, route.provider, route.staticPolicy.effectiveAlias, wire, + ); + const provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, wire, staticPolicy); + const adapter = provider.adapter ?? "openai-responses"; + let declineReasons: ProtocolReasonCode[] = []; + let nativeEligible = false; + if (inbound === "chat") { + const settled: RouteResult = { + ...route, + provider, + staticPolicy, + ...(routeKind === "direct" ? {} : { routeKind }), + }; + const reason = effortRow ? "effort-row" : nativeChatDeclineReason(settled, chatBodyForFeatures(features), config); + nativeEligible = reason === undefined; + if (reason) declineReasons = [reason]; + } + return { provider: route.providerName, model: route.modelId, adapter, nativeEligible, declineReasons }; +} + +/** Whether the Messages ingress could forward this selector with a caller's own credential. */ +function messagesPassthroughPossible(config: OcxConfig, model: string): boolean { + if (config.claudeCode?.nativePassthrough === false) return false; + if (!/^(claude|anthropic)/i.test(model)) return false; + try { + return resolveInboundModel(model, config.claudeCode) === model; + } catch { + return false; + } +} + +export function buildProtocolPlanSnapshot( + config: OcxConfig, + request: ProtocolPlanRequest, + basis: ProtocolPlanV1["basis"] = "preview", +): ProtocolPlanInput { + const base = { + inbound: request.inbound, + requestedModel: request.model, + features: [...request.features], + surfaces: resolveApiSurfaceSettings(config), + settings: resolveProtocolSettings(config), + policyRevision: protocolPolicyRevision(config), + basis, + }; + const reasonCodes: ProtocolReasonCode[] = []; + let routeKey = request.model; + let effortRow = false; + let syntheticRow = false; + try { + const parsed = parseSyntheticRowId(request.model, config); + if (parsed.effortRow) { + routeKey = parsed.effortRow.baseId; + effortRow = true; + syntheticRow = true; + } else if (parsed.fastRow) { + routeKey = parsed.fastRow.baseId; + syntheticRow = true; + } + } catch { + // An unparseable synthetic id routes as written, exactly as the ingress falls through. + } + if (request.inbound === "messages") { + // A synthetic row needs the proxy-owned adapter, so it never takes the passthrough. + if (!syntheticRow && messagesPassthroughPossible(config, request.model)) reasonCodes.push("caller-credential-required"); + try { + routeKey = resolveInboundModel(routeKey, config.claudeCode); + } catch { + return { ...base, routeKind: "unknown", candidates: [], reasonCodes }; + } + } + const settled = settleTargets(config, routeKey); + if (!settled || settled.routes.length === 0) return { ...base, routeKind: "unknown", candidates: [], reasonCodes }; + const features = new Set(request.features); + return { + ...base, + routeKind: settled.routeKind, + candidates: settled.routes.map(route => candidateFor(config, request.inbound, route, settled.routeKind, features, effortRow)), + reasonCodes, + }; +} + +/** A preview plan for one selector, computed from config alone. */ +export function previewProtocolPlan(config: OcxConfig, request: ProtocolPlanRequest): ProtocolPlanV1 { + return planProtocol(buildProtocolPlanSnapshot(config, request, "preview")); +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2eec9b04dfa..6d7b9a33b00 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -162,6 +162,7 @@ "protocol-path.test.ts": "responses", "protocol-trace.test.ts": "responses", "protocol-plan.test.ts": "responses", + "protocol-plan-snapshot.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-plan-snapshot.test.ts b/tests/responses/protocol-plan-snapshot.test.ts new file mode 100644 index 00000000000..e18283b9fd7 --- /dev/null +++ b/tests/responses/protocol-plan-snapshot.test.ts @@ -0,0 +1,160 @@ +/** + * The server-side plan snapshot (src/protocols/plan-snapshot.ts): the route a preview + * describes, built from config without picking, fetching or writing anything. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearComboSelectionState, getCombo, noteComboFailure, pickComboTarget } from "../../src/combos"; +import { isProtocolPlanV1 } from "../../src/protocols/dto"; +import { buildProtocolPlanSnapshot, previewProtocolPlan } from "../../src/protocols/plan-snapshot"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-plan-snapshot-")); + process.env.OPENCODEX_HOME = testDir; + clearComboSelectionState(); +}); + +afterEach(() => { + clearComboSelectionState(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) removeTreeWithRetry(testDir); +}); + +function baseConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "a", + providers: { + a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", apiKey: "ka", models: ["m1"] }, + b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", apiKey: "kb", models: ["m2"] }, + r: { adapter: "openai-responses", baseUrl: "https://r.example/v1", apiKey: "kr", models: ["m3"] }, + }, + combos: { + mixed: { + strategy: "round-robin", + targets: [ + { provider: "a", model: "m1" }, + { provider: "r", model: "m3" }, + ], + }, + }, + routingProfiles: { + fast: { + alias: "ocx/fast", + candidates: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ], + }, + }, + ...overrides, + } as OcxConfig; +} + +describe("buildProtocolPlanSnapshot", () => { + test("a direct Chat model on a Chat provider is a native candidate", () => { + const snapshot = buildProtocolPlanSnapshot(baseConfig(), { model: "m1", inbound: "chat", features: [] }); + expect(snapshot.routeKind).toBe("direct"); + expect(snapshot.candidates).toEqual([ + { provider: "a", model: "m1", adapter: "openai-chat", nativeEligible: true, declineReasons: [] }, + ]); + expect(previewProtocolPlan(baseConfig(), { model: "m1", inbound: "chat", features: [] }).mode).toBe("native"); + }); + + test("a combo expands every configured target and declines the native Chat lane", () => { + const snapshot = buildProtocolPlanSnapshot(baseConfig(), { model: "combo/mixed", inbound: "chat", features: [] }); + expect(snapshot.routeKind).toBe("combo"); + expect(snapshot.candidates.map(c => [c.provider, c.adapter, c.nativeEligible])).toEqual([ + ["a", "openai-chat", false], + ["r", "openai-responses", false], + ]); + expect(snapshot.candidates[0]!.declineReasons).toEqual(["combo-or-policy-route"]); + const plan = previewProtocolPlan(baseConfig(), { model: "combo/mixed", inbound: "chat", features: [] }); + expect(isProtocolPlanV1(plan)).toBe(true); + expect(plan.basis).toBe("preview"); + expect(plan.candidates.map(c => c.mode)).toEqual(["legacy-bridge", "translated"]); + }); + + test("a policy alias expands its configured candidates", () => { + const snapshot = buildProtocolPlanSnapshot(baseConfig(), { model: "ocx/fast", inbound: "responses", features: [] }); + expect(snapshot.routeKind).toBe("policy"); + expect(snapshot.candidates.map(c => c.provider)).toEqual(["a", "b"]); + }); + + test("an unroutable model is unknown with no candidates", () => { + const config = baseConfig({ defaultProvider: "missing" }); + const plan = previewProtocolPlan(config, { model: "nothing-routes-this", inbound: "responses", features: [] }); + expect(plan).toMatchObject({ routeKind: "unknown", mode: "blocked", candidates: [], reasonCodes: ["unknown-model"] }); + const policy = previewProtocolPlan(baseConfig(), { model: "policy/missing", inbound: "responses", features: [] }); + expect(policy.routeKind).toBe("unknown"); + }); + + test("Messages caller-forward passthrough is reported, never assumed", () => { + const plan = previewProtocolPlan(baseConfig(), { model: "claude-sonnet-4-5", inbound: "messages", features: [] }); + expect(plan.reasonCodes[0]).toBe("caller-credential-required"); + expect(plan.candidates.every(c => c.mode !== "native")).toBe(true); + const off = previewProtocolPlan( + baseConfig({ claudeCode: { nativePassthrough: false } } as Partial), + { model: "claude-sonnet-4-5", inbound: "messages", features: [] }, + ); + expect(off.reasonCodes).not.toContain("caller-credential-required"); + }); + + test("a disabled Messages surface blocks the plan", () => { + const config = baseConfig({ apiSurfaces: { messages: { enabled: false } } } as Partial); + const plan = previewProtocolPlan(config, { model: "m1", inbound: "messages", features: [] }); + expect(plan.mode).toBe("blocked"); + expect(plan.reasonCodes).toEqual(["surface-disabled"]); + }); + + test("the policy revision and settings come from config", () => { + const config = baseConfig({ protocols: { unrepresentable: "reject" } } as Partial); + const snapshot = buildProtocolPlanSnapshot(config, { model: "m1", inbound: "chat", features: [] }); + expect(snapshot.settings.unrepresentable).toBe("reject"); + expect(snapshot.policyRevision).not.toBe(buildProtocolPlanSnapshot(baseConfig(), { model: "m1", inbound: "chat", features: [] }).policyRevision); + }); +}); + +describe("snapshot side effects", () => { + test("previewing a combo never advances its round-robin selection", () => { + const config = baseConfig(); + for (let i = 0; i < 3; i++) previewProtocolPlan(config, { model: "combo/mixed", inbound: "responses", features: [] }); + // With untouched state, clearing the first target and picking again still lands on it. + // Had any preview picked, the smooth-weighted counters would now favour the second + // target: a pick sets t0 active and leaves weights {t0:-1, t1:+1}. + noteComboFailure("mixed", getCombo(config, "mixed")!.targets[0]!); + expect(pickComboTarget(config, "mixed")?.targetIndex).toBe(0); + }); + + test("the control: one real pick does shift the next selection", () => { + const config = baseConfig(); + pickComboTarget(config, "mixed"); + noteComboFailure("mixed", getCombo(config, "mixed")!.targets[0]!); + expect(pickComboTarget(config, "mixed")?.targetIndex).toBe(1); + }); + + test("a preview performs no network request", () => { + const original = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { + calls++; + throw new Error("preview must not fetch"); + }) as unknown as typeof fetch; + try { + previewProtocolPlan(baseConfig(), { model: "m1", inbound: "chat", features: ["request.tools"] }); + previewProtocolPlan(baseConfig(), { model: "combo/mixed", inbound: "messages", features: [] }); + } finally { + globalThis.fetch = original; + } + expect(calls).toBe(0); + }); +}); From 7ca15b84301f87dbabc0cf2c33a43b5f2daba897 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:21:59 +0900 Subject: [PATCH 030/173] feat(management): serve protocol vocabulary and request-path previews GET /api/protocols reports the contract version, surfaces, settings and policy revision; POST /api/protocols/plan returns a preview ProtocolPlanV1. The body is bounded and unknown keys are refused so the route cannot become a place to paste a prompt. Mounted lazily and declared with a deferred-verb exemption owned by PF-12. --- scripts/test-layout/layout.json | 1 + src/server/management-api.ts | 11 +++ src/server/management/protocol-routes.ts | 80 +++++++++++++++++++++ src/server/management/route-registry.ts | 2 + tests/fixtures/test-layout-expected.json | 1 + tests/server/protocol-routes.test.ts | 91 ++++++++++++++++++++++++ 6 files changed, 186 insertions(+) create mode 100644 src/server/management/protocol-routes.ts create mode 100644 tests/server/protocol-routes.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7d9c1101bd1..e27b68cb22b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1102,6 +1102,7 @@ "management-provider-validation.test.ts": "server", "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", + "protocol-routes.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "managing-cli.test.ts": "service", "memory-watchdog.test.ts": "server", diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 5c44611ddd5..941648075cb 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -156,6 +156,16 @@ async function handleWorkflowBudgetRoutesOnDemand(ctx: ManagementContext): Promi return handleWorkflowBudgetRoutes(ctx); } +/** + * Lazy like the Lab and routing-profile handlers: the protocol planner reaches the router and + * the ingress eligibility rules, which no other dashboard request needs. + */ +async function handleProtocolRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/protocols")) return null; + const { handleProtocolRoutes } = await import("./management/protocol-routes"); + return handleProtocolRoutes(ctx); +} + async function handleGrokCouponRoutesOnDemand(ctx: ManagementContext): Promise { if (!pathInManagementNamespace(ctx.url.pathname, "/api/grok/reset-coupons", true)) return null; const { handleGrokCouponRoutes } = await import("./management/grok-coupon-routes"); @@ -298,6 +308,7 @@ export async function handleManagementAPI( ?? (await handleRequestHistoryRoutes(ctx)) ?? (await handleQuotaResetRoutesOnDemand(ctx)) ?? (await handleWorkflowBudgetRoutesOnDemand(ctx)) + ?? (await handleProtocolRoutesOnDemand(ctx)) ?? (await handleGrokCouponRoutesOnDemand(ctx)) ?? (await handleAnthropicResetGrantRoutesOnDemand(ctx)) ?? handleMetricsRoutes(ctx) diff --git a/src/server/management/protocol-routes.ts b/src/server/management/protocol-routes.ts new file mode 100644 index 00000000000..5277e7f0f7f --- /dev/null +++ b/src/server/management/protocol-routes.ts @@ -0,0 +1,80 @@ +/** + * Protocol vocabulary and request-path preview for the dashboard. + * + * Loaded on demand from src/server/management-api.ts, like the other optional namespaces: + * the planner reaches the router and the ingress eligibility rules, and a static import + * would put them on every dashboard request. + * + * Both routes are read-only. The preview is computed from config alone + * (src/protocols/plan-snapshot.ts): it sends nothing upstream, advances no combo state, and + * never logs its input. Authentication is inherited from the management chain. + */ +import { jsonResponse } from "../auth-cors"; +import { isProtocol, PROTOCOL_CONTRACT_VERSION } from "../../protocols/contract"; +import { isProtocolFeature, PROTOCOL_FEATURES, type ProtocolFeature } from "../../protocols/features"; +import { previewProtocolPlan, type ProtocolPlanRequest } from "../../protocols/plan-snapshot"; +import { protocolPolicyRevision, resolveApiSurfaceSettings, resolveProtocolSettings } from "../../protocols/settings"; +import type { ManagementContext } from "./context"; +import { readManagementJsonBodyOr } from "./body"; + +export const PROTOCOL_PLAN_LIMITS = { modelLength: 200, features: 24 } as const; + +const PLAN_BODY_KEYS = new Set(["model", "inbound", "features"]); +const INVALID_BODY = Symbol("invalid-body"); + +type ParsedPlanBody = { ok: true; request: ProtocolPlanRequest } | { ok: false; code: string; message: string }; + +function invalid(code: string, message: string): ParsedPlanBody { + return { ok: false, code, message }; +} + +/** Validate a plan request body. Messages name the field, never echo its value. */ +export function parseProtocolPlanBody(body: unknown): ParsedPlanBody { + if (!body || typeof body !== "object" || Array.isArray(body)) return invalid("invalid_body", "body must be a JSON object"); + const record = body as Record; + for (const key of Object.keys(record)) { + if (!PLAN_BODY_KEYS.has(key)) return invalid("unknown_field", "body accepts only model, inbound and features"); + } + const model = typeof record.model === "string" ? record.model.trim() : ""; + if (!model || model.length > PROTOCOL_PLAN_LIMITS.modelLength || /[\u0000-\u001f\u007f]/.test(model)) { + return invalid("invalid_model", `model must be a non-empty string of at most ${PROTOCOL_PLAN_LIMITS.modelLength} characters`); + } + if (!isProtocol(record.inbound)) return invalid("invalid_inbound", "inbound must be responses, chat or messages"); + let features: ProtocolFeature[] = []; + if (record.features !== undefined) { + if (!Array.isArray(record.features) || record.features.length > PROTOCOL_PLAN_LIMITS.features) { + return invalid("invalid_features", `features must be an array of at most ${PROTOCOL_PLAN_LIMITS.features} entries`); + } + if (!record.features.every(isProtocolFeature)) return invalid("invalid_features", "features contains an unknown feature"); + features = [...new Set(record.features)]; + } + return { ok: true, request: { model, inbound: record.inbound, features } }; +} + +export async function handleProtocolRoutes(ctx: ManagementContext): Promise { + const { url, req, config } = ctx; + + if (url.pathname === "/api/protocols") { + if (req.method !== "GET") return null; + return jsonResponse({ + schemaVersion: 1, + contractVersion: PROTOCOL_CONTRACT_VERSION, + policyRevision: protocolPolicyRevision(config), + surfaces: resolveApiSurfaceSettings(config), + settings: resolveProtocolSettings(config), + features: PROTOCOL_FEATURES, + }, 200, req, config); + } + + if (url.pathname === "/api/protocols/plan") { + if (req.method !== "POST") return null; + const body = await readManagementJsonBodyOr(req, INVALID_BODY); + const parsed = body === INVALID_BODY ? invalid("invalid_json", "body must be valid JSON") : parseProtocolPlanBody(body); + if (!parsed.ok) { + return jsonResponse({ error: { code: parsed.code, message: parsed.message } }, 400, req, config); + } + return jsonResponse(previewProtocolPlan(config, parsed.request), 200, req, config); + } + + return null; +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index cdb972d91d5..c147b7be42e 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -337,6 +337,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ // server/management/quota-reset-routes { method: "GET", path: "/api/quota-resets", module: "server/management/quota-reset-routes", mutates: false, mechanism: "negated-guard" }, // server/management/workflow-budget-routes + { method: "GET", path: "/api/protocols", module: "server/management/protocol-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading the protocol vocabulary and active policy revision has no CLI verb yet; PF-12 owns `ocx api protocols`, and until then the dashboard preview is the only reader.", owner: "260924_protocol_first_class PF-12", ownerDoc: "devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md" } }, + { method: "POST", path: "/api/protocols/plan", module: "server/management/protocol-routes", mutates: false, exempt: { reason: "deferred-verb", why: "A request-path preview has no CLI verb yet; PF-12 owns `ocx api explain`. It sends nothing upstream, so the dashboard preview panel is the only caller in this unit.", owner: "260924_protocol_first_class PF-12", ownerDoc: "devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md" } }, { method: "GET", path: "/api/workflow-budget", module: "server/management/workflow-budget-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading a root's live budget is owed a CLI verb -- an operator staring at a 429 is usually already in a terminal -- but the ledger is process memory with no local transport to read it through, so the verb has to be an HTTP call the CLI does not yet make.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } }, { method: "POST", path: "/api/workflow-budget/clear", module: "server/management/workflow-budget-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Clearing one root is owed the same verb as the read above and for the same reason. It is deliberately not shipped as a verb in this work-phase: the read comes first, because an operator who cannot see which ceiling fired has no basis for deciding to forgive it.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } }, // server/management/request-history-routes diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6d7b9a33b00..948a494163a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -928,6 +928,7 @@ "management-provider-validation.test.ts": "server", "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", + "protocol-routes.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "managing-cli.test.ts": "service", "memory-watchdog.test.ts": "server", diff --git a/tests/server/protocol-routes.test.ts b/tests/server/protocol-routes.test.ts new file mode 100644 index 00000000000..00f7dd33103 --- /dev/null +++ b/tests/server/protocol-routes.test.ts @@ -0,0 +1,91 @@ +/** + * GET /api/protocols and POST /api/protocols/plan (src/server/management/protocol-routes.ts): + * read-only, bounded input, and a validated ProtocolPlanV1 with basis "preview". + */ +import { describe, expect, test } from "bun:test"; +import { PROTOCOL_CONTRACT_VERSION } from "../../src/protocols/contract"; +import { isProtocolPlanV1 } from "../../src/protocols/dto"; +import { PROTOCOL_FEATURES } from "../../src/protocols/features"; +import type { ManagementContext } from "../../src/server/management/context"; +import { handleProtocolRoutes, parseProtocolPlanBody } from "../../src/server/management/protocol-routes"; +import type { OcxConfig } from "../../src/types"; + +const config = { + port: 10100, + defaultProvider: "a", + providers: { a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", apiKey: "ka", models: ["m1"] } }, +} as unknown as OcxConfig; + +function ctx(method: string, path: string, body?: string): ManagementContext { + const url = new URL(`http://127.0.0.1:10100${path}`); + const req = new Request(url, { + method, + ...(body !== undefined ? { body, headers: { "content-type": "application/json" } } : {}), + }); + return { req, url, config, deps: {}, version: "test" } as unknown as ManagementContext; +} + +async function post(body: unknown): Promise { + const res = await handleProtocolRoutes(ctx("POST", "/api/protocols/plan", typeof body === "string" ? body : JSON.stringify(body))); + if (!res) throw new Error("route did not answer"); + return res; +} + +describe("GET /api/protocols", () => { + test("returns the vocabulary, the settings and the policy revision", async () => { + const res = await handleProtocolRoutes(ctx("GET", "/api/protocols")); + expect(res?.status).toBe(200); + const body = await res!.json() as Record; + expect(body.schemaVersion).toBe(1); + expect(body.contractVersion).toBe(PROTOCOL_CONTRACT_VERSION); + expect(typeof body.policyRevision).toBe("string"); + expect(body.features).toEqual([...PROTOCOL_FEATURES]); + expect((body.surfaces as Record).chat.enabled).toBe(true); + expect((body.settings as { unrepresentable: string }).unrepresentable).toBe("legacy"); + }); + + test("other methods and paths fall through", async () => { + expect(await handleProtocolRoutes(ctx("POST", "/api/protocols", "{}"))).toBeNull(); + expect(await handleProtocolRoutes(ctx("GET", "/api/protocols/plan"))).toBeNull(); + expect(await handleProtocolRoutes(ctx("GET", "/api/protocolsx"))).toBeNull(); + }); +}); + +describe("POST /api/protocols/plan", () => { + test("answers a valid preview plan", async () => { + const res = await post({ model: "m1", inbound: "chat", features: ["request.tools"] }); + expect(res.status).toBe(200); + const plan = await res.json(); + expect(isProtocolPlanV1(plan)).toBe(true); + expect(plan).toMatchObject({ basis: "preview", inbound: "chat", requestedModel: "m1", mode: "native" }); + }); + + test.each([ + ["invalid JSON", "{", "invalid_json"], + ["a non-object body", [], "invalid_body"], + ["an unknown key", { model: "m1", inbound: "chat", prompt: "x" }, "unknown_field"], + ["a missing model", { inbound: "chat" }, "invalid_model"], + ["an over-long model", { model: "m".repeat(201), inbound: "chat" }, "invalid_model"], + ["a control character in the model", { model: "m\u0001", inbound: "chat" }, "invalid_model"], + ["an unknown inbound", { model: "m1", inbound: "anthropic" }, "invalid_inbound"], + ["too many features", { model: "m1", inbound: "chat", features: Array(25).fill("request.tools") }, "invalid_features"], + ["an unknown feature", { model: "m1", inbound: "chat", features: ["request.prompt"] }, "invalid_features"], + ["non-array features", { model: "m1", inbound: "chat", features: "request.tools" }, "invalid_features"], + ])("rejects %s with 400", async (_label, body, code) => { + const res = await post(body); + expect(res.status).toBe(400); + const payload = await res.json() as { error: { code: string; message: string } }; + expect(payload.error.code).toBe(code); + }); + + test("errors never echo the submitted model", () => { + const parsed = parseProtocolPlanBody({ model: "secret-looking-value\u0001", inbound: "chat" }); + expect(parsed.ok).toBe(false); + expect(JSON.stringify(parsed)).not.toContain("secret-looking-value"); + }); + + test("duplicate features collapse and the model is trimmed", () => { + const parsed = parseProtocolPlanBody({ model: " m1 ", inbound: "chat", features: ["request.tools", "request.tools"] }); + expect(parsed).toEqual({ ok: true, request: { model: "m1", inbound: "chat", features: ["request.tools"] } }); + }); +}); From e92cc4fd499a0f064c339018a1bebeaca9556e05 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:26:12 +0900 Subject: [PATCH 031/173] feat(gui): add a validated client for protocol path previews Plans are checked with the shared isProtocolPlanV1 validator and cached per target, selector, sorted features and policy revision, so a cached preview is reused only while the policy that produced it is still active. A 404 from an older server reads as unavailable rather than as an error. --- gui/src/protocol-api.ts | 112 +++++++++++++++++++++++++++++++++ gui/tests/protocol-api.test.ts | 91 +++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 gui/src/protocol-api.ts create mode 100644 gui/tests/protocol-api.test.ts diff --git a/gui/src/protocol-api.ts b/gui/src/protocol-api.ts new file mode 100644 index 00000000000..1c9085e35ee --- /dev/null +++ b/gui/src/protocol-api.ts @@ -0,0 +1,112 @@ +/** + * Client for the protocol preview routes (`GET /api/protocols`, `POST /api/protocols/plan`). + * + * The dashboard never computes a plan itself; it asks the server and validates the answer + * with the shared leaf validator, so a record from an older or newer server is refused rather + * than half-rendered. An older server that does not have the routes answers 404, which turns + * the preview off quietly instead of showing an error. + */ +import { isProtocol, type Protocol } from "../../src/protocols/contract"; +import { isProtocolPlanV1, type ProtocolPlanV1 } from "../../src/protocols/dto"; +import { isProtocolFeature, type ProtocolFeature } from "../../src/protocols/features"; + +export interface ProtocolPlanQuery { + model: string; + inbound: Protocol; + features: readonly ProtocolFeature[]; +} + +export interface ProtocolInfo { + policyRevision: string; + features: ProtocolFeature[]; + surfaces: Record; +} + +export type ProtocolPlanResult = + | { kind: "plan"; plan: ProtocolPlanV1 } + /** The server predates the preview routes. */ + | { kind: "unavailable" } + | { kind: "error" }; + +const CACHE_LIMIT = 32; +const planCache = new Map(); + +export function protocolPlanCacheKey(apiBase: string, query: ProtocolPlanQuery, policyRevision: string): string { + return JSON.stringify([apiBase, query.model, query.inbound, [...new Set(query.features)].sort(), policyRevision]); +} + +function remember(key: string, plan: ProtocolPlanV1): void { + planCache.delete(key); + planCache.set(key, plan); + while (planCache.size > CACHE_LIMIT) { + const oldest = planCache.keys().next().value; + if (oldest === undefined) break; + planCache.delete(oldest); + } +} + +/** Test seam. */ +export function clearProtocolPlanCache(): void { + planCache.clear(); +} + +type Rec = Record; +function isRec(value: unknown): value is Rec { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function parseProtocolInfo(value: unknown): ProtocolInfo | null { + if (!isRec(value) || value.schemaVersion !== 1 || typeof value.policyRevision !== "string") return null; + if (!Array.isArray(value.features) || !value.features.every(isProtocolFeature)) return null; + if (!isRec(value.surfaces)) return null; + const surfaces = {} as Record; + for (const [name, surface] of Object.entries(value.surfaces)) { + if (!isProtocol(name) || !isRec(surface) || typeof surface.enabled !== "boolean") return null; + surfaces[name] = { enabled: surface.enabled }; + } + if (!surfaces.responses || !surfaces.chat || !surfaces.messages) return null; + return { policyRevision: value.policyRevision, features: [...value.features], surfaces }; +} + +/** `null` when the server has no protocol routes; throws on any other failure. */ +export async function fetchProtocolInfo(apiBase: string, signal?: AbortSignal): Promise { + const res = await fetch(`${apiBase}/api/protocols`, { signal }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const info = parseProtocolInfo(await res.json()); + if (!info) throw new Error("invalid protocol info"); + return info; +} + +/** + * Preview one request path. The current policy revision is read first so a cached plan is + * reused only while the policy that produced it is still the active one. + */ +export async function fetchProtocolPlan( + apiBase: string, + query: ProtocolPlanQuery, + signal?: AbortSignal, +): Promise { + try { + const info = await fetchProtocolInfo(apiBase, signal); + if (!info) return { kind: "unavailable" }; + const key = protocolPlanCacheKey(apiBase, query, info.policyRevision); + const cached = planCache.get(key); + if (cached) return { kind: "plan", plan: cached }; + const res = await fetch(`${apiBase}/api/protocols/plan`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: query.model, inbound: query.inbound, features: [...query.features] }), + signal, + }); + if (res.status === 404) return { kind: "unavailable" }; + if (!res.ok) return { kind: "error" }; + const plan: unknown = await res.json(); + if (!isProtocolPlanV1(plan)) return { kind: "error" }; + remember(protocolPlanCacheKey(apiBase, query, plan.policyRevision), plan); + return { kind: "plan", plan }; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + return { kind: "error" }; + } +} diff --git a/gui/tests/protocol-api.test.ts b/gui/tests/protocol-api.test.ts new file mode 100644 index 00000000000..998c006f43a --- /dev/null +++ b/gui/tests/protocol-api.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { planProtocol } from "../../src/protocols/plan"; +import { + clearProtocolPlanCache, + fetchProtocolPlan, + parseProtocolInfo, + protocolPlanCacheKey, +} from "../src/protocol-api"; + +const originalFetch = globalThis.fetch; +const INFO = { + schemaVersion: 1, + contractVersion: "x", + policyRevision: "p1-00000001", + surfaces: { + responses: { enabled: true, source: "fixed" }, + chat: { enabled: true, source: "fixed" }, + messages: { enabled: true, source: "claude-code-legacy" }, + }, + settings: { unrepresentable: "legacy" }, + features: ["request.tools"], +}; +const PLAN = planProtocol({ + inbound: "chat", + requestedModel: "m1", + routeKind: "direct", + candidates: [{ provider: "a", model: "m1", adapter: "openai-chat", nativeEligible: true, declineReasons: [] }], + features: [], + surfaces: INFO.surfaces, + settings: { unrepresentable: "legacy" }, + policyRevision: INFO.policyRevision, + basis: "preview", +}); + +let calls: string[] = []; +function serve(routes: Record Response>) { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + const path = new URL(url).pathname; + return routes[path]?.() ?? new Response("{}", { status: 404 }); + }) as typeof fetch; +} + +beforeEach(() => { + calls = []; + clearProtocolPlanCache(); +}); +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("the cache key ignores feature order and duplicates but not the policy revision", () => { + const a = protocolPlanCacheKey("http://x", { model: "m", inbound: "chat", features: ["request.tools", "request.seed"] }, "r1"); + const b = protocolPlanCacheKey("http://x", { model: "m", inbound: "chat", features: ["request.seed", "request.tools", "request.tools"] }, "r1"); + expect(a).toBe(b); + expect(protocolPlanCacheKey("http://x", { model: "m", inbound: "chat", features: [] }, "r2")) + .not.toBe(protocolPlanCacheKey("http://x", { model: "m", inbound: "chat", features: [] }, "r1")); + expect(protocolPlanCacheKey("http://y", { model: "m", inbound: "chat", features: [] }, "r1")) + .not.toBe(protocolPlanCacheKey("http://x", { model: "m", inbound: "chat", features: [] }, "r1")); +}); + +test("an older server without the routes turns the preview off", async () => { + serve({}); + expect(await fetchProtocolPlan("http://x", { model: "m1", inbound: "chat", features: [] })).toEqual({ kind: "unavailable" }); +}); + +test("a valid plan is returned and then served from cache for the same policy revision", async () => { + serve({ + "/api/protocols": () => Response.json(INFO), + "/api/protocols/plan": () => Response.json(PLAN), + }); + const first = await fetchProtocolPlan("http://x", { model: "m1", inbound: "chat", features: [] }); + expect(first).toEqual({ kind: "plan", plan: PLAN }); + const second = await fetchProtocolPlan("http://x", { model: "m1", inbound: "chat", features: [] }); + expect(second).toEqual({ kind: "plan", plan: PLAN }); + expect(calls.filter(url => url.endsWith("/api/protocols/plan"))).toHaveLength(1); +}); + +test("a plan that fails validation is an error, not a half-rendered record", async () => { + serve({ + "/api/protocols": () => Response.json(INFO), + "/api/protocols/plan": () => Response.json({ ...PLAN, schemaVersion: 2 }), + }); + expect(await fetchProtocolPlan("http://x", { model: "m1", inbound: "chat", features: [] })).toEqual({ kind: "error" }); +}); + +test("protocol info requires every surface", () => { + expect(parseProtocolInfo(INFO)?.policyRevision).toBe("p1-00000001"); + expect(parseProtocolInfo({ ...INFO, surfaces: { chat: { enabled: true } } })).toBeNull(); +}); From 542780d66f649839195fd65f74dfa9b32e02207c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:26:12 +0900 Subject: [PATCH 032/173] feat(gui): preview the request path on the API page A "Request path preview" section after the endpoints lets an operator pick a model, client API and features and see each candidate's path, delivery mode, fidelity, feature effects and reasons, split into features every candidate guarantees and those only some keep. Delivery mode is labelled as delivery, never as verification. --- .../apikeys-workspace/ApiKeysWorkspace.tsx | 10 + .../protocols/FeatureDispositionList.tsx | 52 ++++ .../protocols/ProtocolPlanPanel.tsx | 256 ++++++++++++++++++ gui/src/i18n/de.ts | 42 +++ gui/src/i18n/en.ts | 42 +++ gui/src/i18n/fr.ts | 42 +++ gui/src/i18n/ja.ts | 42 +++ gui/src/i18n/ko.ts | 42 +++ gui/src/i18n/ru.ts | 42 +++ gui/src/i18n/tr.ts | 42 +++ gui/src/i18n/vi.ts | 42 +++ gui/src/i18n/zh-TW.ts | 42 +++ gui/src/i18n/zh.ts | 42 +++ gui/src/pages/ApiKeys.tsx | 1 + gui/src/styles-apikeys-workspace.css | 77 ++++++ 15 files changed, 816 insertions(+) create mode 100644 gui/src/components/protocols/FeatureDispositionList.tsx create mode 100644 gui/src/components/protocols/ProtocolPlanPanel.tsx diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx index 4982fbba25d..1d1e9ee11cc 100644 --- a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx +++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx @@ -27,6 +27,7 @@ import ApiKeysListPanel from "./ApiKeysListPanel"; import type { UsageReadMetadata } from "../../usage-summary-resource"; import { UsageIncompleteNotice } from "../usage-incomplete-notice"; import { DictationPanel, LiveVoicePanel } from "./AudioApiPanel"; +import { ProtocolPlanPanel } from "../protocols/ProtocolPlanPanel"; export interface ApiKeysWorkspaceProps { keys: ApiKeyEntry[]; @@ -51,6 +52,8 @@ export interface ApiKeysWorkspaceProps { rotationSecret?: { id: string; key: string; rotationId: string } | null; rotationCopied?: boolean; filteredModels: ExternalModelRow[]; + /** Unfiltered catalog for the path preview picker; the model search must not narrow it. */ + previewModels?: ExternalModelRow[]; modelsLoading: boolean; /** Quiet revalidation / retry over rows already on screen — not a skeleton. */ modelsRefreshing?: boolean; @@ -100,6 +103,7 @@ export default function ApiKeysWorkspace({ rotationSecret = null, rotationCopied = false, filteredModels, + previewModels, modelsLoading, modelsRefreshing = false, modelsLoadFailed, @@ -203,6 +207,7 @@ export default function ApiKeysWorkspace({ { id: "keys", label: t("api.section.keys"), meta: keysLoading ? undefined : String(keys.length) }, { id: "connect", label: t("api.section.connect") }, { id: "endpoints", label: t("api.section.endpoints") }, + { id: "plan", label: t("api.section.plan") }, { id: "dictation", label: t("audio.dictation") }, { id: "live-voice", label: t("audio.liveVoice") }, { id: "models", label: t("api.section.models"), meta: String(modelCount) }, @@ -536,6 +541,11 @@ export default function ApiKeysWorkspace({
+ {/* Reference, then prediction: which path a request would take through the + endpoints above. Asked of the server on demand; it sends nothing upstream. */} +
+ +
{active && }
diff --git a/gui/src/components/protocols/FeatureDispositionList.tsx b/gui/src/components/protocols/FeatureDispositionList.tsx new file mode 100644 index 00000000000..ade0693bf43 --- /dev/null +++ b/gui/src/components/protocols/FeatureDispositionList.tsx @@ -0,0 +1,52 @@ +/** + * What one path does to each request feature, as declared by the server's plan. + * + * The disposition is always spelled out as text; colour only repeats it. These are declared + * claims about the translators, not Lab evidence, so nothing here reads as "verified". + */ +import type { FeatureDisposition, ProtocolFeature } from "../../../../src/protocols/features"; +import type { ProtocolFeatureEffectV1 } from "../../../../src/protocols/dto"; +import { useT, type TKey } from "../../i18n/shared"; + +const DISPOSITION_KEYS: Record = { + passthrough: "api.plan.disposition.passthrough", + translated: "api.plan.disposition.translated", + degraded: "api.plan.disposition.degraded", + unsupported: "api.plan.disposition.unsupported", +}; + +const DISPOSITION_TONES: Record = { + passthrough: "badge-green", + translated: "badge-accent", + degraded: "badge-amber", + unsupported: "badge-muted", +}; + +export function FeatureDispositionList({ + effects, + unknownFeatures, +}: { + effects: readonly ProtocolFeatureEffectV1[]; + unknownFeatures: readonly ProtocolFeature[]; +}) { + const t = useT(); + if (effects.length === 0 && unknownFeatures.length === 0) { + return

{t("api.plan.noFeatures")}

; + } + return ( +
    + {effects.map(effect => ( +
  • + {effect.feature} + {t(DISPOSITION_KEYS[effect.disposition])} +
  • + ))} + {unknownFeatures.map(feature => ( +
  • + {feature} + {t("api.plan.disposition.unknown")} +
  • + ))} +
+ ); +} diff --git a/gui/src/components/protocols/ProtocolPlanPanel.tsx b/gui/src/components/protocols/ProtocolPlanPanel.tsx new file mode 100644 index 00000000000..283b83226c2 --- /dev/null +++ b/gui/src/components/protocols/ProtocolPlanPanel.tsx @@ -0,0 +1,256 @@ +/** + * "Request path preview" on the API page: which path a request for one model would take, + * asked of the server and rendered as it answers. + * + * Delivery mode (native / translated / legacy bridge / blocked) is how a request travels, + * not whether it was verified. Verification is a Lab verdict and lives on the compatibility + * matrix; this panel shows no verification badge and never borrows `ExternalModelRow.native`, + * which means "an OpenAI model id", not "a native path". + */ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { DeliveryMode, Fidelity, Protocol, ProtocolHop } from "../../../../src/protocols/contract"; +import type { ProtocolPlanV1 } from "../../../../src/protocols/dto"; +import { FEATURE_SOURCES, PROTOCOL_FEATURES, type ProtocolFeature } from "../../../../src/protocols/features"; +import type { ExternalModelRow, GatewayInboundProtocol } from "../../api-access-models"; +import { useT, type TKey } from "../../i18n/shared"; +import { fetchProtocolPlan } from "../../protocol-api"; +import { FeatureDispositionList } from "./FeatureDispositionList"; + +const INBOUNDS: readonly Protocol[] = ["responses", "chat", "messages"]; + +const MODE_KEYS: Record = { + native: "api.plan.mode.native", + translated: "api.plan.mode.translated", + "legacy-bridge": "api.plan.mode.legacyBridge", + blocked: "api.plan.mode.blocked", +}; + +const MODE_TONES: Record = { + native: "badge-green", + translated: "badge-accent", + "legacy-bridge": "badge-amber", + blocked: "badge-muted", +}; + +const FIDELITY_KEYS: Record = { + preserved: "api.plan.fidelity.preserved", + degraded: "api.plan.fidelity.degraded", + unknown: "api.plan.fidelity.unknown", +}; + +const ROUTE_KIND_KEYS: Record = { + direct: "api.plan.routeKind.direct", + combo: "api.plan.routeKind.combo", + policy: "api.plan.routeKind.policy", + unknown: "api.plan.routeKind.unknown", +}; + +const PATH_SEPARATOR = " → "; + +function PathText({ path }: { path: readonly ProtocolHop[] }) { + const t = useT(); + return path.length > 0 ? {path.join(PATH_SEPARATOR)} : {t("api.plan.noPath")}; +} + +function FeatureNames({ features }: { features: readonly ProtocolFeature[] }) { + const t = useT(); + if (features.length === 0) return {t("api.plan.none")}; + return ( + + {features.map(feature => {feature})} + + ); +} + +function PlanResult({ plan }: { plan: ProtocolPlanV1 }) { + const t = useT(); + return ( +
+
+
+
{t("api.plan.overallMode")}
+
{t(MODE_KEYS[plan.mode])}
+
+
+
{t("api.plan.routeKind")}
+
{t(ROUTE_KIND_KEYS[plan.routeKind])}
+
+ {plan.reasonCodes.length > 0 && ( +
+
{t("api.plan.reasons")}
+
{plan.reasonCodes.map(code => {code})}
+
+ )} +
+
{t("api.plan.guaranteed")}
+
+
+
+
{t("api.plan.partial")}
+
+
+
+
{t("api.plan.policyRevision")}
+
{plan.policyRevision}
+
+
+ {plan.candidates.length === 0 ? ( +

{t("api.plan.noCandidates")}

+ ) : ( +
    + {plan.candidates.map((candidate, index) => ( +
  1. +
    + {candidate.provider}/{candidate.model} + {t(MODE_KEYS[candidate.mode])} + {!candidate.eligible && candidate.mode !== "blocked" && ( + {t("api.plan.ineligible")} + )} +
    +
    +
    +
    {t("api.plan.requestPath")}
    +
    +
    +
    +
    {t("api.plan.responsePath")}
    +
    +
    +
    +
    {t("api.plan.fidelity")}
    +
    {t(FIDELITY_KEYS[candidate.fidelity])}
    +
    + {candidate.reasonCodes.length > 0 && ( +
    +
    {t("api.plan.reasons")}
    +
    {candidate.reasonCodes.map(code => {code})}
    +
    + )} +
    + +
  2. + ))} +
+ )} +
+ ); +} + +export function ProtocolPlanPanel({ + apiBase, + models, + protocolLabel, +}: { + apiBase: string; + models: readonly ExternalModelRow[]; + protocolLabel: (protocol: GatewayInboundProtocol) => string; +}) { + const t = useT(); + const [model, setModel] = useState(""); + const [inbound, setInbound] = useState("chat"); + const [features, setFeatures] = useState>(() => new Set()); + const [plan, setPlan] = useState(null); + const [pending, setPending] = useState(false); + const [failed, setFailed] = useState(false); + const [unavailable, setUnavailable] = useState(false); + const controllerRef = useRef(null); + + // The caller keys this panel by `apiBase`, so a different target remounts it with fresh + // state; all that is left to do here is cancel a preview still in flight. + useEffect(() => () => controllerRef.current?.abort(), []); + + const selectedModel = model || models[0]?.id || ""; + const expressible = useMemo( + () => PROTOCOL_FEATURES.filter(feature => FEATURE_SOURCES[feature].includes(inbound)), + [inbound], + ); + + const changeInbound = (next: Protocol) => { + setInbound(next); + setFeatures(current => new Set([...current].filter(feature => FEATURE_SOURCES[feature].includes(next)))); + }; + + const toggleFeature = (feature: ProtocolFeature, on: boolean) => { + setFeatures(current => { + const next = new Set(current); + if (on) next.add(feature); + else next.delete(feature); + return next; + }); + }; + + const runPreview = async () => { + if (!selectedModel || pending) return; + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + setPending(true); + setFailed(false); + try { + const result = await fetchProtocolPlan(apiBase, { model: selectedModel, inbound, features: [...features] }, controller.signal); + if (controller.signal.aborted) return; + if (result.kind === "unavailable") { + setUnavailable(true); + setPlan(null); + } else if (result.kind === "error") { + setFailed(true); + } else { + setPlan(result.plan); + } + } catch { + // Aborted by a newer preview or by leaving the page; the newer one owns the state. + } finally { + if (controllerRef.current === controller) setPending(false); + } + }; + + return ( +
+

{t("api.plan.title")}

+

{t("api.plan.description")}

+

{t("api.plan.modeNote")}

+ {unavailable ? ( +

{t("api.plan.unavailable")}

+ ) : models.length === 0 ? ( +

{t("api.plan.noModels")}

+ ) : ( + <> +
+ + +
+
+ {t("api.plan.features")} + {expressible.map(feature => ( + + ))} +
+
+ +
+ {failed &&

{t("api.plan.failed")}

} + {plan && } + + )} +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a3ebd511747..ad69ef2cedf 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1988,6 +1988,48 @@ export const de: Record = { "api.section.endpoints": "Endpunkte", "api.section.models": "Modelle", "api.section.examples": "Beispiele", + "api.section.plan": "Pfadvorschau", + "api.plan.title": "Vorschau des Anfragepfads", + "api.plan.description": "Zeigt den Pfad, den eine Anfrage für dieses Modell durch den Proxy nehmen würde. Eine Vorschau sendet nichts an den Anbieter und kostet nichts.", + "api.plan.modeNote": "„Nativ“ beschreibt, wie eine Anfrage zugestellt wird. Es ist kein Prüfergebnis.", + "api.plan.unavailable": "Diese Proxy-Version bietet keine Pfadvorschau.", + "api.plan.noModels": "Noch keine Modelle für eine Vorschau.", + "api.plan.model": "Modell", + "api.plan.inbound": "Client-API", + "api.plan.features": "Anfragefunktionen", + "api.plan.preview": "Vorschau", + "api.plan.previewing": "Vorschau läuft…", + "api.plan.failed": "Die Vorschau konnte nicht berechnet werden.", + "api.plan.overallMode": "Zustellung", + "api.plan.routeKind": "Route", + "api.plan.reasons": "Gründe", + "api.plan.guaranteed": "Von allen Kandidaten garantiert", + "api.plan.partial": "Nur bei einigen Kandidaten", + "api.plan.policyRevision": "Richtlinienrevision", + "api.plan.noCandidates": "Keine Route passt zu diesem Modell.", + "api.plan.ineligible": "Unter der aktuellen Richtlinie abgelehnt", + "api.plan.requestPath": "Anfragepfad", + "api.plan.responsePath": "Antwortpfad", + "api.plan.fidelity": "Treue", + "api.plan.none": "Keine", + "api.plan.noPath": "Kein Pfad", + "api.plan.noFeatures": "Keine Anfragefunktionen ausgewählt.", + "api.plan.mode.native": "Nativ", + "api.plan.mode.translated": "Übersetzt", + "api.plan.mode.legacyBridge": "Legacy-Brücke", + "api.plan.mode.blocked": "Blockiert", + "api.plan.fidelity.preserved": "Erhalten", + "api.plan.fidelity.degraded": "Eingeschränkt", + "api.plan.fidelity.unknown": "Unbekannt", + "api.plan.routeKind.direct": "Direkt", + "api.plan.routeKind.combo": "Combo", + "api.plan.routeKind.policy": "Routing-Richtlinie", + "api.plan.routeKind.unknown": "Unbekanntes Modell", + "api.plan.disposition.passthrough": "Durchgereicht", + "api.plan.disposition.translated": "Übersetzt", + "api.plan.disposition.degraded": "Eingeschränkt", + "api.plan.disposition.unsupported": "Verworfen", + "api.plan.disposition.unknown": "Unbekannt", "api.workspace.details": "API-Schlüsseldetails", "api.workspace.keyDetails": "Schlüsseldetails", "api.workspace.keyPrefix": "Schlüssel-Präfix", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 54cd3520144..ea15afaf884 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2561,6 +2561,48 @@ export const en = { "api.section.endpoints": "Endpoints", "api.section.models": "Models", "api.section.examples": "Examples", + "api.section.plan": "Path preview", + "api.plan.title": "Request path preview", + "api.plan.description": "Shows the path a request for this model would take through the proxy. A preview sends nothing upstream and costs nothing.", + "api.plan.modeNote": "Native describes how a request is delivered. It is not a verification result.", + "api.plan.unavailable": "This proxy version does not offer path previews.", + "api.plan.noModels": "No models to preview yet.", + "api.plan.model": "Model", + "api.plan.inbound": "Client API", + "api.plan.features": "Request features", + "api.plan.preview": "Preview", + "api.plan.previewing": "Previewing…", + "api.plan.failed": "The preview could not be computed.", + "api.plan.overallMode": "Delivery", + "api.plan.routeKind": "Route", + "api.plan.reasons": "Reasons", + "api.plan.guaranteed": "Guaranteed by all candidates", + "api.plan.partial": "Some candidates only", + "api.plan.policyRevision": "Policy revision", + "api.plan.noCandidates": "No route matches this model.", + "api.plan.ineligible": "Refused under the current policy", + "api.plan.requestPath": "Request path", + "api.plan.responsePath": "Response path", + "api.plan.fidelity": "Fidelity", + "api.plan.none": "None", + "api.plan.noPath": "No path", + "api.plan.noFeatures": "No request features selected.", + "api.plan.mode.native": "Native", + "api.plan.mode.translated": "Translated", + "api.plan.mode.legacyBridge": "Legacy bridge", + "api.plan.mode.blocked": "Blocked", + "api.plan.fidelity.preserved": "Preserved", + "api.plan.fidelity.degraded": "Degraded", + "api.plan.fidelity.unknown": "Unknown", + "api.plan.routeKind.direct": "Direct", + "api.plan.routeKind.combo": "Combo", + "api.plan.routeKind.policy": "Routing policy", + "api.plan.routeKind.unknown": "Unknown model", + "api.plan.disposition.passthrough": "Passed through", + "api.plan.disposition.translated": "Translated", + "api.plan.disposition.degraded": "Degraded", + "api.plan.disposition.unsupported": "Dropped", + "api.plan.disposition.unknown": "Unknown", "api.workspace.details": "API key details", "api.workspace.keyDetails": "Key details", "api.workspace.keyPrefix": "Key prefix", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b239c710629..26e73528c37 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2482,6 +2482,48 @@ export const fr: Record = { "api.section.endpoints": "Points de terminaison", "api.section.models": "Modèles", "api.section.examples": "Exemples", + "api.section.plan": "Aperçu du chemin", + "api.plan.title": "Aperçu du chemin de la requête", + "api.plan.description": "Montre le chemin qu’une requête pour ce modèle suivrait dans le proxy. Un aperçu n’envoie rien au fournisseur et ne coûte rien.", + "api.plan.modeNote": "« Natif » décrit la façon dont une requête est acheminée. Ce n’est pas un résultat de vérification.", + "api.plan.unavailable": "Cette version du proxy ne propose pas d’aperçu de chemin.", + "api.plan.noModels": "Aucun modèle à prévisualiser pour l’instant.", + "api.plan.model": "Modèle", + "api.plan.inbound": "API client", + "api.plan.features": "Fonctionnalités de la requête", + "api.plan.preview": "Aperçu", + "api.plan.previewing": "Aperçu en cours…", + "api.plan.failed": "L’aperçu n’a pas pu être calculé.", + "api.plan.overallMode": "Acheminement", + "api.plan.routeKind": "Route", + "api.plan.reasons": "Raisons", + "api.plan.guaranteed": "Garanti par tous les candidats", + "api.plan.partial": "Certains candidats seulement", + "api.plan.policyRevision": "Révision de la politique", + "api.plan.noCandidates": "Aucune route ne correspond à ce modèle.", + "api.plan.ineligible": "Refusé par la politique actuelle", + "api.plan.requestPath": "Chemin de la requête", + "api.plan.responsePath": "Chemin de la réponse", + "api.plan.fidelity": "Fidélité", + "api.plan.none": "Aucune", + "api.plan.noPath": "Aucun chemin", + "api.plan.noFeatures": "Aucune fonctionnalité sélectionnée.", + "api.plan.mode.native": "Natif", + "api.plan.mode.translated": "Traduit", + "api.plan.mode.legacyBridge": "Pont hérité", + "api.plan.mode.blocked": "Bloqué", + "api.plan.fidelity.preserved": "Préservée", + "api.plan.fidelity.degraded": "Dégradée", + "api.plan.fidelity.unknown": "Inconnue", + "api.plan.routeKind.direct": "Directe", + "api.plan.routeKind.combo": "Combo", + "api.plan.routeKind.policy": "Politique de routage", + "api.plan.routeKind.unknown": "Modèle inconnu", + "api.plan.disposition.passthrough": "Transmis tel quel", + "api.plan.disposition.translated": "Traduit", + "api.plan.disposition.degraded": "Dégradé", + "api.plan.disposition.unsupported": "Supprimé", + "api.plan.disposition.unknown": "Inconnu", "api.workspace.details": "Détails de la clé API", "api.workspace.keyDetails": "Détails de la clé", "api.workspace.keyPrefix": "Préfixe de la clé", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4fb724b1d69..07b18982f51 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2437,6 +2437,48 @@ export const ja: Record = { "api.section.endpoints": "エンドポイント", "api.section.models": "モデル", "api.section.examples": "例", + "api.section.plan": "経路プレビュー", + "api.plan.title": "リクエスト経路のプレビュー", + "api.plan.description": "このモデルへのリクエストがプロキシ内でたどる経路を表示します。プレビューは上流に何も送信せず、費用もかかりません。", + "api.plan.modeNote": "「ネイティブ」はリクエストの配送方法を表し、検証結果ではありません。", + "api.plan.unavailable": "このバージョンのプロキシは経路プレビューに対応していません。", + "api.plan.noModels": "プレビューできるモデルがまだありません。", + "api.plan.model": "モデル", + "api.plan.inbound": "クライアント API", + "api.plan.features": "リクエスト機能", + "api.plan.preview": "プレビュー", + "api.plan.previewing": "プレビュー中…", + "api.plan.failed": "プレビューを計算できませんでした。", + "api.plan.overallMode": "配送", + "api.plan.routeKind": "ルート", + "api.plan.reasons": "理由", + "api.plan.guaranteed": "すべての候補で保証", + "api.plan.partial": "一部の候補のみ", + "api.plan.policyRevision": "ポリシーリビジョン", + "api.plan.noCandidates": "このモデルに一致するルートがありません。", + "api.plan.ineligible": "現在のポリシーでは拒否されます", + "api.plan.requestPath": "リクエスト経路", + "api.plan.responsePath": "レスポンス経路", + "api.plan.fidelity": "忠実度", + "api.plan.none": "なし", + "api.plan.noPath": "経路なし", + "api.plan.noFeatures": "リクエスト機能が選択されていません。", + "api.plan.mode.native": "ネイティブ", + "api.plan.mode.translated": "変換", + "api.plan.mode.legacyBridge": "レガシーブリッジ", + "api.plan.mode.blocked": "ブロック", + "api.plan.fidelity.preserved": "維持", + "api.plan.fidelity.degraded": "劣化", + "api.plan.fidelity.unknown": "不明", + "api.plan.routeKind.direct": "直接", + "api.plan.routeKind.combo": "コンボ", + "api.plan.routeKind.policy": "ルーティングポリシー", + "api.plan.routeKind.unknown": "不明なモデル", + "api.plan.disposition.passthrough": "そのまま転送", + "api.plan.disposition.translated": "変換", + "api.plan.disposition.degraded": "劣化", + "api.plan.disposition.unsupported": "破棄", + "api.plan.disposition.unknown": "不明", "api.workspace.details": "APIキーの詳細", "api.workspace.keyDetails": "キーの詳細", "api.workspace.keyPrefix": "キーのプレフィックス", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 856faf63f42..7015b8c4859 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2027,6 +2027,48 @@ export const ko: Record = { "api.section.endpoints": "엔드포인트", "api.section.models": "모델", "api.section.examples": "예제", + "api.section.plan": "경로 미리보기", + "api.plan.title": "요청 경로 미리보기", + "api.plan.description": "이 모델로 보낸 요청이 프록시 안에서 거칠 경로를 보여 줍니다. 미리보기는 업스트림에 아무것도 보내지 않으며 비용도 들지 않습니다.", + "api.plan.modeNote": "'네이티브'는 요청이 전달되는 방식을 뜻하며 검증 결과가 아닙니다.", + "api.plan.unavailable": "이 프록시 버전은 경로 미리보기를 지원하지 않습니다.", + "api.plan.noModels": "아직 미리 볼 모델이 없습니다.", + "api.plan.model": "모델", + "api.plan.inbound": "클라이언트 API", + "api.plan.features": "요청 기능", + "api.plan.preview": "미리보기", + "api.plan.previewing": "미리보는 중…", + "api.plan.failed": "미리보기를 계산하지 못했습니다.", + "api.plan.overallMode": "전달 방식", + "api.plan.routeKind": "라우트", + "api.plan.reasons": "사유", + "api.plan.guaranteed": "모든 후보가 보장", + "api.plan.partial": "일부 후보만", + "api.plan.policyRevision": "정책 리비전", + "api.plan.noCandidates": "이 모델과 일치하는 라우트가 없습니다.", + "api.plan.ineligible": "현재 정책에서 거부됨", + "api.plan.requestPath": "요청 경로", + "api.plan.responsePath": "응답 경로", + "api.plan.fidelity": "충실도", + "api.plan.none": "없음", + "api.plan.noPath": "경로 없음", + "api.plan.noFeatures": "선택한 요청 기능이 없습니다.", + "api.plan.mode.native": "네이티브", + "api.plan.mode.translated": "변환", + "api.plan.mode.legacyBridge": "레거시 브리지", + "api.plan.mode.blocked": "차단", + "api.plan.fidelity.preserved": "유지", + "api.plan.fidelity.degraded": "저하", + "api.plan.fidelity.unknown": "알 수 없음", + "api.plan.routeKind.direct": "직접", + "api.plan.routeKind.combo": "콤보", + "api.plan.routeKind.policy": "라우팅 정책", + "api.plan.routeKind.unknown": "알 수 없는 모델", + "api.plan.disposition.passthrough": "그대로 전달", + "api.plan.disposition.translated": "변환", + "api.plan.disposition.degraded": "저하", + "api.plan.disposition.unsupported": "삭제", + "api.plan.disposition.unknown": "알 수 없음", "api.workspace.details": "API 키 세부 정보", "api.workspace.keyDetails": "키 세부 정보", "api.workspace.keyPrefix": "키 접두사", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 89c62a66e18..057e4df891b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2508,6 +2508,48 @@ export const ru: Record = { "api.section.endpoints": "Эндпоинты", "api.section.models": "Модели", "api.section.examples": "Примеры", + "api.section.plan": "Предпросмотр пути", + "api.plan.title": "Предпросмотр пути запроса", + "api.plan.description": "Показывает путь, который запрос к этой модели прошёл бы через прокси. Предпросмотр ничего не отправляет провайдеру и ничего не стоит.", + "api.plan.modeNote": "«Нативный» описывает способ доставки запроса. Это не результат проверки.", + "api.plan.unavailable": "Эта версия прокси не поддерживает предпросмотр пути.", + "api.plan.noModels": "Пока нет моделей для предпросмотра.", + "api.plan.model": "Модель", + "api.plan.inbound": "Клиентский API", + "api.plan.features": "Возможности запроса", + "api.plan.preview": "Предпросмотр", + "api.plan.previewing": "Выполняется предпросмотр…", + "api.plan.failed": "Не удалось вычислить предпросмотр.", + "api.plan.overallMode": "Доставка", + "api.plan.routeKind": "Маршрут", + "api.plan.reasons": "Причины", + "api.plan.guaranteed": "Гарантировано всеми кандидатами", + "api.plan.partial": "Только у некоторых кандидатов", + "api.plan.policyRevision": "Ревизия политики", + "api.plan.noCandidates": "Нет маршрута для этой модели.", + "api.plan.ineligible": "Отклонено текущей политикой", + "api.plan.requestPath": "Путь запроса", + "api.plan.responsePath": "Путь ответа", + "api.plan.fidelity": "Точность", + "api.plan.none": "Нет", + "api.plan.noPath": "Нет пути", + "api.plan.noFeatures": "Возможности запроса не выбраны.", + "api.plan.mode.native": "Нативный", + "api.plan.mode.translated": "Преобразованный", + "api.plan.mode.legacyBridge": "Устаревший мост", + "api.plan.mode.blocked": "Заблокирован", + "api.plan.fidelity.preserved": "Сохранена", + "api.plan.fidelity.degraded": "Снижена", + "api.plan.fidelity.unknown": "Неизвестна", + "api.plan.routeKind.direct": "Прямой", + "api.plan.routeKind.combo": "Комбо", + "api.plan.routeKind.policy": "Политика маршрутизации", + "api.plan.routeKind.unknown": "Неизвестная модель", + "api.plan.disposition.passthrough": "Передаётся как есть", + "api.plan.disposition.translated": "Преобразуется", + "api.plan.disposition.degraded": "Ухудшается", + "api.plan.disposition.unsupported": "Отбрасывается", + "api.plan.disposition.unknown": "Неизвестно", "api.workspace.details": "Сведения об API-ключе", "api.workspace.keyDetails": "Сведения о ключе", "api.workspace.keyPrefix": "Префикс ключа", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9d496db366b..f9d58271b9c 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2502,6 +2502,48 @@ export const tr: Record = { "api.section.endpoints": "Uç noktalar", "api.section.models": "Modeller", "api.section.examples": "Örnekler", + "api.section.plan": "Yol önizlemesi", + "api.plan.title": "İstek yolu önizlemesi", + "api.plan.description": "Bu model için bir isteğin proxy içinde izleyeceği yolu gösterir. Önizleme sağlayıcıya hiçbir şey göndermez ve ücretsizdir.", + "api.plan.modeNote": "“Yerel” bir isteğin nasıl iletildiğini anlatır. Bir doğrulama sonucu değildir.", + "api.plan.unavailable": "Bu proxy sürümü yol önizlemesi sunmuyor.", + "api.plan.noModels": "Henüz önizlenecek model yok.", + "api.plan.model": "Model", + "api.plan.inbound": "İstemci API’si", + "api.plan.features": "İstek özellikleri", + "api.plan.preview": "Önizle", + "api.plan.previewing": "Önizleniyor…", + "api.plan.failed": "Önizleme hesaplanamadı.", + "api.plan.overallMode": "İletim", + "api.plan.routeKind": "Rota", + "api.plan.reasons": "Nedenler", + "api.plan.guaranteed": "Tüm adaylarca garanti edilir", + "api.plan.partial": "Yalnızca bazı adaylar", + "api.plan.policyRevision": "Politika revizyonu", + "api.plan.noCandidates": "Bu modelle eşleşen rota yok.", + "api.plan.ineligible": "Geçerli politikada reddedilir", + "api.plan.requestPath": "İstek yolu", + "api.plan.responsePath": "Yanıt yolu", + "api.plan.fidelity": "Doğruluk", + "api.plan.none": "Yok", + "api.plan.noPath": "Yol yok", + "api.plan.noFeatures": "İstek özelliği seçilmedi.", + "api.plan.mode.native": "Yerel", + "api.plan.mode.translated": "Çevrilmiş", + "api.plan.mode.legacyBridge": "Eski köprü", + "api.plan.mode.blocked": "Engellendi", + "api.plan.fidelity.preserved": "Korunur", + "api.plan.fidelity.degraded": "Düşer", + "api.plan.fidelity.unknown": "Bilinmiyor", + "api.plan.routeKind.direct": "Doğrudan", + "api.plan.routeKind.combo": "Kombo", + "api.plan.routeKind.policy": "Yönlendirme politikası", + "api.plan.routeKind.unknown": "Bilinmeyen model", + "api.plan.disposition.passthrough": "Olduğu gibi iletilir", + "api.plan.disposition.translated": "Çevrilir", + "api.plan.disposition.degraded": "Düşer", + "api.plan.disposition.unsupported": "Atılır", + "api.plan.disposition.unknown": "Bilinmiyor", "api.workspace.details": "API anahtar detayları", "api.workspace.keyDetails": "Anahtar detayları", "api.workspace.keyPrefix": "Anahtar ön eki", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 2f2c8ba128f..0729ee2c2c7 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2495,6 +2495,48 @@ export const vi: Record = { "api.section.endpoints": "Endpoints", "api.section.models": "Models", "api.section.examples": "Ví dụ", + "api.section.plan": "Xem trước đường đi", + "api.plan.title": "Xem trước đường đi của yêu cầu", + "api.plan.description": "Hiển thị đường đi mà một yêu cầu tới mô hình này sẽ đi qua proxy. Xem trước không gửi gì lên nhà cung cấp và không tốn phí.", + "api.plan.modeNote": "“Gốc” mô tả cách yêu cầu được chuyển đi. Đây không phải là kết quả xác minh.", + "api.plan.unavailable": "Phiên bản proxy này không hỗ trợ xem trước đường đi.", + "api.plan.noModels": "Chưa có mô hình nào để xem trước.", + "api.plan.model": "Mô hình", + "api.plan.inbound": "API phía client", + "api.plan.features": "Tính năng của yêu cầu", + "api.plan.preview": "Xem trước", + "api.plan.previewing": "Đang xem trước…", + "api.plan.failed": "Không thể tính toán bản xem trước.", + "api.plan.overallMode": "Cách chuyển", + "api.plan.routeKind": "Tuyến", + "api.plan.reasons": "Lý do", + "api.plan.guaranteed": "Mọi ứng viên đều đảm bảo", + "api.plan.partial": "Chỉ một số ứng viên", + "api.plan.policyRevision": "Phiên bản chính sách", + "api.plan.noCandidates": "Không có tuyến nào khớp với mô hình này.", + "api.plan.ineligible": "Bị từ chối theo chính sách hiện tại", + "api.plan.requestPath": "Đường đi yêu cầu", + "api.plan.responsePath": "Đường đi phản hồi", + "api.plan.fidelity": "Độ trung thực", + "api.plan.none": "Không có", + "api.plan.noPath": "Không có đường đi", + "api.plan.noFeatures": "Chưa chọn tính năng nào.", + "api.plan.mode.native": "Gốc", + "api.plan.mode.translated": "Đã chuyển đổi", + "api.plan.mode.legacyBridge": "Cầu nối cũ", + "api.plan.mode.blocked": "Bị chặn", + "api.plan.fidelity.preserved": "Giữ nguyên", + "api.plan.fidelity.degraded": "Suy giảm", + "api.plan.fidelity.unknown": "Không rõ", + "api.plan.routeKind.direct": "Trực tiếp", + "api.plan.routeKind.combo": "Combo", + "api.plan.routeKind.policy": "Chính sách định tuyến", + "api.plan.routeKind.unknown": "Mô hình không xác định", + "api.plan.disposition.passthrough": "Chuyển nguyên vẹn", + "api.plan.disposition.translated": "Chuyển đổi", + "api.plan.disposition.degraded": "Suy giảm", + "api.plan.disposition.unsupported": "Bị loại bỏ", + "api.plan.disposition.unknown": "Không rõ", "api.workspace.details": "Chi tiết API key", "api.workspace.keyDetails": "Chi tiết Key", "api.workspace.keyPrefix": "Tiền tố key", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ec111153d28..7310b358947 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2338,6 +2338,48 @@ export const zhTW: Record = { "api.section.endpoints": "端點", "api.section.models": "模型", "api.section.examples": "範例", + "api.section.plan": "路徑預覽", + "api.plan.title": "請求路徑預覽", + "api.plan.description": "顯示對此模型的請求在代理中會經過的路徑。預覽不會向上游傳送任何內容,也不產生費用。", + "api.plan.modeNote": "「原生」描述請求的傳遞方式,並不是驗證結果。", + "api.plan.unavailable": "此代理版本不支援路徑預覽。", + "api.plan.noModels": "尚無可預覽的模型。", + "api.plan.model": "模型", + "api.plan.inbound": "用戶端 API", + "api.plan.features": "請求功能", + "api.plan.preview": "預覽", + "api.plan.previewing": "正在預覽…", + "api.plan.failed": "無法計算預覽。", + "api.plan.overallMode": "傳遞方式", + "api.plan.routeKind": "路由", + "api.plan.reasons": "原因", + "api.plan.guaranteed": "所有候選皆保證", + "api.plan.partial": "僅部分候選", + "api.plan.policyRevision": "政策修訂", + "api.plan.noCandidates": "沒有與此模型相符的路由。", + "api.plan.ineligible": "目前政策下會被拒絕", + "api.plan.requestPath": "請求路徑", + "api.plan.responsePath": "回應路徑", + "api.plan.fidelity": "保真度", + "api.plan.none": "無", + "api.plan.noPath": "無路徑", + "api.plan.noFeatures": "未選擇請求功能。", + "api.plan.mode.native": "原生", + "api.plan.mode.translated": "轉換", + "api.plan.mode.legacyBridge": "舊版橋接", + "api.plan.mode.blocked": "已封鎖", + "api.plan.fidelity.preserved": "保留", + "api.plan.fidelity.degraded": "降級", + "api.plan.fidelity.unknown": "未知", + "api.plan.routeKind.direct": "直連", + "api.plan.routeKind.combo": "組合", + "api.plan.routeKind.policy": "路由政策", + "api.plan.routeKind.unknown": "未知模型", + "api.plan.disposition.passthrough": "原樣轉送", + "api.plan.disposition.translated": "轉換", + "api.plan.disposition.degraded": "降級", + "api.plan.disposition.unsupported": "捨棄", + "api.plan.disposition.unknown": "未知", "api.clientConfig.title": "用戶端設定", "api.clientConfig.rowsLabel": "連接用戶端", "api.clientConfig.details": "詳情", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e631dd8e776..b41e7258a6f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2008,6 +2008,48 @@ export const zh: Record = { "api.section.endpoints": "端点", "api.section.models": "模型", "api.section.examples": "示例", + "api.section.plan": "路径预览", + "api.plan.title": "请求路径预览", + "api.plan.description": "显示对此模型的请求在代理中会经过的路径。预览不会向上游发送任何内容,也不产生费用。", + "api.plan.modeNote": "“原生”描述请求的传递方式,并不是验证结果。", + "api.plan.unavailable": "此代理版本不支持路径预览。", + "api.plan.noModels": "暂无可预览的模型。", + "api.plan.model": "模型", + "api.plan.inbound": "客户端 API", + "api.plan.features": "请求功能", + "api.plan.preview": "预览", + "api.plan.previewing": "正在预览…", + "api.plan.failed": "无法计算预览。", + "api.plan.overallMode": "传递方式", + "api.plan.routeKind": "路由", + "api.plan.reasons": "原因", + "api.plan.guaranteed": "所有候选均保证", + "api.plan.partial": "仅部分候选", + "api.plan.policyRevision": "策略修订", + "api.plan.noCandidates": "没有与此模型匹配的路由。", + "api.plan.ineligible": "当前策略下会被拒绝", + "api.plan.requestPath": "请求路径", + "api.plan.responsePath": "响应路径", + "api.plan.fidelity": "保真度", + "api.plan.none": "无", + "api.plan.noPath": "无路径", + "api.plan.noFeatures": "未选择请求功能。", + "api.plan.mode.native": "原生", + "api.plan.mode.translated": "转换", + "api.plan.mode.legacyBridge": "旧版桥接", + "api.plan.mode.blocked": "已阻止", + "api.plan.fidelity.preserved": "保留", + "api.plan.fidelity.degraded": "降级", + "api.plan.fidelity.unknown": "未知", + "api.plan.routeKind.direct": "直连", + "api.plan.routeKind.combo": "组合", + "api.plan.routeKind.policy": "路由策略", + "api.plan.routeKind.unknown": "未知模型", + "api.plan.disposition.passthrough": "原样透传", + "api.plan.disposition.translated": "转换", + "api.plan.disposition.degraded": "降级", + "api.plan.disposition.unsupported": "丢弃", + "api.plan.disposition.unknown": "未知", "api.workspace.details": "API 密钥详情", "api.workspace.keyDetails": "密钥详情", "api.workspace.keyPrefix": "密钥前缀", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 833c40814a8..de8303792ca 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -524,6 +524,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a rotationSecret={rotationSecret} rotationCopied={rotationCopied} filteredModels={filteredModels} + previewModels={models} modelsLoading={modelsState.showSkeleton && !modelsState.data && !cachedModels} // Only announce progress on a retry after failure — quiet warm revisits stay silent. modelsRefreshing={modelsState.refreshing && modelsState.showError && (modelsState.data !== undefined || cachedModels !== null)} diff --git a/gui/src/styles-apikeys-workspace.css b/gui/src/styles-apikeys-workspace.css index ce9fa08654c..a389bbca25a 100644 --- a/gui/src/styles-apikeys-workspace.css +++ b/gui/src/styles-apikeys-workspace.css @@ -829,3 +829,80 @@ .apikeys-workspace-shell .section-tabs { top: 53px; } .apikeys-workspace-shell .awi-section-anchor { scroll-margin-top: 108px; } } + +/* Request path preview (components/protocols). Delivery mode badges carry their label as + text, so colour only repeats what the words already say. */ +.protocol-plan-form { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} +.protocol-plan-field { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; + flex: 1 1 220px; +} +.protocol-plan-field .input { max-width: 100%; } +.protocol-plan-features { + display: flex; + flex-wrap: wrap; + gap: var(--space-1-5) var(--space-3); + margin: 0; + padding: 0; + border: 0; +} +.protocol-plan-features legend { padding: 0; margin-bottom: var(--space-1); } +.protocol-plan-feature { + display: inline-flex; + align-items: center; + gap: var(--space-1-5); +} +.protocol-plan-result { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.protocol-plan-chips { + display: inline-flex; + flex-wrap: wrap; + gap: var(--space-1); +} +.protocol-plan-result code { overflow-wrap: anywhere; } +.protocol-plan-candidates { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} +.protocol-plan-candidate { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.protocol-plan-candidate-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} +.protocol-feature-list { + display: flex; + flex-direction: column; + gap: var(--space-1); + margin: 0; + padding: 0; + list-style: none; +} +.protocol-feature-list li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} From 4f07bcb0bcf6c7c17487d4c671c868a5d4089281 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:27:44 +0900 Subject: [PATCH 033/173] docs: describe the protocol planner, preview routes and API page panel Structure docs name the planner's inputs, why the snapshot expands combos and policies itself, and where the dashboard panel and its validation live; the public management API reference lists the two read-only routes and states that a preview sends nothing. --- .../content/docs/reference/management-api.md | 12 +++++++++ structure/dashboard-and-usage.md | 10 ++++++++ structure/data-planes/protocol-paths.md | 25 ++++++++++++++++++- structure/gui-and-management-api.md | 1 + 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index d62086f9567..50737648c6b 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -509,6 +509,18 @@ outcome fields from an older server do not establish successful recovery. Credential list responses are deliberately masked. OAuth access tokens and complete provider API keys are not returned to dashboard clients. +### Protocol paths + +| Method and path | Purpose | Notable errors | +| --- | --- | --- | +| `GET /api/protocols` | Return the protocol contract version, which APIs are served, the protocol settings, the current policy revision, and the request features a preview understands | — | +| `POST /api/protocols/plan` | Preview the path a request would take: `{ "model": "...", "inbound": "responses" \| "chat" \| "messages", "features": [...] }` returns each route candidate's request and response path, delivery mode, fidelity, feature effects, and reasons | 400 invalid JSON, unknown field, model over 200 characters, unknown inbound, or more than 24 / unknown features | + +A preview is computed from configuration alone. It sends nothing to any provider, costs nothing, +does not advance combo rotation, and is not logged. The API page in the dashboard shows the same +preview under **Request path preview**. A delivery mode of `native` describes how the request +travels; it is not a compatibility verification. + ### Providers | Method and path | Purpose | Notable errors | diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 1df21acbe49..163b70a2b87 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -48,6 +48,16 @@ requests/sockets. Only allowlisted event types and localized error categories ar displayed. Tests live in `gui/tests/audio-api-client.test.ts`, `gui/tests/audio-api-panel.test.tsx`, `gui/tests/api-auth-memory.test.ts` and `tests/server/api-access-endpoints.test.ts`. + +The API page's request path preview is +`gui/src/components/protocols/ProtocolPlanPanel.tsx`, placed after the endpoints section. It asks +`POST /api/protocols/plan` through `gui/src/protocol-api.ts`, which validates the answer with the +shared `isProtocolPlanV1` and caches it per target, selector, sorted features and policy revision; +a 404 from an older server turns the preview off without an error. The panel shows each candidate's +path, delivery mode, fidelity, reasons and feature effects (`FeatureDispositionList.tsx`), and the +features every eligible candidate guarantees apart from those only some keep. Delivery mode is not a +verification verdict, so the panel shows no Lab badge and does not read `ExternalModelRow.native`. +Tests live in `gui/tests/protocol-api.test.ts` and `tests/server/protocol-routes.test.ts`. The API workspace gives `gui/src/components/section-tabs.tsx` its mobile reading line so scroll-spy and the top-bar offset agree; other consumers keep their existing reading line. The section strip stays one row at every width. diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index e1121b0e40a..7c5e8f8e764 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -26,7 +26,7 @@ reason codes, the first rule that keeps a Chat request off the native Chat lane; or trace reports cannot disagree. `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 +`src/protocols/path.ts`, `src/protocols/dto.ts` and `src/protocols/plan.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. @@ -93,6 +93,29 @@ an unknown value matches nothing. The dashboard renders it with client-side in `gui/src/pages/logs-filter.ts`. `tests/responses/protocol-trace.test.ts` and `tests/usage/request-log-protocol-trace.test.ts` pin derivation, persistence and the filter. +## Planner and preview + +`planProtocol` in `src/protocols/plan.ts` is pure: given a snapshot of the settled route (inbound, +selector, route kind, candidates with their final adapter and whether the ingress would take its +native lane, requested features, surfaces, settings, policy revision) it computes each candidate's +paths through `path.ts`, its feature effects, and whether `reject` would refuse it +(`feature-unrepresentable`). Features preserved by every eligible candidate are guaranteed; those +preserved by only some are partial. A disabled surface blocks every candidate with +`surface-disabled`; an unroutable selector has no candidates and reports `unknown-model`. The +planner never selects a provider. `tests/responses/protocol-plan.test.ts` covers it. + +`buildProtocolPlanSnapshot` in `src/protocols/plan-snapshot.ts` builds that snapshot from config +without side effects. Combo and policy selectors are expanded from their configured targets through +`routeConcreteModel` rather than `routeModel`, which would advance round-robin state or run the +policy evaluator; every other selector goes through `routeModel`'s deterministic branches. Each +candidate's wire is settled the way the ingress settles it (`captureRouteStaticPolicy` for the +original inbound, then `resolveWireProtocolOverride`), and a Chat candidate's native lane is judged +by `nativeChatDeclineReason` against a structural body built from the requested features. Messages +caller-forward passthrough depends on the caller's own credential, so it is reported as +`caller-credential-required` and never assumed. The OpenCode Go session-lane transport is not +modelled. `tests/responses/protocol-plan-snapshot.test.ts` pins the no-side-effect property against +combo selection state. + ## Settings `resolveApiSurfaceSettings` and `resolveProtocolSettings` in `src/protocols/settings.ts` are the diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 3f28489d252..ef0e205fc34 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -200,6 +200,7 @@ this document owns is which module holds which area and what invariant that area | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Claude reset grants | `src/server/management/anthropic-reset-grant-routes.ts` — `GET /api/anthropic/reset-grants`, `POST /api/anthropic/reset-grants/consume` (lazy-loaded). Wire and fail-closed parsing live in `src/providers/anthropic-reset-grants.ts` (the Claude Code 2.1.278 `cedar_ember` contract, sent with `CLAUDE_CLI_USER_AGENT` from `src/providers/claude-cli-identity.ts`); the journal is `src/providers/anthropic-reset-grant-ledger.ts`: a cross-process `BEGIN IMMEDIATE` lock around every synchronous read-modify-write, a 90 s lease, the operation id reused as the upstream `request_id`, same-id retry only inside the vendor's ten-minute window, no settlement inferred from a re-read, and a fail-closed `500 journal_write_failed` when an answer cannot be recorded. Spending requires the `gui-session` principal. The dashboard owner is `gui/src/hooks/useAnthropicResetGrants.ts` with `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` on the Anthropic OAuth rows of `ProviderAuthPanel`; after an unknown outcome the dialog only retries the same id. Design and audit record: [`../devlog/_plan/260923_claude_reset_grants/010_plan.md`](../devlog/_plan/260923_claude_reset_grants/010_plan.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. `PUT` keeps a stored field the body omits (`cooldownMs`, `waitForCooldownMs`, `defaultEffortMode`, `reasoningEffortMode`, `imageInput`, `cooldownWaitPolicy`, per-target `lastResort`); explicit values replace it and defaults are stored sparse. | +| Protocol paths | `src/server/management/protocol-routes.ts` (lazy-loaded) — `GET /api/protocols` returns the contract version, the resolved API surfaces and protocol settings, the policy revision and the feature vocabulary; `POST /api/protocols/plan` takes `{ model, inbound, features? }` (model at most 200 characters, at most 24 features, any other key refused with 400) and returns a `ProtocolPlanV1` with `basis: "preview"` from `src/protocols/plan-snapshot.ts`. Both are read-only, never log their input, and send nothing upstream. Both are `deferred-verb` in the route registry, owned by PF-12 in [`../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md`](../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md). | | Workflow budget | `src/server/management/workflow-budget-routes.ts` — `GET /api/workflow-budget` reads the tracked roots or one root, and `POST /api/workflow-budget/clear` clears exactly one. The clear moves the windowed send ring and the child map and nothing else: `active` belongs to turns still in flight, the spend ledger is a token budget an operator did not ask to forgive, and the lifetime send total survives so a clear cannot launder the record. A refusal event carries `spendScope` and `spendLimit` when a token ceiling fired, so the reason is readable without the config open beside it; no scope id is ever attached, because root ids are client thread headers and identity ids are credentials. Both are `deferred-verb` in the route registry — they are owed CLI verbs, and because the ledger is process memory there is no local projection the CLI could read instead. See [`../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md`](../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md). | | Codex accounts | `src/codex/auth-api/routes.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Per-account quota activation uses the existing `GET/PUT /api/settings` surface and `src/codex/quota-auto-refresh.ts`, keeping scheduled spending separate from credential/authentication mutation. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. New-account config commits add UI-managed selector bindings in the same config save; deletion deliberately retains existing bindings for fail-closed exact routing and re-add stability. Account mutations request catalog convergence only after config durability and expose only the boolean `catalogRefreshPending` completion projection. | | Sidebar | `src/server/management/sidebar-routes.ts` — `GET/POST /api/github/star`, `GET /api/update/badge`, and `POST /api/update/desktop-snapshot`. The snapshot POST accepts the raw admin-token principal or the dedicated `local-desktop-snapshot-capability`; GUI sessions and requests carrying `Origin` cannot publish desktop state. A capability's bounded raw body is verified against its authenticated digest before JSON parsing or storage. Badge state is cosmetic and a failed poll degrades silently. | From 68c62c467d73b6c2f260dfce94b69d07b5b6b592 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:28:23 +0900 Subject: [PATCH 034/173] fix(protocols): report native-lane declines only where a native lane exists Toward a different wire the path reason already explains the route; repeating the ingress's cross-wire decline next to it read as a second, contradictory cause. --- src/protocols/plan.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/protocols/plan.ts b/src/protocols/plan.ts index 931abab96a9..f230a75522d 100644 --- a/src/protocols/plan.ts +++ b/src/protocols/plan.ts @@ -92,7 +92,11 @@ function planCandidate( else if (mode === "translated") reasons.push(requestPath.length === 2 ? "cross-wire-codec" : "cross-wire-ir"); else reasons.push("not-migrated"); if (upstream === "other") reasons.push("upstream-other"); - if (lane === "bridge" && input.inbound !== "responses") reasons.push(...candidate.declineReasons); + // Why the native lane was declined matters only where a native lane could exist: toward a + // different wire the path reason above already says it. + if (lane === "bridge" && input.inbound !== "responses" && upstream === input.inbound) { + reasons.push(...candidate.declineReasons); + } if (!eligible) reasons.push("feature-unrepresentable"); return { From 6828b9aaa3cd8d77776cd8f5d276c8c53fda5883 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:18:19 +0900 Subject: [PATCH 035/173] fix(gui): translate the French route-kind labels The French catalog test rejects English values that carry translatable words; the route label and its combo value were left in English. --- gui/src/i18n/fr.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 26e73528c37..91373afb820 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2495,7 +2495,7 @@ export const fr: Record = { "api.plan.previewing": "Aperçu en cours…", "api.plan.failed": "L’aperçu n’a pas pu être calculé.", "api.plan.overallMode": "Acheminement", - "api.plan.routeKind": "Route", + "api.plan.routeKind": "Routage", "api.plan.reasons": "Raisons", "api.plan.guaranteed": "Garanti par tous les candidats", "api.plan.partial": "Certains candidats seulement", @@ -2516,7 +2516,7 @@ export const fr: Record = { "api.plan.fidelity.degraded": "Dégradée", "api.plan.fidelity.unknown": "Inconnue", "api.plan.routeKind.direct": "Directe", - "api.plan.routeKind.combo": "Combo", + "api.plan.routeKind.combo": "Combinaison", "api.plan.routeKind.policy": "Politique de routage", "api.plan.routeKind.unknown": "Modèle inconnu", "api.plan.disposition.passthrough": "Transmis tel quel", From 6bbf9da5d7b08a3a37e775fe2b33e35a3aa19c8e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:18:19 +0900 Subject: [PATCH 036/173] fix(gui): key plan candidates by provider and model A candidate is identified by its provider and model; the list index added nothing and React Doctor flags index keys. --- gui/src/components/protocols/ProtocolPlanPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/src/components/protocols/ProtocolPlanPanel.tsx b/gui/src/components/protocols/ProtocolPlanPanel.tsx index 283b83226c2..6659293ab92 100644 --- a/gui/src/components/protocols/ProtocolPlanPanel.tsx +++ b/gui/src/components/protocols/ProtocolPlanPanel.tsx @@ -98,8 +98,8 @@ function PlanResult({ plan }: { plan: ProtocolPlanV1 }) {

{t("api.plan.noCandidates")}

) : (
    - {plan.candidates.map((candidate, index) => ( -
  1. + {plan.candidates.map(candidate => ( +
  2. {candidate.provider}/{candidate.model} {t(MODE_KEYS[candidate.mode])} From 1279960f0b8064ae99b8d8bf859e2ddc78596f82 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:18:19 +0900 Subject: [PATCH 037/173] refactor(gui): name the preview model list planModels The API page layout test forbids the classic viewMode toggle by searching for the substring, which the previous prop name contained. --- gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx | 6 +++--- gui/src/pages/ApiKeys.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx index 1d1e9ee11cc..fc321447d92 100644 --- a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx +++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx @@ -53,7 +53,7 @@ export interface ApiKeysWorkspaceProps { rotationCopied?: boolean; filteredModels: ExternalModelRow[]; /** Unfiltered catalog for the path preview picker; the model search must not narrow it. */ - previewModels?: ExternalModelRow[]; + planModels?: ExternalModelRow[]; modelsLoading: boolean; /** Quiet revalidation / retry over rows already on screen — not a skeleton. */ modelsRefreshing?: boolean; @@ -103,7 +103,7 @@ export default function ApiKeysWorkspace({ rotationSecret = null, rotationCopied = false, filteredModels, - previewModels, + planModels, modelsLoading, modelsRefreshing = false, modelsLoadFailed, @@ -544,7 +544,7 @@ export default function ApiKeysWorkspace({ {/* Reference, then prediction: which path a request would take through the endpoints above. Asked of the server on demand; it sends nothing upstream. */}
    - +
    {active && } diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index de8303792ca..15c78c8b835 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -524,7 +524,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a rotationSecret={rotationSecret} rotationCopied={rotationCopied} filteredModels={filteredModels} - previewModels={models} + planModels={models} modelsLoading={modelsState.showSkeleton && !modelsState.data && !cachedModels} // Only announce progress on a retry after failure — quiet warm revisits stay silent. modelsRefreshing={modelsState.refreshing && modelsState.showError && (modelsState.data !== undefined || cachedModels !== null)} From 21cc17145790504fc9bcb45c00164779eb49ddce Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:18:19 +0900 Subject: [PATCH 038/173] test(cli): account for /api/protocols in the headless parity map The protocol routes have owed CLI verbs, recorded as deferred in the route registry; the parity map now says so instead of leaving them uncovered. --- tests/cli/cli-headless-parity.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index d369297a84e..7260cff8f62 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -480,6 +480,9 @@ describe("headless GUI parity CLI", () => { // Claude reset grants: reading is an owed CLI verb (deferred-verb in the route // registry) and spending is dashboard-session-only by design. ["/api/anthropic/reset-grants", "(none — GUI reset-grant dialog; spend requires a dashboard session)"], + // Protocol path preview and settings: the CLI verbs are owed (deferred-verb in the + // route registry) and land with the rollout packet. + ["/api/protocols", "(none yet — deferred ocx api protocols/explain/policy verbs)"], ["/api/settings", "ocx system"], // Routing Intelligence (RI-04..RI-10): profiles + dry-run are mirrored by // `ocx route policy`. Analytics is GUI-first for now; the same request From 64b4de21e56b41491ad36e1b8cb5184c0054228b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:18:45 +0900 Subject: [PATCH 039/173] refactor(inference): add createInferenceSendBudget The ingress-owned send holder is built in one place: default guarded policy and this request's spend tracker as observer. Responses core and later native lanes share it. --- scripts/test-layout/layout.json | 1 + src/server/inference/context.ts | 15 ++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/server/inference-send-budget.test.ts | 24 ++++++++++++++++++++++ 4 files changed, 41 insertions(+) create mode 100644 src/server/inference/context.ts create mode 100644 tests/server/inference-send-budget.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e27b68cb22b..83f0ed74e18 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -192,6 +192,7 @@ "adapter-input-media-guard.test.ts": "adapters", "adapter-registry-authority.test.ts": "adapters", "adapter-resolve.test.ts": "server", + "inference-send-budget.test.ts": "server", "adapter-tool-conformance.test.ts": "adapters", "adapter-usage.test.ts": "adapters", "agent-driven.test.ts": "cli", diff --git a/src/server/inference/context.ts b/src/server/inference/context.ts new file mode 100644 index 00000000000..904d2300f38 --- /dev/null +++ b/src/server/inference/context.ts @@ -0,0 +1,15 @@ +import { createRequestExecutionBudget, type RequestExecutionBudget } from "../../lib/request-execution-budget"; +import type { RequestLogContext } from "../request-log"; +import { attachRequestSpendTracker } from "../responses/request-spend"; + +/** + * The one construction of an ingress-owned send budget: the default guarded policy, no logical + * request id, and this request's spend tracker as the send observer. Attaching the tracker + * parks it on `logCtx`, so callers that inherit a holder must not call this at all. + */ +export function createInferenceSendBudget( + req: Pick, + logCtx: RequestLogContext, +): RequestExecutionBudget { + return createRequestExecutionBudget(undefined, undefined, attachRequestSpendTracker(req, logCtx)); +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 948a494163a..22fb0cd8bae 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -18,6 +18,7 @@ "adapter-input-media-guard.test.ts": "adapters", "adapter-registry-authority.test.ts": "adapters", "adapter-resolve.test.ts": "server", + "inference-send-budget.test.ts": "server", "adapter-tool-conformance.test.ts": "adapters", "adapter-usage.test.ts": "adapters", "agent-driven.test.ts": "cli", diff --git a/tests/server/inference-send-budget.test.ts b/tests/server/inference-send-budget.test.ts new file mode 100644 index 00000000000..e53bdfd4a06 --- /dev/null +++ b/tests/server/inference-send-budget.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { CODEX_TEXT_GUARDED_BUDGET_POLICY } from "../../src/lib/request-execution-budget"; +import { createInferenceSendBudget } from "../../src/server/inference/context"; +import type { RequestLogContext } from "../../src/server/request-log"; + +describe("createInferenceSendBudget", () => { + test("mints a default-policy holder and parks this request's spend tracker on the log", () => { + const logCtx: RequestLogContext = { model: "m", provider: "p" }; + const req = new Request("http://localhost/v1/responses", { method: "POST" }); + const budget = createInferenceSendBudget(req, logCtx); + expect(budget.policy).toBe(CODEX_TEXT_GUARDED_BUDGET_POLICY); + expect(typeof budget.logicalRequestId).toBe("string"); + expect(budget.used).toBe(0); + expect(logCtx.spendTracker).toBeDefined(); + }); + + test("each call mints its own holder", () => { + const req = new Request("http://localhost/v1/responses", { method: "POST" }); + const a = createInferenceSendBudget(req, { model: "m", provider: "p" }); + const b = createInferenceSendBudget(req, { model: "m", provider: "p" }); + expect(a).not.toBe(b); + expect(a.logicalRequestId).not.toBe(b.logicalRequestId); + }); +}); From 1e9871995fca8d5ee70b907e8754ed9d4bca7269 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:18:45 +0900 Subject: [PATCH 040/173] refactor(inference): add a finish-once final request log Three ingress files hand-roll the same once-flag around addFinalRequestLog. One owner lets a native attempt hand its final row to whichever caller owns the request. --- scripts/test-layout/layout.json | 1 + src/server/inference/final-log.ts | 37 +++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/server/inference-final-log.test.ts | 67 ++++++++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 src/server/inference/final-log.ts create mode 100644 tests/server/inference-final-log.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 83f0ed74e18..50dff3c4594 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -192,6 +192,7 @@ "adapter-input-media-guard.test.ts": "adapters", "adapter-registry-authority.test.ts": "adapters", "adapter-resolve.test.ts": "server", + "inference-final-log.test.ts": "server", "inference-send-budget.test.ts": "server", "adapter-tool-conformance.test.ts": "adapters", "adapter-usage.test.ts": "adapters", diff --git a/src/server/inference/final-log.ts b/src/server/inference/final-log.ts new file mode 100644 index 00000000000..2cfe7bdf9e2 --- /dev/null +++ b/src/server/inference/final-log.ts @@ -0,0 +1,37 @@ +import { addFinalRequestLog, type RequestLogContext, type RequestLogEntry } from "../request-log"; + +/** The request-row identity an ingress hands down; absent when nothing records the request. */ +export interface FinalRequestLogIds { + requestId: string; + start: number; +} + +export type FinalRequestLogMeta = Pick; + +export interface FinalRequestLog { + /** Write the final request row once. Every later call, from any path, is a no-op. */ + finish(status: number, meta?: FinalRequestLogMeta): void; + /** True once `finish` has run, whether or not a row was written. */ + finished(): boolean; +} + +/** + * Finish-once ownership of one request's final log row. Terminal callbacks, cancellation and + * the non-stream return race each other, and only the first of them may write the row. + * Without log ids the claim still settles, so a caller's `finished()` check behaves the same + * whether or not the request is recorded. + */ +export function createFinalRequestLog( + logIds: FinalRequestLogIds | undefined, + logCtx: RequestLogContext, +): FinalRequestLog { + let done = false; + return { + finish: (status, meta) => { + if (done) return; + done = true; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta); + }, + finished: () => done, + }; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 22fb0cd8bae..aa5f75e2770 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -18,6 +18,7 @@ "adapter-input-media-guard.test.ts": "adapters", "adapter-registry-authority.test.ts": "adapters", "adapter-resolve.test.ts": "server", + "inference-final-log.test.ts": "server", "inference-send-budget.test.ts": "server", "adapter-tool-conformance.test.ts": "adapters", "adapter-usage.test.ts": "adapters", diff --git a/tests/server/inference-final-log.test.ts b/tests/server/inference-final-log.test.ts new file mode 100644 index 00000000000..cf280ea1889 --- /dev/null +++ b/tests/server/inference-final-log.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createFinalRequestLog } from "../../src/server/inference/final-log"; +import { + clearRequestLogsForTests, + getRequestLogEntries, + type RequestLogContext, +} from "../../src/server/request-log"; +import { resetUsageReadCacheForTests } from "../../src/usage/log"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +function withIsolatedLogs(run: () => void): void { + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-final-log-")); + process.env.OPENCODEX_HOME = home; + clearRequestLogsForTests(); + try { + run(); + } finally { + clearRequestLogsForTests(); + resetUsageReadCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } +} + +describe("createFinalRequestLog", () => { + test("the first finish writes the row and every later finish is a no-op", () => { + withIsolatedLogs(() => { + const logCtx: RequestLogContext = { model: "m", provider: "p" }; + const finalLog = createFinalRequestLog({ requestId: "final-once", start: Date.now() - 5 }, logCtx); + expect(finalLog.finished()).toBe(false); + + finalLog.finish(200, { closeReason: "terminal", terminalStatus: "completed" }); + finalLog.finish(499, { closeReason: "client_cancel" }); + finalLog.finish(502); + + expect(finalLog.finished()).toBe(true); + const rows = getRequestLogEntries().filter(row => row.requestId === "final-once"); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe(200); + expect(rows[0]?.closeReason).toBe("terminal"); + }); + }); + + test("a destructured finish keeps the once-claim", () => { + withIsolatedLogs(() => { + const finish = createFinalRequestLog({ requestId: "final-bound", start: Date.now() }, { model: "m", provider: "p" }).finish; + finish(499, { closeReason: "client_cancel" }); + finish(200, { closeReason: "non_stream" }); + const rows = getRequestLogEntries().filter(row => row.requestId === "final-bound"); + expect(rows.map(row => row.status)).toEqual([499]); + }); + }); + + test("without log ids nothing is written but the claim still settles", () => { + withIsolatedLogs(() => { + const finalLog = createFinalRequestLog(undefined, { model: "m", provider: "p" }); + finalLog.finish(200); + expect(finalLog.finished()).toBe(true); + expect(getRequestLogEntries()).toHaveLength(0); + }); + }); +}); From 175edca08c5961cc3d5bac72472c9d72728e4241 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:18:45 +0900 Subject: [PATCH 041/173] refactor(inference): add beginInferenceAttempt Opening an attempt is four ordered writes on the log context plus identity sealing; the handle keeps them together so a new lane cannot skip or reorder one. --- scripts/test-layout/layout.json | 1 + src/server/inference/attempt.ts | 51 ++++++++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/server/inference-attempt.test.ts | 40 +++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 src/server/inference/attempt.ts create mode 100644 tests/server/inference-attempt.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 50dff3c4594..5e46a9df04a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -192,6 +192,7 @@ "adapter-input-media-guard.test.ts": "adapters", "adapter-registry-authority.test.ts": "adapters", "adapter-resolve.test.ts": "server", + "inference-attempt.test.ts": "server", "inference-final-log.test.ts": "server", "inference-send-budget.test.ts": "server", "adapter-tool-conformance.test.ts": "adapters", diff --git a/src/server/inference/attempt.ts b/src/server/inference/attempt.ts new file mode 100644 index 00000000000..760cf8f5314 --- /dev/null +++ b/src/server/inference/attempt.ts @@ -0,0 +1,51 @@ +import type { OcxUsage } from "../../types"; +import type { PersistedUsageAttempt } from "../../usage/log"; +import { + beginRequestAttempt, + finishRequestAttempt, + sealRequestAttemptIdentity, + type RequestLogContext, +} from "../request-log"; + +export interface InferenceAttemptTarget { + provider: string; + model: string; + adapter: string; +} + +export interface InferenceAttempt { + readonly attempt: PersistedUsageAttempt; + /** Epoch ms the attempt became active; the duration `finish` records is measured from it. */ + readonly startedAt: number; + /** Stamp the attempt's provider/adapter identity and, before its first send, its account. */ + seal(accountLabel?: string): void; + /** Close the attempt row with its status and duration; `usage` overrides the attempt's own. */ + finish(status: number, usage?: OcxUsage): PersistedUsageAttempt; +} + +/** + * Open one physical attempt on `logCtx`: the next ordinal, the active attempt and its start + * time, and the attempt appended to the request's list, in that order. The attempt is the row + * the final request log finishes; `finish` exists for owners that close it themselves. + */ +export function beginInferenceAttempt( + logCtx: RequestLogContext, + target: InferenceAttemptTarget, +): InferenceAttempt { + const attempt = beginRequestAttempt( + (logCtx.attempts?.length ?? 0) + 1, + target.provider, + target.model, + target.adapter, + ); + logCtx.activeAttempt = attempt; + const startedAt = Date.now(); + logCtx.activeAttemptStartedAt = startedAt; + (logCtx.attempts ??= []).push(attempt); + return { + attempt, + startedAt, + seal: accountLabel => sealRequestAttemptIdentity(attempt, target.provider, target.adapter, accountLabel), + finish: (status, usage) => finishRequestAttempt(attempt, status, Date.now() - startedAt, usage), + }; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index aa5f75e2770..eb7eaa13a30 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -18,6 +18,7 @@ "adapter-input-media-guard.test.ts": "adapters", "adapter-registry-authority.test.ts": "adapters", "adapter-resolve.test.ts": "server", + "inference-attempt.test.ts": "server", "inference-final-log.test.ts": "server", "inference-send-budget.test.ts": "server", "adapter-tool-conformance.test.ts": "adapters", diff --git a/tests/server/inference-attempt.test.ts b/tests/server/inference-attempt.test.ts new file mode 100644 index 00000000000..658b622f5df --- /dev/null +++ b/tests/server/inference-attempt.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { beginInferenceAttempt } from "../../src/server/inference/attempt"; +import type { RequestLogContext } from "../../src/server/request-log"; + +describe("beginInferenceAttempt", () => { + test("opens the next ordinal as the active attempt and appends it to the request", () => { + const logCtx: RequestLogContext = { model: "m", provider: "p" }; + const first = beginInferenceAttempt(logCtx, { provider: "a", model: "m1", adapter: "openai-chat" }); + expect(first.attempt.ordinal).toBe(1); + expect(logCtx.activeAttempt).toBe(first.attempt); + expect(logCtx.activeAttemptStartedAt).toBe(first.startedAt); + expect(logCtx.attempts).toEqual([first.attempt]); + + const second = beginInferenceAttempt(logCtx, { provider: "b", model: "m2", adapter: "openai-responses" }); + expect(second.attempt).toMatchObject({ ordinal: 2, provider: "b", model: "m2", adapter: "openai-responses" }); + expect(logCtx.activeAttempt).toBe(second.attempt); + expect(logCtx.attempts).toEqual([first.attempt, second.attempt]); + }); + + test("seal stamps the target identity and only a Codex usage label", () => { + const logCtx: RequestLogContext = { model: "m", provider: "p" }; + const handle = beginInferenceAttempt(logCtx, { provider: "a", model: "m1", adapter: "openai-chat" }); + handle.attempt.adapter = "other"; + handle.seal(undefined); + expect(handle.attempt.adapter).toBe("openai-chat"); + expect(handle.attempt.provider).toBe("a"); + expect(handle.attempt.accountLogLabel).toBeUndefined(); + }); + + test("finish closes the row with status, a non-negative duration and the given usage", () => { + const logCtx: RequestLogContext = { model: "m", provider: "p" }; + const handle = beginInferenceAttempt(logCtx, { provider: "a", model: "m1", adapter: "openai-chat" }); + const finished = handle.finish(502, { inputTokens: 3, outputTokens: 4 }); + expect(finished).toBe(handle.attempt); + expect(handle.attempt.status).toBe(502); + expect(handle.attempt.durationMs).toBeGreaterThanOrEqual(0); + expect(handle.attempt.usage?.inputTokens).toBe(3); + expect(handle.attempt.errorCode).toBeDefined(); + }); +}); From 7ae30d76df635e529116774ed9b0b123308c6c54 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:18:45 +0900 Subject: [PATCH 042/173] refactor(inference): add the client-wire response marker Lets an ingress tell a body already in its own client wire from a Responses body it still has to convert. Nothing marks responses yet. --- scripts/test-layout/layout.json | 1 + src/server/inference/client-wire.ts | 18 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/server/inference-client-wire.test.ts | 16 ++++++++++++++++ 4 files changed, 36 insertions(+) create mode 100644 src/server/inference/client-wire.ts create mode 100644 tests/server/inference-client-wire.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5e46a9df04a..0fa9be2b82e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -192,6 +192,7 @@ "adapter-input-media-guard.test.ts": "adapters", "adapter-registry-authority.test.ts": "adapters", "adapter-resolve.test.ts": "server", + "inference-client-wire.test.ts": "server", "inference-attempt.test.ts": "server", "inference-final-log.test.ts": "server", "inference-send-budget.test.ts": "server", diff --git a/src/server/inference/client-wire.ts b/src/server/inference/client-wire.ts new file mode 100644 index 00000000000..8825ce7e055 --- /dev/null +++ b/src/server/inference/client-wire.ts @@ -0,0 +1,18 @@ +import type { Protocol } from "../../protocols/contract"; + +const clientWires = new WeakMap(); + +/** + * Record that `response` is already in `protocol`'s client wire, so an ingress can tell a body + * it may pass through from a Responses body it still has to convert. Identity-keyed: a + * rebuilt or cloned Response carries no mark. Returns the same response. + */ +export function markClientWire(response: Response, protocol: Protocol): Response { + clientWires.set(response, protocol); + return response; +} + +/** The client wire `response` was marked with, or undefined for an unmarked response. */ +export function clientWireOf(response: Response): Protocol | undefined { + return clientWires.get(response); +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index eb7eaa13a30..2e8a97e2294 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -18,6 +18,7 @@ "adapter-input-media-guard.test.ts": "adapters", "adapter-registry-authority.test.ts": "adapters", "adapter-resolve.test.ts": "server", + "inference-client-wire.test.ts": "server", "inference-attempt.test.ts": "server", "inference-final-log.test.ts": "server", "inference-send-budget.test.ts": "server", diff --git a/tests/server/inference-client-wire.test.ts b/tests/server/inference-client-wire.test.ts new file mode 100644 index 00000000000..711d5386993 --- /dev/null +++ b/tests/server/inference-client-wire.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { clientWireOf, markClientWire } from "../../src/server/inference/client-wire"; + +describe("client-wire marker", () => { + test("a marked response reports its protocol and returns itself", () => { + const response = new Response("{}"); + expect(markClientWire(response, "chat")).toBe(response); + expect(clientWireOf(response)).toBe("chat"); + }); + + test("an unmarked or cloned response carries no mark", () => { + const marked = markClientWire(new Response("{}"), "messages"); + expect(clientWireOf(new Response("{}"))).toBeUndefined(); + expect(clientWireOf(marked.clone())).toBeUndefined(); + }); +}); From 6b94522c059416df443157613d7683181ec0b592 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:19:00 +0900 Subject: [PATCH 043/173] refactor(responses): build the ingress send budget through inference/context Same construction, same short-circuit on an inherited holder. core.ts no longer imports request-spend directly, so the owner inventory drops it: the import graph from core.ts is what the module-boundary test compares against. --- src/server/responses/core.ts | 5 ++--- tests/helpers/responses-core-source.ts | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 9905ed7dc5f..c54e68fdccf 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -9,8 +9,7 @@ import type { import { createTranslatorBudget } from "../../lib/translator-budget"; import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar"; import { captureCallerDirectAuth } from "../../providers/caller-authorization"; -import { createRequestExecutionBudget } from "../../lib/request-execution-budget"; -import { attachRequestSpendTracker } from "./request-spend"; +import { createInferenceSendBudget } from "../inference/context"; import { finalizeOwnedTranslatorBudget } from "./core-lifetime"; import type { TranslatorBudget } from "../../lib/translator-budget"; import { executeComboResponses } from "./core-combo"; @@ -58,7 +57,7 @@ export async function handleResponses( || req.headers.get("x-opencodex-vision-describe") === "1", translatorBudget, // Once at ingress, spend observer included: a combo child inherits the parent's holder. - sendBudget: options.sendBudget ?? createRequestExecutionBudget(undefined, undefined, attachRequestSpendTracker(req, logCtx)), + sendBudget: options.sendBudget ?? createInferenceSendBudget(req, logCtx), }); return ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget) : response; } catch (error) { diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 1411e86f887..be7d246e0b4 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -35,7 +35,6 @@ export const RESPONSES_CORE_MODULES = [ "request-sidecar-auth.ts", "response-effects.ts", "request-send-budget.ts", - "request-spend.ts", "passthrough-execution.ts", "passthrough-dispatch.ts", "reset-replay.ts", From dbdaec17d33d2eb00e0dd360bd02199bf5c91158 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:19:15 +0900 Subject: [PATCH 044/173] refactor(chat): finish the bridged Chat log through createFinalRequestLog Same once-claim, same fields, same call sites; the local flag and its closure go. --- src/server/chat-completions.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 182e739e2c7..2714652bd38 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -47,8 +47,8 @@ import { httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, - type RequestLogEntry, } from "./request-log"; +import { createFinalRequestLog } from "./inference/final-log"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; import { providerConsumesCallerAuthorization } from "../providers/caller-authorization"; @@ -387,12 +387,7 @@ async function handleChatCompletionsWithBudget( }); linkRequestSessionLane(req, internalReq); - let nativeLogged = false; - const finalizeNativeLog = (status: number, meta: { terminalStatus?: RequestLogEntry["terminalStatus"]; closeReason: "terminal" | "client_cancel" | "non_stream" }) => { - if (!logIds || nativeLogged) return; - nativeLogged = true; - addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta); - }; + const finalizeNativeLog = createFinalRequestLog(logIds, logCtx).finish; const upstream = await handleResponses(internalReq, config, logCtx, { openAiSidecarAuth, allowStoredOpenAiSidecarAuth: !!(callerAuthorizationRoute && settledRoute From c381d708afc6d206b6130148cfc1b77a4805a0ad Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:19:34 +0900 Subject: [PATCH 045/173] refactor(claude): finish Messages logs through createFinalRequestLog Both the caller-forward passthrough and the routed replay used the same once-flag closure; the shared owner keeps their fields and call order unchanged. --- src/server/claude-messages.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index e7c25b322ce..ec9eb4ee034 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -49,7 +49,8 @@ import { evidenceFromBody } from "../routing/request-evidence"; import { resolveWireProtocolOverride } from "./adapter-resolve"; import type { OcxConfig } from "../types"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; -import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log"; +import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext } from "./request-log"; +import { createFinalRequestLog } from "./inference/final-log"; import { conversationIdFromClaudeMetadata, getOrAllocateRequestSessionLane, @@ -468,12 +469,7 @@ async function anthropicNativePassthrough( logCtx.model = model; logCtx.provider = "anthropic-native"; logCtx.requestedModel = model; - let logged = false; - const finalize = (status: number, meta: { closeReason: PassthroughCloseReason | "non_stream" }) => { - if (!logIds || logged) return; - logged = true; - addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta); - }; + const finalize = createFinalRequestLog(logIds, logCtx).finish; const base = (config.claudeCode?.anthropicBaseUrl ?? "https://api.anthropic.com").replace(/\/$/, ""); const search = new URL(req.url).search; @@ -1022,12 +1018,7 @@ async function handleClaudeMessagesWithBudget( // via the terminal callbacks; routed streams get the Responses-vocabulary log tap // BEFORE translation (the translated Anthropic stream has no response.completed // frame, so tapping it records a bogus 502 with no usage/cache detail). - let nativeLogged = false; - const finalizeNativeLog = (status: number, meta: { terminalStatus?: RequestLogEntry["terminalStatus"]; closeReason: "terminal" | "client_cancel" }) => { - if (!logIds || nativeLogged) return; - nativeLogged = true; - addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta); - }; + const finalizeNativeLog = createFinalRequestLog(logIds, logCtx).finish; const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, { // Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner. codexAuthPolicy: config, From 82cdb60bb49ac850c17c08eacb9362207c6a281c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:20:03 +0900 Subject: [PATCH 046/173] refactor(chat-native): open the attempt through beginInferenceAttempt The four context writes and the identity seal run in the original order, before any effort normalization or send. --- src/server/chat-native.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 4906432a47f..a41af7389a3 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -58,16 +58,15 @@ import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectio import { linkAbortSignal } from "./responses"; import { addFinalRequestLog, - beginRequestAttempt, noteProviderAttemptSend, recordKeyAttemptFailure, recordKeyWireAttemptUsage, recordFirstOutput, recordAttemptCredentialSource, - sealRequestAttemptIdentity, type RequestLogContext, } from "./request-log"; import { jsonCompletionSse, nativeChatSse, structuredError, usageFromChat } from "./chat-native-sse"; +import { beginInferenceAttempt } from "./inference/attempt"; import { registerTurn, unregisterTurn } from "./lifecycle"; import { attachRequestSpendTracker } from "./responses/request-spend"; import { workflowRefusalResponse } from "./workflow-refusal"; @@ -175,16 +174,13 @@ interface HandleNativeChatOptions { export async function handleNativeChatCompletions(options: HandleNativeChatOptions): Promise { const { req, config, logCtx, logIds, route, requestedModel, requestedStream, translatorBudget } = options; logCtx.inboundProtocol = "chat"; - const attempt = beginRequestAttempt( - (logCtx.attempts?.length ?? 0) + 1, - route.providerName, - route.modelId, - "openai-chat", - ); - logCtx.activeAttempt = attempt; - logCtx.activeAttemptStartedAt = Date.now(); - (logCtx.attempts ??= []).push(attempt); - sealRequestAttemptIdentity(attempt, route.providerName, "openai-chat", logCtx.accountLogLabel); + const attemptHandle = beginInferenceAttempt(logCtx, { + provider: route.providerName, + model: route.modelId, + adapter: "openai-chat", + }); + attemptHandle.seal(logCtx.accountLogLabel); + const { attempt } = attemptHandle; let logged = false; const finishLog = (status: number, message?: string, closeReason: "non_stream" | "terminal" | "client_cancel" = "non_stream") => { From a8811a7e1f53b925222302e557760e81288a771b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:20:08 +0900 Subject: [PATCH 047/173] refactor(chat-native): claim the final log through createFinalRequestLog The native finishLog still records the redacted failure text before the row, once; only the once-flag moves to the shared owner. --- src/server/chat-native.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index a41af7389a3..6ae174bde6b 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -57,7 +57,6 @@ import type { OcxConfig, OcxProviderConfig } from "../types"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers"; import { linkAbortSignal } from "./responses"; import { - addFinalRequestLog, noteProviderAttemptSend, recordKeyAttemptFailure, recordKeyWireAttemptUsage, @@ -67,6 +66,7 @@ import { } from "./request-log"; import { jsonCompletionSse, nativeChatSse, structuredError, usageFromChat } from "./chat-native-sse"; import { beginInferenceAttempt } from "./inference/attempt"; +import { createFinalRequestLog } from "./inference/final-log"; import { registerTurn, unregisterTurn } from "./lifecycle"; import { attachRequestSpendTracker } from "./responses/request-spend"; import { workflowRefusalResponse } from "./workflow-refusal"; @@ -182,12 +182,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio attemptHandle.seal(logCtx.accountLogLabel); const { attempt } = attemptHandle; - let logged = false; + const finalLog = createFinalRequestLog(logIds, logCtx); const finishLog = (status: number, message?: string, closeReason: "non_stream" | "terminal" | "client_cancel" = "non_stream") => { - if (logged) return; - logged = true; + if (finalLog.finished()) return; + // The failure text lands on the context before the row is written, and only once. if (message) logCtx.upstreamError = redactSecretString(message).slice(0, 500); - if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason }); + finalLog.finish(status, { closeReason }); }; const fail = (status: number, message: string, type?: string, code?: string | null): Response => { const safeMessage = redactSecretString(message); From 75e237ba5d63394fd0d98bdc854588cf3534474b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:20:35 +0900 Subject: [PATCH 048/173] refactor(chat-native): split runNativeChatAttempt from the ingress wrapper The wrapper opens the attempt and owns the final log row; runNativeChatAttempt runs the effort normalization, send loop, key failover, 429 replay, relay and usage and reports every outcome through the finishLog it is given. A combo child can then run a native attempt whose final row belongs to the parent. Statement order is unchanged. --- src/server/chat-native.ts | 50 ++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 6ae174bde6b..c258f40899b 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -65,7 +65,7 @@ import { type RequestLogContext, } from "./request-log"; import { jsonCompletionSse, nativeChatSse, structuredError, usageFromChat } from "./chat-native-sse"; -import { beginInferenceAttempt } from "./inference/attempt"; +import { beginInferenceAttempt, type InferenceAttempt } from "./inference/attempt"; import { createFinalRequestLog } from "./inference/final-log"; import { registerTurn, unregisterTurn } from "./lifecycle"; import { attachRequestSpendTracker } from "./responses/request-spend"; @@ -159,7 +159,7 @@ function chatCompletionJson(value: unknown): Rec | null { return value; } -interface HandleNativeChatOptions { +export interface HandleNativeChatOptions { req: Request; config: OcxConfig; logCtx: RequestLogContext; @@ -171,8 +171,23 @@ interface HandleNativeChatOptions { translatorBudget: TranslatorBudget; } +/** Records the outcome on whichever final row owns this attempt; the first call wins. */ +export type NativeChatFinishLog = ( + status: number, + message?: string, + closeReason?: "non_stream" | "terminal" | "client_cancel", +) => void; + +export interface NativeChatExecution extends HandleNativeChatOptions { + finishLog: NativeChatFinishLog; +} + +/** + * The native Chat ingress: opens the attempt on the request's log context and owns the + * request's final log row, then runs the attempt. + */ export async function handleNativeChatCompletions(options: HandleNativeChatOptions): Promise { - const { req, config, logCtx, logIds, route, requestedModel, requestedStream, translatorBudget } = options; + const { logCtx, logIds, route } = options; logCtx.inboundProtocol = "chat"; const attemptHandle = beginInferenceAttempt(logCtx, { provider: route.providerName, @@ -180,24 +195,37 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio adapter: "openai-chat", }); attemptHandle.seal(logCtx.accountLogLabel); - const { attempt } = attemptHandle; const finalLog = createFinalRequestLog(logIds, logCtx); - const finishLog = (status: number, message?: string, closeReason: "non_stream" | "terminal" | "client_cancel" = "non_stream") => { + const finishLog: NativeChatFinishLog = (status, message, closeReason = "non_stream") => { if (finalLog.finished()) return; // The failure text lands on the context before the row is written, and only once. if (message) logCtx.upstreamError = redactSecretString(message).slice(0, 500); finalLog.finish(status, { closeReason }); }; + return runNativeChatAttempt({ ...options, finishLog }, attemptHandle); +} + +/** + * One native Chat attempt on an already-open attempt row: effort normalization, the send + * loop with key failover and 429 replay, relay and usage. Every outcome is reported through + * `execution.finishLog`, so the caller decides which final row it lands on. + */ +export async function runNativeChatAttempt( + execution: NativeChatExecution, + attemptHandle: InferenceAttempt, +): Promise { + const { req, config, logCtx, logIds, route, requestedModel, requestedStream, translatorBudget, finishLog } = execution; + const { attempt } = attemptHandle; const fail = (status: number, message: string, type?: string, code?: string | null): Response => { const safeMessage = redactSecretString(message); finishLog(status, safeMessage); return chatCompletionsErrorResponse(status, safeMessage, type, code); }; - normalizePinnedChatEffort(options); - logCtx.requestedServiceTier = typeof options.chatBody.service_tier === "string" - ? options.chatBody.service_tier + normalizePinnedChatEffort(execution); + logCtx.requestedServiceTier = typeof execution.chatBody.service_tier === "string" + ? execution.chatBody.service_tier : undefined; const upstream = new AbortController(); @@ -246,7 +274,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio recordAttemptCredentialSource(attempt, route.providerName, activeProvider, activeAdapter.name); return buildOpenAIChatPassthroughRequest( activeProvider, - options.chatBody, + execution.chatBody, route.modelId, requestedStream, fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"), @@ -302,7 +330,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio dispatchOverride: async (_input, init, execute) => { if (!providerApiKeySelectionIsCurrent(config, route.providerName, activeProvider)) { const current = resolveCurrentProviderApiKeyTransport(config, route.providerName, activeProvider); - if (!current || !isNativeChatRouteEligible({ ...route, provider: current }, options.chatBody, config)) { + if (!current || !isNativeChatRouteEligible({ ...route, provider: current }, execution.chatBody, config)) { throw new Error("Provider key selection is no longer available for native Chat"); } activeProvider = current; @@ -392,7 +420,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio now: Date.now(), attemptedKey: activeProvider.apiKey, attemptedSelection: activeProvider._apiKeyAttempt, - promptCacheKey: typeof options.chatBody.prompt_cache_key === "string" ? options.chatBody.prompt_cache_key : undefined, + promptCacheKey: typeof execution.chatBody.prompt_cache_key === "string" ? execution.chatBody.prompt_cache_key : undefined, }); if (!rotated) break; // Rotation also records the failed key's cooldown and persists the next healthy key. From efd1e5ae6117ca023b8116154dd9fe62e8118cbe Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:21:05 +0900 Subject: [PATCH 049/173] refactor(responses): group core.ts compatibility re-exports by owner Same names from the same owners, one statement per module. core.ts sits at its size cap and the native Chat combo work needs headroom there. --- src/server/responses/core.ts | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c54e68fdccf..635d5e37c17 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -30,7 +30,6 @@ import { releaseCodexAuthContextProbeLease } from "../../codex/auth-context"; /** Public Responses entry and compatibility exports. Implementations live with their owners. */ - /** * Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough * wire, image/web-search bridges, and the terminal-guard continuation. @@ -181,29 +180,19 @@ async function handleResponsesInner( const requestDispatchers: ResponsesDispatchers = { handleResponses, handleComboResponses }; export { adapterNeedsForcedContinuation } from "./core-replay"; -export { sidecarOutcomeRecorder } from "./core-codex-account"; -export { codexLogAccountId } from "./core-codex-account"; +export { + sidecarOutcomeRecorder, codexLogAccountId, usesCodexForwardPoolAuth, preAuthUpstreamHostCircuitKey, + upstreamHostCircuitOpenResponse, shouldRetryCodexPoolAccountQuota, shouldRetryCodexScopedQuotaOnAlternate, + shouldRetryCodexPoolAccountTransient, codexAccountGatedCanonicalWireModel, codexForwardTerminalOutcomeRecorder, +} from "./core-codex-account"; export { shouldAttemptOpaqueBlobRecovery } from "./core-opaque-recovery"; -export { readDisplaySafeErrorText } from "./core-errors"; -export { usesCodexForwardPoolAuth } from "./core-codex-account"; -export { preAuthUpstreamHostCircuitKey } from "./core-codex-account"; -export { upstreamHostCircuitOpenResponse } from "./core-codex-account"; -export { shouldRetryCodexPoolAccountQuota, shouldRetryCodexScopedQuotaOnAlternate } from "./core-codex-account"; -export { shouldRetryCodexPoolAccountTransient } from "./core-codex-account"; -export { codexAccountGatedCanonicalWireModel } from "./core-codex-account"; -export { codexForwardTerminalOutcomeRecorder } from "./core-codex-account"; -export { decodeRequestErrorResponse } from "./core-errors"; -export { comboUnavailableResponse } from "./core-errors"; -export type { ConsumedComboFailure } from "./core-options"; -export type { HandleResponsesOptions } from "./core-options"; -export { clientCancelledResponse } from "./core-errors"; -export { sanitizedRetryAfter } from "./core-combo-failure"; -export { consumeComboFailure } from "./core-combo-failure"; -export { usageFromComboFailureText } from "./core-combo-failure"; -export { createChildPassthroughCallbackGate } from "./core-combo-failure"; -export { buildComboChildHeaders } from "./core-combo-failure"; -export { UPSTREAM_JSON_BODY_READ_OPTIONS } from "./core-lifetime"; +export { readDisplaySafeErrorText, decodeRequestErrorResponse, comboUnavailableResponse, clientCancelledResponse } from "./core-errors"; +export type { ConsumedComboFailure, HandleResponsesOptions } from "./core-options"; +export { + sanitizedRetryAfter, consumeComboFailure, usageFromComboFailureText, createChildPassthroughCallbackGate, + buildComboChildHeaders, +} from "./core-combo-failure"; +export { UPSTREAM_JSON_BODY_READ_OPTIONS, linkAbortSignal } from "./core-lifetime"; export { poolCredentialRefreshIncompleteResponse } from "./core-auth"; export { applyServiceTierGate } from "./core-normalize"; -export { linkAbortSignal } from "./core-lifetime"; export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call"; From f662151fb844c5d766c1ce0a9ce86c9976c27e74 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:21:24 +0900 Subject: [PATCH 050/173] docs(structure): describe the shared inference primitives and native Chat split --- structure/transports/responses.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 4b187a1794b..2585be7b6c1 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -460,6 +460,26 @@ send-holder/permit behavior. Cross-owner source assertions read the actual imple `tests/helpers/responses-core-source.ts`; focused passthrough and subagent assertions read their specific delivery/preparation owner. Existing runtime Lab-boundary tests still start at `core.ts`. +### Shared inference primitives + +`src/server/inference/` holds the execution pieces the Responses pipeline and the native lanes +share, so a native lane reuses them instead of copying them. The directory is Lab-free and is +reachable from `core.ts`. + +| Module | Contract | +| --- | --- | +| `context.ts` | `createInferenceSendBudget(req, logCtx)` is the one construction of an ingress-owned send holder: the default guarded policy with this request's spend tracker as observer. `handleResponses` calls it only when no holder was inherited, because attaching the tracker parks it on `logCtx`. | +| `final-log.ts` | `createFinalRequestLog(logIds, logCtx)` owns one request's final row: the first `finish(status, meta)` writes it, every later call is a no-op, and without log ids the claim settles with nothing written. The bridged Chat and Messages ingresses and native Chat finish through it. | +| `attempt.ts` | `beginInferenceAttempt(logCtx, { provider, model, adapter })` opens the next attempt ordinal, makes it the active attempt with its start time, appends it to the request, and returns `seal(accountLabel?)` and `finish(status, usage?)`. | +| `client-wire.ts` | `markClientWire(response, protocol)` / `clientWireOf(response)` record, per `Response` identity, that a body is already in a client's wire. Nothing marks responses yet. | + +Native Chat in `src/server/chat-native.ts` is split in two. `handleNativeChatCompletions` opens +the attempt and owns the final log row; `runNativeChatAttempt(execution, attemptHandle)` runs +effort normalization, the send loop, key failover, 429 replay, relay and usage, and reports each +outcome through the `finishLog` it is given. A caller that owns a different final row can +therefore run a native attempt without the attempt writing that row itself. Native Chat keeps +its own spend tracker rather than a send holder. + ## Adapter-to-Responses bridge `src/bridge.ts` is a re-export facade; the implementation lives in `src/bridge/`. From 4d471587f9f74e08300518f597c54514178e31af Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:33:39 +0900 Subject: [PATCH 051/173] feat(claude): gate Messages exposure through resolveApiSurfaceSettings /v1/messages and count_tokens now share the one surface reader, so an explicit apiSurfaces.messages value wins, a malformed one closes both, and absence still inherits claudeCode.enabled. --- src/server/claude-messages.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index ec9eb4ee034..fc4490bedf1 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -61,6 +61,7 @@ import { import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; import { featuresFromMessagesBody } from "../protocols/features"; +import { resolveApiSurfaceSettings } from "../protocols/settings"; import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace"; import { isApiAuthRequired, @@ -151,11 +152,19 @@ export function buildClaudeReplayConfig(config: OcxConfig): OcxConfig { }; } +/** + * Messages exposure, shared by /v1/messages and /v1/messages/count_tokens so the two can never + * disagree. `resolveApiSurfaceSettings` is the only reader: an explicit + * `apiSurfaces.messages.enabled` wins, a malformed one closes the surface, and absence inherits + * `claudeCode.enabled`. + */ function claudeInboundDisabled(config: OcxConfig): Response | null { - if (config.claudeCode?.enabled === false) { - return anthropicErrorResponse(403, "Claude inbound is disabled (GUI: Claude ON toggle / config.claudeCode.enabled)", "permission_error"); - } - return null; + const messages = resolveApiSurfaceSettings(config).messages; + if (messages.enabled) return null; + const detail = messages.source === "invalid" + ? "config.apiSurfaces.messages is not a valid setting, so the surface stays closed" + : "GUI: API page Messages toggle / config.apiSurfaces.messages.enabled / config.claudeCode.enabled"; + return anthropicErrorResponse(403, `Messages API is disabled (${detail})`, "permission_error"); } async function readAnthropicBody(req: Request, budget: TranslatorBudget, maxBytes: number): Promise { From b3e1c9e7055edda2af0a7e32826d9cd547408640 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:33:54 +0900 Subject: [PATCH 052/173] feat(management): report API surfaces with their source in API access metadata The keys payload gains surfaces.{responses,chat,messages} from the shared resolver. claudeCodeEnabled stays for older dashboards and now mirrors the resolved Messages state, so they never advertise a closed endpoint. --- src/server/management/api-access.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/server/management/api-access.ts b/src/server/management/api-access.ts index 93f5d4b952a..b840430d875 100644 --- a/src/server/management/api-access.ts +++ b/src/server/management/api-access.ts @@ -3,6 +3,7 @@ import { isWildcardHostname } from "../../codex/loopback-target"; import { localCredentialDestinationHostname, localInferenceDestination } from "../../lib/local-destinations"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers-destination"; import { LIVE_AUDIO_MODEL, TRANSCRIPTION_MODEL } from "../audio-upstream"; +import { resolveApiSurfaceSettings, type ApiSurfaceSettings } from "../../protocols/settings"; export interface AudioApiAccess { transcriptionEndpoint: string; @@ -23,6 +24,13 @@ export interface ApiAccessEndpoints { chatCompletionsEndpoint: string; messagesEndpoint: string; modelsEndpoint: string; + /** Which public APIs are served and who decided it, from `resolveApiSurfaceSettings`. */ + surfaces: ApiSurfaceSettings; + /** + * Back-compat for dashboards that predate `surfaces`: they hide the Messages endpoint when + * this is false. It mirrors `surfaces.messages.enabled`, not `claudeCode.enabled`, so an + * older dashboard never advertises a closed endpoint or hides an open one. + */ claudeCodeEnabled: boolean; audio: AudioApiAccess; /** Back-compat alias for older GUI clients. */ @@ -165,13 +173,15 @@ export function buildApiAccessEndpoints( const apiConfigured = !!keyed && keyed.disabled !== true && keyed.adapter === "openai-responses" && keyed.authMode !== "forward" && keyed.baseUrl.replace(/\/+$/, "") === "https://api.openai.com/v1" && typeof keyed.apiKey === "string" && !!keyed.apiKey.trim(); + const surfaces = resolveApiSurfaceSettings(config); return { baseUrl, responsesEndpoint, chatCompletionsEndpoint: `${baseUrl}/chat/completions`, messagesEndpoint: `${baseUrl}/messages`, modelsEndpoint: `${baseUrl}/models`, - claudeCodeEnabled: config.claudeCode?.enabled !== false, + surfaces, + claudeCodeEnabled: surfaces.messages.enabled, audio: { transcriptionEndpoint: `${baseUrl}/audio/transcriptions`, dictationStreamEndpoint: `${socketBase.href}/audio/transcriptions/stream`, From 7142cafaa82dad55e7db9e33cb10102fab64ee8f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:34:34 +0900 Subject: [PATCH 053/173] refactor(claude): install claudeCode blocks through one sentinel-stamping writer PUT /api/claude-code and the native Claude toggle each re-stated the auth-mode migration stamp. The protocol settings route writes the same block next, so the rule lives in commitClaudeCodeBlock rather than in a third copy. --- src/claude/claude-code-block.ts | 16 ++++++++++++++++ src/server/management/agent-settings-routes.ts | 15 ++++++--------- .../management/native-integration-routes.ts | 17 ++++++----------- 3 files changed, 28 insertions(+), 20 deletions(-) create mode 100644 src/claude/claude-code-block.ts diff --git a/src/claude/claude-code-block.ts b/src/claude/claude-code-block.ts new file mode 100644 index 00000000000..485e065cd16 --- /dev/null +++ b/src/claude/claude-code-block.ts @@ -0,0 +1,16 @@ +/** + * The one way a management route installs a new `claudeCode` block on the live config. + * + * Every writer stamps the auth-mode migration sentinel. `runClaudeAuthModeMigration` reads a + * block with no `authMode` and no sentinel as a pre-upgrade subscriber and pins it to literal + * subscription, so a route that CREATES the block (a bare `{ enabled }` toggle) without the + * sentinel silently converts an Auto user on the next start. Persisting stays with the caller, + * through `saveConfigPreservingClaudeCode`, whose baseline guard keeps a concurrent hand edit + * of the block unless this process changed it too. + */ +import type { OcxClaudeCodeConfig, OcxConfig } from "../types"; + +export function commitClaudeCodeBlock(config: OcxConfig, next: OcxClaudeCodeConfig, now = new Date()): void { + if (!next.authModeMigratedAt) next.authModeMigratedAt = now.toISOString(); + config.claudeCode = next; +} diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 5e976740fd7..b7d9175c67f 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1740,15 +1740,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } } if (body.fastMode !== undefined) config.fastMode = nextFastMode; - config.claudeCode = next; - // Stamp the migration sentinel on EVERY persist of this block. The migration reads - // "a claudeCode block with no authMode" as a pre-upgrade subscriber and pins it to - // literal subscription — correct for a config written before `auto` existed, fatal - // for one written after. Without this, choosing Auto (which DELETES authMode) or - // merely toggling Claude on (App.tsx PUTs `{enabled}` alone and creates the block) - // would be converted into a sticky manual subscription by the next startServer, and - // auto would survive exactly one proxy lifetime with no way back. - if (!next.authModeMigratedAt) next.authModeMigratedAt = new Date().toISOString(); + // Stamps the auth-mode migration sentinel on EVERY persist of this block. Without it, + // choosing Auto (which DELETES authMode) or merely toggling Claude on (App.tsx PUTs + // `{enabled}` alone and creates the block) would be converted into a sticky manual + // subscription by the next startServer, with no way back. + const { commitClaudeCodeBlock } = await import("../../claude/claude-code-block"); + commitClaudeCodeBlock(config, next); const { saveConfigPreservingClaudeCode: save } = await import("../../config"); save(config); const warnings: string[] = []; diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index d47a6dcc2b3..fcd85d639ef 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -1,4 +1,5 @@ import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state"; +import { commitClaudeCodeBlock } from "../../claude/claude-code-block"; /** * Toggle routes for the integrations that are NOT file-merged clients. * @@ -886,19 +887,13 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro } satisfies NativeToggleEnvelope); } - const next = { ...(config.claudeCode ?? {}), enabled }; /* - * Stamp the migration sentinel on every persist of this block, exactly as - * PUT /api/claude-code does (agent-settings-routes.ts:1068). - * - * The migration reads "a claudeCode block with no authMode" as a pre-upgrade - * subscriber and pins it to literal subscription. Toggling Claude ON is one - * of the two ways a block gets CREATED, so without this the next startServer - * would silently convert a user's Auto auth mode into a sticky manual - * subscription — a failure that surfaces nowhere near this route. + * Same block writer as PUT /api/claude-code: it stamps the migration sentinel. + * Toggling Claude ON is one of the two ways a block gets CREATED, so without the + * sentinel the next startServer would silently convert a user's Auto auth mode into + * a sticky manual subscription — a failure that surfaces nowhere near this route. */ - if (!next.authModeMigratedAt) next.authModeMigratedAt = new Date().toISOString(); - config.claudeCode = next; + commitClaudeCodeBlock(config, { ...(config.claudeCode ?? {}), enabled }); /* * `deps.` first: ManagementApiDeps carries this seam so route tests with an From d2ef09b56d4fbe77745d4822bf2313013398e956 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:35:33 +0900 Subject: [PATCH 054/173] feat(protocols): validate and apply protocol settings patches Strict parsing refuses unknown keys and wrong types. Closing Messages also writes claudeCode.enabled=false so an older binary after rollback stays closed; opening writes only the explicit surface value. A snapshot lets the route undo a failed save. --- .../management/protocol-settings-patch.ts | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/server/management/protocol-settings-patch.ts diff --git a/src/server/management/protocol-settings-patch.ts b/src/server/management/protocol-settings-patch.ts new file mode 100644 index 00000000000..2eb06ebd427 --- /dev/null +++ b/src/server/management/protocol-settings-patch.ts @@ -0,0 +1,145 @@ +/** + * Validation and in-memory application of `PATCH /api/protocols/settings`. + * + * Pure with respect to I/O: the route persists through `saveConfigPreservingClaudeCode` and + * restores the snapshot taken here when the save fails, so a refused write never leaves the + * live config serving a state the file does not hold. + * + * The one asymmetric rule is the Messages surface. Closing it writes + * `apiSurfaces.messages.enabled = false` AND `claudeCode.enabled = false` in the same save: + * a binary older than `apiSurfaces` reads only `claudeCode.enabled`, so a rollback after a + * close must still find the endpoint closed. Opening writes only the explicit surface value; + * an older binary then keeps reading `claudeCode.enabled`, which errs closed. + */ +import { commitClaudeCodeBlock } from "../../claude/claude-code-block"; +import type { ProtocolRolloutSettings, UnrepresentablePolicy } from "../../protocols/settings"; +import type { OcxConfig } from "../../types"; + +export interface ProtocolSettingsPatch { + messagesEnabled?: boolean; + unrepresentable?: UnrepresentablePolicy; + rollout?: Partial; +} + +export type ParsedProtocolSettingsPatch = + | { ok: true; patch: ProtocolSettingsPatch } + | { ok: false; code: string; message: string }; + +const PATCH_KEYS = new Set(["messagesEnabled", "unrepresentable", "rollout"]); +export const PROTOCOL_ROLLOUT_KEYS = [ + "nativeChatCombos", + "managedMessagesNative", + "managedMessagesNativeOAuth", + "directEncoders", + "shadowPlan", +] as const satisfies readonly (keyof ProtocolRolloutSettings)[]; +const ROLLOUT_KEYS = new Set(PROTOCOL_ROLLOUT_KEYS); + +type Rec = Record; +function isRec(value: unknown): value is Rec { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function invalid(code: string, message: string): ParsedProtocolSettingsPatch { + return { ok: false, code, message }; +} + +/** Strict: unknown keys and wrong types are refused; messages name the field, never the value. */ +export function parseProtocolSettingsPatch(body: unknown): ParsedProtocolSettingsPatch { + if (!isRec(body)) return invalid("invalid_body", "body must be a JSON object"); + const keys = Object.keys(body); + if (keys.length === 0) return invalid("empty_body", "body must set messagesEnabled, unrepresentable or rollout"); + for (const key of keys) { + if (!PATCH_KEYS.has(key)) return invalid("unknown_field", "body accepts only messagesEnabled, unrepresentable and rollout"); + } + const patch: ProtocolSettingsPatch = {}; + if (body.messagesEnabled !== undefined) { + if (typeof body.messagesEnabled !== "boolean") return invalid("invalid_messages_enabled", "messagesEnabled must be a boolean"); + patch.messagesEnabled = body.messagesEnabled; + } + if (body.unrepresentable !== undefined) { + if (body.unrepresentable !== "legacy" && body.unrepresentable !== "reject") { + return invalid("invalid_unrepresentable", "unrepresentable must be \"legacy\" or \"reject\""); + } + patch.unrepresentable = body.unrepresentable; + } + if (body.rollout !== undefined) { + if (!isRec(body.rollout)) return invalid("invalid_rollout", "rollout must be an object"); + const rollout: Partial = {}; + for (const [key, value] of Object.entries(body.rollout)) { + if (!ROLLOUT_KEYS.has(key)) return invalid("unknown_rollout_field", `rollout accepts only ${PROTOCOL_ROLLOUT_KEYS.join(", ")}`); + if (typeof value !== "boolean") return invalid("invalid_rollout", "rollout values must be booleans"); + rollout[key as keyof ProtocolRolloutSettings] = value; + } + patch.rollout = rollout; + } + return { ok: true, patch }; +} + +/** The config subtrees a patch may touch, captured so a failed save can be undone. */ +export interface ProtocolSettingsSnapshot { + apiSurfaces: unknown; + protocols: unknown; + claudeCode: unknown; +} + +export function snapshotProtocolSettings(config: OcxConfig): ProtocolSettingsSnapshot { + return { + apiSurfaces: structuredClone(config.apiSurfaces), + protocols: structuredClone(config.protocols), + claudeCode: structuredClone(config.claudeCode), + }; +} + +export function restoreProtocolSettings(config: OcxConfig, snapshot: ProtocolSettingsSnapshot): void { + const target = config as unknown as Rec; + for (const key of ["apiSurfaces", "protocols", "claudeCode"] as const) { + if (snapshot[key] === undefined) delete target[key]; + else target[key] = snapshot[key]; + } +} + +export type ApplyProtocolSettingsResult = + | { ok: true; claudeCodeChanged: boolean } + | { ok: false; code: string; message: string }; + +/** + * Apply a validated patch to the live config. Checks that depend on the merged state run + * before anything is written, so a refusal leaves the config untouched. + */ +export function applyProtocolSettingsPatch(config: OcxConfig, patch: ProtocolSettingsPatch): ApplyProtocolSettingsResult { + const protocols: Rec = isRec(config.protocols) ? { ...config.protocols } : {}; + if (patch.rollout) { + const rollout: Rec = { ...(isRec(protocols.rollout) ? protocols.rollout : {}), ...patch.rollout }; + // The OAuth extension is meaningless without the key-auth lane it extends; the resolver + // would silently read it as off, so refuse turning it on alone instead of storing a switch + // that does nothing. Turning the key-auth lane off leaves the extension inert, not wrong. + if (patch.rollout.managedMessagesNativeOAuth === true && rollout.managedMessagesNative !== true) { + return { + ok: false, + code: "rollout_dependency", + message: "rollout.managedMessagesNativeOAuth requires rollout.managedMessagesNative", + }; + } + protocols.rollout = rollout; + } + if (patch.unrepresentable !== undefined) protocols.unrepresentable = patch.unrepresentable; + if (patch.unrepresentable !== undefined || patch.rollout) { + config.protocols = protocols as OcxConfig["protocols"]; + } + + let claudeCodeChanged = false; + if (patch.messagesEnabled !== undefined) { + // A malformed block is replaced, not merged: the explicit value is what the operator asked for. + const surfaces: Rec = isRec(config.apiSurfaces) ? { ...config.apiSurfaces } : {}; + const messages: Rec = isRec(surfaces.messages) ? { ...surfaces.messages } : {}; + messages.enabled = patch.messagesEnabled; + surfaces.messages = messages; + config.apiSurfaces = surfaces as OcxConfig["apiSurfaces"]; + if (!patch.messagesEnabled && config.claudeCode?.enabled !== false) { + commitClaudeCodeBlock(config, { ...(config.claudeCode ?? {}), enabled: false }); + claudeCodeChanged = true; + } + } + return { ok: true, claudeCodeChanged }; +} From e927c8f36030b53fe572ad0b8e21e527325ea45d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:36:03 +0900 Subject: [PATCH 055/173] feat(management): add PATCH /api/protocols/settings The Messages toggle and protocol switches persist through the locked saveConfigPreservingClaudeCode, undo the in-memory change when the save fails, and answer with the fresh GET /api/protocols shape. Declared as a PF-12 deferred verb. --- src/server/management/protocol-routes.ts | 79 +++++++++++++++++++++--- src/server/management/route-registry.ts | 1 + 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/server/management/protocol-routes.ts b/src/server/management/protocol-routes.ts index 5277e7f0f7f..f3dfb00bca9 100644 --- a/src/server/management/protocol-routes.ts +++ b/src/server/management/protocol-routes.ts @@ -5,17 +5,27 @@ * the planner reaches the router and the ingress eligibility rules, and a static import * would put them on every dashboard request. * - * Both routes are read-only. The preview is computed from config alone + * GET and the plan preview are read-only. The preview is computed from config alone * (src/protocols/plan-snapshot.ts): it sends nothing upstream, advances no combo state, and - * never logs its input. Authentication is inherited from the management chain. + * never logs its input. PATCH /api/protocols/settings is the one writer; it validates in + * src/server/management/protocol-settings-patch.ts, persists through the locked + * saveConfigPreservingClaudeCode, and answers with the fresh GET shape. Authentication is + * inherited from the management chain. */ import { jsonResponse } from "../auth-cors"; import { isProtocol, PROTOCOL_CONTRACT_VERSION } from "../../protocols/contract"; import { isProtocolFeature, PROTOCOL_FEATURES, type ProtocolFeature } from "../../protocols/features"; import { previewProtocolPlan, type ProtocolPlanRequest } from "../../protocols/plan-snapshot"; import { protocolPolicyRevision, resolveApiSurfaceSettings, resolveProtocolSettings } from "../../protocols/settings"; +import type { OcxConfig } from "../../types"; import type { ManagementContext } from "./context"; import { readManagementJsonBodyOr } from "./body"; +import { + applyProtocolSettingsPatch, + parseProtocolSettingsPatch, + restoreProtocolSettings, + snapshotProtocolSettings, +} from "./protocol-settings-patch"; export const PROTOCOL_PLAN_LIMITS = { modelLength: 200, features: 24 } as const; @@ -51,19 +61,68 @@ export function parseProtocolPlanBody(body: unknown): ParsedPlanBody { return { ok: true, request: { model, inbound: record.inbound, features } }; } +function protocolInfo(config: OcxConfig) { + return { + schemaVersion: 1, + contractVersion: PROTOCOL_CONTRACT_VERSION, + policyRevision: protocolPolicyRevision(config), + surfaces: resolveApiSurfaceSettings(config), + settings: resolveProtocolSettings(config), + features: PROTOCOL_FEATURES, + }; +} + +/** Only SQLITE_BUSY is contention worth retrying; any other lock failure repeats forever. */ +function isConfigLockContention(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + if ((error as { code?: unknown }).code !== "CONFIG_MUTATION_LOCK_UNAVAILABLE") return false; + return (error as { cause?: { code?: unknown } }).cause?.code === "SQLITE_BUSY"; +} + +async function patchProtocolSettings(ctx: ManagementContext): Promise { + const { req, config } = ctx; + const body = await readManagementJsonBodyOr(req, INVALID_BODY); + const parsed = body === INVALID_BODY + ? { ok: false as const, code: "invalid_json", message: "body must be valid JSON" } + : parseProtocolSettingsPatch(body); + if (!parsed.ok) return jsonResponse({ error: { code: parsed.code, message: parsed.message } }, 400, req, config); + + const snapshot = snapshotProtocolSettings(config); + const applied = applyProtocolSettingsPatch(config, parsed.patch); + if (!applied.ok) { + restoreProtocolSettings(config, snapshot); + return jsonResponse({ error: { code: applied.code, message: applied.message } }, 400, req, config); + } + // `deps.` first: route tests with an in-memory fixture must never write the real config. + const persist = ctx.deps.saveConfigPreservingClaudeCode + ?? (await import("../../config")).saveConfigPreservingClaudeCode; + try { + persist(config); + } catch (error) { + // Undo in memory too: a live config that serves a state the file does not hold would + // reopen (or keep closed) the surface only until the next restart. + restoreProtocolSettings(config, snapshot); + return isConfigLockContention(error) + ? jsonResponse({ error: { code: "config_busy", message: "Another process is saving the configuration. Try again in a moment." } }, 409, req, config) + : jsonResponse({ error: { code: "write_failed", message: "The configuration could not be saved." } }, 500, req, config); + } + // Closing Messages also turned the Claude integration off; prune its agent definitions the + // way the Claude page toggle does. + if (applied.claudeCodeChanged) await ctx.syncClaudeAgentDefsBestEffort?.(); + return jsonResponse(protocolInfo(config), 200, req, config); +} + export async function handleProtocolRoutes(ctx: ManagementContext): Promise { const { url, req, config } = ctx; if (url.pathname === "/api/protocols") { if (req.method !== "GET") return null; - return jsonResponse({ - schemaVersion: 1, - contractVersion: PROTOCOL_CONTRACT_VERSION, - policyRevision: protocolPolicyRevision(config), - surfaces: resolveApiSurfaceSettings(config), - settings: resolveProtocolSettings(config), - features: PROTOCOL_FEATURES, - }, 200, req, config); + return jsonResponse(protocolInfo(config), 200, req, config); + } + + if (url.pathname === "/api/protocols/settings") { + if (req.method !== "PATCH") return null; + return patchProtocolSettings(ctx); } if (url.pathname === "/api/protocols/plan") { diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index c147b7be42e..a49c0eb43b9 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -339,6 +339,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ // server/management/workflow-budget-routes { method: "GET", path: "/api/protocols", module: "server/management/protocol-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading the protocol vocabulary and active policy revision has no CLI verb yet; PF-12 owns `ocx api protocols`, and until then the dashboard preview is the only reader.", owner: "260924_protocol_first_class PF-12", ownerDoc: "devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md" } }, { method: "POST", path: "/api/protocols/plan", module: "server/management/protocol-routes", mutates: false, exempt: { reason: "deferred-verb", why: "A request-path preview has no CLI verb yet; PF-12 owns `ocx api explain`. It sends nothing upstream, so the dashboard preview panel is the only caller in this unit.", owner: "260924_protocol_first_class PF-12", ownerDoc: "devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md" } }, + { method: "PATCH", path: "/api/protocols/settings", module: "server/management/protocol-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Changing the Messages surface and protocol rollout switches has no CLI verb yet; PF-12 owns `ocx api policy`. Until then the API page Messages toggle is the only caller, and hand-editing apiSurfaces/protocols in config.json stays available.", owner: "260924_protocol_first_class PF-12", ownerDoc: "devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md" } }, { method: "GET", path: "/api/workflow-budget", module: "server/management/workflow-budget-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading a root's live budget is owed a CLI verb -- an operator staring at a 429 is usually already in a terminal -- but the ledger is process memory with no local transport to read it through, so the verb has to be an HTTP call the CLI does not yet make.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } }, { method: "POST", path: "/api/workflow-budget/clear", module: "server/management/workflow-budget-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Clearing one root is owed the same verb as the read above and for the same reason. It is deliberately not shipped as a verb in this work-phase: the read comes first, because an operator who cannot see which ceiling fired has no basis for deciding to forgive it.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } }, // server/management/request-history-routes From ac2211a56af41b2fce8045ac0a4026ed38e11078 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:38:11 +0900 Subject: [PATCH 056/173] test(management): cover PATCH /api/protocols/settings and surface metadata Strict body, both keys written on close, only the surface on open, rollback on a failed save, 409 on lock contention, and surfaces in the API access metadata. --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + tests/server/api-access-endpoints.test.ts | 16 ++ tests/server/protocol-settings-route.test.ts | 166 +++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 tests/server/protocol-settings-route.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0fa9be2b82e..72d79d7fa62 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1107,6 +1107,7 @@ "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", "protocol-routes.test.ts": "server", + "protocol-settings-route.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "managing-cli.test.ts": "service", "memory-watchdog.test.ts": "server", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2e8a97e2294..b102d41957f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -933,6 +933,7 @@ "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", "protocol-routes.test.ts": "server", + "protocol-settings-route.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "managing-cli.test.ts": "service", "memory-watchdog.test.ts": "server", diff --git a/tests/server/api-access-endpoints.test.ts b/tests/server/api-access-endpoints.test.ts index fef90701359..f5a87c6bca9 100644 --- a/tests/server/api-access-endpoints.test.ts +++ b/tests/server/api-access-endpoints.test.ts @@ -116,6 +116,22 @@ describe("buildApiAccessEndpoints", () => { expect(buildApiAccessEndpoints({ claudeCode: { enabled: false } }).claudeCodeEnabled).toBe(false); }); + test("reports every API surface with the source that decided it", () => { + expect(buildApiAccessEndpoints({}).surfaces).toEqual({ + responses: { enabled: true, source: "fixed" }, + chat: { enabled: true, source: "fixed" }, + messages: { enabled: true, source: "claude-code-legacy" }, + }); + // An explicit surface value wins over the Claude integration, and the back-compat flag + // follows the resolved surface so an older dashboard shows the endpoint that is served. + const open = buildApiAccessEndpoints({ apiSurfaces: { messages: { enabled: true } }, claudeCode: { enabled: false } }); + expect(open.surfaces.messages).toEqual({ enabled: true, source: "api-surfaces" }); + expect(open.claudeCodeEnabled).toBe(true); + const invalid = buildApiAccessEndpoints({ apiSurfaces: { messages: { enabled: "yes" } } } as never); + expect(invalid.surfaces.messages).toEqual({ enabled: false, source: "invalid" }); + expect(invalid.claudeCodeEnabled).toBe(false); + }); + test("audio metadata derives TLS and IPv6 URLs without claiming connectivity", () => { const result = buildApiAccessEndpoints({ hostname: "::", port: 10100 }, { requestOrigin: "https://[2001:db8::1]:8443" }); expect(result.audio).toEqual({ diff --git a/tests/server/protocol-settings-route.test.ts b/tests/server/protocol-settings-route.test.ts new file mode 100644 index 00000000000..050b3360cdb --- /dev/null +++ b/tests/server/protocol-settings-route.test.ts @@ -0,0 +1,166 @@ +/** + * PATCH /api/protocols/settings (src/server/management/protocol-routes.ts): strict body, the + * Messages close writes both keys in one save, the open writes only the surface, a failed save + * leaves the live config as it was, and the answer is the fresh GET /api/protocols shape. + */ +import { describe, expect, test } from "bun:test"; +import type { ManagementContext } from "../../src/server/management/context"; +import { handleProtocolRoutes } from "../../src/server/management/protocol-routes"; +import { parseProtocolSettingsPatch } from "../../src/server/management/protocol-settings-patch"; +import type { OcxConfig } from "../../src/types"; + +interface Harness { + config: OcxConfig; + /** Snapshots of every persisted config; the real save is never reached. */ + saves: OcxConfig[]; + syncs: number; + ctx: (body: unknown, method?: string) => ManagementContext; +} + +function harness(extra: Record = {}, save?: (config: OcxConfig) => void): Harness { + const state: Harness = { + config: { port: 10100, providers: {}, ...extra } as unknown as OcxConfig, + saves: [], + syncs: 0, + ctx: (body, method = "PATCH") => { + const url = new URL("http://127.0.0.1:10100/api/protocols/settings"); + const req = new Request(url, { + method, + body: typeof body === "string" ? body : JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); + return { + req, url, config: state.config, version: "test", + deps: { + saveConfigPreservingClaudeCode: (config: OcxConfig) => { + save?.(config); + state.saves.push(structuredClone(config)); + }, + }, + syncClaudeAgentDefsBestEffort: async () => { state.syncs++; }, + } as unknown as ManagementContext; + }, + }; + return state; +} + +async function patch(h: Harness, body: unknown): Promise { + const res = await handleProtocolRoutes(h.ctx(body)); + if (!res) throw new Error("route did not answer"); + return res; +} + +describe("PATCH /api/protocols/settings", () => { + test("closing Messages writes apiSurfaces and claudeCode in one save", async () => { + const h = harness({ claudeCode: { enabled: true, model: "m" } }); + const res = await patch(h, { messagesEnabled: false }); + expect(res.status).toBe(200); + expect(h.saves).toHaveLength(1); + const saved = h.saves[0]!; + expect(saved.apiSurfaces).toEqual({ messages: { enabled: false } }); + expect(saved.claudeCode?.enabled).toBe(false); + expect(saved.claudeCode?.model).toBe("m"); + // The block writer shared with PUT /api/claude-code stamps the auth-mode sentinel. + expect(typeof saved.claudeCode?.authModeMigratedAt).toBe("string"); + expect(h.syncs).toBe(1); + const body = await res.json() as { schemaVersion: number; surfaces: { messages: unknown }; policyRevision: string }; + expect(body.schemaVersion).toBe(1); + expect(body.surfaces.messages).toEqual({ enabled: false, source: "api-surfaces" }); + expect(typeof body.policyRevision).toBe("string"); + }); + + test("closing Messages when Claude is already off leaves the claudeCode block alone", async () => { + const h = harness({ claudeCode: { enabled: false } }); + expect((await patch(h, { messagesEnabled: false })).status).toBe(200); + expect(h.saves[0]!.claudeCode).toEqual({ enabled: false }); + expect(h.syncs).toBe(0); + }); + + test("opening Messages writes only apiSurfaces", async () => { + const h = harness({ claudeCode: { enabled: false } }); + const res = await patch(h, { messagesEnabled: true }); + expect(res.status).toBe(200); + expect(h.saves[0]!.apiSurfaces).toEqual({ messages: { enabled: true } }); + expect(h.saves[0]!.claudeCode).toEqual({ enabled: false }); + expect(h.syncs).toBe(0); + expect((await res.json() as { surfaces: { messages: unknown } }).surfaces.messages) + .toEqual({ enabled: true, source: "api-surfaces" }); + }); + + test("opening replaces a malformed surface value instead of merging into it", async () => { + const h = harness({ apiSurfaces: { messages: "off" } }); + expect((await patch(h, { messagesEnabled: true })).status).toBe(200); + expect(h.saves[0]!.apiSurfaces).toEqual({ messages: { enabled: true } }); + }); + + test("policy and rollout switches merge into protocols", async () => { + const h = harness({ protocols: { rollout: { directEncoders: true } } }); + const res = await patch(h, { unrepresentable: "reject", rollout: { shadowPlan: true } }); + expect(res.status).toBe(200); + expect(h.saves[0]!.protocols).toEqual({ unrepresentable: "reject", rollout: { directEncoders: true, shadowPlan: true } }); + const body = await res.json() as { settings: { unrepresentable: string; rollout: Record } }; + expect(body.settings.unrepresentable).toBe("reject"); + expect(body.settings.rollout).toMatchObject({ directEncoders: true, shadowPlan: true, nativeChatCombos: false }); + }); + + test("the OAuth native-Messages switch cannot be turned on without the key-auth one", async () => { + const h = harness(); + const res = await patch(h, { rollout: { managedMessagesNativeOAuth: true } }); + expect(res.status).toBe(400); + expect((await res.json() as { error: { code: string } }).error.code).toBe("rollout_dependency"); + expect(h.saves).toHaveLength(0); + expect(h.config.protocols).toBeUndefined(); + }); + + test("a failed save restores the live config and reports a write failure", async () => { + const h = harness({ claudeCode: { enabled: true } }, () => { throw new Error("disk full at /secret/path"); }); + const res = await patch(h, { messagesEnabled: false }); + expect(res.status).toBe(500); + const text = await res.text(); + expect(text).toContain("write_failed"); + expect(text).not.toContain("/secret/path"); + expect(h.config.apiSurfaces).toBeUndefined(); + expect(h.config.claudeCode).toEqual({ enabled: true }); + expect(h.syncs).toBe(0); + }); + + test("lock contention answers 409", async () => { + const busy = Object.assign(new Error("busy"), { code: "CONFIG_MUTATION_LOCK_UNAVAILABLE", cause: { code: "SQLITE_BUSY" } }); + const h = harness({}, () => { throw busy; }); + const res = await patch(h, { messagesEnabled: true }); + expect(res.status).toBe(409); + expect(h.config.apiSurfaces).toBeUndefined(); + }); + + test.each([ + ["invalid JSON", "{", "invalid_json"], + ["a non-object body", [], "invalid_body"], + ["an empty body", {}, "empty_body"], + ["an unknown key", { messagesEnabled: true, claudeCode: { enabled: true } }, "unknown_field"], + ["a string messagesEnabled", { messagesEnabled: "false" }, "invalid_messages_enabled"], + ["an unknown policy", { unrepresentable: "drop" }, "invalid_unrepresentable"], + ["a non-object rollout", { rollout: true }, "invalid_rollout"], + ["an unknown rollout switch", { rollout: { turbo: true } }, "unknown_rollout_field"], + ["a non-boolean rollout switch", { rollout: { shadowPlan: 1 } }, "invalid_rollout"], + ])("rejects %s with 400 and writes nothing", async (_label, body, code) => { + const h = harness({ claudeCode: { enabled: true } }); + const res = await patch(h, body); + expect(res.status).toBe(400); + expect((await res.json() as { error: { code: string } }).error.code).toBe(code); + expect(h.saves).toHaveLength(0); + expect(h.config.claudeCode).toEqual({ enabled: true }); + }); + + test("errors name the field, never the submitted value", () => { + const parsed = parseProtocolSettingsPatch({ unrepresentable: "secret-looking-value" }); + expect(parsed.ok).toBe(false); + expect(JSON.stringify(parsed)).not.toContain("secret-looking-value"); + }); + + test("other methods fall through", async () => { + const h = harness(); + expect(await handleProtocolRoutes(h.ctx({ messagesEnabled: true }, "PUT"))).toBeNull(); + expect(await handleProtocolRoutes(h.ctx({ messagesEnabled: true }, "POST"))).toBeNull(); + expect(h.saves).toHaveLength(0); + }); +}); From f0885c702402be5089c33371541529a1a7c68a3c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:38:11 +0900 Subject: [PATCH 057/173] test(claude): record the Messages surface upgrade/rollback matrix Absent inherits, dashboard-written false is closed on old and new binaries, explicit true with Claude off is open only on the new one, invalid values close, and count_tokens agrees with /v1/messages in every row. --- scripts/test-layout/layout.json | 1 + .../messages-surface-matrix.test.ts | 130 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 3 files changed, 132 insertions(+) create mode 100644 tests/claude-integration/messages-surface-matrix.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 72d79d7fa62..f5a082c7cd3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -417,6 +417,7 @@ "claude-management-api.test.ts": "claude-integration", "claude-manual-env.test.ts": "gui", "claude-messages-endpoint.test.ts": "claude-integration", + "messages-surface-matrix.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", "claude-native-affinity.test.ts": "claude-integration", diff --git a/tests/claude-integration/messages-surface-matrix.test.ts b/tests/claude-integration/messages-surface-matrix.test.ts new file mode 100644 index 00000000000..273b846860c --- /dev/null +++ b/tests/claude-integration/messages-surface-matrix.test.ts @@ -0,0 +1,130 @@ +/** + * Upgrade/rollback matrix for the Messages surface (PF-04, + * devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md). + * + * "new binary" is this tree: `resolveApiSurfaceSettings` gates /v1/messages and + * /v1/messages/count_tokens. "old binary" is every release before `apiSurfaces` existed, whose + * only reader was `config.claudeCode.enabled === false` → 403. The safe direction is the + * invariant: a state the dashboard writes may be open on the new binary and closed on the old + * one, never the reverse. + */ +import { describe, expect, test } from "bun:test"; +import { handleClaudeCountTokens, handleClaudeMessages } from "../../src/server/claude-messages"; +import { applyProtocolSettingsPatch } from "../../src/server/management/protocol-settings-patch"; +import { buildApiAccessEndpoints } from "../../src/server/management/api-access"; +import { resolveApiSurfaceSettings } from "../../src/protocols/settings"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; + +/** The pre-PF-04 reader, quoted: absent or anything but literal false was open. */ +function oldBinaryOpen(config: OcxConfig): boolean { + return config.claudeCode?.enabled !== false; +} + +function cfg(extra: Record): OcxConfig { + return { port: 10100, providers: {}, ...extra } as unknown as OcxConfig; +} + +/** An unparseable body: open surfaces fail on it with 400, closed ones answer 403 first. */ +function badRequest(path: string): Request { + return new Request(`http://127.0.0.1:10100${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }); +} + +async function refused(res: Response): Promise { + if (res.status !== 403) return false; + const body = await res.json() as { error?: { type?: string; message?: string } }; + return body.error?.type === "permission_error" && (body.error.message ?? "").includes("Messages API is disabled"); +} + +async function newBinaryOpen(config: OcxConfig): Promise<{ messages: boolean; countTokens: boolean }> { + const messages = await handleClaudeMessages(badRequest("/v1/messages"), config, {} as RequestLogContext); + const countTokens = await handleClaudeCountTokens(badRequest("/v1/messages/count_tokens"), config); + return { messages: !(await refused(messages)), countTokens: !(await refused(countTokens)) }; +} + +type Row = { label: string; config: OcxConfig; newOpen: boolean; oldOpen: boolean; source: string }; + +const MATRIX: Row[] = [ + { label: "absent, Claude untouched → inherit open", config: cfg({}), newOpen: true, oldOpen: true, source: "claude-code-legacy" }, + { label: "absent, Claude on → inherit open", config: cfg({ claudeCode: { enabled: true } }), newOpen: true, oldOpen: true, source: "claude-code-legacy" }, + { label: "absent, Claude off → inherit closed", config: cfg({ claudeCode: { enabled: false } }), newOpen: false, oldOpen: false, source: "claude-code-legacy" }, + { + label: "explicit false as the dashboard writes it → closed on both", + config: cfg({ apiSurfaces: { messages: { enabled: false } }, claudeCode: { enabled: false } }), + newOpen: false, oldOpen: false, source: "api-surfaces", + }, + { + label: "explicit true with Claude off → new open, old closed (safe direction)", + config: cfg({ apiSurfaces: { messages: { enabled: true } }, claudeCode: { enabled: false } }), + newOpen: true, oldOpen: false, source: "api-surfaces", + }, + { label: "empty messages block → inherit", config: cfg({ apiSurfaces: { messages: {} } }), newOpen: true, oldOpen: true, source: "claude-code-legacy" }, + { label: "non-object apiSurfaces → closed", config: cfg({ apiSurfaces: "on" }), newOpen: false, oldOpen: true, source: "invalid" }, + { label: "non-object messages → closed", config: cfg({ apiSurfaces: { messages: true } }), newOpen: false, oldOpen: true, source: "invalid" }, + { label: "string enabled → closed", config: cfg({ apiSurfaces: { messages: { enabled: "true" } } }), newOpen: false, oldOpen: true, source: "invalid" }, + { label: "null enabled → closed", config: cfg({ apiSurfaces: { messages: { enabled: null } } }), newOpen: false, oldOpen: true, source: "invalid" }, +]; + +describe("Messages surface upgrade/rollback matrix", () => { + test.each(MATRIX.map(row => [row.label, row] as const))("%s", async (_label, row) => { + const resolved = resolveApiSurfaceSettings(row.config).messages; + expect(resolved).toEqual({ enabled: row.newOpen, source: row.source as typeof resolved.source }); + expect(oldBinaryOpen(row.config)).toBe(row.oldOpen); + // count_tokens and /v1/messages never disagree, and both follow the resolver. + expect(await newBinaryOpen(row.config)).toEqual({ messages: row.newOpen, countTokens: row.newOpen }); + // The dashboard metadata reports the same state, including to older dashboards. + const endpoints = buildApiAccessEndpoints(row.config); + expect(endpoints.surfaces.messages).toEqual(resolved); + expect(endpoints.claudeCodeEnabled).toBe(row.newOpen); + }); + + test("a closed surface never answers 403 on only one of the two routes", async () => { + for (const row of MATRIX) { + const open = await newBinaryOpen(row.config); + expect(open.messages).toBe(open.countTokens); + } + }); +}); + +describe("states the settings PATCH produces keep the safe direction", () => { + const starts: Array<[string, OcxConfig]> = [ + ["untouched", cfg({})], + ["Claude on", cfg({ claudeCode: { enabled: true } })], + ["Claude off", cfg({ claudeCode: { enabled: false } })], + ["explicit true, Claude off", cfg({ apiSurfaces: { messages: { enabled: true } }, claudeCode: { enabled: false } })], + ["invalid surface", cfg({ apiSurfaces: { messages: { enabled: "no" } } })], + ]; + + test.each(starts)("closing from %s closes the new and the old binary", async (_label, start) => { + const config = structuredClone(start); + expect(applyProtocolSettingsPatch(config, { messagesEnabled: false }).ok).toBe(true); + expect(resolveApiSurfaceSettings(config).messages).toEqual({ enabled: false, source: "api-surfaces" }); + expect(oldBinaryOpen(config)).toBe(false); + expect(await newBinaryOpen(config)).toEqual({ messages: false, countTokens: false }); + }); + + test.each(starts)("opening from %s opens the new binary and never opens the old one", async (_label, start) => { + const config = structuredClone(start); + const oldBefore = oldBinaryOpen(config); + expect(applyProtocolSettingsPatch(config, { messagesEnabled: true }).ok).toBe(true); + expect(resolveApiSurfaceSettings(config).messages).toEqual({ enabled: true, source: "api-surfaces" }); + // Opening writes only apiSurfaces: the old binary's reader is exactly as it was. + expect(oldBinaryOpen(config)).toBe(oldBefore); + expect(config.claudeCode).toEqual(start.claudeCode); + expect(await newBinaryOpen(config)).toEqual({ messages: true, countTokens: true }); + }); + + test("new binary closed implies old binary closed for every PATCH result", () => { + for (const [, start] of starts) { + for (const messagesEnabled of [true, false]) { + const config = structuredClone(start); + applyProtocolSettingsPatch(config, { messagesEnabled }); + if (!resolveApiSurfaceSettings(config).messages.enabled) expect(oldBinaryOpen(config)).toBe(false); + } + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b102d41957f..9f4baf46437 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -243,6 +243,7 @@ "claude-management-api.test.ts": "claude-integration", "claude-manual-env.test.ts": "gui", "claude-messages-endpoint.test.ts": "claude-integration", + "messages-surface-matrix.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", "claude-native-affinity.test.ts": "claude-integration", From ce7eca3e87448335d84cc245c6c49b99c1eb1756 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:39:09 +0900 Subject: [PATCH 058/173] feat(gui): parse API surfaces and add the protocol settings client parseApiSurfaces reads surfaces from the keys payload and answers undefined for an older server, so the page can fall back instead of guessing. patchProtocolSettings validates the fresh GET shape the server returns. --- gui/src/pages/api-keys-utils.ts | 29 ++++++++++++++++++++++ gui/src/protocol-api.ts | 43 ++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/gui/src/pages/api-keys-utils.ts b/gui/src/pages/api-keys-utils.ts index a687f1ea698..806541b0e78 100644 --- a/gui/src/pages/api-keys-utils.ts +++ b/gui/src/pages/api-keys-utils.ts @@ -63,6 +63,35 @@ export function isApiAuthMatrix(value: unknown): value is ApiAuthMatrixRow[] { /** Shared by both key-name inputs; the server rejects anything longer. */ export const API_KEY_NAME_MAX_LENGTH = 64; +/** Who decided a surface's state; mirrors `ApiSurfaceSource` in src/protocols/settings.ts. */ +export type ApiSurfaceSource = "fixed" | "api-surfaces" | "claude-code-legacy" | "invalid"; +export interface ApiSurfaceInfo { + enabled: boolean; + source: ApiSurfaceSource; +} +export type ApiSurfacesInfo = Record; + +const SURFACE_SOURCES = new Set(["fixed", "api-surfaces", "claude-code-legacy", "invalid"]); +const SURFACE_NAMES = ["responses", "chat", "messages"] as const satisfies readonly GatewayInboundProtocol[]; + +/** + * `surfaces` from the keys payload, or `undefined` when an older server sent none or the value + * is unusable. Callers fall back to the pre-surfaces display rather than inventing a state. + */ +export function parseApiSurfaces(value: unknown): ApiSurfacesInfo | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + const surfaces = {} as ApiSurfacesInfo; + for (const name of SURFACE_NAMES) { + const surface = record[name]; + if (!surface || typeof surface !== "object" || Array.isArray(surface)) return undefined; + const { enabled, source } = surface as Record; + if (typeof enabled !== "boolean" || !SURFACE_SOURCES.has(source as ApiSurfaceSource)) return undefined; + surfaces[name] = { enabled, source: source as ApiSurfaceSource }; + } + return surfaces; +} + export interface ApiEndpointInfo { baseUrl: string; responses: string; diff --git a/gui/src/protocol-api.ts b/gui/src/protocol-api.ts index 1c9085e35ee..2a33b30fd89 100644 --- a/gui/src/protocol-api.ts +++ b/gui/src/protocol-api.ts @@ -1,5 +1,6 @@ /** - * Client for the protocol preview routes (`GET /api/protocols`, `POST /api/protocols/plan`). + * Client for the protocol routes (`GET /api/protocols`, `POST /api/protocols/plan`, + * `PATCH /api/protocols/settings`). * * The dashboard never computes a plan itself; it asks the server and validates the answer * with the shared leaf validator, so a record from an older or newer server is refused rather @@ -110,3 +111,43 @@ export async function fetchProtocolPlan( return { kind: "error" }; } } + +export interface ProtocolSettingsPatchBody { + messagesEnabled?: boolean; +} + +export type ProtocolSettingsPatchResult = + | { kind: "ok"; info: ProtocolInfo } + /** The server predates the settings route. */ + | { kind: "unavailable" } + | { kind: "error"; code?: string }; + +/** + * Change protocol settings on the target `apiBase` names (this machine or the shared hub). The + * server answers with the fresh `GET /api/protocols` shape, validated like any other read. + */ +export async function patchProtocolSettings( + apiBase: string, + body: ProtocolSettingsPatchBody, + signal?: AbortSignal, +): Promise { + try { + const res = await fetch(`${apiBase}/api/protocols/settings`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + if (res.status === 404 || res.status === 405) return { kind: "unavailable" }; + const payload: unknown = await res.json().catch(() => null); + if (!res.ok) { + const code = isRec(payload) && isRec(payload.error) && typeof payload.error.code === "string" ? payload.error.code : undefined; + return code ? { kind: "error", code } : { kind: "error" }; + } + const info = parseProtocolInfo(payload); + return info ? { kind: "ok", info } : { kind: "error" }; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + return { kind: "error" }; + } +} From 9de268c3aef3cc43cc5d6fa06cd1d73252c3531e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:39:45 +0900 Subject: [PATCH 059/173] feat(gui): add API surface card copy to every locale State, source and Messages toggle strings for the three API cards, including the note that closing Messages also turns the Claude integration off. --- gui/src/i18n/de.ts | 12 ++++++++++++ gui/src/i18n/en.ts | 12 ++++++++++++ gui/src/i18n/fr.ts | 12 ++++++++++++ gui/src/i18n/ja.ts | 12 ++++++++++++ gui/src/i18n/ko.ts | 12 ++++++++++++ gui/src/i18n/ru.ts | 12 ++++++++++++ gui/src/i18n/tr.ts | 12 ++++++++++++ gui/src/i18n/vi.ts | 12 ++++++++++++ gui/src/i18n/zh-TW.ts | 12 ++++++++++++ gui/src/i18n/zh.ts | 12 ++++++++++++ 10 files changed, 120 insertions(+) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index ad69ef2cedf..c454d45529c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1939,6 +1939,18 @@ export const de: Record = { "api.chatCompletionsEndpoint": "Chat Completions API", "api.messagesEndpoint": "Messages API", "api.modelsEndpoint": "Models API", + "api.surface.on": "An", + "api.surface.off": "Aus", + "api.surface.source.fixed": "Immer bereitgestellt", + "api.surface.source.explicit": "Explizite Einstellung", + "api.surface.source.inherited": "Von den Claude-Einstellungen übernommen", + "api.surface.source.invalid": "Ungültiger Wert — geschlossen", + "api.surface.messagesToggle": "Messages API bereitstellen", + "api.surface.messagesCloseNote": "Wenn Messages ausgeschaltet wird, wird auch die Claude-Integration ausgeschaltet, damit eine ältere opencodex-Version den Endpunkt ebenfalls geschlossen hält.", + "api.surface.openClaude": "Claude-Einstellungen", + "api.surface.toggleFailed": "Die Messages-Einstellung konnte nicht gespeichert werden.", + "api.surface.toggleUnavailable": "Diese Proxy-Version kann die Messages API hier nicht ändern. Nutze stattdessen die Claude-Seite.", + "api.surface.closedNote": "Anfragen an diesen Endpunkt werden mit 403 abgelehnt.", "api.endpointNote": "Nutze die Basis-URL für OpenAI-kompatible Clients. Responses und Chat Completions liegen unter /v1.", "api.endpointsTitle": "Gateway-Endpunkte", "api.authBaseUrlNote": "Konfiguriere Clients mit der Basis-URL und wähle dann den protokollspezifischen Endpunkt unten.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ea15afaf884..ce56fab25e2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2538,6 +2538,18 @@ export const en = { "api.chatCompletionsEndpoint": "Chat Completions API", "api.messagesEndpoint": "Messages API", "api.modelsEndpoint": "Models API", + "api.surface.on": "On", + "api.surface.off": "Off", + "api.surface.source.fixed": "Always served", + "api.surface.source.explicit": "Explicit setting", + "api.surface.source.inherited": "Inherited from Claude settings", + "api.surface.source.invalid": "Invalid value — closed", + "api.surface.messagesToggle": "Serve the Messages API", + "api.surface.messagesCloseNote": "Turning Messages off also turns off the Claude integration, so an older opencodex version keeps the endpoint closed too.", + "api.surface.openClaude": "Claude settings", + "api.surface.toggleFailed": "The Messages setting could not be saved.", + "api.surface.toggleUnavailable": "This proxy version cannot change the Messages API here. Use the Claude page instead.", + "api.surface.closedNote": "Requests to this endpoint are refused with 403.", "api.endpointNote": "Use the base URL with OpenAI-compatible clients. Responses and Chat Completions are exposed under /v1.", "api.endpointsTitle": "Endpoints", "api.authTitle": "Authentication", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 91373afb820..56a982b9fa4 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2459,6 +2459,18 @@ export const fr: Record = { "api.chatCompletionsEndpoint": "API Chat Completions", "api.messagesEndpoint": "API Messages", "api.modelsEndpoint": "API des modèles", + "api.surface.on": "Activé", + "api.surface.off": "Désactivé", + "api.surface.source.fixed": "Toujours servi", + "api.surface.source.explicit": "Réglage explicite", + "api.surface.source.inherited": "Hérité des réglages Claude", + "api.surface.source.invalid": "Valeur invalide — fermé", + "api.surface.messagesToggle": "Servir l’API Messages", + "api.surface.messagesCloseNote": "Désactiver Messages désactive aussi l’intégration Claude, afin qu’une version plus ancienne d’opencodex garde également le point de terminaison fermé.", + "api.surface.openClaude": "Réglages Claude", + "api.surface.toggleFailed": "Le réglage Messages n’a pas pu être enregistré.", + "api.surface.toggleUnavailable": "Cette version du proxy ne peut pas modifier l’API Messages ici. Utilisez plutôt la page Claude.", + "api.surface.closedNote": "Les requêtes vers ce point de terminaison sont refusées avec 403.", "api.endpointNote": "Utilisez l’URL de base avec les clients compatibles avec OpenAI. Responses et Chat Completions sont accessibles sous /v1.", "api.endpointsTitle": "Points de terminaison", "api.authTitle": "Authentification", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 07b18982f51..4de59d7e296 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2390,6 +2390,18 @@ export const ja: Record = { "api.chatCompletionsEndpoint": "Chat Completions API", "api.messagesEndpoint": "Messages API", "api.modelsEndpoint": "Models API", + "api.surface.on": "オン", + "api.surface.off": "オフ", + "api.surface.source.fixed": "常に提供", + "api.surface.source.explicit": "明示的な設定", + "api.surface.source.inherited": "Claude 設定から継承", + "api.surface.source.invalid": "無効な値 — 閉鎖", + "api.surface.messagesToggle": "Messages API を提供する", + "api.surface.messagesCloseNote": "Messages をオフにすると Claude 連携もオフになるため、古い opencodex バージョンでもエンドポイントは閉じたままになります。", + "api.surface.openClaude": "Claude 設定", + "api.surface.toggleFailed": "Messages の設定を保存できませんでした。", + "api.surface.toggleUnavailable": "このプロキシのバージョンではここで Messages API を変更できません。Claude ページを使ってください。", + "api.surface.closedNote": "このエンドポイントへのリクエストは 403 で拒否されます。", "api.authTitle": "認証", "api.authBaseUrlNote": "クライアントにはベース URL を設定し、下のプロトコル別エンドポイントを選んでください。", "api.authLoopback": "ループバックの認証は経路によって異なります。独立した音声クライアントにはOpenCodexデータキーが必要です。リモート接続にはデータキーまたはOPENCODEX_API_AUTH_TOKENが必要です。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7015b8c4859..b859b1fc431 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1979,6 +1979,18 @@ export const ko: Record = { "api.chatCompletionsEndpoint": "Chat Completions API", "api.messagesEndpoint": "Messages API", "api.modelsEndpoint": "Models API", + "api.surface.on": "켜짐", + "api.surface.off": "꺼짐", + "api.surface.source.fixed": "항상 제공", + "api.surface.source.explicit": "명시적 설정", + "api.surface.source.inherited": "Claude 설정에서 상속", + "api.surface.source.invalid": "잘못된 값 — 닫힘", + "api.surface.messagesToggle": "Messages API 제공", + "api.surface.messagesCloseNote": "Messages를 끄면 Claude 연동도 함께 꺼지므로, 이전 opencodex 버전에서도 엔드포인트가 닫힌 채로 유지됩니다.", + "api.surface.openClaude": "Claude 설정", + "api.surface.toggleFailed": "Messages 설정을 저장하지 못했습니다.", + "api.surface.toggleUnavailable": "이 프록시 버전에서는 여기서 Messages API를 바꿀 수 없습니다. Claude 페이지를 사용하세요.", + "api.surface.closedNote": "이 엔드포인트로 오는 요청은 403으로 거부됩니다.", "api.endpointsTitle": "게이트웨이 엔드포인트", "api.authBaseUrlNote": "클라이언트에는 기본 URL을 설정한 뒤 아래에서 프로토콜별 엔드포인트를 선택하세요.", "api.authTitle": "인증", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 057e4df891b..cb23ef3e984 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2460,6 +2460,18 @@ export const ru: Record = { "api.chatCompletionsEndpoint": "Chat Completions API", "api.messagesEndpoint": "Messages API", "api.modelsEndpoint": "Models API", + "api.surface.on": "Вкл.", + "api.surface.off": "Выкл.", + "api.surface.source.fixed": "Доступен всегда", + "api.surface.source.explicit": "Явная настройка", + "api.surface.source.inherited": "Унаследовано из настроек Claude", + "api.surface.source.invalid": "Недопустимое значение — закрыто", + "api.surface.messagesToggle": "Предоставлять Messages API", + "api.surface.messagesCloseNote": "Отключение Messages также отключает интеграцию Claude, чтобы более старая версия opencodex тоже держала эту конечную точку закрытой.", + "api.surface.openClaude": "Настройки Claude", + "api.surface.toggleFailed": "Не удалось сохранить настройку Messages.", + "api.surface.toggleUnavailable": "Эта версия прокси не может изменить Messages API здесь. Используйте страницу Claude.", + "api.surface.closedNote": "Запросы к этой конечной точке отклоняются с кодом 403.", "api.endpointsTitle": "Конечные точки", "api.authTitle": "Аутентификация", "api.authBaseUrlNote": "Настройте клиентов с базовым URL, затем выберите нужный протокольный endpoint ниже.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f9d58271b9c..466b0485d34 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2479,6 +2479,18 @@ export const tr: Record = { "api.chatCompletionsEndpoint": "Chat Completions API", "api.messagesEndpoint": "Messages API", "api.modelsEndpoint": "Models API", + "api.surface.on": "Açık", + "api.surface.off": "Kapalı", + "api.surface.source.fixed": "Her zaman sunulur", + "api.surface.source.explicit": "Açık ayar", + "api.surface.source.inherited": "Claude ayarlarından devralındı", + "api.surface.source.invalid": "Geçersiz değer — kapalı", + "api.surface.messagesToggle": "Messages API'yi sun", + "api.surface.messagesCloseNote": "Messages kapatıldığında Claude entegrasyonu da kapanır; böylece eski bir opencodex sürümü de uç noktayı kapalı tutar.", + "api.surface.openClaude": "Claude ayarları", + "api.surface.toggleFailed": "Messages ayarı kaydedilemedi.", + "api.surface.toggleUnavailable": "Bu proxy sürümü Messages API'yi burada değiştiremez. Bunun yerine Claude sayfasını kullanın.", + "api.surface.closedNote": "Bu uç noktaya gelen istekler 403 ile reddedilir.", "api.endpointNote": "OpenAI uyumlu istemcilerle taban URL'yi kullanın.", "api.endpointsTitle": "Uç noktalar", "api.authTitle": "Kimlik Doğrulama", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 0729ee2c2c7..581f9398856 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2472,6 +2472,18 @@ export const vi: Record = { "api.chatCompletionsEndpoint": "Chat Completions API", "api.messagesEndpoint": "Messages API", "api.modelsEndpoint": "Models API", + "api.surface.on": "Bật", + "api.surface.off": "Tắt", + "api.surface.source.fixed": "Luôn phục vụ", + "api.surface.source.explicit": "Thiết lập tường minh", + "api.surface.source.inherited": "Kế thừa từ cài đặt Claude", + "api.surface.source.invalid": "Giá trị không hợp lệ — đã đóng", + "api.surface.messagesToggle": "Phục vụ Messages API", + "api.surface.messagesCloseNote": "Tắt Messages cũng tắt tích hợp Claude, để phiên bản opencodex cũ hơn cũng giữ endpoint này đóng.", + "api.surface.openClaude": "Cài đặt Claude", + "api.surface.toggleFailed": "Không lưu được thiết lập Messages.", + "api.surface.toggleUnavailable": "Phiên bản proxy này không thể đổi Messages API tại đây. Hãy dùng trang Claude.", + "api.surface.closedNote": "Yêu cầu tới endpoint này bị từ chối với mã 403.", "api.endpointNote": "Dùng URL cơ sở cho các ứng dụng hỗ trợ OpenAI tương thích. Responses và Chat Completions được đặt ở /v1.", "api.endpointsTitle": "Endpoints", "api.authTitle": "Xác thực (Authentication)", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 7310b358947..03122163cfc 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1861,6 +1861,18 @@ export const zhTW: Record = { "api.chatCompletionsEndpoint": "Chat Completions API", "api.messagesEndpoint": "Messages API", "api.modelsEndpoint": "Models API", + "api.surface.on": "開啟", + "api.surface.off": "關閉", + "api.surface.source.fixed": "一律提供", + "api.surface.source.explicit": "明確設定", + "api.surface.source.inherited": "繼承自 Claude 設定", + "api.surface.source.invalid": "無效值 — 已關閉", + "api.surface.messagesToggle": "提供 Messages API", + "api.surface.messagesCloseNote": "關閉 Messages 也會關閉 Claude 整合,讓舊版 opencodex 也保持此端點關閉。", + "api.surface.openClaude": "Claude 設定", + "api.surface.toggleFailed": "無法儲存 Messages 設定。", + "api.surface.toggleUnavailable": "此代理版本無法在這裡變更 Messages API,請改用 Claude 頁面。", + "api.surface.closedNote": "對此端點的請求會以 403 拒絕。", "api.endpointNote": "請將基礎 URL 用於 OpenAI 相容客戶端。Responses 與 Chat Completions 在 /v1 下提供。", "api.endpointsTitle": "閘道器端點", "api.authTitle": "身份驗證", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b41e7258a6f..9caed8a5614 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1960,6 +1960,18 @@ export const zh: Record = { "api.chatCompletionsEndpoint": "Chat Completions API", "api.messagesEndpoint": "Messages API", "api.modelsEndpoint": "Models API", + "api.surface.on": "开启", + "api.surface.off": "关闭", + "api.surface.source.fixed": "始终提供", + "api.surface.source.explicit": "显式设置", + "api.surface.source.inherited": "继承自 Claude 设置", + "api.surface.source.invalid": "无效值 — 已关闭", + "api.surface.messagesToggle": "提供 Messages API", + "api.surface.messagesCloseNote": "关闭 Messages 也会关闭 Claude 集成,这样旧版 opencodex 也会保持该端点关闭。", + "api.surface.openClaude": "Claude 设置", + "api.surface.toggleFailed": "无法保存 Messages 设置。", + "api.surface.toggleUnavailable": "此代理版本无法在这里更改 Messages API,请改用 Claude 页面。", + "api.surface.closedNote": "对该端点的请求会以 403 拒绝。", "api.endpointsTitle": "网关端点", "api.authBaseUrlNote": "客户端应使用基础 URL,然后选择下面的协议端点。", "api.authTitle": "身份验证", From 0746c1ad66a6804578b520a6592d425e3efae8c0 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:41:14 +0900 Subject: [PATCH 060/173] feat(gui): show Responses, Chat Completions and Messages as API cards Each card states whether the API is served, its endpoint, and who decided it. The Messages card keeps showing while closed, carries the toggle, and links to the Claude page. Without surfaces from the server the flat endpoint list stays. --- gui/src/pages/api-keys-endpoints-panel.tsx | 45 ++++++--- gui/src/pages/api-surface-cards.tsx | 109 +++++++++++++++++++++ gui/src/styles-apikeys-workspace.css | 34 +++++++ 3 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 gui/src/pages/api-surface-cards.tsx diff --git a/gui/src/pages/api-keys-endpoints-panel.tsx b/gui/src/pages/api-keys-endpoints-panel.tsx index 008b03c79f9..d065f8d568a 100644 --- a/gui/src/pages/api-keys-endpoints-panel.tsx +++ b/gui/src/pages/api-keys-endpoints-panel.tsx @@ -6,8 +6,9 @@ * different one that happened to share a file. */ import { useI18n, type TKey } from "../i18n/shared"; -import type { ApiAuthDisposition, ApiAuthMatrixRow, ApiEndpointInfo } from "./api-keys-utils"; +import type { ApiAuthDisposition, ApiAuthMatrixRow, ApiEndpointInfo, ApiSurfacesInfo } from "./api-keys-utils"; import { EndpointUrl } from "./api-keys-copy"; +import { ApiSurfaceCards } from "./api-surface-cards"; /** The server ships the rules; the GUI only names them. */ function dispositionLabel(value: ApiAuthDisposition, t: (key: TKey) => string): string { @@ -20,12 +21,23 @@ export function ApiKeysEndpointsPanel({ endpoints, claudeCodeEnabled, authMatrix, + surfaces, + apiBase, + onSurfacesChanged, }: { endpoints: ApiEndpointInfo; claudeCodeEnabled: boolean; authMatrix: ApiAuthMatrixRow[]; + /** Absent from a server that predates API surface settings; the flat list is kept then. */ + surfaces?: ApiSurfacesInfo; + /** Management origin the Messages toggle writes to (this machine or the shared hub). */ + apiBase?: string; + onSurfacesChanged?: () => void; }) { const { t } = useI18n(); + const cards = surfaces && apiBase !== undefined && onSurfacesChanged + ? { surfaces, apiBase, onChanged: onSurfacesChanged } + : null; return (

    {t("api.endpointsTitle")}

    @@ -34,25 +46,30 @@ export function ApiKeysEndpointsPanel({ {t("api.baseUrl")}
    -
    - {t("api.responsesEndpoint")} - -
    -
    - {t("api.chatCompletionsEndpoint")} - -
    - {claudeCodeEnabled && ( -
    - {t("api.messagesEndpoint")} - -
    + {cards ? null : ( + <> +
    + {t("api.responsesEndpoint")} + +
    +
    + {t("api.chatCompletionsEndpoint")} + +
    + {claudeCodeEnabled && ( +
    + {t("api.messagesEndpoint")} + +
    + )} + )}
    {t("api.modelsEndpoint")}
    + {cards ? : null}

    {t("api.endpointNote")}

    {/* Not a disclosure. This is the one thing a user needs before their first request succeeds, and the prose it replaces was wrong about Chat diff --git a/gui/src/pages/api-surface-cards.tsx b/gui/src/pages/api-surface-cards.tsx new file mode 100644 index 00000000000..6310e28f3ca --- /dev/null +++ b/gui/src/pages/api-surface-cards.tsx @@ -0,0 +1,109 @@ +/** + * One card per public API (Responses, Chat Completions, Messages): whether it is served, where + * it lives, and who decided that. The server resolves every state + * (`resolveApiSurfaceSettings`); this component only names it. + * + * Messages is the one switchable surface. Its card stays visible while closed, so the operator + * can see the endpoint that is being refused and reopen it. The toggle writes through + * `PATCH /api/protocols/settings` on the target `apiBase` names, then asks the page to reload + * the keys payload rather than predicting the new state. + */ +import { useState } from "react"; +import { Notice, Switch } from "../ui"; +import { useI18n, type TKey } from "../i18n/shared"; +import { navigateHash } from "../hash-routing"; +import { patchProtocolSettings } from "../protocol-api"; +import type { GatewayInboundProtocol } from "../api-access-models"; +import type { ApiEndpointInfo, ApiSurfaceInfo, ApiSurfaceSource, ApiSurfacesInfo } from "./api-keys-utils"; +import { EndpointUrl } from "./api-keys-copy"; + +const CLAUDE_HASH = "integrations/claude"; + +const SOURCE_KEYS: Record = { + fixed: "api.surface.source.fixed", + "api-surfaces": "api.surface.source.explicit", + "claude-code-legacy": "api.surface.source.inherited", + invalid: "api.surface.source.invalid", +}; + +const CARDS: ReadonlyArray<{ id: GatewayInboundProtocol; titleKey: TKey; url: (endpoints: ApiEndpointInfo) => string }> = [ + { id: "responses", titleKey: "api.responsesEndpoint", url: endpoints => endpoints.responses }, + { id: "chat", titleKey: "api.chatCompletionsEndpoint", url: endpoints => endpoints.chatCompletions }, + { id: "messages", titleKey: "api.messagesEndpoint", url: endpoints => endpoints.messages }, +]; + +type ToggleError = "failed" | "unavailable" | null; + +export function ApiSurfaceCards({ + apiBase, + endpoints, + surfaces, + onChanged, +}: { + apiBase: string; + endpoints: ApiEndpointInfo; + surfaces: ApiSurfacesInfo; + onChanged: () => void; +}) { + const { t } = useI18n(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const toggleMessages = async (messages: ApiSurfaceInfo) => { + if (pending) return; + setPending(true); + setError(null); + try { + const result = await patchProtocolSettings(apiBase, { messagesEnabled: !messages.enabled }); + if (result.kind === "ok") onChanged(); + else setError(result.kind === "unavailable" ? "unavailable" : "failed"); + } finally { + setPending(false); + } + }; + + return ( +
    + {CARDS.map(card => { + const surface = surfaces[card.id]; + const stateLabel = surface.enabled ? t("api.surface.on") : t("api.surface.off"); + return ( +
    +
    + {t(card.titleKey)} + {card.id === "messages" ? ( + { void toggleMessages(surface); }} + label={`${t("api.surface.messagesToggle")}: ${stateLabel}`} + /> + ) : null} + {stateLabel} +
    + + + {t(SOURCE_KEYS[surface.source])} + + {card.id === "messages" ? ( + <> + {!surface.enabled ? {t("api.surface.closedNote")} : null} + {surface.enabled ? {t("api.surface.messagesCloseNote")} : null} + + {error ? ( + {t(error === "unavailable" ? "api.surface.toggleUnavailable" : "api.surface.toggleFailed")} + ) : null} + + ) : null} +
    + ); + })} +
    + ); +} diff --git a/gui/src/styles-apikeys-workspace.css b/gui/src/styles-apikeys-workspace.css index a389bbca25a..91f5495ba48 100644 --- a/gui/src/styles-apikeys-workspace.css +++ b/gui/src/styles-apikeys-workspace.css @@ -906,3 +906,37 @@ align-items: center; gap: var(--space-2); } + +/* API surface cards (PF-04): one card per public API. Inherits the endpoint URL styling from + .api-endpoints; a closed surface stays visible, dimmed rather than hidden. */ +.api-endpoints.api-surface-cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.api-surface-card { + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.api-surface-card-off { + border-style: dashed; +} +.api-surface-card-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} +.api-surface-card-title { + flex: 1 1 auto; + min-width: 0; + font-weight: var(--weight-semibold); +} +.api-surface-invalid { + color: var(--red); +} +.api-surface-card-link { + align-self: flex-start; +} +@media (max-width: 960px) { + .api-endpoints.api-surface-cards { grid-template-columns: minmax(0, 1fr); } +} From 8efa12d37d417c0e71970f882cc2aaf796bf4ef1 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:41:14 +0900 Subject: [PATCH 061/173] feat(gui): feed API surfaces and the Messages toggle into the API page The keys payload's surfaces are validated on read and in the session cache, and a successful toggle reloads the payload from the same apiBase rather than guessing. --- .../apikeys-workspace/ApiKeysWorkspace.tsx | 16 +++++++++++++++- gui/src/pages/ApiKeys.tsx | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx index fc321447d92..4aa6cf2fa19 100644 --- a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx +++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx @@ -14,6 +14,7 @@ import { type ApiAuthMatrixRow, type ApiEndpointInfo, type ApiKeyEntry, + type ApiSurfacesInfo, type ModelTests, } from "../../pages/api-keys-utils"; import { @@ -44,6 +45,10 @@ export interface ApiKeysWorkspaceProps { keysLoadFailed: boolean; endpoints: ApiEndpointInfo; claudeCodeEnabled: boolean; + /** Per-API state and source; absent from a server that predates surface settings. */ + surfaces?: ApiSurfacesInfo; + /** Reload after the Messages toggle wrote a new setting. */ + onSurfacesChanged?: () => void; localeTag?: string; newName: string; creating: boolean; @@ -95,6 +100,8 @@ export default function ApiKeysWorkspace({ keysLoadFailed, endpoints, claudeCodeEnabled, + surfaces, + onSurfacesChanged, localeTag, newName, creating, @@ -539,7 +546,14 @@ export default function ApiKeysWorkspace({ 0} />
    - +
    {/* Reference, then prediction: which path a request would take through the endpoints above. Asked of the server on demand; it sends nothing upstream. */} diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 15c78c8b835..4f00868c082 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -22,7 +22,9 @@ import { isApiAuthMatrix, isApiKeyUsage, isAudioApiInfo, + parseApiSurfaces, type ApiEndpointInfo, + type ApiSurfacesInfo, type ApiAuthMatrixRow, type ApiKeyEntry, type ModelTestResult, @@ -45,6 +47,7 @@ interface KeysResponse extends UsageReadMetadata { messagesEndpoint?: string; modelsEndpoint?: string; claudeCodeEnabled?: boolean; + surfaces?: unknown; audio?: unknown; } @@ -60,6 +63,8 @@ type CachedKeysShape = UsageReadMetadata & { keys: ApiKeyEntry[]; endpoints: ApiEndpointInfo; claudeCodeEnabled: boolean; + /** Absent when the server predates API surface settings. */ + surfaces?: ApiSurfacesInfo; /** Dataset-level: absent means nothing is attributable yet, which is a * different statement from a key whose counters are zero. */ attributionSince?: string; @@ -91,6 +96,12 @@ function seedEndpointsFromApiBase(apiBase: string): ApiEndpointInfo { function validCachedKeys(cached: CachedKeysShape | null): CachedKeysShape | null { if (!cached || !isApiAuthMatrix(cached.authMatrix)) return null; if (!Array.isArray(cached.keys) || cached.keys.some(key => !key || !isApiKeyUsage(key.usage) || !validPendingRotation(key.pendingRotation))) return null; + if (cached.surfaces !== undefined && !parseApiSurfaces(cached.surfaces)) { + // Drop an unusable surface record rather than the whole entry: the page falls back to + // the flat endpoint list until the network answer arrives. + const { surfaces: _surfaces, ...rest } = cached; + cached = rest; + } if (cached.endpoints?.audio !== undefined && !isAudioApiInfo(cached.endpoints.audio, cached.endpoints.baseUrl)) { const { audio: _audio, ...endpoints } = cached.endpoints; return { ...cached, endpoints }; @@ -151,6 +162,8 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a if (rows.some(key => !isApiKeyUsage(key.usage) || !validPendingRotation(key.pendingRotation))) throw new Error(t("api.keysLoadFailed")); const validatedKeys = rows as ApiKeyEntry[]; const derived = deriveApiEndpoints(data.endpoint ?? ""); + // An older server sends no surfaces; the endpoints panel then keeps its flat list. + const surfaces = parseApiSurfaces(data.surfaces); const next: CachedKeysShape = { // Validate rather than coerce. A missing or malformed `usage` used to // become zeroes, which says "used zero times" about data we could not @@ -165,6 +178,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a ...(isAudioApiInfo(data.audio, data.baseUrl ?? derived.baseUrl) ? { audio: data.audio } : {}), }, claudeCodeEnabled: data.claudeCodeEnabled !== false, + ...(surfaces ? { surfaces } : {}), ...(data.attributionSince ? { attributionSince: data.attributionSince } : {}), ...(data.historyTruncated === true ? { historyTruncated: true } : {}), ...readUsageMetadata(data), @@ -230,6 +244,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a const keys = keysData?.keys ?? []; const endpoints = keysData?.endpoints ?? seedEndpointsFromApiBase(apiBase); const claudeCodeEnabled = keysData?.claudeCodeEnabled ?? true; + const surfaces = keysData?.surfaces; const attributionSince = keysData?.attributionSince; const historyTruncated = keysData?.historyTruncated === true; // `?? []` only ever fires while there is no key data at all — both the network @@ -516,6 +531,8 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a keysLoadFailed={keysState.showError} endpoints={endpoints} claudeCodeEnabled={claudeCodeEnabled} + surfaces={surfaces} + onSurfacesChanged={() => { refreshKeys(); }} localeTag={localeTag} newName={newName} creating={creating} From 30ab0e9327ec08c4c0a6b4b122716e624cfa2f81 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:42:24 +0900 Subject: [PATCH 062/173] test(gui): cover API surface cards, parsing and the settings client Three cards with state and source, a closed Messages card that stays visible, the older-server fallback, and a toggle that PATCHes the target apiBase then reloads. --- gui/tests/api-surface-cards.test.tsx | 185 +++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 gui/tests/api-surface-cards.test.tsx diff --git a/gui/tests/api-surface-cards.test.tsx b/gui/tests/api-surface-cards.test.tsx new file mode 100644 index 00000000000..0ea6bc19849 --- /dev/null +++ b/gui/tests/api-surface-cards.test.tsx @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { DICTS } from "../src/i18n/shared"; +import { ApiKeysEndpointsPanel } from "../src/pages/api-keys-endpoints-panel"; +import { parseApiSurfaces, type ApiSurfacesInfo } from "../src/pages/api-keys-utils"; +import { patchProtocolSettings } from "../src/protocol-api"; + +const en = DICTS.en; +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +const endpoints = { + baseUrl: "http://127.0.0.1:10100/v1", + responses: "http://127.0.0.1:10100/v1/responses", + chatCompletions: "http://127.0.0.1:10100/v1/chat/completions", + messages: "http://127.0.0.1:10100/v1/messages", + models: "http://127.0.0.1:10100/v1/models", +}; +const authMatrix = [{ endpoint: "/v1/responses", bearer: "rejected" as const, dedicated: "required" as const, xApiKey: "rejected" as const }]; + +function surfaces(messages: ApiSurfacesInfo["messages"]): ApiSurfacesInfo { + return { + responses: { enabled: true, source: "fixed" }, + chat: { enabled: true, source: "fixed" }, + messages, + }; +} + +const INFO = { + schemaVersion: 1, + policyRevision: "p1-00000002", + surfaces: surfaces({ enabled: false, source: "api-surfaces" }), + settings: { unrepresentable: "legacy" }, + features: ["request.tools"], +}; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mount(props: Partial[0]>): Promise<{ root: Root; container: HTMLElement }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + return { root, container }; +} + +describe("parseApiSurfaces", () => { + test("accepts the server shape", () => { + expect(parseApiSurfaces(INFO.surfaces)).toEqual(INFO.surfaces); + }); + + test.each([ + ["absent", undefined], + ["an array", []], + ["a missing surface", { responses: { enabled: true, source: "fixed" }, chat: { enabled: true, source: "fixed" } }], + ["a non-boolean state", surfaces({ enabled: "yes" as never, source: "api-surfaces" })], + ["an unknown source", surfaces({ enabled: true, source: "guess" as never })], + ])("answers undefined for %s", (_label, value) => { + expect(parseApiSurfaces(value)).toBeUndefined(); + }); +}); + +describe("API surface cards", () => { + test("an older server without surfaces keeps the flat endpoint list", async () => { + const { root, container } = await mount({ claudeCodeEnabled: false }); + expect(container.querySelector(".api-surface-card")).toBeNull(); + expect(container.textContent).not.toContain(en["api.messagesEndpoint"]); + await act(async () => root.unmount()); + }); + + test("three cards with state and source, and a closed Messages card stays visible", async () => { + const { root, container } = await mount({ + surfaces: surfaces({ enabled: false, source: "claude-code-legacy" }), + apiBase: "http://127.0.0.1:10100", + onSurfacesChanged: () => {}, + }); + const cards = [...container.querySelectorAll("[data-surface]")].map(card => card.getAttribute("data-surface")); + expect(cards).toEqual(["responses", "chat", "messages"]); + const messages = container.querySelector('[data-surface="messages"]')!; + expect(messages.textContent).toContain(endpoints.messages); + expect(messages.textContent).toContain(en["api.surface.off"]); + expect(messages.textContent).toContain(en["api.surface.source.inherited"]); + expect(messages.textContent).toContain(en["api.surface.closedNote"]); + expect(messages.textContent).toContain(en["api.surface.openClaude"]); + expect(container.querySelector('[data-surface="responses"]')!.textContent).toContain(en["api.surface.source.fixed"]); + // Only Messages is switchable. + expect(container.querySelectorAll(".switch")).toHaveLength(1); + await act(async () => root.unmount()); + }); + + test("an invalid value reads as closed", async () => { + const { root, container } = await mount({ + surfaces: surfaces({ enabled: false, source: "invalid" }), + apiBase: "", + onSurfacesChanged: () => {}, + }); + const messages = container.querySelector('[data-surface="messages"]')!; + expect(messages.textContent).toContain(en["api.surface.source.invalid"]); + expect(messages.textContent).toContain(en["api.surface.off"]); + await act(async () => root.unmount()); + }); + + test("the toggle PATCHes the target apiBase and asks the page to reload", async () => { + const calls: Array<{ url: string; method?: string; body?: string }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), method: init?.method, body: String(init?.body) }); + return new Response(JSON.stringify(INFO), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + let changed = 0; + const { root, container } = await mount({ + surfaces: surfaces({ enabled: true, source: "claude-code-legacy" }), + apiBase: "http://hub.example:10100", + onSurfacesChanged: () => { changed++; }, + }); + const toggle = container.querySelector('[data-surface="messages"] .switch')!; + await act(async () => { toggle.click(); }); + expect(calls).toEqual([{ url: "http://hub.example:10100/api/protocols/settings", method: "PATCH", body: JSON.stringify({ messagesEnabled: false }) }]); + expect(changed).toBe(1); + await act(async () => root.unmount()); + }); + + test("a failed toggle says so and does not reload", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ error: { code: "write_failed" } }), { status: 500 })) as unknown as typeof fetch; + let changed = 0; + const { root, container } = await mount({ + surfaces: surfaces({ enabled: true, source: "api-surfaces" }), + apiBase: "", + onSurfacesChanged: () => { changed++; }, + }); + await act(async () => { container.querySelector('[data-surface="messages"] .switch')!.click(); }); + expect(container.textContent).toContain(en["api.surface.toggleFailed"]); + expect(changed).toBe(0); + await act(async () => root.unmount()); + }); +}); + +describe("patchProtocolSettings", () => { + test("an older server without the route reads as unavailable", async () => { + globalThis.fetch = (async () => new Response("{}", { status: 404 })) as unknown as typeof fetch; + expect(await patchProtocolSettings("", { messagesEnabled: true })).toEqual({ kind: "unavailable" }); + }); + + test("a refusal carries the server's code", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ error: { code: "config_busy" } }), { status: 409 })) as unknown as typeof fetch; + expect(await patchProtocolSettings("", { messagesEnabled: true })).toEqual({ kind: "error", code: "config_busy" }); + }); + + test("a success returns the validated fresh info", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify(INFO), { status: 200 })) as unknown as typeof fetch; + const result = await patchProtocolSettings("", { messagesEnabled: false }); + expect(result.kind).toBe("ok"); + if (result.kind === "ok") expect(result.info.surfaces.messages.enabled).toBe(false); + }); +}); From 9e30c81ddd22ce73381fddb88166475e3e0e80c7 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:43:36 +0900 Subject: [PATCH 063/173] docs(structure): describe the Messages surface reader, settings writer and API cards Protocol paths now names the ingress reader, the PATCH writer and its rollback, and why closing Messages also writes claudeCode.enabled; the dashboard doc covers the three API cards and the older-server fallback. --- structure/config.md | 2 +- structure/dashboard-and-usage.md | 10 ++++++++++ structure/data-planes/protocol-paths.md | 21 ++++++++++++++++++++- structure/gui-and-management-api.md | 2 +- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/structure/config.md b/structure/config.md index 35d172e6672..5d37ed9567d 100644 --- a/structure/config.md +++ b/structure/config.md @@ -592,7 +592,7 @@ 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. +`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, meaning and the one writer (`PATCH /api/protocols/settings`), including why closing Messages also writes `claudeCode.enabled` through `commitClaudeCodeBlock` (`src/claude/claude-code-block.ts`, the sentinel-stamping block writer every management route uses). 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. diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 163b70a2b87..f8c715b6ffe 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -49,6 +49,16 @@ displayed. Tests live in `gui/tests/audio-api-client.test.ts`, `gui/tests/audio-api-panel.test.tsx`, `gui/tests/api-auth-memory.test.ts` and `tests/server/api-access-endpoints.test.ts`. +The endpoints panel (`gui/src/pages/api-keys-endpoints-panel.tsx`) shows the base URL and models +endpoint, then one card per public API from `gui/src/pages/api-surface-cards.tsx`: state, endpoint +and the decision source (always served, explicit, inherited from Claude settings, or invalid and +closed). The Messages card stays visible while closed, carries the toggle that calls +`PATCH /api/protocols/settings` on the page's `apiBase` (machine or shared target) before reloading +the keys payload, and links to `#integrations/claude`. `parseApiSurfaces` +(`gui/src/pages/api-keys-utils.ts`) validates `surfaces` from the keys payload and the session +cache; a server without it keeps the flat endpoint list gated on `claudeCodeEnabled`. Tests live in +`gui/tests/api-surface-cards.test.tsx`. + The API page's request path preview is `gui/src/components/protocols/ProtocolPlanPanel.tsx`, placed after the endpoints section. It asks `POST /api/protocols/plan` through `gui/src/protocol-api.ts`, which validates the answer with the diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 7c5e8f8e764..2b3aac2b1b4 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -124,7 +124,26 @@ always served. The Messages surface uses an explicit `apiSurfaces.messages.enabl 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. +the key-auth one. + +`claudeInboundDisabled` in `src/server/claude-messages.ts` is the Messages ingress reader: both +`/v1/messages` and `/v1/messages/count_tokens` call it, so the two routes cannot disagree, and a +closed surface answers 403 before the body is read. `buildApiAccessEndpoints` +(`src/server/management/api-access.ts`) reports the resolved `surfaces` in the keys payload and +keeps `claudeCodeEnabled` for older dashboards, set from the resolved Messages state rather than +from `claudeCode.enabled`. No other request path reads these settings yet. + +`PATCH /api/protocols/settings` is the one writer. `src/server/management/protocol-settings-patch.ts` +validates the body strictly and applies it in memory; the route persists through +`saveConfigPreservingClaudeCode` and restores the pre-patch snapshot when the save throws, so the +live config never serves a state the file does not hold. Closing Messages writes +`apiSurfaces.messages.enabled = false` and `claudeCode.enabled = false` in one save, through +`commitClaudeCodeBlock` (`src/claude/claude-code-block.ts`, shared with the Claude settings routes +and responsible for the auth-mode migration sentinel); a binary older than `apiSurfaces` reads only +`claudeCode.enabled`, so a downgrade after a close stays closed. Opening writes only the explicit +surface value, so after a downgrade the older reader decides, and it errs closed. The resulting +upgrade/rollback matrix is `tests/claude-integration/messages-surface-matrix.test.ts`; the route +contract is `tests/server/protocol-settings-route.test.ts`. `src/config/schema/config-schema.ts` keeps `apiSurfaces` raw on purpose: degrading a mistyped `enabled` to absence would turn it into "inherit" and could reopen a surface, so the resolver diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index ef0e205fc34..53c1d2f4103 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -200,7 +200,7 @@ this document owns is which module holds which area and what invariant that area | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Claude reset grants | `src/server/management/anthropic-reset-grant-routes.ts` — `GET /api/anthropic/reset-grants`, `POST /api/anthropic/reset-grants/consume` (lazy-loaded). Wire and fail-closed parsing live in `src/providers/anthropic-reset-grants.ts` (the Claude Code 2.1.278 `cedar_ember` contract, sent with `CLAUDE_CLI_USER_AGENT` from `src/providers/claude-cli-identity.ts`); the journal is `src/providers/anthropic-reset-grant-ledger.ts`: a cross-process `BEGIN IMMEDIATE` lock around every synchronous read-modify-write, a 90 s lease, the operation id reused as the upstream `request_id`, same-id retry only inside the vendor's ten-minute window, no settlement inferred from a re-read, and a fail-closed `500 journal_write_failed` when an answer cannot be recorded. Spending requires the `gui-session` principal. The dashboard owner is `gui/src/hooks/useAnthropicResetGrants.ts` with `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` on the Anthropic OAuth rows of `ProviderAuthPanel`; after an unknown outcome the dialog only retries the same id. Design and audit record: [`../devlog/_plan/260923_claude_reset_grants/010_plan.md`](../devlog/_plan/260923_claude_reset_grants/010_plan.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. `PUT` keeps a stored field the body omits (`cooldownMs`, `waitForCooldownMs`, `defaultEffortMode`, `reasoningEffortMode`, `imageInput`, `cooldownWaitPolicy`, per-target `lastResort`); explicit values replace it and defaults are stored sparse. | -| Protocol paths | `src/server/management/protocol-routes.ts` (lazy-loaded) — `GET /api/protocols` returns the contract version, the resolved API surfaces and protocol settings, the policy revision and the feature vocabulary; `POST /api/protocols/plan` takes `{ model, inbound, features? }` (model at most 200 characters, at most 24 features, any other key refused with 400) and returns a `ProtocolPlanV1` with `basis: "preview"` from `src/protocols/plan-snapshot.ts`. Both are read-only, never log their input, and send nothing upstream. Both are `deferred-verb` in the route registry, owned by PF-12 in [`../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md`](../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md). | +| Protocol paths | `src/server/management/protocol-routes.ts` (lazy-loaded) — `GET /api/protocols` returns the contract version, the resolved API surfaces and protocol settings, the policy revision and the feature vocabulary; `POST /api/protocols/plan` takes `{ model, inbound, features? }` (model at most 200 characters, at most 24 features, any other key refused with 400) and returns a `ProtocolPlanV1` with `basis: "preview"` from `src/protocols/plan-snapshot.ts`. Both are read-only, never log their input, and send nothing upstream. `PATCH /api/protocols/settings` takes `{ messagesEnabled?, unrepresentable?, rollout? }` (strict: unknown keys and wrong types are 400), validates and applies through `src/server/management/protocol-settings-patch.ts`, persists with `saveConfigPreservingClaudeCode`, restores the live config if the save fails (409 on lock contention, 500 otherwise), and answers with the fresh `GET /api/protocols` body; closing Messages also writes `claudeCode.enabled = false` through `commitClaudeCodeBlock` ([Protocol Paths](data-planes/protocol-paths.md#settings)). All three are `deferred-verb` in the route registry, owned by PF-12 in [`../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md`](../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md). | | Workflow budget | `src/server/management/workflow-budget-routes.ts` — `GET /api/workflow-budget` reads the tracked roots or one root, and `POST /api/workflow-budget/clear` clears exactly one. The clear moves the windowed send ring and the child map and nothing else: `active` belongs to turns still in flight, the spend ledger is a token budget an operator did not ask to forgive, and the lifetime send total survives so a clear cannot launder the record. A refusal event carries `spendScope` and `spendLimit` when a token ceiling fired, so the reason is readable without the config open beside it; no scope id is ever attached, because root ids are client thread headers and identity ids are credentials. Both are `deferred-verb` in the route registry — they are owed CLI verbs, and because the ledger is process memory there is no local projection the CLI could read instead. See [`../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md`](../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md). | | Codex accounts | `src/codex/auth-api/routes.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Per-account quota activation uses the existing `GET/PUT /api/settings` surface and `src/codex/quota-auto-refresh.ts`, keeping scheduled spending separate from credential/authentication mutation. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. New-account config commits add UI-managed selector bindings in the same config save; deletion deliberately retains existing bindings for fail-closed exact routing and re-add stability. Account mutations request catalog convergence only after config durability and expose only the boolean `catalogRefreshPending` completion projection. | | Sidebar | `src/server/management/sidebar-routes.ts` — `GET/POST /api/github/star`, `GET /api/update/badge`, and `POST /api/update/desktop-snapshot`. The snapshot POST accepts the raw admin-token principal or the dedicated `local-desktop-snapshot-capability`; GUI sessions and requests carrying `Origin` cannot publish desktop state. A capability's bounded raw body is verified against its authenticated digest before JSON parsing or storage. Badge state is cosmetic and a failed poll degrades silently. | From ab82e7fbbb7d2850897d57484804d161f4347281 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:43:36 +0900 Subject: [PATCH 064/173] docs: document apiSurfaces and PATCH /api/protocols/settings The server config reference explains inheritance, fail-closed values and the downgrade behavior of the dashboard toggle; the management API table gains the settings route and its errors. --- .../docs/reference/configuration/server.md | 23 ++++++++++++++++++- .../content/docs/reference/management-api.md | 6 +++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index d3aee62c2c1..3ec5c6fe566 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -500,9 +500,30 @@ shell-injection surface. Delivery is attempted once; there is no retry. Read recent detections with `ocx provider resets` or `GET /api/quota-resets`. +## API surfaces (`apiSurfaces`) + +Responses (`/v1/responses`) and Chat Completions (`/v1/chat/completions`) are always served. +The Messages API (`/v1/messages` and `/v1/messages/count_tokens`) can be closed on its own. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `apiSurfaces.messages.enabled?` | `boolean` | inherit | `true` serves the Messages API, `false` refuses both routes with 403. Unset inherits `claudeCode.enabled`, so a Claude integration that is off also closes Messages. | + +A present but malformed value (a non-object `apiSurfaces` or `messages`, or a non-boolean +`enabled`) closes the Messages API rather than falling back to the inherited value. Both +routes always agree. + +The dashboard's API page shows one card per API with the setting's source (explicit, +inherited from Claude settings, or invalid) and a toggle for Messages. Turning Messages off +there writes `apiSurfaces.messages.enabled: false` **and** `claudeCode.enabled: false` in the +same save, so a proxy version older than this setting, which only reads `claudeCode.enabled`, +keeps the endpoint closed after a downgrade. Turning it on writes only +`apiSurfaces.messages.enabled: true`; an older version then still follows +`claudeCode.enabled` and may keep Messages closed, which is the safe direction. + ## Claude Code (`claudeCode`) -These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx claude` launcher, and the Claude dashboard page. +These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx claude` launcher, and the Claude dashboard page. Whether the Messages API is served at all is decided by [`apiSurfaces`](#api-surfaces-apisurfaces), which inherits `claudeCode.enabled` while unset. | Key | Type | Default | Description | | --- | --- | --- | --- | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 50737648c6b..813e906eb9e 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -515,12 +515,18 @@ keys are not returned to dashboard clients. | --- | --- | --- | | `GET /api/protocols` | Return the protocol contract version, which APIs are served, the protocol settings, the current policy revision, and the request features a preview understands | — | | `POST /api/protocols/plan` | Preview the path a request would take: `{ "model": "...", "inbound": "responses" \| "chat" \| "messages", "features": [...] }` returns each route candidate's request and response path, delivery mode, fidelity, feature effects, and reasons | 400 invalid JSON, unknown field, model over 200 characters, unknown inbound, or more than 24 / unknown features | +| `PATCH /api/protocols/settings` | Change protocol settings: `{ "messagesEnabled"?: boolean, "unrepresentable"?: "legacy" \| "reject", "rollout"?: { ...boolean switches } }`. Returns the same body as `GET /api/protocols`. Closing Messages also sets `claudeCode.enabled` to `false` in the same save; opening it writes only `apiSurfaces.messages.enabled` | 400 invalid JSON, empty body, unknown field, wrong type, or `rollout.managedMessagesNativeOAuth` without `rollout.managedMessagesNative`; 409 configuration busy; 500 save failed (nothing changes) | A preview is computed from configuration alone. It sends nothing to any provider, costs nothing, does not advance combo rotation, and is not logged. The API page in the dashboard shows the same preview under **Request path preview**. A delivery mode of `native` describes how the request travels; it is not a compatibility verification. +`PATCH /api/protocols/settings` is the only writer here and backs the Messages toggle on the +API page. The rollout switches are staged and default off; see +[API surfaces](/reference/configuration/server/#api-surfaces-apisurfaces) for how the Messages +setting interacts with `claudeCode.enabled` across upgrades and downgrades. + ### Providers | Method and path | Purpose | Notable errors | From 24c37d6274cd63d24e93f399189ca05258b4c022 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:30:47 +0900 Subject: [PATCH 065/173] feat(protocols): add the request source envelope Later packets rebuild each candidate from the source body; the envelope keeps it for the request, scans features once, and charges every fresh copy to the translator budget. --- src/protocols/envelope.ts | 43 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/protocols/envelope.ts diff --git a/src/protocols/envelope.ts b/src/protocols/envelope.ts new file mode 100644 index 00000000000..cdd2af534f1 --- /dev/null +++ b/src/protocols/envelope.ts @@ -0,0 +1,43 @@ +/** + * The source envelope of one inference request (PF-06). + * + * SERVER SIDE, not a leaf: it charges the request's translator budget. The envelope keeps the + * body the ingress parsed, by reference, for the lifetime of the request and nothing longer — + * it is a local of the ingress, never stored on a shared or long-lived object. Features are + * scanned once, on first use, so a caller that never asks pays nothing. `freshBody()` hands out + * an independent copy, charged under `request_copies`, so a consumer that rewrites its body in + * place can never leak that rewrite into another consumer's input. + */ +import { jsonUtf8Bytes } from "../lib/json-byte-size"; +import type { TranslatorBudget } from "../lib/translator-budget"; +import type { Protocol } from "./contract"; +import { featuresFromBody, type ProtocolFeature } from "./features"; + +export interface ProtocolEnvelope { + readonly inbound: Protocol; + /** The request features, scanned from the source body on the first call and cached. */ + features(): ReadonlySet; + /** A structured clone of the source body, charged to the translator budget. */ + freshBody(): Record; +} + +export function createProtocolEnvelope(input: { + inbound: Protocol; + body: Record; + translatorBudget: TranslatorBudget; +}): ProtocolEnvelope { + const { inbound, body, translatorBudget } = input; + let features: ReadonlySet | undefined; + return { + inbound, + features() { + features ??= featuresFromBody(inbound, body); + return features; + }, + freshBody() { + // Charged before the copy exists, so an over-budget request fails without allocating it. + translatorBudget.chargeRetained(jsonUtf8Bytes(body), { kind: "request_copies" }); + return structuredClone(body); + }, + }; +} From 2a9a8fdc4f8d7e236a8a12e0c07bf9025941a465 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:31:04 +0900 Subject: [PATCH 066/173] feat(protocols): add the unrepresentable-feature guard A pure verdict over the settled path, so ingress refusal and the per-candidate check in PF-07 judge a request by the same declared dispositions. It stays a dashboard-safe leaf. --- src/protocols/guard.ts | 28 +++++++++++++++++++++++ tests/responses/protocol-contract.test.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/protocols/guard.ts diff --git a/src/protocols/guard.ts b/src/protocols/guard.ts new file mode 100644 index 00000000000..ffb951fd2ad --- /dev/null +++ b/src/protocols/guard.ts @@ -0,0 +1,28 @@ +/** + * Refuse a request whose features its path cannot carry (PF-06). + * + * LEAF MODULE (see `contract.ts`). The caller supplies the request path it computed for the + * settled route (`path.ts`); this module only judges it. Only a declared `unsupported` + * disposition refuses. A hop into `other` has no declared disposition and so never refuses by + * itself, but a loss declared on an earlier known hop still does: an unknown adapter cannot + * restore what the internal Responses body already dropped. + */ +import type { Protocol, ProtocolHop, ProtocolReasonCode } from "./contract"; +import { featureEffectsForPath, unrepresentableFeatures, type ProtocolFeature } from "./features"; + +export type RepresentableVerdict = + | { ok: true } + | { ok: false; features: ProtocolFeature[]; reasonCodes: ProtocolReasonCode[] }; + +export function checkRepresentable(input: { + inbound: Protocol; + requestPath: readonly ProtocolHop[]; + features: Iterable; + /** `UnrepresentablePolicy` from `settings.ts`, spelled out so this module stays a leaf. */ + policy: "legacy" | "reject"; +}): RepresentableVerdict { + if (input.policy !== "reject") return { ok: true }; + const { effects } = featureEffectsForPath(input.inbound, input.requestPath, input.features); + const features = unrepresentableFeatures(effects); + return features.length === 0 ? { ok: true } : { ok: false, features, reasonCodes: ["feature-unrepresentable"] }; +} diff --git a/tests/responses/protocol-contract.test.ts b/tests/responses/protocol-contract.test.ts index bfbefea9968..9ee89941a5e 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", "path.ts", "plan.ts"]; + const LEAVES = ["contract.ts", "features.ts", "baseline.ts", "dto.ts", "path.ts", "plan.ts", "guard.ts"]; const ALLOWED = new Set(["./contract", "./features", "./dto", "./path", "../compatibility/manifest"]); for (const file of LEAVES) { From 9ffd5c02197f08ec7f7b6c3b1ffba65cee1ebd25 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:31:42 +0900 Subject: [PATCH 067/173] feat(protocols): name codec entry points over the existing translators Ingress code gets one codec surface per protocol before direct IR decoding lands; each entry delegates to today's translator, so no request changes shape. --- src/protocols/codecs/chat.ts | 17 +++++++++++++++++ src/protocols/codecs/messages.ts | 18 ++++++++++++++++++ src/protocols/codecs/responses.ts | 17 +++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 src/protocols/codecs/chat.ts create mode 100644 src/protocols/codecs/messages.ts create mode 100644 src/protocols/codecs/responses.ts diff --git a/src/protocols/codecs/chat.ts b/src/protocols/codecs/chat.ts new file mode 100644 index 00000000000..a9308f48617 --- /dev/null +++ b/src/protocols/codecs/chat.ts @@ -0,0 +1,17 @@ +/** + * Chat Completions codec entry points (PF-06). + * + * A named surface over the existing translator so an ingress calls one codec per protocol. + * No behavior of its own: `chatToResponsesBody` is `chatCompletionsToResponsesBody` + * (`src/chat/inbound.ts`), including its validation and the fields it drops, which + * `src/protocols/features.ts` declares. + */ +import { chatCompletionsToResponsesBody } from "../../chat/inbound"; +import { featuresFromChatBody } from "../features"; + +/** Project a Chat Completions body onto the internal Responses bridge body. */ +export function chatToResponsesBody(body: unknown): Record { + return chatCompletionsToResponsesBody(body); +} + +export const chatFeatures = featuresFromChatBody; diff --git a/src/protocols/codecs/messages.ts b/src/protocols/codecs/messages.ts new file mode 100644 index 00000000000..9e0747ac801 --- /dev/null +++ b/src/protocols/codecs/messages.ts @@ -0,0 +1,18 @@ +/** + * Anthropic Messages codec entry points (PF-06). + * + * A named surface over the existing translator; no behavior of its own. + * `messagesToResponsesTranslation` is `anthropicToResponsesTranslation` (`src/claude/inbound.ts`) + * with the same model resolution, budget charging and prompt-cache key derivation. + */ +import { anthropicToResponsesTranslation, type ClaudeInboundTranslation } from "../../claude/inbound"; +import { featuresFromMessagesBody } from "../features"; + +/** Project a Messages body onto the internal Responses bridge body. */ +export function messagesToResponsesTranslation( + ...args: Parameters +): ClaudeInboundTranslation { + return anthropicToResponsesTranslation(...args); +} + +export const messagesFeatures = featuresFromMessagesBody; diff --git a/src/protocols/codecs/responses.ts b/src/protocols/codecs/responses.ts new file mode 100644 index 00000000000..6e9eed3ef46 --- /dev/null +++ b/src/protocols/codecs/responses.ts @@ -0,0 +1,17 @@ +/** + * Responses codec entry points (PF-06). + * + * A named surface over the existing parser; no behavior of its own. `responsesToIr` is + * `parseRequest` (`src/responses/parser.ts`), which turns a Responses body into the + * adapter-neutral `OcxParsedRequest` every adapter builds from. + */ +import { parseRequest } from "../../responses/parser"; +import type { OcxParsedRequest } from "../../types"; +import { featuresFromResponsesBody } from "../features"; + +/** Parse a Responses body into the adapter-neutral request. */ +export function responsesToIr(...args: Parameters): OcxParsedRequest { + return parseRequest(...args); +} + +export const responsesFeatures = featuresFromResponsesBody; From 0b20754039c48eb6720b235bcfef9396e4efe552 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:31:42 +0900 Subject: [PATCH 068/173] refactor(ingress): call the Chat and Messages bridge through the codec surface Pure renames: the codec entries delegate to the same translators with the same arguments. --- src/server/chat-completions.ts | 4 ++-- src/server/claude-messages.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 2714652bd38..5f3d22a86b6 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -9,8 +9,8 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { assertChatCompletionsRoutingBody, ChatCompletionsRequestError, - chatCompletionsToResponsesBody, } from "../chat/inbound"; +import { chatToResponsesBody } from "../protocols/codecs/chat"; import { normalizeChatImageParts } from "../chat/image-parts"; import { chatCompletionsErrorResponse, @@ -264,7 +264,7 @@ async function handleChatCompletionsWithBudget( try { // Validate the full Chat boundary after routing. Native Chat keeps `chatBody` as // its wire source; this Responses projection is used only by the fallback path. - internalBody = chatCompletionsToResponsesBody(chatBody); + internalBody = chatToResponsesBody(chatBody); if (effortRow) { internalBody.reasoning = { ...(isRec(internalBody.reasoning) ? internalBody.reasoning : {}), diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index fc4490bedf1..5956a67eb45 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -18,7 +18,8 @@ import { sseFieldValue } from "../lib/sse-decoder"; import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; import { createToolCallIdAllocator } from "../adapters/tool-call-id"; -import { AnthropicRequestError, DesktopModelMappingUnavailableError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; +import { messagesToResponsesTranslation } from "../protocols/codecs/messages"; +import { AnthropicRequestError, DesktopModelMappingUnavailableError, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; import { isKnownDesktop3pModelId, resolveDesktop3pAlias } from "../claude/desktop-3p"; import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../claude/alias"; import { recordDesktopRequest } from "../claude/desktop-health"; @@ -852,7 +853,7 @@ async function handleClaudeMessagesWithBudget( }; delete anthropicBody.thinking; } - const translation = anthropicToResponsesTranslation(anthropicBody, cc, translatorBudget); + const translation = messagesToResponsesTranslation(anthropicBody, cc, translatorBudget); internalBody = translation.body; // The Anthropic translator builds its body from model/input/store/stream plus sampling // fields only, so the caller intent is applied to the TRANSLATED body rather than the From 115207aab2ffce62027b5413c3b7e2e13fa40ffb Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:32:19 +0900 Subject: [PATCH 069/173] feat(protocols): phrase unrepresentable refusals from feature keys only Both ingresses word the refusal the same way and cannot echo request content into it. --- src/protocols/guard.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/protocols/guard.ts b/src/protocols/guard.ts index ffb951fd2ad..447f2fb68a9 100644 --- a/src/protocols/guard.ts +++ b/src/protocols/guard.ts @@ -26,3 +26,8 @@ export function checkRepresentable(input: { const features = unrepresentableFeatures(effects); return features.length === 0 ? { ok: true } : { ok: false, features, reasonCodes: ["feature-unrepresentable"] }; } + +/** Client-facing refusal text. Names feature keys only, never request content. */ +export function unrepresentableMessage(features: readonly ProtocolFeature[]): string { + return `The selected route cannot carry these request features: ${features.join(", ")}`; +} From 8f672a4dfc0e02dee9e3c4b59532b7750ac329df Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:32:19 +0900 Subject: [PATCH 070/173] feat(chat): refuse unrepresentable features at ingress under the reject policy A single-provider route whose path would drop a feature now fails with a 400 before any send, logged and traced as blocked. The legacy default builds nothing and changes nothing. --- src/server/chat-completions.ts | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 5f3d22a86b6..e70b74a8f2f 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -70,9 +70,13 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { handleNativeChatCompletions, nativeChatDeclineReason } from "./chat-native"; -import type { ProtocolReasonCode } from "../protocols/contract"; +import { upstreamWireForAdapter, type ProtocolReasonCode } from "../protocols/contract"; +import { createProtocolEnvelope } from "../protocols/envelope"; import { featuresFromChatBody } from "../protocols/features"; -import { markProtocolEntry } from "../protocols/trace"; +import { checkRepresentable, unrepresentableMessage } from "../protocols/guard"; +import { requestPathForLane } from "../protocols/path"; +import { resolveProtocolSettings } from "../protocols/settings"; +import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace"; import { jsonCompletionSse } from "./chat-native-sse"; import { parseRequestEffortRowId } from "./effort-row"; import { parseSyntheticRowId } from "./fast-row"; @@ -240,11 +244,32 @@ async function handleChatCompletionsWithBudget( /* unknown model: let handleResponses shape the 404 */ } + // Off by default: under the legacy policy nothing below is built and the request is unchanged. + const envelope = resolveProtocolSettings(config).unrepresentable === "reject" + ? createProtocolEnvelope({ inbound: "chat", body: chatBody, translatorBudget }) + : undefined; + // Combo and policy children are judged per candidate (PF-07); an unknown model has no route. + if (envelope && settledRoute && !settledRoute.combo && settledRoute.routeKind !== "policy") { + const verdict = checkRepresentable({ + inbound: "chat", + requestPath: chatNativeRoute + ? requestPathForLane("chat", "native", "chat") + : requestPathForLane("chat", "bridge", upstreamWireForAdapter(settledRoute.provider.adapter)), + features: envelope.features(), + policy: "reject", + }); + if (!verdict.ok) { + markProtocolBlocked(logCtx, { inbound: "chat", reasonCodes: verdict.reasonCodes, features: verdict.features }); + logCtx.errorCode = "unsupported_feature"; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" }); + return chatCompletionsErrorResponse(400, unrepresentableMessage(verdict.features), "invalid_request_error", "unsupported_feature"); + } + } markProtocolEntry(logCtx, { inbound: "chat", lane: chatNativeRoute ? "native" : "bridge", reasonCodes: !chatNativeRoute && nativeDecline ? [nativeDecline] : [], - features: () => featuresFromChatBody(chatBody), + features: envelope ? () => envelope.features() : () => featuresFromChatBody(chatBody), }); if (chatNativeRoute) { return handleNativeChatCompletions({ From 52ad310e00e6e6cccbbe96ac21e86eb55f246f01 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:32:42 +0900 Subject: [PATCH 071/173] feat(claude): refuse unrepresentable Messages features under the reject policy The bridged Messages route is judged after the wire settles and refused in Anthropic error shape before any send. Features are fixed before an effort override rewrites thinking. --- src/server/claude-messages.ts | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 5956a67eb45..cc979f0b7fb 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -61,8 +61,12 @@ import { } from "./request-log-conversation"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; +import { upstreamWireForAdapter } from "../protocols/contract"; +import { createProtocolEnvelope, type ProtocolEnvelope } from "../protocols/envelope"; import { featuresFromMessagesBody } from "../protocols/features"; -import { resolveApiSurfaceSettings } from "../protocols/settings"; +import { checkRepresentable, unrepresentableMessage } from "../protocols/guard"; +import { requestPathForLane } from "../protocols/path"; +import { resolveApiSurfaceSettings, resolveProtocolSettings } from "../protocols/settings"; import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace"; import { isApiAuthRequired, @@ -740,6 +744,8 @@ async function handleClaudeMessagesWithBudget( let effortRow: ParsedEffortRowId | null = null; let fastRow: ParsedFastRowId | null = null; let requestedModel = ""; + // Built only under the reject policy; the legacy default leaves this request untouched. + let envelope: ProtocolEnvelope | undefined; try { anthropicBody = await readAnthropicBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes)); // Defensive [1m] strip (devlog 138): clients normally remove the context-variant @@ -807,7 +813,15 @@ async function handleClaudeMessagesWithBudget( // proxy-owned `speed` + beta (anthropic-speed wire) and its usage.speed observation would be // silently skipped. Translation reaches the adapter, which owns both. const messagesBody = anthropicBody; - const messagesFeatures = () => featuresFromMessagesBody(messagesBody); + if (isRec(messagesBody) && resolveProtocolSettings(config).unrepresentable === "reject") { + envelope = createProtocolEnvelope({ inbound: "messages", body: messagesBody, translatorBudget }); + } + const sourceEnvelope = envelope; + // The bridge entry mark below reads these before an effort override rewrites `thinking`, + // which also fixes the envelope's cached features on the caller's own settings. + const messagesFeatures = sourceEnvelope + ? () => sourceEnvelope.features() + : () => featuresFromMessagesBody(messagesBody); if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model, cc)) { markProtocolEntry(logCtx, { inbound: "messages", lane: "native", features: messagesFeatures }); return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); @@ -900,6 +914,7 @@ async function handleClaudeMessagesWithBudget( // Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens", // verified live 2026-07-11). Strip them for that route; routed providers keep them. + let settledRoute: ReturnType | undefined; try { const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); // Same reason as the native Chat lane: this route can be sent from here, so @@ -916,6 +931,7 @@ async function handleClaudeMessagesWithBudget( ); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy); logCtx.routeDecision = route.routeDecision; + settledRoute = route; if (route.provider.adapter === "openai-responses") { delete internalBody.max_output_tokens; delete internalBody.temperature; @@ -959,6 +975,22 @@ async function handleClaudeMessagesWithBudget( /* unknown model: let handleResponses shape the 404 */ } + // Combo and policy children are judged per candidate (PF-07); an unknown model has no route. + if (envelope && settledRoute && !settledRoute.combo && settledRoute.routeKind !== "policy") { + const verdict = checkRepresentable({ + inbound: "messages", + requestPath: requestPathForLane("messages", "bridge", upstreamWireForAdapter(settledRoute.provider.adapter)), + features: envelope.features(), + policy: "reject", + }); + if (!verdict.ok) { + markProtocolBlocked(logCtx, { inbound: "messages", reasonCodes: verdict.reasonCodes, features: verdict.features }); + logCtx.errorCode = "unsupported_feature"; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" }); + return anthropicErrorResponse(400, unrepresentableMessage(verdict.features), "invalid_request_error"); + } + } + const headers = new Headers({ "content-type": "application/json" }); let trustedClaudeMainAuth: { authorization: string; chatgptAccountId?: string } | undefined; for (const name of FORWARD_HEADERS) { From a7eb15c708387e3c983d2e72928b5fdd28021537 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:34:03 +0900 Subject: [PATCH 072/173] test(protocols): pin the source envelope and the unrepresentable guard Covers lazy one-time feature scans, copy isolation and budget charging, and which paths the guard refuses, including that an unknown-adapter hop never refuses by itself. --- scripts/test-layout/layout.json | 3 + tests/fixtures/test-layout-expected.json | 3 + tests/responses/protocol-envelope.test.ts | 76 +++++++++++++++++++++++ tests/responses/protocol-guard.test.ts | 71 +++++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 tests/responses/protocol-envelope.test.ts create mode 100644 tests/responses/protocol-guard.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f5a082c7cd3..d1b9972d56c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -341,6 +341,9 @@ "protocol-trace.test.ts": "responses", "protocol-plan.test.ts": "responses", "protocol-plan-snapshot.test.ts": "responses", + "protocol-envelope.test.ts": "responses", + "protocol-guard.test.ts": "responses", + "protocol-ingress-guard.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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9f4baf46437..29f4acb2995 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -167,6 +167,9 @@ "protocol-trace.test.ts": "responses", "protocol-plan.test.ts": "responses", "protocol-plan-snapshot.test.ts": "responses", + "protocol-envelope.test.ts": "responses", + "protocol-guard.test.ts": "responses", + "protocol-ingress-guard.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-envelope.test.ts b/tests/responses/protocol-envelope.test.ts new file mode 100644 index 00000000000..06344360de0 --- /dev/null +++ b/tests/responses/protocol-envelope.test.ts @@ -0,0 +1,76 @@ +/** + * The request source envelope (PF-06, src/protocols/envelope.ts): features are scanned once and + * only on demand; every fresh body is an independent copy charged to the translator budget. + */ +import { describe, expect, test } from "bun:test"; +import { createProtocolEnvelope } from "../../src/protocols/envelope"; +import { isTranslatorBudgetExceededError } from "../../src/lib/translator-budget"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +function chatBody(): Record { + return { + model: "fixture/model", + messages: [{ role: "user", content: [{ type: "text", text: "fixture" }] }], + seed: 7, + }; +} + +describe("createProtocolEnvelope", () => { + test("features are not scanned at creation and are scanned exactly once", () => { + let reads = 0; + const body = chatBody(); + Object.defineProperty(body, "n", { enumerable: true, get: () => { reads++; return 2; } }); + const envelope = createProtocolEnvelope({ inbound: "chat", body, translatorBudget: createTestTranslatorBudget() }); + expect(reads).toBe(0); + + const first = envelope.features(); + const readsAfterFirst = reads; + expect(readsAfterFirst).toBeGreaterThan(0); + expect([...first].sort()).toEqual(["request.multiple_choices", "request.seed"]); + + expect(envelope.features()).toBe(first); + expect(reads).toBe(readsAfterFirst); + expect(envelope.inbound).toBe("chat"); + }); + + test("freshBody returns an independent copy each time", () => { + const source = chatBody(); + const envelope = createProtocolEnvelope({ inbound: "chat", body: source, translatorBudget: createTestTranslatorBudget() }); + const a = envelope.freshBody(); + const b = envelope.freshBody(); + expect(a).toEqual(source); + expect(a).not.toBe(source); + expect(a).not.toBe(b); + + a.model = "rewritten"; + (a.messages as Array>)[0]!.role = "system"; + delete a.seed; + expect(source).toEqual(chatBody()); + expect(b).toEqual(chatBody()); + }); + + test("each fresh copy is charged to the translator budget", () => { + const budget = createTestTranslatorBudget(); + const source = chatBody(); + const envelope = createProtocolEnvelope({ inbound: "chat", body: source, translatorBudget: budget }); + const bytes = Buffer.byteLength(JSON.stringify(source), "utf8"); + expect(budget.snapshot().currentBytes).toBe(0); + envelope.features(); + expect(budget.snapshot().currentBytes).toBe(0); + envelope.freshBody(); + expect(budget.snapshot().currentBytes).toBe(bytes); + envelope.freshBody(); + expect(budget.snapshot().currentBytes).toBe(2 * bytes); + }); + + test("a copy over the turn budget is refused", () => { + const source = chatBody(); + const bytes = Buffer.byteLength(JSON.stringify(source), "utf8"); + const budget = createTestTranslatorBudget({ maxTurnBytes: bytes + 1 }); + const envelope = createProtocolEnvelope({ inbound: "chat", body: source, translatorBudget: budget }); + envelope.freshBody(); + let thrown: unknown; + try { envelope.freshBody(); } catch (error) { thrown = error; } + expect(isTranslatorBudgetExceededError(thrown)).toBe(true); + }); +}); diff --git a/tests/responses/protocol-guard.test.ts b/tests/responses/protocol-guard.test.ts new file mode 100644 index 00000000000..0d9fc4dc830 --- /dev/null +++ b/tests/responses/protocol-guard.test.ts @@ -0,0 +1,71 @@ +/** + * The unrepresentable-feature guard (PF-06, src/protocols/guard.ts): only a declared + * `unsupported` disposition on the request path refuses, and only under the reject policy. + */ +import { describe, expect, test } from "bun:test"; +import { checkRepresentable, unrepresentableMessage } from "../../src/protocols/guard"; +import type { ProtocolFeature } from "../../src/protocols/features"; + +describe("checkRepresentable", () => { + test("n > 1 on Chat into a Responses upstream is refused under reject", () => { + expect(checkRepresentable({ + inbound: "chat", + requestPath: ["chat", "responses"], + features: ["request.multiple_choices", "request.tools"], + policy: "reject", + })).toEqual({ ok: false, features: ["request.multiple_choices"], reasonCodes: ["feature-unrepresentable"] }); + }); + + test("the legacy policy never refuses", () => { + expect(checkRepresentable({ + inbound: "chat", + requestPath: ["chat", "responses"], + features: ["request.multiple_choices", "request.seed"], + policy: "legacy", + })).toEqual({ ok: true }); + }); + + test("a native same-wire path carries every feature", () => { + expect(checkRepresentable({ + inbound: "chat", + requestPath: ["chat", "chat"], + features: ["request.multiple_choices", "request.logit_bias", "request.audio"], + policy: "reject", + })).toEqual({ ok: true }); + }); + + test("degraded features pass; only unsupported ones refuse", () => { + expect(checkRepresentable({ + inbound: "chat", + requestPath: ["chat", "responses"], + features: ["request.documents"], + policy: "reject", + })).toEqual({ ok: true }); + }); + + test("a hop into an `other` adapter never refuses by itself", () => { + const features: ProtocolFeature[] = ["request.background", "request.previous_response_id", "request.store"]; + expect(checkRepresentable({ inbound: "responses", requestPath: ["responses", "ir", "other"], features, policy: "reject" })) + .toEqual({ ok: true }); + expect(checkRepresentable({ + inbound: "chat", + requestPath: ["chat", "responses-internal", "ir", "other"], + features: ["request.tools", "request.images"], + policy: "reject", + })).toEqual({ ok: true }); + }); + + test("a loss declared before the `other` hop still refuses", () => { + expect(checkRepresentable({ + inbound: "chat", + requestPath: ["chat", "responses-internal", "ir", "other"], + features: ["request.multiple_choices"], + policy: "reject", + })).toEqual({ ok: false, features: ["request.multiple_choices"], reasonCodes: ["feature-unrepresentable"] }); + }); + + test("the refusal message names feature keys only", () => { + expect(unrepresentableMessage(["request.multiple_choices", "request.seed"])) + .toBe("The selected route cannot carry these request features: request.multiple_choices, request.seed"); + }); +}); From b17152fe63b4f9d1bcce1f7480d4c0f627d3cc79 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:34:03 +0900 Subject: [PATCH 073/173] test(ingress): pin reject-policy refusals on Chat and Messages Each surface answers 400 in its own error shape with no upstream send and a blocked trace; the legacy default still forwards the same request. --- .../responses/protocol-ingress-guard.test.ts | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tests/responses/protocol-ingress-guard.test.ts diff --git a/tests/responses/protocol-ingress-guard.test.ts b/tests/responses/protocol-ingress-guard.test.ts new file mode 100644 index 00000000000..c84766f7107 --- /dev/null +++ b/tests/responses/protocol-ingress-guard.test.ts @@ -0,0 +1,132 @@ +/** + * Ingress refusal of unrepresentable features (PF-06). Under `protocols.unrepresentable: + * "reject"` a single-provider Chat or Messages route whose path would drop a requested feature + * answers 400 in its own error shape, sends nothing upstream, and logs a blocked trace. The + * legacy default forwards the same request and only reports the loss in the trace. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { getRequestLogEntries } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +let upstream: ReturnType | undefined; +let requests = 0; +let releaseSpendHome: (() => void) | undefined; + +afterEach(async () => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + await upstream?.stop(true); + upstream = undefined; +}); + +function fixtureConfig(policy?: "reject" | "legacy"): OcxConfig { + requests = 0; + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { + requests++; + await req.text(); + return Response.json({ id: "resp_fixture", status: "completed", output: [], + usage: { input_tokens: 3, output_tokens: 1 } }); + } }); + releaseSpendHome ??= acquireOwnedSpendHome(); + return { + port: 0, + defaultProvider: "fixture", + providers: { fixture: { + adapter: "openai-responses", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + authMode: "key", apiKey: "fixture-key", allowPrivateNetwork: true, models: ["model"], + } }, + ...(policy ? { protocols: { unrepresentable: policy } } : {}), + } as OcxConfig; +} + +function rowFor(requestId: string) { + const rows = getRequestLogEntries().filter(entry => entry.requestId === requestId); + expect(rows).toHaveLength(1); + return rows[0]!; +} + +function chatRequest(): Request { + return new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", n: 2, stream: false, messages: [{ role: "user", content: "fixture" }] }), + }); +} + +function messagesRequest(): Request { + return new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", max_tokens: 32, top_k: 5, stream: false, + messages: [{ role: "user", content: "fixture" }] }), + }); +} + +describe("Chat Completions ingress guard", () => { + test("reject: n > 1 into a Responses upstream is refused with no upstream send", async () => { + const config = fixtureConfig("reject"); + const requestId = `pf06-chat-reject-${crypto.randomUUID()}`; + const response = await handleChatCompletions(chatRequest(), config, { model: "", provider: "" }, + { requestId, start: Date.now() }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { + type: "invalid_request_error", + code: "unsupported_feature", + message: "The selected route cannot carry these request features: request.multiple_choices", + } }); + expect(requests).toBe(0); + const row = rowFor(requestId); + expect(row.status).toBe(400); + expect(row.protocolTrace).toMatchObject({ + inbound: "chat", mode: "blocked", requestPath: [], reasonCodes: ["feature-unrepresentable"], + }); + }); + + test("legacy default: the same request is forwarded and the loss shows in the trace", async () => { + const config = fixtureConfig(); + const requestId = `pf06-chat-legacy-${crypto.randomUUID()}`; + const response = await handleChatCompletions(chatRequest(), config, { model: "", provider: "" }, + { requestId, start: Date.now() }); + await response.text(); + expect(response.status).not.toBe(400); + expect(requests).toBe(1); + expect(rowFor(requestId).protocolTrace).toMatchObject({ + inbound: "chat", + requestPath: ["chat", "responses"], + featureEffects: [{ feature: "request.multiple_choices", disposition: "unsupported" }], + }); + }); +}); + +describe("Messages ingress guard", () => { + test("reject: top_k into a Responses upstream is refused in Anthropic error shape", async () => { + const config = fixtureConfig("reject"); + const requestId = `pf06-messages-reject-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(messagesRequest(), config, { model: "", provider: "" }, + { requestId, start: Date.now() }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ type: "error", error: { + type: "invalid_request_error", + message: "The selected route cannot carry these request features: request.top_k", + } }); + expect(requests).toBe(0); + const row = rowFor(requestId); + expect(row.status).toBe(400); + expect(row.protocolTrace).toMatchObject({ + inbound: "messages", mode: "blocked", requestPath: [], reasonCodes: ["feature-unrepresentable"], + }); + }); + + test("legacy default: the same request is forwarded", async () => { + const config = fixtureConfig(); + const requestId = `pf06-messages-legacy-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(messagesRequest(), config, { model: "", provider: "" }, + { requestId, start: Date.now() }); + await response.text(); + expect(response.status).not.toBe(400); + expect(requests).toBe(1); + }); +}); From 180116202aac1e0b52d508107d46ed23eaa65aaa Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:34:28 +0900 Subject: [PATCH 074/173] docs(structure): describe the source envelope, codecs and ingress guard The protocol-paths doc owns src/protocols; it now records the new leaf, what the reject policy does at each ingress, and that the legacy default builds nothing. --- structure/data-planes/protocol-paths.md | 40 +++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 2b3aac2b1b4..2ef936a6c62 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -26,7 +26,7 @@ reason codes, the first rule that keeps a Chat request off the native Chat lane; or trace reports cannot disagree. `contract.ts`, `src/protocols/features.ts`, `src/protocols/baseline.ts`, -`src/protocols/path.ts`, `src/protocols/dto.ts` and `src/protocols/plan.ts` are leaf modules: the dashboard imports them directly, so they import +`src/protocols/path.ts`, `src/protocols/dto.ts`, `src/protocols/plan.ts` and `src/protocols/guard.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. @@ -116,6 +116,39 @@ caller-forward passthrough depends on the caller's own credential, so it is repo modelled. `tests/responses/protocol-plan-snapshot.test.ts` pins the no-side-effect property against combo selection state. +## Source envelope, codecs and guard + +`src/protocols/envelope.ts` (server side; it charges the translator budget) wraps the body an +ingress parsed. It keeps that body by reference for the request only, scans its features on the +first `features()` call and caches them, and hands out `freshBody()` copies, each a +`structuredClone` charged under `request_copies`, so a consumer that rewrites its body cannot +leak the rewrite into another consumer's input. + +`src/protocols/codecs/{chat,messages,responses}.ts` are named entry points over the existing +translators — `chatToResponsesBody` is `chatCompletionsToResponsesBody`, +`messagesToResponsesTranslation` is `anthropicToResponsesTranslation`, `responsesToIr` is +`parseRequest` — plus each protocol's feature scanner. They add no behavior; the Chat and +Messages ingresses call the bridge through them. + +`checkRepresentable` in `src/protocols/guard.ts` judges a request path the caller computed with +`path.ts`: under the `legacy` policy it always passes; under `reject` it refuses the features +`featureEffectsForPath` finds `unsupported`, with reason `feature-unrepresentable`. A hop into +`other` has no disposition and never refuses by itself, but a loss declared on an earlier known +hop (the internal Responses body) still does. + +Only when `resolveProtocolSettings(config).unrepresentable === "reject"` do the Chat and Messages +ingresses build an envelope and run the guard, after the route and its wire settle and before the +request is sent: Chat on the native path when the native lane was chosen, otherwise on the +bridge path to the settled adapter's wire; Messages on the bridge path. Combo and policy routes +and an unroutable model are not judged at ingress. A refusal answers 400 in the ingress's own +error shape (Chat `invalid_request_error` / `unsupported_feature`; Anthropic +`invalid_request_error`) naming feature keys only, marks the trace blocked, and writes the +final log row with no upstream send. Under the default `legacy` policy nothing is built and the +would-be loss appears only as the trace's `featureEffects`. The Messages envelope's features are +fixed at the bridge entry mark, before an effort override rewrites `thinking`. +`tests/responses/protocol-envelope.test.ts`, `tests/responses/protocol-guard.test.ts` and +`tests/responses/protocol-ingress-guard.test.ts` pin them. + ## Settings `resolveApiSurfaceSettings` and `resolveProtocolSettings` in `src/protocols/settings.ts` are the @@ -124,14 +157,15 @@ always served. The Messages surface uses an explicit `apiSurfaces.messages.enabl 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. +the key-auth one. The Chat and Messages ingresses read the unrepresentable policy (above); no +request path reads the rollout switches yet. `claudeInboundDisabled` in `src/server/claude-messages.ts` is the Messages ingress reader: both `/v1/messages` and `/v1/messages/count_tokens` call it, so the two routes cannot disagree, and a closed surface answers 403 before the body is read. `buildApiAccessEndpoints` (`src/server/management/api-access.ts`) reports the resolved `surfaces` in the keys payload and keeps `claudeCodeEnabled` for older dashboards, set from the resolved Messages state rather than -from `claudeCode.enabled`. No other request path reads these settings yet. +from `claudeCode.enabled`. `PATCH /api/protocols/settings` is the one writer. `src/server/management/protocol-settings-patch.ts` validates the body strictly and applies it in memory; the route persists through From 00feb2e11e2180dd08cc5001362d9a4734a7f5a1 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:42:10 +0900 Subject: [PATCH 075/173] refactor(outbound): export the Chat and Messages terminal mapping helpers The direct client encoders must map terminals, failures and incomplete reasons exactly as the Responses-to-client converters do. Extracting the existing statements into named helpers lets both paths share one mapping instead of a copy; converter output is unchanged. --- src/chat/outbound.ts | 148 ++++++++++++++++++++++++++--------------- src/claude/outbound.ts | 72 ++++++++++++-------- 2 files changed, 141 insertions(+), 79 deletions(-) diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index 85080387cd8..817aa11b7fb 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -31,7 +31,7 @@ function uuid(): string { return crypto.randomUUID().replace(/-/g, ""); } -function completionId(): string { +export function completionId(): string { return `chatcmpl-${uuid().slice(0, 24)}`; } @@ -133,12 +133,12 @@ function streamErrorStatus(message: string): number { return 502; } -function dataFrame(payload: Rec | "[DONE]"): string { +export function dataFrame(payload: Rec | "[DONE]"): string { if (payload === "[DONE]") return "data: [DONE]\n\n"; return `data: ${JSON.stringify(payload)}\n\n`; } -function chunkBase(id: string, model: string, created: number): Rec { +export function chunkBase(id: string, model: string, created: number): Rec { return { id, object: "chat.completion.chunk", @@ -161,6 +161,90 @@ function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: st return nextBytes; } +/** Details a streamed Chat failure carries into its error frame. */ +export interface ChatCompletionsStreamFailure { + code?: string | null; + type?: string; + status?: number; +} + +/** + * The `data: {error}` payload a streamed Chat failure ends with, and whether it is one of the + * fixed, bounded failures that must be delivered even when the translation budget is exhausted. + * Provider text is redacted; the two fixed failures carry no provider text at all. + */ +export function chatCompletionsStreamErrorPayload( + message: string, + details?: ChatCompletionsStreamFailure, +): { payload: Rec; bounded: boolean } { + const translatorOverflow = details?.code === "translation_buffer_limit"; + const safeMessage = translatorOverflow ? "upstream translation buffer exceeded the safe limit" + : details?.code === "invalid_refusal" ? "upstream refusal representations are inconsistent" + : redactSecretString(message); + const statusHint = details?.status ?? streamErrorStatus(safeMessage); + const classified = classifyError(statusHint, details?.type ?? "upstream_error", safeMessage); + if (translatorOverflow) { + classified.code = "translation_buffer_limit"; + // Provider-controlled overflow is an upstream failure on every path: + // streaming frame, collector, and defensive JSON agree on 502. + classified.type = "upstream_error"; + } else if (details?.code === "invalid_refusal") { + classified.code = details.code; + classified.type = "upstream_error"; + } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) { + classified.code = CYBER_POLICY_ERROR_CODE; + classified.type = cyberPolicyErrorType(details?.type); + } else if (details?.code !== undefined && details.code !== null && !classified.code) { + classified.code = details.code; + } + return { + payload: { + error: { + message: classified.message, + type: classified.type, + param: null, + code: classified.code, + }, + }, + bounded: translatorOverflow || details?.code === "invalid_refusal", + }; +} + +/** How a Responses `response.failed` error object becomes a streamed Chat failure. */ +export function chatCompletionsFailedResponse(error: Rec): { message: string; details: ChatCompletionsStreamFailure } { + const message = typeof error.message === "string" ? error.message : "upstream request failed"; + const code = typeof error.code === "string" ? error.code : null; + const type = typeof error.type === "string" ? error.type : undefined; + return { + message, + details: { + code, + ...(code === "translation_buffer_limit" + ? { status: 502, type: "upstream_error" } + : { type, ...(code === CYBER_POLICY_ERROR_CODE ? { status: 400 } : {}) }), + }, + }; +} + +/** + * A Responses incomplete reason as the Chat client sees it: a truthful early finish for an + * output cap or a content filter, and a failure for every other reason (stall, adapter EOF, + * proxy-synthesized incompletes), which must never look like a clean stop. + */ +export function chatCompletionsIncompleteOutcome( + reason: unknown, + message: unknown, +): { finishReason: "length" | "content_filter" } | { failMessage: string } { + if (reason === "max_output_tokens") return { finishReason: "length" }; + if (reason === "content_filter") return { finishReason: "content_filter" }; + const why = typeof reason === "string" ? reason : "unknown"; + return { + failMessage: typeof message === "string" && message.length > 0 + ? message + : `upstream stream ended early (${why})`, + }; +} + function refusalTranslationError(): ChatCompletionsStreamError { // Never include provider-controlled refusal text or correlation IDs in diagnostics. return new ChatCompletionsStreamError("upstream refusal representations are inconsistent", { @@ -506,38 +590,12 @@ export function responsesSseToChatCompletionsSse( // Deliver the error frame then close the stream abnormally (no [DONE]). // Do not controller.error() — that can drop already-enqueued bytes from consumers // like response.text(). - const translatorOverflow = details?.code === "translation_buffer_limit"; - const safeMessage = translatorOverflow ? "upstream translation buffer exceeded the safe limit" - : details?.code === "invalid_refusal" ? "upstream refusal representations are inconsistent" - : redactSecretString(message); - const statusHint = details?.status ?? streamErrorStatus(safeMessage); - const classified = classifyError(statusHint, details?.type ?? "upstream_error", safeMessage); - if (translatorOverflow) { - classified.code = "translation_buffer_limit"; - // Provider-controlled overflow is an upstream failure on every path: - // streaming frame, collector, and defensive JSON agree on 502. - classified.type = "upstream_error"; - } else if (details?.code === "invalid_refusal") { - classified.code = details.code; - classified.type = "upstream_error"; - } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) { - classified.code = CYBER_POLICY_ERROR_CODE; - classified.type = cyberPolicyErrorType(details?.type); - } else if (details?.code !== undefined && details.code !== null && !classified.code) { - classified.code = details.code; - } + const { payload, bounded } = chatCompletionsStreamErrorPayload(message, details); try { - const frame = encoder.encode(dataFrame({ - error: { - message: classified.message, - type: classified.type, - param: null, - code: classified.code, - }, - })); + const frame = encoder.encode(dataFrame(payload)); // These fixed, bounded failures must survive even when decoder-owned input // still fills the budget. They contain no provider text or IDs. - if (translatorOverflow || details?.code === "invalid_refusal") controller.enqueue(frame); + if (bounded) controller.enqueue(frame); else enqueueLiveFrame(frame); emittedFrames++; } catch { @@ -688,37 +746,23 @@ export function responsesSseToChatCompletionsSse( case "response.incomplete": { const response = isRec(data.response) ? data.response : {}; const details = isRec(response.incomplete_details) ? response.incomplete_details : {}; - const reason = details.reason === "max_output_tokens" ? "length" - : details.reason === "content_filter" ? "content_filter" - : undefined; - if (reason !== undefined) { + const outcome = chatCompletionsIncompleteOutcome(details.reason, details.message); + if ("finishReason" in outcome) { // Truthful OpenAI-compatible finish reasons: the turn ended, just early. snapshotRefusals(response); - finish(reason, response.usage); + finish(outcome.finishReason, response.usage); } else { // upstream_stall_timeout / adapter_eof / proxy-synthesized incompletes are // failures, not early finishes: emit an error frame and close WITHOUT // [DONE] instead of a success-looking stop/tool_calls + [DONE]. - const why = typeof details.reason === "string" ? details.reason : "unknown"; - const message = typeof details.message === "string" && details.message.length > 0 - ? details.message - : `upstream stream ended early (${why})`; - fail(message); + fail(outcome.failMessage); } break; } case "response.failed": { const response = isRec(data.response) ? data.response : {}; - const error = isRec(response.error) ? response.error : {}; - const message = typeof error.message === "string" ? error.message : "upstream request failed"; - const code = typeof error.code === "string" ? error.code : null; - const type = typeof error.type === "string" ? error.type : undefined; - fail(message, { - code, - ...(code === "translation_buffer_limit" - ? { status: 502, type: "upstream_error" } - : { type, ...(code === CYBER_POLICY_ERROR_CODE ? { status: 400 } : {}) }), - }); + const failure = chatCompletionsFailedResponse(isRec(response.error) ? response.error : {}); + fail(failure.message, failure.details); break; } default: diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 5756f68a360..17ddcedaf51 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -33,7 +33,7 @@ function reasoningIdentityDigest(value: string): string { } /** Fixed-size identity that preserves protocol boundaries without retaining upstream strings. */ -function boundedReasoningIdentity(value: unknown): string { +export function boundedReasoningIdentity(value: unknown): string { if (typeof value === "number") { if (Number.isSafeInteger(value) && value >= 0) return `n${value}`; if (Number.isFinite(value)) return `d${value}`; @@ -104,7 +104,7 @@ export function anthropicUsage(usage: unknown, webSearchRequests = 0): Rec { }; } -function sseFrame(name: string, data: Rec): string { +export function sseFrame(name: string, data: Rec): string { return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`; } @@ -161,7 +161,7 @@ export function sanitizeWebSearchInput(input: unknown): Rec { * input (query/queries) and the web_search_tool_result content (hits, or the error * object when the search failed). Shared by the SSE and JSON translation paths. */ -function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultContent: unknown; completed: boolean } { +export function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultContent: unknown; completed: boolean } { const action = isRec(item.action) ? item.action : {}; const queries = Array.isArray(action.queries) ? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0) @@ -205,7 +205,7 @@ function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultConte * is not a claim about upstream's tokenizer, and it is not final — `message_delta` carries the * authoritative count for every reader that waits for it, exactly as before. */ -function messageSnapshot(model: string, confirmedUsage?: Rec, inputTokenFloor?: number): Rec { +export function messageSnapshot(model: string, confirmedUsage?: Rec, inputTokenFloor?: number): Rec { const usage = confirmedUsage ?? (typeof inputTokenFloor === "number" && Number.isFinite(inputTokenFloor) && inputTokenFloor > 0 ? { input_tokens: Math.trunc(inputTokenFloor), output_tokens: 0 } @@ -222,6 +222,43 @@ function messageSnapshot(model: string, confirmedUsage?: Rec, inputTokenFloor?: }; } +/** + * A Responses incomplete reason as the Anthropic client sees it: an output cap and a content + * filter are real stop reasons; every other reason is a retryable overload so Claude Code backs + * off instead of accepting a truncated turn. + */ +export function anthropicIncompleteOutcome( + reason: unknown, + message: unknown, +): { stopReason: "max_tokens" | "refusal" } | { failMessage: string } { + if (reason === "max_output_tokens") return { stopReason: "max_tokens" }; + if (reason === "content_filter") return { stopReason: "refusal" }; + return { + failMessage: typeof message === "string" && message.trim() + ? message + : `upstream response was incomplete${typeof reason === "string" ? ` (${reason})` : ""}`, + }; +} + +/** + * The HTTP status a Responses `response.failed` error object maps to on the Anthropic wire. + * Internal failure envelopes carry the classified {type, code, message} but no numeric status, + * so it is derived with the same mapping /api/logs uses; a classified 429/401/400 then reaches + * Claude Code as its real Anthropic error type instead of a retryable overload. + */ +export function anthropicFailedStatus(error: Rec, message: string): number { + const code = typeof error.code === "string" ? error.code : undefined; + return code === "translation_buffer_limit" + ? 413 + : typeof error.status === "number" + ? error.status + : httpStatusFromTerminalError({ + type: typeof error.type === "string" ? error.type : undefined, + code: typeof error.code === "string" ? error.code : null, + message, + }); +} + interface OpenBlock { kind: "text" | "thinking" | "tool_use"; index: number; @@ -668,16 +705,9 @@ export function responsesSseToAnthropicSse( case "response.incomplete": { const response = isRec(data.response) ? data.response : {}; const details = isRec(response.incomplete_details) ? response.incomplete_details : {}; - if (details.reason === "max_output_tokens") { - finish("max_tokens", response.usage); - } else if (details.reason === "content_filter") { - finish("refusal", response.usage); - } else { - const message = typeof details.message === "string" && details.message.trim() - ? details.message - : `upstream response was incomplete${typeof details.reason === "string" ? ` (${details.reason})` : ""}`; - fail(529, message, true); - } + const outcome = anthropicIncompleteOutcome(details.reason, details.message); + if ("stopReason" in outcome) finish(outcome.stopReason, response.usage); + else fail(529, outcome.failMessage, true); break; } case "response.failed": { @@ -688,19 +718,7 @@ export function responsesSseToAnthropicSse( if (code === "translation_buffer_limit") { throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_TURN_BYTES); } - const status = code === "translation_buffer_limit" - ? 413 - : typeof error.status === "number" - ? error.status - // Internal response.failed envelopes carry the classified {type, code, message} - // but no numeric status. Derive it with the same mapping /api/logs uses so a - // classified 429/401/400 reaches Claude Code as its real Anthropic error type - // instead of being masked as retryable overload. - : httpStatusFromTerminalError({ - type: typeof error.type === "string" ? error.type : undefined, - code: typeof error.code === "string" ? error.code : null, - message, - }); + const status = anthropicFailedStatus(error, message); // Unclassified status-absent response.failed (relaySseWithFailedTail synthetic // tail) still lands on a transient 5xx here — the mid-stream reset shape maps to // overloaded_error by design. From 517a06d057f060584ba846058834df0fa52f0301 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:47:38 +0900 Subject: [PATCH 076/173] feat(protocols): encode AdapterEvent streams directly as Chat Completions The routed Chat path re-encoded adapter events as Responses SSE only to parse them back. The driver walks the bridge's item state machine and calls a client writer where the bridge would emit a frame a converter reads; the Chat writer applies the converter's mapping. Nothing calls it yet. --- src/protocols/encoders/adapter-events.ts | 874 +++++++++++++++++++++++ src/protocols/encoders/chat.ts | 236 ++++++ 2 files changed, 1110 insertions(+) create mode 100644 src/protocols/encoders/adapter-events.ts create mode 100644 src/protocols/encoders/chat.ts diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts new file mode 100644 index 00000000000..41ddac2ed02 --- /dev/null +++ b/src/protocols/encoders/adapter-events.ts @@ -0,0 +1,874 @@ +/** + * AdapterEvent -> client wire, without the internal Responses SSE (PF-09). + * + * The routed Chat and Messages paths used to re-encode adapter events as Responses SSE + * (`bridgeToResponsesSSE`) and then parse that SSE again into the client's wire + * (`responsesSseToChatCompletionsSse`, `responsesSseToAnthropicSse`). This driver walks the + * adapter events with the bridge's own item state machine and, where the bridge would emit a + * Responses frame a client converter reacts to, calls the matching `ClientWireWriter` method + * instead. The writers in `chat.ts` and `messages.ts` then apply the converters' mapping, so the + * client-visible frames stay what they were. + * + * What is ported from `src/bridge/sse.ts`, and must stay in step with it: + * - item boundaries: which event closes the open message, reasoning item, tool call or search, + * the signature-update grouping, hidden-thinking and redacted envelopes, the Kiro blob; + * - tool naming (declared-name normalization, namespace mapping, freeform and tool-search + * classification), argument streaming gated on `toolCallArgumentsCouldBeJson`, the integral + * float repair on the completed arguments and the malformed-arguments failure; + * - terminals: completed, truncated `done`, incomplete, error, adapter EOF, the stall watchdog, + * translator-budget overflow and the proxy-error catch, each with the bridge's usage and + * durability rules; + * - the wire-silence heartbeat and stall ticks, pull-based stepping (one event's frames per + * pull) and cancellation, which stops the upstream exactly once. + * + * Not ported, because no Chat or Messages client observes it: Responses item ids and sequence + * numbers, message phases, url_citation annotations, freeform input rewriting and remote + * compaction (the server never encodes a compaction turn directly). Declared-tool enforcement is + * off on both wires (#4735), so it is not applied here either. Replay-cache side effects and the + * completed-response effects belong to the caller, which folds the same events with + * `buildResponseJSON` at the terminal through `beforeTerminal`. + */ +import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "../../types"; +import { normalizeDeclaredToolName } from "../../types"; +import { coerceIntegerToolArguments } from "../../lib/tool-argument-integers"; +import { classifyError, isCyberPolicyCode, type OcxErrorPayload } from "../../lib/errors"; +import { redactSecretString } from "../../lib/redact"; +import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; +import { createCitationMarkerFilter, type CitationMarkerFilter } from "../../responses/citation-markers"; +import { isTruncatedStopReason, truncationReasonFor } from "../../responses/truncated-stop-reason"; +import { safeWebSearchSources } from "../../web-search/sources"; +import { resolveStallTimeoutSec } from "../../stall-timeout"; +import type { RelayedEventObservation } from "../../usage/attempt-delivery"; +import { + adapterFailureFromEvent, + toolCallArgumentsCouldBeJson, + toolCallArgumentsUsable, + uuid, +} from "../../bridge/internal"; + +export type ClientToolKind = "function" | "custom" | "tool_search"; + +export interface ClientToolCall { + itemId: string; + callId: string; + /** Request-visible name: declared-name normalized, namespace mapping applied. */ + name: string; + kind: ClientToolKind; +} + +export interface ClientWebSearch { + itemId: string; + status: "completed" | "failed"; + queries: string[]; + sources: { url: string; title?: string }[]; +} + +/** + * How the turn ended, in the shape the bridge's terminal Responses frame had. The caller builds + * its request-log view from it; the writers map it to the client's finish/stop reason. + */ +export interface EncodedTerminal { + status: "completed" | "incomplete" | "failed"; + usage?: OcxUsage; + /** Whether the bridged terminal carried a usage object (`usage: null` otherwise). */ + usageOnWire: boolean; + endTurn?: boolean; + incomplete?: { reason: string; message?: string; retryable?: boolean }; + error?: OcxErrorPayload; + retryable?: boolean; + /** Translator-budget overflow: one bounded error frame and nothing else. */ + overflow?: true; + /** A `done` event: the bridge handed this response to `onCompletedResponse`. */ + completedResponse: boolean; + providerState?: OcxProviderContinuationState; + /** Whether the bridge reported `usage` through `onUsage` for this terminal. */ + reportUsage: boolean; + /** Whether the bridge awaited thought-signature durability before this terminal. */ + durable: boolean; +} + +/** Frame output owned by the driver: budget reservation, delivery release and relay counts. */ +export interface ClientFrameSink { + readonly budget: TranslatorBudget; + emit(text: string, observation?: RelayedEventObservation): void; + /** Admit every frame before exposing any of them. */ + emitBatch(frames: { text: string; observation?: RelayedEventObservation }[]): void; + /** A fixed, bounded frame that must reach the client even when the budget is exhausted. */ + emitBounded(text: string): void; + /** A transport keepalive; it does not count as wire activity. */ + emitKeepalive(text: string): void; + desiredSize(): number; +} + +/** One method per Responses frame a Chat or Messages converter reacts to. */ +export interface ClientWireWriter { + start(): void; + /** Wire silence while awaiting upstream (the bridge's heartbeat frame). */ + heartbeat(): void; + text(delta: string): void; + messageDone(): void; + reasoning(delta: string, itemId: string, channel: "summary" | "raw"): void; + reasoningDone(item: { itemId: string; signature?: string; redacted?: string[] }): void; + toolStart(call: ClientToolCall): void; + toolArgsDelta(itemId: string, delta: string): void; + toolArgsDone(itemId: string, args: string): void; + toolDone(call: ClientToolCall & { arguments: string; status: "completed" | "incomplete" }): void; + webSearchDone(search: ClientWebSearch): void; + terminal(terminal: EncodedTerminal): void; + overflow(): void; + /** Client cancellation: release anything the writer still holds. */ + dispose(): void; +} + +export interface ClientEncodeHooks { + /** First non-empty text or reasoning delta (TTFT), as the bridge reports it. */ + onFirstOutput?(): void; + /** Before the terminal frames: the caller's fold, durability barrier and effects. */ + beforeTerminal?(terminal: EncodedTerminal): void | Promise; + /** After the terminal frames were admitted; called at most once. */ + afterTerminal?(terminal: EncodedTerminal): void; + /** The bridge's `onCancel`: at the terminal and on client cancel, at most once. */ + stopUpstream?(): void; + onClientCancel?(): void; + onRelayed?(observation: RelayedEventObservation): void; +} + +export interface AdapterEventEncodeOptions { + translatorBudget: TranslatorBudget; + hideThinkingSummary?: boolean; + toolNsMap?: ReadonlyMap; + declaredToolNames?: ReadonlySet; + toolParameterSchemas?: ReadonlyMap>; + freeformToolNames?: ReadonlySet; + toolSearchToolNames?: ReadonlySet; + stallTimeoutSec?: number; + /** Wire-silence heartbeat and stall tick; the bridge's 2 s default. */ + heartbeatMs?: number; + hooks?: ClientEncodeHooks; + /** Test seam for the beat and keepalive timers. */ + timers?: { + setInterval: (handler: () => void, ms: number) => unknown; + clearInterval: (id: unknown) => void; + }; +} + +type OpenToolCall = ClientToolCall & { args: string; argsBytes: number; namespace?: string }; + +/** Drive `events` through `writer` as a pull-based byte stream. */ +export function encodeAdapterEventStream( + events: AsyncIterable, + createWriter: (sink: ClientFrameSink) => ClientWireWriter, + options: AdapterEventEncodeOptions & { keepaliveMs?: number }, +): ReadableStream { + const budget = options.translatorBudget; + const hooks = options.hooks ?? {}; + const heartbeatMs = options.heartbeatMs ?? 2_000; + const setTimer = options.timers?.setInterval ?? ((handler: () => void, ms: number) => setInterval(handler, ms)); + const clearTimer = options.timers?.clearInterval ?? ((id: unknown) => clearInterval(id as ReturnType)); + const maxStallTicks = Math.ceil((resolveStallTimeoutSec(options.stallTimeoutSec) * 1000) / heartbeatMs); + const textEncoder = new TextEncoder(); + + let controller!: ReadableStreamDefaultController; + let closed = false; + let clientCancelled = false; + let terminated = false; + let terminalSettled = false; + let stepping = false; + let gated = false; + let emittedFrames = 0; + let upstreamActivity = false; + let wireActivity = false; + let stallTicks = 0; + let beat: unknown; + let keepalive: unknown; + const queuedFrameBytes: number[] = []; + + const relayed = (observation?: RelayedEventObservation) => { + try { hooks.onRelayed?.(observation ?? {}); } catch { /* counters never break the stream */ } + }; + const enqueueCharged = (text: string, observation?: RelayedEventObservation, activity = true): void => { + if (closed) return; + const frame = textEncoder.encode(text); + const reservation = budget.reserveTransient(frame.byteLength, { kind: "live_transient" }); + try { + controller.enqueue(frame); + } catch { + reservation.release(); + closed = true; + return; + } + reservation.commitRetained(); + queuedFrameBytes.push(frame.byteLength); + emittedFrames++; + if (activity) wireActivity = true; + relayed(observation); + }; + const sink: ClientFrameSink = { + budget, + emit: (text, observation) => enqueueCharged(text, observation), + emitBatch: frames => { + if (closed) return; + const staged: { frame: Uint8Array; reservation: ReturnType; observation?: RelayedEventObservation }[] = []; + try { + for (const { text, observation } of frames) { + const frame = textEncoder.encode(text); + staged.push({ frame, reservation: budget.reserveTransient(frame.byteLength, { kind: "live_transient" }), ...(observation ? { observation } : {}) }); + } + } catch (error) { + for (const entry of staged) entry.reservation.release(); + throw error; + } + for (const entry of staged) { + if (closed) { entry.reservation.release(); continue; } + try { + controller.enqueue(entry.frame); + } catch { + entry.reservation.release(); + closed = true; + continue; + } + entry.reservation.commitRetained(); + queuedFrameBytes.push(entry.frame.byteLength); + emittedFrames++; + relayed(entry.observation); + } + wireActivity = true; + }, + emitBounded: text => { + if (closed) return; + try { + controller.enqueue(textEncoder.encode(text)); + emittedFrames++; + relayed({ terminal: true }); + } catch { + closed = true; + } + }, + emitKeepalive: text => enqueueCharged(text, undefined, false), + desiredSize: () => controller.desiredSize ?? 0, + }; + const writer = createWriter(sink); + + const releaseDeliveredFrame = () => { + const bytes = queuedFrameBytes.shift(); + if (bytes !== undefined) budget.releaseRetained(bytes, { kind: "live_transient" }); + }; + const stopTimers = () => { + if (beat !== undefined) clearTimer(beat); + if (keepalive !== undefined) clearTimer(keepalive); + beat = undefined; + keepalive = undefined; + }; + const closeController = () => { + if (closed) return; + closed = true; + try { controller.close(); } catch { /* already closed */ } + }; + + // Iterator ownership, as in the bridge: a return() before the first next() never enters the + // generator, so its finally blocks could not cancel a prepared upstream body. + const it = events[Symbol.asyncIterator](); + let iteratorStarted = false; + let iteratorReturned = false; + let upstreamDone = false; + const returnIterator = () => { + if (iteratorReturned) return; + iteratorReturned = true; + const finishReturn = () => { + try { void it.return?.()?.catch(() => {}); } catch { /* best-effort cleanup */ } + }; + if (!iteratorStarted) { + iteratorStarted = true; + try { void it.next().then(finishReturn, () => {}).catch(() => {}); } catch { /* best-effort */ } + return; + } + finishReturn(); + }; + let upstreamStopped = false; + const stopUpstreamOnce = () => { + if (upstreamStopped) return; + upstreamStopped = true; + try { hooks.stopUpstream?.(); } catch { /* cancellation must not strand the client stream */ } + returnIterator(); + }; + const settleTerminal = (terminal: EncodedTerminal) => { + if (terminalSettled || clientCancelled) return; + terminalSettled = true; + try { hooks.afterTerminal?.(terminal); } catch { /* terminal bookkeeping never breaks the stream */ } + }; + /** Terminal paths the bridge runs synchronously (stall, overflow): never awaited. */ + const runBeforeTerminalSync = (terminal: EncodedTerminal) => { + try { void Promise.resolve(hooks.beforeTerminal?.(terminal)).catch(() => {}); } catch { /* best-effort */ } + }; + + let firstOutputReported = false; + const reportFirstOutput = (event: AdapterEvent) => { + if (firstOutputReported) return; + const nonEmpty = event.type === "text_delta" ? event.text.length > 0 + : event.type === "thinking_delta" ? event.thinking.length > 0 + : event.type === "reasoning_raw_delta" ? event.text.length > 0 + : false; + if (!nonEmpty) return; + firstOutputReported = true; + try { hooks.onFirstOutput?.(); } catch { /* metrics must not break the stream */ } + }; + + // Item state, ported from bridgeToResponsesSSE. + let currentMsg: { phase?: OcxMessagePhase; citationFilter: CitationMarkerFilter } | null = null; + let currentReasoning: { itemId: string } | null = null; + let currentRawReasoning: { itemId: string } | null = null; + let pendingSignature: string | undefined; + let pendingRedacted: string[] = []; + let hiddenRawBytes = 0; + let pendingKiroRedacted: string | undefined; + let currentToolCall: OpenToolCall | null = null; + let currentWebSearch: { itemId: string; eventId: string } | null = null; + + const takeReasoningEnvelope = (): { signature?: string; redacted?: string[] } | undefined => { + if (!pendingSignature && pendingRedacted.length === 0) return undefined; + const envelope = { + ...(pendingSignature ? { signature: pendingSignature } : {}), + ...(pendingRedacted.length > 0 ? { redacted: pendingRedacted } : {}), + }; + pendingSignature = undefined; + pendingRedacted = []; + return envelope; + }; + const closeCurrentMessage = () => { + if (!currentMsg) return; + // A citation span can straddle a delta boundary; the filter releases its held tail here. + const trailing = currentMsg.citationFilter.flush(); + if (trailing) writer.text(trailing); + writer.messageDone(); + currentMsg = null; + }; + const closeCurrentReasoning = () => { + if (!currentReasoning) return; + const envelope = takeReasoningEnvelope(); + writer.reasoningDone({ itemId: currentReasoning.itemId, ...envelope }); + currentReasoning = null; + }; + const closeCurrentRawReasoning = () => { + if (!currentRawReasoning) return; + writer.reasoningDone({ itemId: currentRawReasoning.itemId }); + currentRawReasoning = null; + }; + // hideThinkingSummary: a signed or redacted block still round-trips as an envelope-only item. + const flushHiddenReasoningEnvelope = () => { + const envelope = takeReasoningEnvelope(); + if (!envelope) return; + writer.reasoningDone({ itemId: `rs_${uuid()}`, ...envelope }); + }; + const flushHiddenRawReasoning = () => { + if (hiddenRawBytes === 0) return; + hiddenRawBytes = 0; + writer.reasoningDone({ itemId: `rs_${uuid()}` }); + }; + // Kiro's blob lands after every item `done` closed, never on arrival (see the bridge). + const flushKiroRedactedReasoning = () => { + if (!pendingKiroRedacted) return; + pendingKiroRedacted = undefined; + writer.reasoningDone({ itemId: `rs_${uuid()}` }); + }; + const closeCurrentToolCall = () => { + if (!currentToolCall) return; + const call = currentToolCall; + // Empty input serializes as "{}"; integral floats are repaired against the schema (#1611). + const argsStr = coerceIntegerToolArguments( + call.args || "{}", + options.toolParameterSchemas?.get(call.name), + call.namespace === undefined ? call.name : undefined, + ); + if (call.kind === "function") writer.toolArgsDone(call.itemId, argsStr); + writer.toolDone({ + itemId: call.itemId, callId: call.callId, name: call.name, kind: call.kind, + arguments: call.kind === "function" ? argsStr : call.args, + status: "completed", + }); + budget.closeCall(call.callId); + currentToolCall = null; + }; + // Failed/incomplete terminal with an open call: no arguments-done frame, status incomplete. + const failCurrentToolCall = () => { + if (!currentToolCall) return; + const call = currentToolCall; + writer.toolDone({ + itemId: call.itemId, callId: call.callId, name: call.name, kind: call.kind, + arguments: call.args || "{}", + status: "incomplete", + }); + budget.closeCall(call.callId); + currentToolCall = null; + }; + const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[]) => { + if (!currentWebSearch) return; + writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [] }); + currentWebSearch = null; + }; + const appendToolArgs = (call: OpenToolCall, fragment: string) => { + const nextBytes = call.argsBytes + Buffer.byteLength(fragment); + const scope = { kind: "tool_args" as const, ...(call.callId ? { callId: call.callId } : {}) }; + const reservation = budget.reserveTransient(nextBytes, scope); + try { + const value = call.args + fragment; + reservation.commitRetained(); + budget.releaseRetained(call.argsBytes, scope); + call.args = value; + call.argsBytes = nextBytes; + } catch (error) { + reservation.release(); + throw error; + } + }; + const closeOpenItemsForTermination = (closeMessage: boolean) => { + if (closeMessage) { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + } + flushHiddenRawReasoning(); + if (currentToolCall) failCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("failed", []); + }; + + let handlingOverflow = false; + const terminateForOverflow = () => { + if (handlingOverflow || terminated || clientCancelled || closed) return; + handlingOverflow = true; + if (currentToolCall) { + budget.closeCall(currentToolCall.callId); + currentToolCall = null; + } + currentWebSearch = null; + const terminal: EncodedTerminal = { + status: "failed", + error: adapterFailureFromEvent({ + type: "error", + status: 502, + errorType: "upstream_error", + code: "translation_buffer_limit", + message: "upstream translation buffer exceeded the safe limit", + }).error, + usageOnWire: false, + overflow: true, + completedResponse: false, + reportUsage: false, + durable: false, + }; + runBeforeTerminalSync(terminal); + try { writer.overflow(); } catch { /* the bounded frame is best-effort once the client is gone */ } + settleTerminal(terminal); + terminated = true; + stopUpstreamOnce(); + stopTimers(); + closeController(); + gated = true; + stepping = false; + }; + /** The bridge's attemptTerminationCleanup: false when an overflow already ended the stream. */ + const cleanupForTermination = (action: () => void): boolean => { + try { + action(); + return !terminated && !closed; + } catch (error) { + if (!isTranslatorBudgetExceededError(error)) throw error; + terminateForOverflow(); + return false; + } + }; + const deliverTerminal = async (terminal: EncodedTerminal): Promise => { + await hooks.beforeTerminal?.(terminal); + writer.terminal(terminal); + settleTerminal(terminal); + }; + /** Terminal delivery outside the step try: an overflow while writing it ends the stream. */ + const deliverTerminalGuarded = async (terminal: EncodedTerminal): Promise => { + try { + await deliverTerminal(terminal); + return true; + } catch (error) { + if (isTranslatorBudgetExceededError(error)) terminateForOverflow(); + return false; + } + }; + + const stall = () => { + if (!cleanupForTermination(() => closeOpenItemsForTermination(true))) return; + // Synchronous, like the bridge's beat callback: no durability barrier on this kill path. + const terminal: EncodedTerminal = { + status: "incomplete", + incomplete: { reason: "upstream_stall_timeout" }, + usageOnWire: false, + completedResponse: false, + reportUsage: false, + durable: false, + }; + runBeforeTerminalSync(terminal); + try { + writer.terminal(terminal); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) terminateForOverflow(); + return; + } + settleTerminal(terminal); + stopUpstreamOnce(); + terminated = true; + stopTimers(); + closeController(); + }; + + const step = async (): Promise => { + if (stepping || closed) return; + stepping = true; + gated = false; + const emittedAtStart = emittedFrames; + try { + while (!terminated && !closed && emittedFrames === emittedAtStart) { + iteratorStarted = true; + const next = await it.next(); + // A cancel during the await must not process or charge a late event. + if (closed || clientCancelled) { + gated = true; + stepping = false; + return; + } + if (next.done) { upstreamDone = true; break; } + const event = next.value; + let terminal: EncodedTerminal | undefined; + upstreamActivity = true; + stallTicks = 0; + if (event.type !== "heartbeat" && event.type !== "thinking_signature" && event.type !== "kiro_redacted_reasoning") { + wireActivity = true; + } + reportFirstOutput(event); + // A signature update belongs to the current thinking block; the next semantic event + // closes that block (or flushes the hidden envelope). + if (pendingSignature !== undefined && event.type !== "thinking_signature" && event.type !== "heartbeat") { + if (currentReasoning) closeCurrentReasoning(); + else flushHiddenReasoningEnvelope(); + } + switch (event.type) { + case "assistant_boundary": { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + flushHiddenReasoningEnvelope(); + break; + } + case "text_delta": { + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + // Only an explicit phase change starts a new message. + if (currentMsg && event.phase !== undefined && currentMsg.phase !== event.phase) closeCurrentMessage(); + if (!currentMsg) { + currentMsg = { citationFilter: createCitationMarkerFilter(), ...(event.phase ? { phase: event.phase } : {}) }; + } + const visible = currentMsg.citationFilter.push(event.text); + if (visible) writer.text(visible); + break; + } + case "thinking_delta": { + if (options.hideThinkingSummary) { + flushHiddenRawReasoning(); + break; + } + if (currentMsg) closeCurrentMessage(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (!currentReasoning) currentReasoning = { itemId: `rs_${uuid()}` }; + writer.reasoning(event.thinking, currentReasoning.itemId, "summary"); + break; + } + case "thinking_signature": { + pendingSignature = event.signature; + break; + } + case "redacted_thinking": { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + pendingRedacted.push(event.data); + // A redacted block is complete on arrival. + flushHiddenReasoningEnvelope(); + break; + } + case "kiro_redacted_reasoning": { + pendingKiroRedacted = event.data; + break; + } + case "reasoning_raw_delta": { + if (options.hideThinkingSummary) { + hiddenRawBytes += Buffer.byteLength(event.text); + break; + } + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (!currentRawReasoning) currentRawReasoning = { itemId: `rs_${uuid()}` }; + writer.reasoning(event.text, currentRawReasoning.itemId, "raw"); + break; + } + case "tool_call_start": { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + const effectiveName = normalizeDeclaredToolName(event.name, options.declaredToolNames); + const mapped = options.toolNsMap?.get(effectiveName); + const name = mapped?.name ?? effectiveName; + const toolSearch = options.toolSearchToolNames?.has(name) ?? false; + const freeform = !toolSearch && (mapped + ? mapped.freeform === true + : (options.freeformToolNames?.has(name) ?? false)); + const kind: ClientToolKind = toolSearch ? "tool_search" : freeform ? "custom" : "function"; + const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`; + const call: ClientToolCall = { itemId, callId: event.id, name, kind }; + writer.toolStart(call); + currentToolCall = { ...call, args: "", argsBytes: 0, ...(mapped?.namespace ? { namespace: mapped.namespace } : {}) }; + budget.openCall(event.id); + break; + } + case "tool_call_delta": { + if (!currentToolCall) break; + appendToolArgs(currentToolCall, event.arguments); + // Hold fragments whose buffer can never parse as JSON (#765). + if (currentToolCall.kind === "function" && toolCallArgumentsCouldBeJson(currentToolCall.args)) { + writer.toolArgsDelta(currentToolCall.itemId, event.arguments); + } + break; + } + case "tool_call_end": { + // Streamed fragments cannot be repaired: unusable arguments fail the turn. + if (currentToolCall && currentToolCall.kind === "function" && !toolCallArgumentsUsable(currentToolCall.args)) { + failCurrentToolCall(); + terminal = { + status: "failed", + error: classifyError(502, "upstream_error", "upstream stream produced malformed tool call arguments"), + usageOnWire: false, + completedResponse: false, + reportUsage: false, + durable: false, + }; + break; + } + closeCurrentToolCall(); + break; + } + case "web_search_call_begin": { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("completed", []); + currentWebSearch = { itemId: `ws_${uuid()}`, eventId: event.id }; + break; + } + case "web_search_call_end": { + if (!currentWebSearch || currentWebSearch.eventId !== event.id) { + if (currentWebSearch) closeCurrentWebSearch("completed", []); + currentWebSearch = { itemId: `ws_${uuid()}`, eventId: event.id }; + } + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources)); + break; + } + case "done": { + const truncated = isTruncatedStopReason(event.stopReason); + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) { + if (truncated) failCurrentToolCall(); + else closeCurrentToolCall(); + } + if (currentWebSearch) closeCurrentWebSearch(truncated ? "failed" : "completed", []); + flushHiddenReasoningEnvelope(); + flushKiroRedactedReasoning(); + const truncation = truncationReasonFor(event.stopReason); + terminal = { + status: truncation ? "incomplete" : "completed", + ...(truncation ? { incomplete: { reason: truncation } } : {}), + ...(event.usage ? { usage: event.usage } : {}), + usageOnWire: true, + ...(event.endTurn !== undefined ? { endTurn: event.endTurn } : {}), + completedResponse: true, + ...(event.providerState ? { providerState: event.providerState } : {}), + reportUsage: true, + durable: true, + }; + break; + } + case "incomplete": { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) failCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("failed", []); + flushHiddenReasoningEnvelope(); + terminal = { + status: "incomplete", + incomplete: { + reason: event.reason, + ...(event.message ? { message: event.message } : {}), + ...(event.retryable !== undefined ? { retryable: event.retryable } : {}), + }, + ...(event.usage ? { usage: event.usage } : {}), + usageOnWire: true, + ...(event.endTurn !== undefined ? { endTurn: event.endTurn } : {}), + completedResponse: false, + reportUsage: true, + durable: true, + }; + break; + } + case "error": { + if (event.code === "translation_buffer_limit") { + terminateForOverflow(); + return; + } + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) failCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("failed", []); + const failure = adapterFailureFromEvent(event); + const retryable = isCyberPolicyCode(failure.error.code) ? false : event.retryable; + terminal = { + status: "failed", + error: failure.error, + ...(event.usage ? { usage: event.usage } : {}), + usageOnWire: event.usage !== undefined, + ...(retryable !== undefined ? { retryable } : {}), + completedResponse: false, + reportUsage: event.usage !== undefined, + durable: true, + }; + break; + } + default: + break; + } + if (terminal) { + await deliverTerminal(terminal); + stopUpstreamOnce(); + terminated = true; + break; + } + } + } catch (err) { + if (isTranslatorBudgetExceededError(err)) { + terminateForOverflow(); + return; + } + if (!terminated && !closed) { + if (!cleanupForTermination(() => closeOpenItemsForTermination(false))) return; + const failure = classifyError(500, "proxy_error", redactSecretString(err instanceof Error ? err.message : String(err))); + const terminal: EncodedTerminal = { + status: "failed", + error: failure, + usageOnWire: false, + ...(isCyberPolicyCode(failure.code) ? { retryable: false } : {}), + completedResponse: false, + reportUsage: false, + durable: false, + }; + try { + if (!await deliverTerminalGuarded(terminal)) return; + } catch { /* a failing hook on the failure path must not throw into the pull */ } + stopUpstreamOnce(); + terminated = true; + } + } + + if (!terminated && !upstreamDone) { + gated = true; + stepping = false; + return; + } + stopTimers(); + if (!terminated && !closed) { + // The adapter generator ended without a terminal: a truncated stream, never a success. + if (!cleanupForTermination(() => closeOpenItemsForTermination(true))) return; + const terminal: EncodedTerminal = { + status: "incomplete", + incomplete: { reason: "adapter_eof" }, + usageOnWire: true, + completedResponse: false, + reportUsage: true, + durable: true, + }; + try { + if (!await deliverTerminalGuarded(terminal)) return; + } catch { /* see the catch path above */ } + terminated = true; + } + closeController(); + gated = true; + stepping = false; + }; + + return new ReadableStream({ + start(streamController) { + controller = streamController; + try { + writer.start(); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) terminateForOverflow(); + else throw error; + } + // Default HWM=1: one event's frames fill the queue, then stepping pauses until demand. + gated = true; + beat = setTimer(() => { + if (closed || gated) return; + if (upstreamActivity) { + upstreamActivity = false; + stallTicks = 0; + } else if (++stallTicks >= maxStallTicks) { + stall(); + return; + } + // Wire silence is independent of invisible upstream heartbeats. + if (wireActivity) { + wireActivity = false; + return; + } + try { writer.heartbeat(); } catch { /* a keepalive never fails the stream */ } + }, heartbeatMs); + if (options.keepaliveMs !== undefined && options.keepaliveMs > 0) { + keepalive = setTimer(() => { + if (terminated || closed) return; + try { writer.heartbeat(); } catch { /* the read loop is ending anyway */ } + }, options.keepaliveMs); + } + }, + pull() { + releaseDeliveredFrame(); + return step(); + }, + cancel() { + // The client disconnected: stop emitting and stop the upstream turn (RC2). + clientCancelled = true; + closed = true; + stopTimers(); + stopUpstreamOnce(); + while (queuedFrameBytes.length > 0) releaseDeliveredFrame(); + if (currentToolCall) { + budget.closeCall(currentToolCall.callId); + currentToolCall = null; + } + try { writer.dispose(); } catch { /* best-effort release */ } + try { hooks.onClientCancel?.(); } catch { /* bookkeeping never throws into cancel */ } + }, + }); +} diff --git a/src/protocols/encoders/chat.ts b/src/protocols/encoders/chat.ts new file mode 100644 index 00000000000..8c09bce2f17 --- /dev/null +++ b/src/protocols/encoders/chat.ts @@ -0,0 +1,236 @@ +/** + * AdapterEvent -> OpenAI Chat Completions, without the internal Responses SSE (PF-09). + * + * The writer reproduces `responsesSseToChatCompletionsSse` (src/chat/outbound.ts) for the frames + * the bridge produced: one role frame first, content and `reasoning_content` deltas, each + * function call as ONE complete tool_call chunk at its completion (Chat tool-call fields are + * append-only, so the converter never streamed partial arguments), a finish chunk carrying the + * usage, then `[DONE]`; failures end with one `{error}` frame and no `[DONE]`. Ids, the finish + * and error mapping and the usage shape are the converter's own exported helpers. + */ +import type { AdapterEvent } from "../../types"; +import type { TranslatorBudget } from "../../lib/translator-budget"; +import { responsesUsage } from "../../bridge/internal"; +import { + chatCompletionsErrorResponse, + chatCompletionsFailedResponse, + chatCompletionsIncompleteOutcome, + chatCompletionsStreamErrorPayload, + chatCompletionsUsage, + chunkBase, + collectChatCompletion, + completionId, + dataFrame, + isChatCompletionsStreamError, + type ChatCompletionsStreamFailure, +} from "../../chat/outbound"; +import { + encodeAdapterEventStream, + type AdapterEventEncodeOptions, + type ClientFrameSink, + type ClientWireWriter, +} from "./adapter-events"; +import type { RelayedEventObservation } from "../../usage/attempt-delivery"; + +type Rec = Record; + +export interface ChatCompletionEncodeOptions extends AdapterEventEncodeOptions { + /** The model string the client asked for; every chunk carries it. */ + model: string; +} + +function createChatCompletionWriter(sink: ClientFrameSink, model: string): ClientWireWriter { + const id = completionId(); + const created = Math.floor(Date.now() / 1000); + let started = false; + let sawToolUse = false; + let terminated = false; + let failed = false; + // call_id -> streaming index: OpenAI requires a stable index per tool call. + const toolIndexByCallId = new Map(); + const toolIndexByItemId = new Map(); + const toolNameByIndex = new Map(); + const emittedToolIndexes = new Set(); + let nextToolIndex = 0; + let staged: { text: string; observation?: RelayedEventObservation }[] | undefined; + + const emit = (payload: Rec | "[DONE]", observation?: RelayedEventObservation) => { + if (failed) return; + const text = dataFrame(payload); + if (staged) staged.push({ text, ...(observation ? { observation } : {}) }); + else sink.emit(text, observation); + }; + const ensureRole = () => { + if (started) return; + started = true; + const frame = chunkBase(id, model, created); + frame.choices = [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }]; + emit(frame); + }; + const emitToolCall = (toolIndex: number, callId: string, name: string, args: string) => { + if (!callId || emittedToolIndexes.has(toolIndex)) return; + emittedToolIndexes.add(toolIndex); + ensureRole(); + const frame = chunkBase(id, model, created); + frame.choices = [{ + index: 0, + delta: { + tool_calls: [{ + index: toolIndex, + id: callId, + type: "function", + function: { name, arguments: args }, + }], + }, + finish_reason: null, + }]; + emit(frame, { sideEffect: true, semanticBytes: Buffer.byteLength(args) }); + }; + // Every started call reaches toolDone before a finishing terminal (the driver closes or fails + // it first), so the converter's pending-call flush has nothing left to flush here. + const finish = (finishReason: string, usage: Rec | null) => { + if (terminated) return; + // Admit every terminal frame before exposing any of them. + const batch: NonNullable = []; + staged = batch; + try { + ensureRole(); + const frame = chunkBase(id, model, created); + frame.choices = [{ index: 0, delta: {}, finish_reason: finishReason }]; + if (usage) frame.usage = chatCompletionsUsage(usage); + emit(frame, { terminal: true }); + emit("[DONE]"); + } finally { + staged = undefined; + } + sink.emitBatch(batch); + terminated = true; + }; + const fail = (message: string, details?: ChatCompletionsStreamFailure) => { + if (terminated) return; + terminated = true; + failed = true; + // A real error event, then an abnormal close without [DONE]. + const { payload, bounded } = chatCompletionsStreamErrorPayload(message, details); + try { + if (bounded) sink.emitBounded(dataFrame(payload)); + else sink.emit(dataFrame(payload), { terminal: true }); + } catch { + /* the converter drops an unadmittable error frame the same way */ + } + }; + + return { + start: ensureRole, + heartbeat: ensureRole, + text(delta) { + if (!delta) return; + ensureRole(); + const frame = chunkBase(id, model, created); + frame.choices = [{ index: 0, delta: { content: delta }, finish_reason: null }]; + emit(frame, { semanticBytes: Buffer.byteLength(delta) }); + }, + messageDone() { /* the converter reads nothing from a finished message */ }, + reasoning(delta) { + if (!delta) return; + ensureRole(); + // Many OpenAI-compatible clients accept reasoning_content; harmless if ignored. + const frame = chunkBase(id, model, created); + frame.choices = [{ index: 0, delta: { reasoning_content: delta }, finish_reason: null }]; + emit(frame, { semanticBytes: Buffer.byteLength(delta) }); + }, + reasoningDone() { /* reasoning items carry nothing a Chat client reads */ }, + toolStart(call) { + // Custom and tool-search calls have no Chat representation; the converter skips them. + if (call.kind !== "function") return; + ensureRole(); + sawToolUse = true; + let toolIndex = toolIndexByCallId.get(call.callId); + if (toolIndex === undefined) { + toolIndex = nextToolIndex++; + toolIndexByCallId.set(call.callId, toolIndex); + } + toolIndexByItemId.set(call.itemId, toolIndex); + if (call.name) toolNameByIndex.set(toolIndex, call.name); + }, + toolArgsDelta() { /* arguments are delivered once, complete, at toolDone */ }, + toolArgsDone() { /* likewise */ }, + toolDone(call) { + if (call.kind !== "function") return; + sawToolUse = true; + if (!call.callId) return; + const toolIndex = toolIndexByCallId.get(call.callId) ?? toolIndexByItemId.get(call.itemId) ?? nextToolIndex++; + toolIndexByCallId.set(call.callId, toolIndex); + toolIndexByItemId.set(call.itemId, toolIndex); + if (call.name) toolNameByIndex.set(toolIndex, call.name); + // The completed arguments are never empty ("{}" at least), so they win over the stream. + emitToolCall(toolIndex, call.callId, call.name || toolNameByIndex.get(toolIndex) || "", call.arguments); + }, + webSearchDone() { /* server-side search has no Chat representation */ }, + terminal(terminal) { + if (terminal.status === "completed") { + finish(sawToolUse ? "tool_calls" : "stop", responsesUsage(terminal.usage)); + return; + } + if (terminal.status === "incomplete") { + const outcome = chatCompletionsIncompleteOutcome(terminal.incomplete?.reason, terminal.incomplete?.message); + if ("finishReason" in outcome) { + finish(outcome.finishReason, terminal.usageOnWire ? responsesUsage(terminal.usage) : null); + } else { + fail(outcome.failMessage); + } + return; + } + const failure = chatCompletionsFailedResponse((terminal.error ?? {}) as unknown as Rec); + fail(failure.message, failure.details); + }, + overflow() { + fail("upstream translation buffer exceeded the safe limit", { + code: "translation_buffer_limit", + status: 502, + type: "upstream_error", + }); + }, + dispose() { /* nothing retained beyond the driver's frames */ }, + }; +} + +/** Stream adapter events as Chat Completions SSE bytes. */ +export function encodeChatCompletionSse( + events: AsyncIterable, + options: ChatCompletionEncodeOptions, +): ReadableStream { + return encodeAdapterEventStream(events, sink => createChatCompletionWriter(sink, options.model), options); +} + +/** + * Fold an encoded Chat stream into the non-streaming client response, with the status mapping + * the Chat ingress applied to its collected completion: a stream failure becomes the matching + * Chat error response, anything else unexpected a 502. + */ +export async function collectChatCompletionResponse( + stream: ReadableStream, + model: string, + translatorBudget: TranslatorBudget, +): Promise { + try { + const completion = await collectChatCompletion(stream, model, translatorBudget); + return new Response(JSON.stringify(completion), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + if (isChatCompletionsStreamError(err)) { + return chatCompletionsErrorResponse(err.status, err.message, err.type, err.code); + } + return chatCompletionsErrorResponse(502, err instanceof Error ? err.message : String(err), "server_error"); + } +} + +/** Non-streaming client: the encoded stream folded exactly as the Chat collector folds it. */ +export function foldChatCompletion( + events: AsyncIterable, + options: ChatCompletionEncodeOptions, +): Promise { + return collectChatCompletionResponse(encodeChatCompletionSse(events, options), options.model, options.translatorBudget); +} From 639cd9f92109c540bc9334804d7d9cd1825b2c05 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:48:45 +0900 Subject: [PATCH 077/173] feat(protocols): encode AdapterEvent streams directly as Anthropic Messages Same driver, with a writer that applies the Responses-to-Anthropic converter's block, ping, signature, WebSearch and error rules. Unlike the converter it is pull-based, so a slow client no longer lets translated frames queue without bound. Nothing calls it yet. --- src/protocols/encoders/messages.ts | 462 +++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 src/protocols/encoders/messages.ts diff --git a/src/protocols/encoders/messages.ts b/src/protocols/encoders/messages.ts new file mode 100644 index 00000000000..c4167aba3cc --- /dev/null +++ b/src/protocols/encoders/messages.ts @@ -0,0 +1,462 @@ +/** + * AdapterEvent -> Anthropic Messages, without the internal Responses SSE (PF-09). + * + * The writer reproduces `responsesSseToAnthropicSse` (src/claude/outbound.ts) for the frames the + * bridge produced: `message_start` + `ping` on the first semantic output (never before an initial + * error), text blocks per assistant message, thinking blocks buffered until their item closes + * and then emitted with the genuine signature or the bounded ocxr1 fallback, redacted blocks + * ahead of the thinking block they came with, tool_use blocks with streamed `input_json_delta` + * (WebSearch input buffered and sanitized), the server_tool_use / web_search_tool_result pair, + * `message_delta` + `message_stop`, and mid-stream `error` frames. Pings follow the bridge's + * wire-silence heartbeat and the converter's 20 s keepalive, only while the client has demand. + */ +import type { AdapterEvent } from "../../types"; +import { + isTranslatorBudgetExceededError, + type TranslatorBudget, +} from "../../lib/translator-budget"; +import { isTransientUpstreamStatus } from "../../lib/upstream-retry"; +import { encodeReasoningEnvelope } from "../../responses/reasoning-envelope"; +import { responsesUsage, webSearchAction } from "../../bridge/internal"; +import { + anthropicErrorBody, + anthropicErrorResponse, + anthropicFailedStatus, + anthropicIncompleteOutcome, + anthropicUsage, + boundedReasoningIdentity, + collectAnthropicMessage, + isClaudeWebSearchToolName, + messageSnapshot, + sanitizeWebSearchInput, + sseFrame, + webSearchPairFromItem, +} from "../../claude/outbound"; +import { + encodeAdapterEventStream, + type AdapterEventEncodeOptions, + type ClientFrameSink, + type ClientWireWriter, +} from "./adapter-events"; +import type { RelayedEventObservation } from "../../usage/attempt-delivery"; + +type Rec = Record; + +/** The converter's transport keepalive interval. */ +const MESSAGES_KEEPALIVE_MS = 20_000; + +export interface AnthropicMessageEncodeOptions extends AdapterEventEncodeOptions { + /** The model string the client asked for; `message_start` and the folded message carry it. */ + model: string; + /** This proxy's prompt estimate for `message_start` when no usage arrived first (#4857). */ + inputTokenFloor?: number; +} + +interface OpenBlock { + kind: "text" | "thinking" | "tool_use"; + index: number; + bufferWebSearchArgs?: boolean; + argsBuf?: string; + argsBufBytes?: number; + webSearchArgsEmitted?: boolean; + toolArgsEmitted?: boolean; + callId?: string; + reasoningPartKey?: string; + reasoningItemKey?: string; + thinkingBuf?: string; + thinkingBufBytes?: number; + reasoningSig?: string; +} + +function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number { + let nextBytes = previousBytes + Buffer.byteLength(fragment); + const previousLast = previous.charCodeAt(previous.length - 1); + const fragmentFirst = fragment.charCodeAt(0); + if (previousLast >= 0xd800 && previousLast <= 0xdbff + && fragmentFirst >= 0xdc00 && fragmentFirst <= 0xdfff) { + nextBytes -= 2; + } + return nextBytes; +} + +function createAnthropicMessageWriter( + sink: ClientFrameSink, + model: string, + inputTokenFloor: number | undefined, +): ClientWireWriter { + const budget = sink.budget; + let started = false; + let terminated = false; + // Only a delivered terminal forbids the bounded overflow error. + let terminalDelivered = false; + let blockIndex = 0; + let open: OpenBlock | null = null; + let sawToolUse = false; + let webSearchRequests = 0; + + const emit = (name: string, data: Rec, observation?: RelayedEventObservation) => sink.emit(sseFrame(name, data), observation); + const releaseThinkingBuffer = (block: OpenBlock | null) => { + if (block?.kind !== "thinking") return; + budget.releaseRetained(block.thinkingBufBytes ?? 0, { kind: "reasoning" }); + block.thinkingBufBytes = 0; + }; + const appendRetained = (previous: string, previousBytes: number, fragment: string, scope: { kind: "reasoning" | "tool_args"; callId?: string }) => { + const nextBytes = appendedUtf8Bytes(previous, previousBytes, fragment); + const reservation = budget.reserveTransient(nextBytes, scope); + try { + const value = previous + fragment; + reservation.commitRetained(); + budget.releaseRetained(previousBytes, scope); + return { value, bytes: nextBytes }; + } catch (error) { + reservation.release(); + throw error; + } + }; + const ensureStarted = () => { + if (started) return; + started = true; + emit("message_start", { type: "message_start", message: messageSnapshot(model, undefined, inputTokenFloor) }); + emit("ping", { type: "ping" }); + }; + const closeOpenBlock = () => { + if (!open) return; + if (open.kind === "tool_use" && open.bufferWebSearchArgs && !open.webSearchArgsEmitted) { + let parsed: unknown = {}; + const rawArgs = open.argsBuf ?? ""; + try { parsed = rawArgs.length > 0 ? JSON.parse(rawArgs) : {}; } catch { parsed = {}; } + emit("content_block_delta", { + type: "content_block_delta", + index: open.index, + delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) }, + }); + open.webSearchArgsEmitted = true; + } + if (open.kind === "thinking") { + // The index and every thinking frame wait for closure, so redacted blocks from the + // same item can go first. + open.index = blockIndex++; + emit("content_block_start", { + type: "content_block_start", index: open.index, + content_block: { type: "thinking", thinking: "", signature: "" }, + }); + if (open.thinkingBuf) { + emit("content_block_delta", { + type: "content_block_delta", index: open.index, + delta: { type: "thinking_delta", thinking: open.thinkingBuf }, + }, { semanticBytes: Buffer.byteLength(open.thinkingBuf) }); + } + const signature = open.reasoningSig ?? encodeReasoningEnvelope({ txt: open.thinkingBuf ?? "" }, budget); + emit("content_block_delta", { + type: "content_block_delta", index: open.index, + delta: { type: "signature_delta", signature }, + }); + } + emit("content_block_stop", { type: "content_block_stop", index: open.index }); + releaseThinkingBuffer(open); + if (open.callId) budget.closeCall(open.callId); + open = null; + }; + const ensureBlock = (kind: "text" | "thinking") => { + ensureStarted(); + if (open && open.kind === kind) return; + closeOpenBlock(); + if (kind === "thinking") { + open = { kind, index: -1, thinkingBuf: "", thinkingBufBytes: 0 }; + return; + } + const index = blockIndex++; + emit("content_block_start", { type: "content_block_start", index, content_block: { type: "text", text: "" } }); + open = { kind, index }; + }; + const finish = (stopReason: string, usage: unknown) => { + if (terminated) return; + terminated = true; + ensureStarted(); + closeOpenBlock(); + emit("message_delta", { + type: "message_delta", + delta: { stop_reason: stopReason, stop_sequence: null }, + usage: anthropicUsage(usage, webSearchRequests), + }); + emit("message_stop", { type: "message_stop" }, { terminal: true }); + terminalDelivered = true; + }; + // Transient upstream statuses become overloaded_error so Anthropic SDK clients back off. + const fail = (status: number, message: string, upstreamDerived = false, code?: string) => { + if (terminated && (code !== "translation_buffer_limit" || terminalDelivered)) return; + terminated = true; + if (code === "translation_buffer_limit") { + releaseThinkingBuffer(open); + if (open?.callId) budget.closeCall(open.callId); + open = null; + terminalDelivered = true; + // No normal close frames are valid after overflow: one bounded typed terminal. + sink.emitBounded(sseFrame("error", anthropicErrorBody(413, message, "request_too_large", "translation_buffer_limit"))); + return; + } + const type = upstreamDerived && isTransientUpstreamStatus(status) ? "overloaded_error" : undefined; + // An initial failure is an error stream, not a partial message: no message_start first. + if (started) closeOpenBlock(); + emit("error", anthropicErrorBody(status, message, type, code), { terminal: true }); + terminalDelivered = true; + }; + + return { + start() { /* the bridge's lifecycle preludes carry no usage, so nothing starts here */ }, + heartbeat() { + if (terminated || sink.desiredSize() <= 0) return; + sink.emitKeepalive(sseFrame("ping", { type: "ping" })); + }, + text(delta) { + if (!delta) return; + ensureBlock("text"); + const active = open; + if (!active || active.kind !== "text") return; + emit("content_block_delta", { + type: "content_block_delta", index: active.index, + delta: { type: "text_delta", text: delta }, + }, { semanticBytes: Buffer.byteLength(delta) }); + }, + messageDone() { + if (open && open.kind === "text") closeOpenBlock(); + }, + reasoning(delta, itemId, channel) { + if (!delta) return; + const itemKey = boundedReasoningIdentity(itemId); + if (open?.kind === "thinking" && open.reasoningItemKey !== itemKey) closeOpenBlock(); + ensureBlock("thinking"); + const active = open as OpenBlock | null; + if (!active || active.kind !== "thinking") return; + // The bridge writes summary deltas at summary_index 0 and raw deltas at content_index 0. + const slot = channel === "summary" ? `s${boundedReasoningIdentity(0)}` : `c${boundedReasoningIdentity(0)}`; + const partKey = `${itemKey}:${slot}`; + const needsPartSeparator = active.reasoningPartKey !== undefined && active.reasoningPartKey !== partKey; + const next = appendRetained( + active.thinkingBuf ?? "", + active.thinkingBufBytes ?? 0, + `${needsPartSeparator ? "\n\n" : ""}${delta}`, + { kind: "reasoning" }, + ); + active.thinkingBuf = next.value; + active.thinkingBufBytes = next.bytes; + active.reasoningItemKey = itemKey; + active.reasoningPartKey = partKey; + }, + reasoningDone({ itemId, signature, redacted }) { + const red = redacted ?? []; + const itemKey = boundedReasoningIdentity(itemId); + // A late or unrelated item cannot reorder or sign another item's text. + if (open?.kind === "thinking" && open.reasoningItemKey !== itemKey) closeOpenBlock(); + if (red.length > 0) { + ensureStarted(); + if (open?.kind !== "thinking") closeOpenBlock(); + } + for (const data of red) { + const index = blockIndex++; + emit("content_block_start", { type: "content_block_start", index, content_block: { type: "redacted_thinking", data } }); + emit("content_block_stop", { type: "content_block_stop", index }); + } + if (signature && open?.kind !== "thinking") ensureBlock("thinking"); + const active = open as OpenBlock | null; + if (active?.kind === "thinking") { + if (signature) active.reasoningSig = signature; + closeOpenBlock(); + } + }, + toolStart(call) { + // Custom and tool-search calls have no Anthropic representation; the converter skips them. + if (call.kind !== "function") return; + ensureStarted(); + closeOpenBlock(); + sawToolUse = true; + const index = blockIndex++; + emit("content_block_start", { + type: "content_block_start", index, + content_block: { type: "tool_use", id: call.callId, name: call.name, input: {} }, + }, { sideEffect: true }); + budget.openCall(call.callId); + open = { + kind: "tool_use", + index, + callId: call.callId, + bufferWebSearchArgs: isClaudeWebSearchToolName(call.name), + argsBuf: "", + argsBufBytes: 0, + webSearchArgsEmitted: false, + toolArgsEmitted: false, + }; + }, + toolArgsDelta(_itemId, delta) { + if (!delta) return; + if (!open || open.kind !== "tool_use") return; + if (open.bufferWebSearchArgs) { + const next = appendRetained(open.argsBuf ?? "", open.argsBufBytes ?? 0, delta, { + kind: "tool_args", + ...(open.callId ? { callId: open.callId } : {}), + }); + open.argsBuf = next.value; + open.argsBufBytes = next.bytes; + return; + } + emit("content_block_delta", { + type: "content_block_delta", index: open.index, + delta: { type: "input_json_delta", partial_json: delta }, + }, { semanticBytes: Buffer.byteLength(delta) }); + open.toolArgsEmitted = true; + }, + toolArgsDone(_itemId, args) { + if (!open || open.kind !== "tool_use" || open.bufferWebSearchArgs || open.toolArgsEmitted) return; + if (args.length === 0) return; + emit("content_block_delta", { + type: "content_block_delta", index: open.index, + delta: { type: "input_json_delta", partial_json: args }, + }, { semanticBytes: Buffer.byteLength(args) }); + open.toolArgsEmitted = true; + }, + toolDone(call) { + if (call.kind !== "function" || !open || open.kind !== "tool_use") return; + if (open.bufferWebSearchArgs && !open.webSearchArgsEmitted) { + const rawArgs = call.arguments.length > 0 ? call.arguments : (open.argsBuf ?? ""); + let parsed: unknown = {}; + try { parsed = rawArgs.length > 0 ? JSON.parse(rawArgs) : {}; } catch { parsed = {}; } + emit("content_block_delta", { + type: "content_block_delta", + index: open.index, + delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) }, + }); + open.webSearchArgsEmitted = true; + } else if (!open.bufferWebSearchArgs && !open.toolArgsEmitted && call.arguments.length > 0) { + emit("content_block_delta", { + type: "content_block_delta", index: open.index, + delta: { type: "input_json_delta", partial_json: call.arguments }, + }, { semanticBytes: Buffer.byteLength(call.arguments) }); + open.toolArgsEmitted = true; + } + closeOpenBlock(); + }, + webSearchDone(search) { + // Server-side search becomes the pair Claude Code parses natively. It never marks a + // tool use, so the stop reason stays end_turn unless a real tool ran. + ensureStarted(); + closeOpenBlock(); + const pair = webSearchPairFromItem({ + type: "web_search_call", + id: search.itemId, + status: search.status, + action: webSearchAction(search.queries), + ...(search.sources.length > 0 ? { sources: search.sources } : {}), + }); + const toolIndex = blockIndex++; + emit("content_block_start", { + type: "content_block_start", index: toolIndex, + content_block: { type: "server_tool_use", id: pair.id, name: "web_search" }, + }, { sideEffect: true }); + emit("content_block_delta", { + type: "content_block_delta", index: toolIndex, + delta: { type: "input_json_delta", partial_json: JSON.stringify(pair.input) }, + }); + emit("content_block_stop", { type: "content_block_stop", index: toolIndex }); + const resultIndex = blockIndex++; + emit("content_block_start", { + type: "content_block_start", index: resultIndex, + content_block: { type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent }, + }); + emit("content_block_stop", { type: "content_block_stop", index: resultIndex }); + if (pair.completed) webSearchRequests++; + }, + terminal(terminal) { + if (terminal.status === "completed") { + if (terminal.endTurn === false && !sawToolUse) { + fail(529, "upstream turn ended without a final answer", true); + return; + } + finish(sawToolUse ? "tool_use" : "end_turn", responsesUsage(terminal.usage)); + return; + } + if (terminal.status === "incomplete") { + const outcome = anthropicIncompleteOutcome(terminal.incomplete?.reason, terminal.incomplete?.message); + if ("stopReason" in outcome) finish(outcome.stopReason, terminal.usageOnWire ? responsesUsage(terminal.usage) : null); + else fail(529, outcome.failMessage, true); + return; + } + const error = (terminal.error ?? {}) as unknown as Rec; + if (error.code === "translation_buffer_limit") { + fail(413, "upstream translation buffer exceeded the safe limit", false, "translation_buffer_limit"); + return; + } + const message = typeof error.message === "string" ? error.message : "upstream request failed"; + fail(anthropicFailedStatus(error, message), message, true); + }, + overflow() { + fail(413, "upstream translation buffer exceeded the safe limit", false, "translation_buffer_limit"); + }, + dispose() { + releaseThinkingBuffer(open); + if (open?.callId) budget.closeCall(open.callId); + }, + }; +} + +/** Stream adapter events as Anthropic Messages SSE bytes. */ +export function encodeAnthropicMessageSse( + events: AsyncIterable, + options: AnthropicMessageEncodeOptions, +): ReadableStream { + return encodeAdapterEventStream( + events, + sink => createAnthropicMessageWriter(sink, options.model, options.inputTokenFloor), + { ...options, keepaliveMs: MESSAGES_KEEPALIVE_MS }, + ); +} + +/** + * Fold an encoded Messages stream into the non-streaming client response, with the status + * mapping the Messages ingress applied to its collected message: a translator overflow is 413, + * a stream that ended in an error frame is 502, anything else unexpected a 502 api_error. + */ +export async function collectAnthropicMessageResponse( + stream: ReadableStream, + model: string, + translatorBudget: TranslatorBudget, +): Promise { + let message: Rec; + try { + message = await collectAnthropicMessage(stream, model, translatorBudget); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) { + return anthropicErrorResponse(413, error.message, "request_too_large", error.code); + } + return anthropicErrorResponse(502, error instanceof Error ? error.message : String(error), "api_error"); + } + const isError = message.type === "error"; + const translatedError = isError && typeof message.error === "object" + ? (message as { error: { code?: unknown; message?: unknown } }).error + : undefined; + if (translatedError?.code === "translation_buffer_limit") { + return anthropicErrorResponse( + 413, + typeof translatedError.message === "string" + ? translatedError.message + : "upstream translation buffer exceeded the safe limit", + "request_too_large", + "translation_buffer_limit", + ); + } + return new Response(JSON.stringify(message), { + status: isError ? 502 : 200, + headers: { "Content-Type": "application/json" }, + }); +} + +/** Non-streaming client: the encoded stream folded exactly as the Messages collector folds it. */ +export function foldAnthropicMessage( + events: AsyncIterable, + options: AnthropicMessageEncodeOptions, +): Promise { + return collectAnthropicMessageResponse( + encodeAnthropicMessageSse(events, options), + options.model, + options.translatorBudget, + ); +} From f2c2d6f650720415e47f4f70329911e5fff7b58a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:48:56 +0900 Subject: [PATCH 078/173] feat(responses): add the clientEncoder option for direct Chat and Messages delivery The ingresses need a way to ask adapter delivery for their own wire. The option carries the protocol, the client's stream bit, the client-visible model and the Messages input floor; nothing sets or reads it yet. --- src/server/responses/core-options.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 5c8562baca7..fff50864da8 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -39,6 +39,18 @@ export interface ConsumedComboFailure { +/** + * Direct client encoding for a translated Chat or Messages turn (PF-09). `model` is the model + * string the client asked for, which every client frame carries; `inputTokenFloor` is the + * Messages prompt estimate `message_start` reports when no usage arrived first (#4857). + */ +export interface ClientEncoderOption { + protocol: "chat" | "messages"; + stream: boolean; + model: string; + inputTokenFloor?: number; +} + export interface HandleResponsesOptions { /** Internal Claude replay identity; consumed only by the final canonical Go transport. */ claudeGoAffinity?: { sessionLane?: string }; @@ -147,6 +159,14 @@ export interface HandleResponsesOptions { * rebuilds headers and carries the fact through this flag. */ visionDescribeTerminal?: boolean; + /** + * Set only by the Chat and Messages ingresses when `protocols.rollout.directEncoders` is on and + * the settled route is one non-Responses target. Adapter delivery then encodes the adapter + * events straight into the client's wire and returns a response marked with `markClientWire`. + * Passthrough, run-turn adapters, combo children and compaction turns ignore it and keep + * returning a Responses body, which the ingress converts as before. + */ + clientEncoder?: ClientEncoderOption; } /** Values shared by the call, not a bag of mutable pipeline state. */ From 99e0fa68e44c5a916913057fb5ef504110eb5b62 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:49:22 +0900 Subject: [PATCH 079/173] feat(inference): log client-wire responses from reported facts A body already in the Chat or Messages wire cannot be tapped as Responses SSE for the request log. The producer records the payloads and terminal the tap would have read, and responseWithDeferredRequestLog subscribes instead of reading the body; the row, status mapping and 499 cancel rule are unchanged. Unmarked responses take the old path. --- src/server/inference/client-wire-log.ts | 49 ++++++++++++++++++++++++ src/server/inference/client-wire.ts | 51 +++++++++++++++++++++++++ src/server/relay.ts | 9 +++++ 3 files changed, 109 insertions(+) create mode 100644 src/server/inference/client-wire-log.ts diff --git a/src/server/inference/client-wire-log.ts b/src/server/inference/client-wire-log.ts new file mode 100644 index 00000000000..a3a3d0062a8 --- /dev/null +++ b/src/server/inference/client-wire-log.ts @@ -0,0 +1,49 @@ +import { + addFinalRequestLog, + httpStatusForRequestLogTerminal, + inspectResponseLogSsePayloadParsed, + type RequestLogContext, + type RequestLogEntry, +} from "../request-log"; +import type { ClientWireLog } from "./client-wire"; + +/** + * The deferred request log of a client-wire response. It records what the Responses SSE tap + * (`trackSseForRequestLog`) records for a bridged body, from the facts the producer reports + * instead of from the body: the same payload inspection until the terminal, the same terminal + * transport phase and status mapping, and 499 for a client cancel before any terminal. The row + * is written once. + */ +export function recordClientWireRequestLog( + log: ClientWireLog, + requestId: string, + start: number, + logCtx: RequestLogContext, + addLog: (entry: RequestLogEntry) => void, +): void { + let logged = false; + const inspect = (payload: Record) => { + try { + inspectResponseLogSsePayloadParsed(logCtx, JSON.stringify(payload), payload); + } catch { /* request log metadata is best-effort */ } + }; + log.subscribe(event => { + if (logged) return; + if (event.kind === "observe") { + inspect(event.payload); + return; + } + logged = true; + if (event.kind === "cancel") { + addFinalRequestLog(requestId, start, logCtx, 499, { closeReason: "client_cancel" }, addLog); + return; + } + inspect(event.payload); + logCtx.transportPhase = "terminal_sse"; + logCtx.terminalSource = "upstream"; + addFinalRequestLog(requestId, start, logCtx, httpStatusForRequestLogTerminal(event.status, logCtx), { + terminalStatus: event.status, + closeReason: "terminal", + }, addLog); + }); +} diff --git a/src/server/inference/client-wire.ts b/src/server/inference/client-wire.ts index 8825ce7e055..1c0b17adcd9 100644 --- a/src/server/inference/client-wire.ts +++ b/src/server/inference/client-wire.ts @@ -16,3 +16,54 @@ export function markClientWire(response: Response, protocol: Protocol): Response export function clientWireOf(response: Response): Protocol | undefined { return clientWires.get(response); } + +/** What the request log of a client-wire response learns, in the order the stream learned it. */ +export type ClientWireLogEvent = + /** A Responses-vocabulary payload the legacy log tap would have inspected. */ + | { kind: "observe"; payload: Record } + | { kind: "terminal"; status: "completed" | "failed" | "incomplete"; payload: Record } + | { kind: "cancel" }; + +/** + * A client-wire body cannot be tapped for the request log the way a Responses body is: its + * frames are not Responses events. The producer records the facts the tap would have read, and + * the deferred request log subscribes. Events recorded before the subscription are replayed to + * it; the producer records at most a start payload, one terminal and one cancel. + */ +export interface ClientWireLog { + record(event: ClientWireLogEvent): void; + subscribe(listener: (event: ClientWireLogEvent) => void): void; +} + +export function createClientWireLog(): ClientWireLog { + let pending: ClientWireLogEvent[] | undefined = []; + let listener: ((event: ClientWireLogEvent) => void) | undefined; + const deliver = (event: ClientWireLogEvent) => { + try { listener?.(event); } catch { /* logging never throws into the stream */ } + }; + return { + record(event) { + if (pending) pending.push(event); + else deliver(event); + }, + subscribe(next) { + if (listener) return; + listener = next; + const buffered = pending ?? []; + pending = undefined; + for (const event of buffered) deliver(event); + }, + }; +} + +const clientWireLogs = new WeakMap(); + +/** Attach the log channel a client-wire response reports through. Returns the same response. */ +export function attachClientWireLog(response: Response, log: ClientWireLog): Response { + clientWireLogs.set(response, log); + return response; +} + +export function clientWireLogOf(response: Response): ClientWireLog | undefined { + return clientWireLogs.get(response); +} diff --git a/src/server/relay.ts b/src/server/relay.ts index 1bbb9fd72c7..afcafc66173 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -32,6 +32,8 @@ import { } from "./sse-frame-buffer"; import { replaceSseDataPayload, sseDataPayload } from "./sse-payload-rewrite"; import { createBoundedResponseLogBody } from "./response-log-body"; +import { clientWireLogOf } from "./inference/client-wire"; +import { recordClientWireRequestLog } from "./inference/client-wire-log"; const nativePassthroughSseResponses = new WeakSet(); const eagerRelaySseResponses = new WeakSet(); @@ -817,6 +819,13 @@ export function responseWithDeferredRequestLog( if (isNativePassthroughSseResponse(response)) { return response; } + // A body already in the client's wire is not Responses SSE or JSON; its producer reports the + // facts the tap below would read (PF-09 direct encoders). + const clientWireLog = clientWireLogOf(response); + if (clientWireLog) { + recordClientWireRequestLog(clientWireLog, requestId, start, logCtx, addLog); + return response; + } if (!response.body || !contentType.includes("text/event-stream")) { if (response.body && (contentType.includes("application/json") || response.status >= 400)) { const body = createBoundedResponseLogBody(response.body, { From cda345e79684b725671d322b8a1096d78e8af162 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:50:21 +0900 Subject: [PATCH 080/173] refactor(bridge): let a caller fold events without recording buffered delivery A direct client encoder counts the frames it relays and still folds the same events with buildResponseJSON for the completion effects. Recording that fold as a buffered delivery would count the turn twice. Default behavior is unchanged. --- src/bridge/response-json.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index ca9a7213d44..48fef166e08 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -49,7 +49,13 @@ import { bridgeToResponsesSSE } from "./sse"; export function buildResponseJSON( events: AdapterEvent[], modelId: string, - options?: Parameters[2], + options?: Parameters[2] & { + /** + * False when the body is not what the client receives: a direct client encoder counts its + * own relayed frames and folds the same events here only for the completion effects. + */ + recordBufferedDelivery?: boolean; + }, ): Record { // Default-budget safety net: a caller that omits the budget gets a bounded // default (disposed with the call), never the unbounded append path. @@ -58,7 +64,9 @@ export function buildResponseJSON( // A buffered turn delivers its whole answer as one body, so nothing calls the per-frame // recorder on the SSE bridge. Without this the attempt would persist adapter events with // zero relayed ones, which is the loss signal -- raised on every non-streaming request. - attemptDeliveryRecorder(options.translatorBudget)?.noteBufferedDelivery(body); + if (options.recordBufferedDelivery !== false) { + attemptDeliveryRecorder(options.translatorBudget)?.noteBufferedDelivery(body); + } return body; } const budget = createTranslatorBudget(); From ff3e3aeaa6634a1611331b7e2b81d89881321f52 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:51:42 +0900 Subject: [PATCH 081/173] feat(responses): deliver adapter events in the client's wire when asked With clientEncoder set, the streaming adapter branch encodes the guarded events for Chat or Messages instead of bridging them to Responses SSE. The events are also collected on the translator budget and folded with buildResponseJSON at the terminal, so replay caches, response state, completion notice and key usage run as the bridge ran them. Combo children, compaction turns and Responses-wire upstreams keep the Responses body. --- .../inference/client-encoder-delivery.ts | 197 ++++++++++++++++++ src/server/responses/adapter-delivery.ts | 59 ++++-- .../lib/reasoning-replay-scope-source.test.ts | 4 +- 3 files changed, 243 insertions(+), 17 deletions(-) create mode 100644 src/server/inference/client-encoder-delivery.ts diff --git a/src/server/inference/client-encoder-delivery.ts b/src/server/inference/client-encoder-delivery.ts new file mode 100644 index 00000000000..1edf4b978f2 --- /dev/null +++ b/src/server/inference/client-encoder-delivery.ts @@ -0,0 +1,197 @@ +/** + * Adapter delivery straight into a Chat or Messages client's wire (PF-09). + * + * The streaming adapter branch normally bridges the guarded event stream into Responses SSE and + * lets the ingress convert that into the client's wire. With a `clientEncoder` option the same + * events are encoded directly instead; everything the bridge did besides framing keeps happening: + * + * - the events are also collected, charged to the request's translator budget, and folded with + * `buildResponseJSON` at the terminal, so the replay caches, `rememberResponseState`, + * `notifyResponseComplete` and `commitReasoningReplayServingRoute` see a completed response + * exactly when the bridge's `onCompletedResponse` would have fired; + * - the key-usage binding receives the adapter usage under the bridge's rules; + * - the stream is tracked for turn lifetime and stops the upstream the same way; + * - the request log receives the facts its Responses SSE tap used to read (client-wire.ts). + */ +import type { AdapterEvent, OcxProviderContinuationState, OcxReasoningReplayScopeRef, OcxUsage } from "../../types"; +import type { AdmissionLease } from "../../lib/admission"; +import { + releaseTranslatedEvent, + retainTranslatedEvent, + type TranslatorBudget, +} from "../../lib/translator-budget"; +import { buildResponseJSON } from "../../bridge"; +import { responsesUsage, uuid } from "../../bridge/internal"; +import { awaitThoughtSignatureDurability } from "../../responses/thought-signature-replay"; +import { attemptDeliveryRecorder } from "../../usage/attempt-delivery"; +import { encodeChatCompletionSse, collectChatCompletionResponse } from "../../protocols/encoders/chat"; +import { encodeAnthropicMessageSse, collectAnthropicMessageResponse } from "../../protocols/encoders/messages"; +import type { ClientEncodeHooks, EncodedTerminal } from "../../protocols/encoders/adapter-events"; +import { upstreamWireForAdapter } from "../../protocols/contract"; +import { trackStreamLifetime } from "../lifecycle"; +import type { RequestLogContext } from "../request-log"; +import type { ClientEncoderOption, HandleResponsesOptions } from "../responses/core-options"; +import { attachClientWireLog, createClientWireLog, markClientWire } from "./client-wire"; + +const CLIENT_SSE_HEADERS = { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", +} as const; + +/** + * The encoder this delivery may use, or undefined to keep the Responses body. A combo child's + * body is read by the combo's commit logic as Responses SSE, a compaction turn must emit its + * synthetic item, and a Responses-wire upstream is the passthrough case this work leaves alone. + */ +export function clientEncoderForDelivery( + options: Pick, + routedCompaction: boolean, + adapterName: string, +): ClientEncoderOption | undefined { + const encoder = options.clientEncoder; + if (!encoder || options.comboAttempt || routedCompaction) return undefined; + if (upstreamWireForAdapter(adapterName) === "responses") return undefined; + return encoder; +} + +export interface ClientEncodedDelivery { + encoder: ClientEncoderOption; + events: AsyncIterable; + logCtx: RequestLogContext; + translatorBudget: TranslatorBudget; + /** Model on the folded response and the log payloads, as on the bridge's snapshots. */ + responseModelId: string; + adapterName: string; + fold: { + replayCacheScope?: OcxReasoningReplayScopeRef; + hideThinkingSummary?: boolean; + toolNsMap?: Map; + declaredToolNames?: ReadonlySet; + toolParameterSchemas?: ReadonlyMap>; + freeformToolNames?: Set; + toolSearchToolNames?: Set; + }; + stallTimeoutSec?: number; + turnAdmissionLease?: AdmissionLease; + onFirstOutput?: () => void; + /** The bridge's `onCancel`: stop completion notification and abort the upstream. */ + stopUpstream: () => void; + /** Turn-lifetime cleanup when the client body finishes or is cancelled. */ + onStreamDone: () => void; + /** The bridge's `onCompletedResponse`, fed the folded response. */ + onCompletedResponse: (response: Record, providerState?: OcxProviderContinuationState) => void; + /** The bridge's `onUsage`. */ + bindUsage: (usage: OcxUsage | undefined) => void; +} + +export async function deliverClientEncodedResponse(input: ClientEncodedDelivery): Promise { + const { encoder, logCtx, translatorBudget } = input; + const log = createClientWireLog(); + const responseId = `resp_${uuid()}`; + const createdAt = Math.floor(Date.now() / 1000); + const snapshot = (status: string): Record => ({ + id: responseId, object: "response", created_at: createdAt, + status, model: input.responseModelId, output: [], usage: null, + }); + log.record({ kind: "observe", payload: { type: "response.created", response: snapshot("in_progress") } }); + + // Collected copies: the adapter's own objects may already carry a lease from a buffered batch. + const collected: AdapterEvent[] = []; + let sealed = false; + const releaseCollected = () => { + sealed = true; + for (const event of collected) releaseTranslatedEvent(event, translatorBudget); + collected.length = 0; + }; + const events = (async function* (): AsyncGenerator { + for await (const event of input.events) { + if (!sealed) { + const copy = { ...event } as AdapterEvent; + retainTranslatedEvent(copy, translatorBudget, collected.at(-1)); + collected.push(copy); + } + yield event; + } + })(); + const fold = (): Record | undefined => { + if (sealed) return undefined; + sealed = true; + try { + return buildResponseJSON(collected, input.responseModelId, { + ...input.fold, + enforceDeclaredToolNames: false, + translatorBudget, + recordBufferedDelivery: false, + }); + } catch { + return undefined; + } finally { + releaseCollected(); + } + }; + + const recorder = attemptDeliveryRecorder(translatorBudget); + let completedResponseDelivered = false; + const terminalPayload = (terminal: EncodedTerminal): Record => ({ + type: terminal.status === "completed" ? "response.completed" + : terminal.status === "failed" ? "response.failed" + : "response.incomplete", + response: { + ...snapshot(terminal.status), + ...(terminal.endTurn !== undefined ? { end_turn: terminal.endTurn } : {}), + usage: terminal.usageOnWire ? responsesUsage(terminal.usage) : null, + ...(terminal.incomplete ? { incomplete_details: { ...terminal.incomplete } } : {}), + ...(terminal.error ? { error: terminal.error, last_error: terminal.error } : {}), + ...(terminal.retryable !== undefined ? { retryable: terminal.retryable } : {}), + }, + }); + const hooks: ClientEncodeHooks = { + ...(input.onFirstOutput ? { onFirstOutput: input.onFirstOutput } : {}), + async beforeTerminal(terminal) { + const response = terminal.overflow ? (releaseCollected(), undefined) : fold(); + if (terminal.durable) await awaitThoughtSignatureDurability(); + if (terminal.completedResponse && response && !completedResponseDelivered) { + completedResponseDelivered = true; + input.onCompletedResponse(response, terminal.providerState); + } + if (terminal.reportUsage) input.bindUsage(terminal.usage); + }, + afterTerminal(terminal) { + log.record({ kind: "terminal", status: terminal.status, payload: terminalPayload(terminal) }); + }, + stopUpstream: input.stopUpstream, + onClientCancel() { + releaseCollected(); + log.record({ kind: "cancel" }); + }, + onRelayed(observation) { + recorder?.noteRelayedEvent(observation); + }, + }; + const encodeOptions = { + model: encoder.model, + translatorBudget, + ...input.fold, + ...(input.stallTimeoutSec !== undefined ? { stallTimeoutSec: input.stallTimeoutSec } : {}), + hooks, + }; + const encoded = encoder.protocol === "chat" + ? encodeChatCompletionSse(events, encodeOptions) + : encodeAnthropicMessageSse(events, { + ...encodeOptions, + ...(encoder.inputTokenFloor !== undefined ? { inputTokenFloor: encoder.inputTokenFloor } : {}), + }); + const tracked = trackStreamLifetime(encoded, new AbortController(), input.onStreamDone, input.turnAdmissionLease); + + let response: Response; + if (encoder.stream) { + response = new Response(tracked, { status: 200, headers: CLIENT_SSE_HEADERS }); + } else { + // The routed turn always streams internally; a non-streaming client gets the fold. + response = encoder.protocol === "chat" + ? await collectChatCompletionResponse(tracked, encoder.model, translatorBudget) + : await collectAnthropicMessageResponse(tracked, encoder.model, translatorBudget); + } + return attachClientWireLog(markClientWire(response, encoder.protocol), log); +} diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index e0351128bd6..9ba2c7c6176 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -20,6 +20,7 @@ import { ResponseBodyInactivityError, } from "../../lib/response-body-inactivity"; import { resolveStallTimeoutSec } from "../../stall-timeout"; +import { clientEncoderForDelivery, deliverClientEncodedResponse } from "../inference/client-encoder-delivery"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ export async function deliverAdapterResponse( @@ -114,6 +115,47 @@ export async function deliverAdapterResponse( }) : eventStream; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; + // One completion owner for both deliveries: the bridge calls it from its terminal, the + // direct client encoder from the fold of the same events. + const onCompletedResponse = (response: Record, providerState?: OcxProviderContinuationState) => { + commitReasoningReplayServingRoute(); + rememberKiroDeliveredFinalAnswer(transportState.activeAdapter.name, response); + // Compaction turns must NOT enter the continuation cache: _rawBody still holds the full + // PRE-compaction history, and a later previous_response_id expansion would rehydrate the + // giant stale chain Codex just replaced. + if (!routedCompaction) { + rememberResponseState( + parsed._rawBody, + response, + continuationStateForResponse(providerState), + responseStateOptions(transportState.activeAdapter.name === "kiro"), + ); + } + notifyResponseComplete(response); + }; + const clientEncoder = clientEncoderForDelivery(options, !!routedCompaction, transportState.activeAdapter.name); + if (clientEncoder) { + return deliverClientEncodedResponse({ + encoder: clientEncoder, + events: guardedEventStream, + logCtx, + translatorBudget, + responseModelId: parsed._responseModelId ?? parsed.modelId, + adapterName: transportState.activeAdapter.name, + fold: { + replayCacheScope: parsed._reasoningReplayScope, + hideThinkingSummary: parsed.options.hideThinkingSummary, + toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames, + }, + stallTimeoutSec: config.stallTimeoutSec, + turnAdmissionLease: options.turnAdmissionLease, + ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), + stopUpstream: () => { cancelResponseCompletion(); upstream.abort(); }, + onStreamDone: cleanupUpstreamAbort, + onCompletedResponse, + bindUsage: usage => transportState.bindKeyUsageFromBridge(usage), + }); + } const sseStream = bridgeToResponsesSSE( guardedEventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => { cancelResponseCompletion(); upstream.abort(); }, 2_000, @@ -134,22 +176,7 @@ export async function deliverAdapterResponse( // Raw adapter usage, pre wire-normalization (see the runTurn branch above). transportState.bindKeyUsageFromBridge(usage); }, - onCompletedResponse: (response: Record, providerState?: OcxProviderContinuationState) => { - commitReasoningReplayServingRoute(); - rememberKiroDeliveredFinalAnswer(transportState.activeAdapter.name, response); - // Compaction turns must NOT enter the continuation cache: _rawBody still holds the full - // PRE-compaction history, and a later previous_response_id expansion would rehydrate the - // giant stale chain Codex just replaced. - if (!routedCompaction) { - rememberResponseState( - parsed._rawBody, - response, - continuationStateForResponse(providerState), - responseStateOptions(transportState.activeAdapter.name === "kiro"), - ); - } - notifyResponseComplete(response); - }, + onCompletedResponse, }, ); const bridgeTurnAc = new AbortController(); diff --git a/tests/lib/reasoning-replay-scope-source.test.ts b/tests/lib/reasoning-replay-scope-source.test.ts index a76b62daa3e..7d7d1937d8c 100644 --- a/tests/lib/reasoning-replay-scope-source.test.ts +++ b/tests/lib/reasoning-replay-scope-source.test.ts @@ -12,7 +12,9 @@ describe("reasoning replay scope propagation", () => { const core = readResponsesCoreSource(); const images = source("images/loop.ts"); const webSearch = source("web-search/loop.ts"); - expect(core.match(/replayCacheScope: parsed\._reasoningReplayScope,/g)).toHaveLength(4); + // Five: the fifth is the direct client encoder's fold in adapter-delivery.ts (PF-09), which + // runs the same buildResponseJSON replay-cache effects the bridge would have run. + expect(core.match(/replayCacheScope: parsed\._reasoningReplayScope,/g)).toHaveLength(5); expect(images.match(/replayCacheScope: parsed\._reasoningReplayScope,/g)).toHaveLength(1); expect(webSearch.match(/replayCacheScope: parsed\._reasoningReplayScope,/g)).toHaveLength(1); expect(`${core}\n${images}\n${webSearch}`).not.toContain("replayCacheScope: parsed._clientThreadId"); From 7013d8cc742bc120f32f7c249a5fe4109d6dc820 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:52:45 +0900 Subject: [PATCH 082/173] feat(ingress): request direct encoding and pass client-wire responses through With protocols.rollout.directEncoders on, the Chat and Messages ingresses ask for their own wire on a settled single non-Responses route. A response marked as already in that wire skips the Responses-to-client conversion and keeps the deferred request log. Delivery re-checks the final route, so policy and combo turns keep the bridged body. Off by default. --- src/server/chat-completions.ts | 9 +++++++ src/server/claude-messages.ts | 10 +++++++ .../inference/client-encoder-delivery.ts | 27 ++++++++++++++++--- src/server/responses/adapter-delivery.ts | 2 +- 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index e70b74a8f2f..e92579902cd 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -49,6 +49,8 @@ import { type RequestLogContext, } from "./request-log"; import { createFinalRequestLog } from "./inference/final-log"; +import { clientWireOf } from "./inference/client-wire"; +import { directEncodersApply } from "./inference/client-encoder-delivery"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; import { providerConsumesCallerAuthorization } from "../providers/caller-authorization"; @@ -434,7 +436,14 @@ async function handleChatCompletionsWithBudget( ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }), + ...(directEncodersApply(config, settledRoute) + ? { clientEncoder: { protocol: "chat" as const, stream, model: requestedModel } } + : {}), }); + // Already in the Chat wire (direct encoder): no conversion, still the deferred request log. + if (clientWireOf(upstream) === "chat") { + return logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream; + } // Rewrite non-2xx before deferred logging so /api/logs records the client-facing status // (e.g. cyber_policy remapped from a passthrough 5xx to HTTP 400). diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index cc979f0b7fb..996f67845ed 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -60,6 +60,9 @@ import { sessionLaneIdFromRequest, } from "./request-log-conversation"; import { responseWithDeferredRequestLog } from "./relay"; +import { clientWireOf } from "./inference/client-wire"; +import { directEncodersApply } from "./inference/client-encoder-delivery"; +import type { ClientEncoderOption } from "./responses/core-options"; import { handleResponses } from "./responses"; import { upstreamWireForAdapter } from "../protocols/contract"; import { createProtocolEnvelope, type ProtocolEnvelope } from "../protocols/envelope"; @@ -911,6 +914,7 @@ async function handleClaudeMessagesWithBudget( // the translated Anthropic SSE into a message JSON for non-streaming clients. internalBody.stream = true; + let clientEncoder: ClientEncoderOption | undefined; // Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens", // verified live 2026-07-11). Strip them for that route; routed providers keep them. @@ -932,6 +936,9 @@ async function handleClaudeMessagesWithBudget( route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy); logCtx.routeDecision = route.routeDecision; settledRoute = route; + if (directEncodersApply(config, route)) { + clientEncoder = { protocol: "messages", stream, model: requestedModel, inputTokenFloor: claudeRequestTokenFloor() }; + } if (route.provider.adapter === "openai-responses") { delete internalBody.max_output_tokens; delete internalBody.temperature; @@ -1083,8 +1090,11 @@ async function handleClaudeMessagesWithBudget( ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }), + ...(clientEncoder ? { clientEncoder } : {}), }); const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream; + // Already in the Messages wire (direct encoder): no conversion. + if (clientWireOf(upstream) === "messages") return response; if (!response.ok) { // Read the shared provenance verdict before consuming and re-wrapping the body. A refusal diff --git a/src/server/inference/client-encoder-delivery.ts b/src/server/inference/client-encoder-delivery.ts index 1edf4b978f2..b491c07c856 100644 --- a/src/server/inference/client-encoder-delivery.ts +++ b/src/server/inference/client-encoder-delivery.ts @@ -13,7 +13,7 @@ * - the stream is tracked for turn lifetime and stops the upstream the same way; * - the request log receives the facts its Responses SSE tap used to read (client-wire.ts). */ -import type { AdapterEvent, OcxProviderContinuationState, OcxReasoningReplayScopeRef, OcxUsage } from "../../types"; +import type { AdapterEvent, OcxConfig, OcxProviderContinuationState, OcxReasoningReplayScopeRef, OcxUsage } from "../../types"; import type { AdmissionLease } from "../../lib/admission"; import { releaseTranslatedEvent, @@ -28,6 +28,7 @@ import { encodeChatCompletionSse, collectChatCompletionResponse } from "../../pr import { encodeAnthropicMessageSse, collectAnthropicMessageResponse } from "../../protocols/encoders/messages"; import type { ClientEncodeHooks, EncodedTerminal } from "../../protocols/encoders/adapter-events"; import { upstreamWireForAdapter } from "../../protocols/contract"; +import { resolveProtocolSettings } from "../../protocols/settings"; import { trackStreamLifetime } from "../lifecycle"; import type { RequestLogContext } from "../request-log"; import type { ClientEncoderOption, HandleResponsesOptions } from "../responses/core-options"; @@ -40,17 +41,35 @@ const CLIENT_SSE_HEADERS = { } as const; /** - * The encoder this delivery may use, or undefined to keep the Responses body. A combo child's - * body is read by the combo's commit logic as Responses SSE, a compaction turn must emit its - * synthetic item, and a Responses-wire upstream is the passthrough case this work leaves alone. + * Whether a Chat or Messages ingress asks for direct encoding on the route it settled: the + * rollout switch is on and the route is one concrete non-Responses target. Combo and policy + * routes read or retry the Responses body after delivery, and a Responses-wire route is the + * passthrough case this work leaves alone. + */ +export function directEncodersApply( + config: Pick, + route: { combo?: unknown; routeKind?: string; provider: { adapter: string } } | null | undefined, +): boolean { + if (!route || !resolveProtocolSettings(config).rollout.directEncoders) return false; + if (route.combo !== undefined || route.routeKind === "policy") return false; + return upstreamWireForAdapter(route.provider.adapter) !== "responses"; +} + +/** + * The encoder this delivery may use, or undefined to keep the Responses body. The final route + * is re-checked because core can still change it after the ingress decided: a combo child's + * body is read by the combo's commit logic, a policy route may hop on an error body, a + * compaction turn must emit its synthetic item, and a Responses-wire upstream stays as it is. */ export function clientEncoderForDelivery( options: Pick, + logCtx: Pick, routedCompaction: boolean, adapterName: string, ): ClientEncoderOption | undefined { const encoder = options.clientEncoder; if (!encoder || options.comboAttempt || routedCompaction) return undefined; + if (logCtx.routeDecision?.routeKind === "policy" || logCtx.routeDecision?.routeKind === "combo") return undefined; if (upstreamWireForAdapter(adapterName) === "responses") return undefined; return encoder; } diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 9ba2c7c6176..31d8b6ef618 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -133,7 +133,7 @@ export async function deliverAdapterResponse( } notifyResponseComplete(response); }; - const clientEncoder = clientEncoderForDelivery(options, !!routedCompaction, transportState.activeAdapter.name); + const clientEncoder = clientEncoderForDelivery(options, logCtx, !!routedCompaction, transportState.activeAdapter.name); if (clientEncoder) { return deliverClientEncodedResponse({ encoder: clientEncoder, From 3df55844e821abaf88200f44642c380a5d1b1811 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:52:59 +0900 Subject: [PATCH 083/173] feat(protocols): trace directly encoded attempts as upstream-ir-client responses An attempt delivered through a direct encoder no longer returns through the internal Responses hop, so its response path becomes [upstream, ir, client]. The request still travels the bridge until the codecs decode to IR, and the mode and request path say so. --- .../inference/client-encoder-delivery.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/server/inference/client-encoder-delivery.ts b/src/server/inference/client-encoder-delivery.ts index b491c07c856..059d0a879ea 100644 --- a/src/server/inference/client-encoder-delivery.ts +++ b/src/server/inference/client-encoder-delivery.ts @@ -28,6 +28,8 @@ import { encodeChatCompletionSse, collectChatCompletionResponse } from "../../pr import { encodeAnthropicMessageSse, collectAnthropicMessageResponse } from "../../protocols/encoders/messages"; import type { ClientEncodeHooks, EncodedTerminal } from "../../protocols/encoders/adapter-events"; import { upstreamWireForAdapter } from "../../protocols/contract"; +import { markAttemptProtocolPath } from "../../protocols/trace"; +import { deliveryModeForLane, requestPathForLane } from "../../protocols/path"; import { resolveProtocolSettings } from "../../protocols/settings"; import { trackStreamLifetime } from "../lifecycle"; import type { RequestLogContext } from "../request-log"; @@ -104,8 +106,25 @@ export interface ClientEncodedDelivery { bindUsage: (usage: OcxUsage | undefined) => void; } +/** + * Record the attempt's observed path: the request still goes through the internal Responses + * bridge until the codecs decode to IR directly, so the request path and mode stay the bridge's; + * the response now reaches the client from the IR without the internal Responses hop. + */ +function markDirectEncoderPath(logCtx: RequestLogContext, encoder: ClientEncoderOption, adapter: string): void { + const attempt = logCtx.activeAttempt; + if (!attempt) return; + const upstream = upstreamWireForAdapter(attempt.adapter || adapter); + markAttemptProtocolPath(attempt, { + mode: deliveryModeForLane(encoder.protocol, "bridge", upstream), + requestPath: requestPathForLane(encoder.protocol, "bridge", upstream), + responsePath: [upstream, "ir", encoder.protocol], + }); +} + export async function deliverClientEncodedResponse(input: ClientEncodedDelivery): Promise { const { encoder, logCtx, translatorBudget } = input; + markDirectEncoderPath(logCtx, encoder, input.adapterName); const log = createClientWireLog(); const responseId = `resp_${uuid()}`; const createdAt = Math.floor(Date.now() / 1000); From 8183a2bb04ced88e91b853932083bf834b1c9eea Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:58:46 +0900 Subject: [PATCH 084/173] test(protocols): pin direct encoder parity with the bridge and converters Golden parity: each event sequence goes through the Responses bridge plus the existing converter and through the direct encoder, streamed and folded, and must reach the client unchanged apart from ids and timestamps. Delivery tests cover the gates, the client-wire request log, the completion effects and the traced response path. --- scripts/test-layout/layout.json | 3 + tests/fixtures/test-layout-expected.json | 3 + .../protocol-direct-encoders-chat.test.ts | 386 ++++++++++++++++++ .../protocol-direct-encoders-messages.test.ts | 319 +++++++++++++++ .../inference-client-encoder-delivery.test.ts | 187 +++++++++ 5 files changed, 898 insertions(+) create mode 100644 tests/responses/protocol-direct-encoders-chat.test.ts create mode 100644 tests/responses/protocol-direct-encoders-messages.test.ts create mode 100644 tests/server/inference-client-encoder-delivery.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d1b9972d56c..ee9bb48f2e7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -196,6 +196,7 @@ "inference-attempt.test.ts": "server", "inference-final-log.test.ts": "server", "inference-send-budget.test.ts": "server", + "inference-client-encoder-delivery.test.ts": "server", "adapter-tool-conformance.test.ts": "adapters", "adapter-usage.test.ts": "adapters", "agent-driven.test.ts": "cli", @@ -344,6 +345,8 @@ "protocol-envelope.test.ts": "responses", "protocol-guard.test.ts": "responses", "protocol-ingress-guard.test.ts": "responses", + "protocol-direct-encoders-chat.test.ts": "responses", + "protocol-direct-encoders-messages.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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 29f4acb2995..387c7cb41d7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -22,6 +22,7 @@ "inference-attempt.test.ts": "server", "inference-final-log.test.ts": "server", "inference-send-budget.test.ts": "server", + "inference-client-encoder-delivery.test.ts": "server", "adapter-tool-conformance.test.ts": "adapters", "adapter-usage.test.ts": "adapters", "agent-driven.test.ts": "cli", @@ -170,6 +171,8 @@ "protocol-envelope.test.ts": "responses", "protocol-guard.test.ts": "responses", "protocol-ingress-guard.test.ts": "responses", + "protocol-direct-encoders-chat.test.ts": "responses", + "protocol-direct-encoders-messages.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-direct-encoders-chat.test.ts b/tests/responses/protocol-direct-encoders-chat.test.ts new file mode 100644 index 00000000000..c1334980940 --- /dev/null +++ b/tests/responses/protocol-direct-encoders-chat.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import { + chatCompletionsErrorResponse, + collectChatCompletion, + isChatCompletionsStreamError, + responsesSseToChatCompletionsSse, +} from "../../src/chat/outbound"; +import { encodeChatCompletionSse, foldChatCompletion } from "../../src/protocols/encoders/chat"; +import type { AdapterEvent } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +/** + * PF-09 golden parity: one AdapterEvent sequence through (a) the Responses bridge plus the + * Responses-to-Chat converter and (b) the direct Chat encoder must reach the client as the same + * frames. Only the completion id and the creation second are normalized. + */ + +interface ToolOptions { + hideThinkingSummary?: boolean; + declaredToolNames?: Set; + freeformToolNames?: Set; + toolParameterSchemas?: Map>; +} + +async function* replay(events: AdapterEvent[]): AsyncGenerator { + for (const event of events) yield event; +} + +function legacyChatStream(events: AdapterEvent[], options: ToolOptions = {}) { + const translatorBudget = createTestTranslatorBudget(); + const responses = bridgeToResponsesSSE( + replay(events), "internal/model", undefined, options.freeformToolNames, undefined, undefined, 2_000, + { + translatorBudget, + ...(options.hideThinkingSummary ? { hideThinkingSummary: true } : {}), + ...(options.declaredToolNames ? { declaredToolNames: options.declaredToolNames } : {}), + ...(options.toolParameterSchemas ? { toolParameterSchemas: options.toolParameterSchemas } : {}), + // The Chat inbound wire never enforces the declared catalog (#4735). + enforceDeclaredToolNames: false, + }, + ); + return { stream: responsesSseToChatCompletionsSse(responses, "client-model", { translatorBudget }), translatorBudget }; +} + +function directOptions(options: ToolOptions = {}) { + return { + model: "client-model", + translatorBudget: createTestTranslatorBudget(), + ...(options.hideThinkingSummary ? { hideThinkingSummary: true } : {}), + ...(options.declaredToolNames ? { declaredToolNames: options.declaredToolNames } : {}), + ...(options.freeformToolNames ? { freeformToolNames: options.freeformToolNames } : {}), + ...(options.toolParameterSchemas ? { toolParameterSchemas: options.toolParameterSchemas } : {}), + }; +} + +function normalizeFrames(text: string): unknown[] { + return text.split("\n\n").filter(block => block.trim().length > 0).map(block => { + const data = block.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5).trim()).join(""); + if (data === "[DONE]") return "[DONE]"; + const parsed = JSON.parse(data) as Record; + if ("id" in parsed) parsed.id = "ID"; + if ("created" in parsed) parsed.created = 0; + return parsed; + }); +} + +async function expectStreamParity(events: AdapterEvent[], options: ToolOptions = {}): Promise { + const legacy = normalizeFrames(await new Response(legacyChatStream(events, options).stream).text()); + const direct = normalizeFrames(await new Response(encodeChatCompletionSse(replay(events), directOptions(options))).text()); + expect(direct).toEqual(legacy); + return direct; +} + +/** The Chat ingress's non-stream mapping over the legacy collector. */ +async function legacyFold(events: AdapterEvent[], options: ToolOptions = {}): Promise { + const { stream, translatorBudget } = legacyChatStream(events, options); + try { + const completion = await collectChatCompletion(stream, "client-model", translatorBudget); + return new Response(JSON.stringify(completion), { status: 200, headers: { "Content-Type": "application/json" } }); + } catch (err) { + if (isChatCompletionsStreamError(err)) return chatCompletionsErrorResponse(err.status, err.message, err.type, err.code); + return chatCompletionsErrorResponse(502, err instanceof Error ? err.message : String(err), "server_error"); + } +} + +async function normalizedJson(response: Response): Promise<{ status: number; body: Record }> { + const body = await response.json() as Record; + if ("id" in body) body.id = "ID"; + if ("created" in body) body.created = 0; + return { status: response.status, body }; +} + +async function expectFoldParity(events: AdapterEvent[], options: ToolOptions = {}) { + const legacy = await normalizedJson(await legacyFold(events, options)); + const direct = await normalizedJson(await foldChatCompletion(replay(events), directOptions(options))); + expect(direct).toEqual(legacy); + return direct; +} + +const usage = { + inputTokens: 120, outputTokens: 30, cachedInputTokens: 40, cacheCreationInputTokens: 8, reasoningOutputTokens: 6, +}; + +const SCENARIOS: Record = { + "text with usage": { + events: [ + { type: "text_delta", text: "Hello" }, + { type: "heartbeat" }, + { type: "text_delta", text: ", world" }, + { type: "done", usage }, + ], + }, + "citation markers are stripped across delta boundaries": { + events: [ + { type: "text_delta", text: "See citeturn1" }, + { type: "text_delta", text: "view0 for details" }, + { type: "done" }, + ], + }, + "tool call with streamed arguments": { + events: [ + { type: "text_delta", text: "Checking." }, + { type: "tool_call_start", id: "call_1", name: "lookup" }, + { type: "tool_call_delta", arguments: "{\"q\":" }, + { type: "tool_call_delta", arguments: "\"weather\"}" }, + { type: "tool_call_end" }, + { type: "done", usage }, + ], + }, + "parallel tool calls keep stable indexes": { + events: [ + { type: "tool_call_start", id: "call_a", name: "alpha" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "call_b", name: "beta" }, + { type: "tool_call_delta", arguments: "{\"x\":1}" }, + { type: "tool_call_end" }, + { type: "done" }, + ], + }, + "no-argument tool call serializes as {}": { + events: [ + { type: "tool_call_start", id: "call_empty", name: "list_apps" }, + { type: "tool_call_end" }, + { type: "done" }, + ], + }, + "integral float arguments are repaired against the schema": { + events: [ + { type: "tool_call_start", id: "call_int", name: "sleep" }, + { type: "tool_call_delta", arguments: "{\"ms\":120000.0}" }, + { type: "tool_call_end" }, + { type: "done" }, + ], + options: { + toolParameterSchemas: new Map([["sleep", { type: "object", properties: { ms: { type: "integer" } } }]]), + }, + }, + "summary and raw reasoning become reasoning_content": { + events: [ + { type: "thinking_delta", thinking: "Think " }, + { type: "thinking_delta", thinking: "harder." }, + { type: "thinking_signature", signature: "sig-1" }, + { type: "reasoning_raw_delta", text: "raw notes" }, + { type: "text_delta", text: "Answer" }, + { type: "done" }, + ], + }, + "hidden thinking produces no reasoning_content": { + events: [ + { type: "thinking_delta", thinking: "secret" }, + { type: "thinking_signature", signature: "sig-2" }, + { type: "reasoning_raw_delta", text: "hidden raw" }, + { type: "text_delta", text: "Visible" }, + { type: "done" }, + ], + options: { hideThinkingSummary: true }, + }, + "freeform tool calls have no Chat representation": { + events: [ + { type: "tool_call_start", id: "call_patch", name: "apply_patch" }, + { type: "tool_call_delta", arguments: "*** Begin Patch\n*** End Patch" }, + { type: "tool_call_end" }, + { type: "done" }, + ], + options: { freeformToolNames: new Set(["apply_patch"]) }, + }, + "length stop finishes with length": { + events: [ + { type: "text_delta", text: "cut" }, + { type: "done", stopReason: "max_tokens", usage }, + ], + }, + "content filter incomplete finishes with content_filter": { + events: [ + { type: "text_delta", text: "partial" }, + { type: "incomplete", reason: "content_filter", usage }, + ], + }, + "other incomplete reasons fail without [DONE]": { + events: [ + { type: "text_delta", text: "partial" }, + { type: "incomplete", reason: "upstream_disconnect", message: "socket reset" }, + ], + }, + "mid-stream error frame": { + events: [ + { type: "text_delta", text: "partial" }, + { type: "error", message: "rate limited by provider", status: 429, errorType: "rate_limit_error" }, + ], + }, + "error before any output": { + events: [ + { type: "error", message: "invalid api key", status: 401 }, + ], + }, + "cyber policy error keeps its code": { + events: [ + { type: "error", message: "blocked", status: 400, code: "cyber_policy" }, + ], + }, + "translation buffer overflow": { + events: [ + { type: "text_delta", text: "x" }, + { type: "error", message: "too big", code: "translation_buffer_limit" }, + ], + }, + "malformed tool arguments fail the turn": { + events: [ + { type: "tool_call_start", id: "call_bad", name: "lookup" }, + { type: "tool_call_delta", arguments: "{\"q\":" }, + { type: "tool_call_end" }, + { type: "done" }, + ], + }, + "open tool call at an error is delivered incomplete": { + events: [ + { type: "tool_call_start", id: "call_open", name: "lookup" }, + { type: "tool_call_delta", arguments: "{\"q\":1}" }, + { type: "error", message: "upstream exploded", status: 502 }, + ], + }, + "adapter EOF without a terminal": { + events: [ + { type: "text_delta", text: "dangling" }, + ], + }, + "web search activity is invisible to Chat": { + events: [ + { type: "web_search_call_begin", id: "ws1" }, + { type: "web_search_call_end", id: "ws1", queries: ["bun"], sources: [{ url: "https://bun.sh", title: "Bun" }] }, + { type: "text_delta", text: "Found it" }, + { type: "done" }, + ], + }, + "assistant boundary splits messages": { + events: [ + { type: "text_delta", text: "first" }, + { type: "assistant_boundary" }, + { type: "text_delta", text: "second" }, + { type: "done", endTurn: false }, + ], + }, +}; + +describe("direct Chat encoder matches bridge + converter (stream)", () => { + for (const [name, scenario] of Object.entries(SCENARIOS)) { + test(name, async () => { + await expectStreamParity(scenario.events, scenario.options); + }); + } + + test("frame shape: one role frame, stable tool index, usage on the finish chunk", async () => { + const frames = await expectStreamParity(SCENARIOS["tool call with streamed arguments"]!.events); + const roles = frames.filter(frame => JSON.stringify(frame).includes("\"role\":\"assistant\"")); + expect(roles).toHaveLength(1); + const toolFrames = frames.filter(frame => JSON.stringify(frame).includes("tool_calls")); + expect(toolFrames).toHaveLength(1); + expect(JSON.stringify(toolFrames[0])).toContain("\"arguments\":\"{\\\"q\\\":\\\"weather\\\"}\""); + const finish = frames.at(-2) as { choices: { finish_reason: string }[]; usage: Record }; + expect(finish.choices[0]!.finish_reason).toBe("tool_calls"); + expect(finish.usage.prompt_tokens).toBe(120); + expect(frames.at(-1)).toBe("[DONE]"); + }); + + test("a failure ends with an error frame and no [DONE]", async () => { + const frames = await expectStreamParity(SCENARIOS["mid-stream error frame"]!.events); + expect(frames).not.toContain("[DONE]"); + expect(frames.at(-1)).toHaveProperty("error"); + }); +}); + +describe("direct Chat fold matches bridge + collector (non-stream)", () => { + for (const [name, scenario] of Object.entries(SCENARIOS)) { + test(name, async () => { + await expectFoldParity(scenario.events, scenario.options); + }); + } + + test("status mapping: a length stop is a 200 completion, a stream failure is an error status", async () => { + const length = await expectFoldParity(SCENARIOS["length stop finishes with length"]!.events); + expect(length.status).toBe(200); + expect((length.body.choices as { finish_reason: string }[])[0]!.finish_reason).toBe("length"); + expect((await expectFoldParity(SCENARIOS["mid-stream error frame"]!.events)).status).toBeGreaterThanOrEqual(400); + }); +}); + +describe("direct Chat encoder stream lifecycle", () => { + test("client cancel stops the upstream once and never reports a terminal", async () => { + let stopped = 0; + let terminals = 0; + let cancelled = 0; + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + async function* slow(): AsyncGenerator { + yield { type: "text_delta", text: "first" }; + await gate; + yield { type: "done" }; + } + const stream = encodeChatCompletionSse(slow(), { + ...directOptions(), + hooks: { + stopUpstream: () => { stopped++; }, + afterTerminal: () => { terminals++; }, + onClientCancel: () => { cancelled++; }, + }, + }); + const reader = stream.getReader(); + await reader.read(); + await reader.read(); + await reader.cancel(); + release?.(); + expect(stopped).toBe(1); + expect(cancelled).toBe(1); + expect(terminals).toBe(0); + }); + + test("hooks: first output once, completed response flagged, terminal once", async () => { + const seen: string[] = []; + await new Response(encodeChatCompletionSse(replay(SCENARIOS["text with usage"]!.events), { + ...directOptions(), + hooks: { + onFirstOutput: () => { seen.push("first"); }, + beforeTerminal: terminal => { seen.push(`before:${terminal.status}:${terminal.completedResponse}:${terminal.reportUsage}`); }, + afterTerminal: terminal => { seen.push(`after:${terminal.status}`); }, + stopUpstream: () => { seen.push("stop"); }, + }, + })).text(); + expect(seen).toEqual(["first", "before:completed:true:true", "after:completed", "stop"]); + }); + + test("stall watchdog fails the turn like the bridge", async () => { + const ticks: (() => void)[] = []; + const timers = { + setInterval: (handler: () => void) => { ticks.push(handler); return ticks.length - 1; }, + clearInterval: () => {}, + }; + async function* hang(): AsyncGenerator { + yield { type: "text_delta", text: "waiting" }; + await new Promise(() => {}); + } + const legacyBudget = createTestTranslatorBudget(); + const legacy = responsesSseToChatCompletionsSse( + bridgeToResponsesSSE(hang(), "internal/model", undefined, undefined, undefined, undefined, 1_000, { + translatorBudget: legacyBudget, stallTimeoutSec: 2, enforceDeclaredToolNames: false, timers, + }), + "client-model", + { translatorBudget: legacyBudget }, + ); + const legacyText = new Response(legacy).text(); + await Bun.sleep(5); + for (let i = 0; i < 3; i++) for (const tick of ticks) tick(); + const legacyFrames = normalizeFrames(await legacyText); + + ticks.length = 0; + const direct = encodeChatCompletionSse(hang(), { ...directOptions(), heartbeatMs: 1_000, stallTimeoutSec: 2, timers }); + const directText = new Response(direct).text(); + await Bun.sleep(5); + for (let i = 0; i < 3; i++) for (const tick of ticks) tick(); + const directFrames = normalizeFrames(await directText); + + expect(directFrames).toEqual(legacyFrames); + expect(JSON.stringify(directFrames.at(-1))).toContain("upstream_stall_timeout"); + }); +}); diff --git a/tests/responses/protocol-direct-encoders-messages.test.ts b/tests/responses/protocol-direct-encoders-messages.test.ts new file mode 100644 index 00000000000..1f62ecebe31 --- /dev/null +++ b/tests/responses/protocol-direct-encoders-messages.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import { + anthropicErrorResponse, + collectAnthropicMessage, + responsesSseToAnthropicSse, +} from "../../src/claude/outbound"; +import { isTranslatorBudgetExceededError } from "../../src/lib/translator-budget"; +import { encodeAnthropicMessageSse, foldAnthropicMessage } from "../../src/protocols/encoders/messages"; +import type { AdapterEvent } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +/** + * PF-09 golden parity: one AdapterEvent sequence through (a) the Responses bridge plus the + * Responses-to-Anthropic converter and (b) the direct Messages encoder must reach the client as + * the same events. Only the message id and the server-side search ids are normalized. + */ + +interface ToolOptions { + hideThinkingSummary?: boolean; + toolParameterSchemas?: Map>; +} + +const INPUT_FLOOR = 42; + +async function* replay(events: AdapterEvent[]): AsyncGenerator { + for (const event of events) yield event; +} + +function legacyMessagesStream(events: AdapterEvent[], options: ToolOptions = {}) { + const translatorBudget = createTestTranslatorBudget(); + const responses = bridgeToResponsesSSE( + replay(events), "internal/model", undefined, undefined, undefined, undefined, 2_000, + { + translatorBudget, + ...(options.hideThinkingSummary ? { hideThinkingSummary: true } : {}), + ...(options.toolParameterSchemas ? { toolParameterSchemas: options.toolParameterSchemas } : {}), + // The Anthropic inbound wire never enforces the declared catalog (#4735). + enforceDeclaredToolNames: false, + }, + ); + return { + stream: responsesSseToAnthropicSse(responses, "client-model", { + translatorBudget, inputTokenFloor: INPUT_FLOOR, pingIntervalMs: 0, + }), + translatorBudget, + }; +} + +function directOptions(options: ToolOptions = {}) { + return { + model: "client-model", + inputTokenFloor: INPUT_FLOOR, + translatorBudget: createTestTranslatorBudget(), + ...(options.hideThinkingSummary ? { hideThinkingSummary: true } : {}), + ...(options.toolParameterSchemas ? { toolParameterSchemas: options.toolParameterSchemas } : {}), + }; +} + +/** Replace generated ids with stable placeholders, numbered by first appearance. */ +function normalizeIds(value: unknown, ids: Map): unknown { + if (typeof value === "string") { + if (/^(ws|msg)_[0-9a-f]{32}$/.test(value)) { + if (!ids.has(value)) ids.set(value, `${value.slice(0, value.indexOf("_"))}#${ids.size}`); + return ids.get(value); + } + return value; + } + if (Array.isArray(value)) return value.map(entry => normalizeIds(entry, ids)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, normalizeIds(entry, ids)])); + } + return value; +} + +function normalizeEvents(text: string): unknown[] { + const ids = new Map(); + return text.split("\n\n").filter(block => block.trim().length > 0).map(block => { + const lines = block.split("\n"); + const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim(); + const data = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trim()).join(""); + return { event, data: normalizeIds(JSON.parse(data), ids) }; + }); +} + +async function expectStreamParity(events: AdapterEvent[], options: ToolOptions = {}): Promise<{ event?: string; data: any }[]> { + const legacy = normalizeEvents(await new Response(legacyMessagesStream(events, options).stream).text()); + const direct = normalizeEvents(await new Response(encodeAnthropicMessageSse(replay(events), directOptions(options))).text()); + expect(direct).toEqual(legacy); + return direct as { event?: string; data: any }[]; +} + +/** The Messages ingress's non-stream mapping over the legacy collector. */ +async function legacyFold(events: AdapterEvent[], options: ToolOptions = {}): Promise { + const { stream, translatorBudget } = legacyMessagesStream(events, options); + let message: Record; + try { + message = await collectAnthropicMessage(stream, "client-model", translatorBudget); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) return anthropicErrorResponse(413, error.message, "request_too_large", error.code); + return anthropicErrorResponse(502, error instanceof Error ? error.message : String(error), "api_error"); + } + const isError = message.type === "error"; + const translatedError = isError && typeof message.error === "object" + ? (message as { error: { code?: unknown; message?: unknown } }).error + : undefined; + if (translatedError?.code === "translation_buffer_limit") { + return anthropicErrorResponse( + 413, + typeof translatedError.message === "string" ? translatedError.message : "upstream translation buffer exceeded the safe limit", + "request_too_large", + "translation_buffer_limit", + ); + } + return new Response(JSON.stringify(message), { status: isError ? 502 : 200, headers: { "Content-Type": "application/json" } }); +} + +async function expectFoldParity(events: AdapterEvent[], options: ToolOptions = {}) { + const normalize = async (response: Response) => ({ + status: response.status, + body: normalizeIds(await response.json(), new Map()) as Record, + }); + const legacy = await normalize(await legacyFold(events, options)); + const direct = await normalize(await foldAnthropicMessage(replay(events), directOptions(options))); + expect(direct).toEqual(legacy); + return direct; +} + +const usage = { + inputTokens: 120, outputTokens: 30, cachedInputTokens: 40, cacheCreationInputTokens: 8, reasoningOutputTokens: 6, +}; + +const SCENARIOS: Record = { + "text with cache-aware usage": { + events: [ + { type: "text_delta", text: "Hello" }, + { type: "heartbeat" }, + { type: "text_delta", text: ", world" }, + { type: "done", usage }, + ], + }, + "tool call streams input_json_delta": { + events: [ + { type: "text_delta", text: "Checking." }, + { type: "tool_call_start", id: "toolu_1", name: "lookup" }, + { type: "tool_call_delta", arguments: "{\"q\":" }, + { type: "tool_call_delta", arguments: "\"weather\"}" }, + { type: "tool_call_end" }, + { type: "done", usage }, + ], + }, + "no-argument tool call": { + events: [ + { type: "tool_call_start", id: "toolu_empty", name: "list_apps" }, + { type: "tool_call_end" }, + { type: "done" }, + ], + }, + "parallel tool calls get their own blocks": { + events: [ + { type: "tool_call_start", id: "toolu_a", name: "alpha" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "toolu_b", name: "beta" }, + { type: "tool_call_delta", arguments: "{\"x\":1.0}" }, + { type: "tool_call_end" }, + { type: "done" }, + ], + options: { toolParameterSchemas: new Map([["beta", { type: "object", properties: { x: { type: "integer" } } }]]) }, + }, + "WebSearch client tool input is buffered and sanitized": { + events: [ + { type: "tool_call_start", id: "toolu_ws", name: "WebSearch" }, + { type: "tool_call_delta", arguments: "{\"query\":\"bun\",\"allowed_domains\":[]," }, + { type: "tool_call_delta", arguments: "\"blocked_domains\":[\"a.com\"]}" }, + { type: "tool_call_end" }, + { type: "done" }, + ], + }, + "signed thinking block precedes the answer": { + events: [ + { type: "thinking_delta", thinking: "Think " }, + { type: "thinking_delta", thinking: "harder." }, + { type: "thinking_signature", signature: "sig-1" }, + { type: "text_delta", text: "Answer" }, + { type: "done" }, + ], + }, + "raw reasoning gets the ocxr1 fallback signature": { + events: [ + { type: "reasoning_raw_delta", text: "raw notes" }, + { type: "tool_call_start", id: "toolu_r", name: "lookup" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done" }, + ], + }, + "redacted thinking is its own block": { + events: [ + { type: "thinking_delta", thinking: "visible" }, + { type: "redacted_thinking", data: "opaque-blob" }, + { type: "text_delta", text: "After" }, + { type: "done" }, + ], + }, + "hidden thinking still returns its signature": { + events: [ + { type: "thinking_delta", thinking: "secret" }, + { type: "thinking_signature", signature: "sig-2" }, + { type: "text_delta", text: "Visible" }, + { type: "done" }, + ], + options: { hideThinkingSummary: true }, + }, + "server-side web search pair": { + events: [ + { type: "web_search_call_begin", id: "ws1" }, + { type: "web_search_call_end", id: "ws1", queries: ["bun"], sources: [{ url: "https://bun.sh", title: "Bun" }] }, + { type: "text_delta", text: "Found it" }, + { type: "done", usage }, + ], + }, + "failed server-side search": { + events: [ + { type: "web_search_call_begin", id: "ws2" }, + { type: "web_search_call_end", id: "ws2", queries: ["a", "b"], status: "failed" }, + { type: "done" }, + ], + }, + "length stop becomes max_tokens": { + events: [ + { type: "text_delta", text: "cut" }, + { type: "done", stopReason: "max_tokens", usage }, + ], + }, + "content filter becomes refusal": { + events: [ + { type: "text_delta", text: "partial" }, + { type: "incomplete", reason: "content_filter", usage }, + ], + }, + "other incomplete reasons are a retryable overload": { + events: [ + { type: "text_delta", text: "partial" }, + { type: "incomplete", reason: "upstream_disconnect" }, + ], + }, + "turn ended without a final answer": { + events: [ + { type: "text_delta", text: "commentary" }, + { type: "done", endTurn: false }, + ], + }, + "error before output has no message_start": { + events: [ + { type: "error", message: "invalid api key", status: 401 }, + ], + }, + "mid-stream error closes the open block first": { + events: [ + { type: "text_delta", text: "partial" }, + { type: "error", message: "rate limited by provider", status: 429, errorType: "rate_limit_error" }, + ], + }, + "translation buffer overflow": { + events: [ + { type: "text_delta", text: "x" }, + { type: "error", message: "too big", code: "translation_buffer_limit" }, + ], + }, + "malformed tool arguments": { + events: [ + { type: "tool_call_start", id: "toolu_bad", name: "lookup" }, + { type: "tool_call_delta", arguments: "{\"q\":" }, + { type: "tool_call_end" }, + { type: "done" }, + ], + }, + "adapter EOF without a terminal": { + events: [ + { type: "text_delta", text: "dangling" }, + ], + }, +}; + +describe("direct Messages encoder matches bridge + converter (stream)", () => { + for (const [name, scenario] of Object.entries(SCENARIOS)) { + test(name, async () => { + await expectStreamParity(scenario.events, scenario.options); + }); + } + + test("frame shape: message_start with the input floor, then ping, message_stop last", async () => { + const frames = await expectStreamParity(SCENARIOS["tool call streams input_json_delta"]!.events); + expect(frames[0]!.event).toBe("message_start"); + expect(frames[0]!.data.message.usage).toEqual({ input_tokens: INPUT_FLOOR, output_tokens: 0 }); + expect(frames[1]!.event).toBe("ping"); + expect(frames.at(-2)!.data.delta.stop_reason).toBe("tool_use"); + expect(frames.at(-1)!.event).toBe("message_stop"); + }); + + test("an initial failure is an error stream without message_start", async () => { + const frames = await expectStreamParity(SCENARIOS["error before output has no message_start"]!.events); + expect(frames.map(frame => frame.event)).toEqual(["error"]); + }); +}); + +describe("direct Messages fold matches bridge + collector (non-stream)", () => { + for (const [name, scenario] of Object.entries(SCENARIOS)) { + test(name, async () => { + await expectFoldParity(scenario.events, scenario.options); + }); + } + + test("status mapping: overflow is 413, a stream error is 502", async () => { + expect((await expectFoldParity(SCENARIOS["translation buffer overflow"]!.events)).status).toBe(413); + expect((await expectFoldParity(SCENARIOS["adapter EOF without a terminal"]!.events)).status).toBe(502); + }); +}); diff --git a/tests/server/inference-client-encoder-delivery.test.ts b/tests/server/inference-client-encoder-delivery.test.ts new file mode 100644 index 00000000000..f2aa53baaca --- /dev/null +++ b/tests/server/inference-client-encoder-delivery.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from "bun:test"; +import { + clientEncoderForDelivery, + deliverClientEncodedResponse, + directEncodersApply, +} from "../../src/server/inference/client-encoder-delivery"; +import { + attachClientWireLog, + clientWireLogOf, + clientWireOf, + createClientWireLog, + markClientWire, + type ClientWireLogEvent, +} from "../../src/server/inference/client-wire"; +import { beginInferenceAttempt } from "../../src/server/inference/attempt"; +import { responseWithDeferredRequestLog } from "../../src/server/relay"; +import type { RequestLogContext, RequestLogEntry } from "../../src/server/request-log"; +import { markProtocolEntry, protocolTraceForRequest } from "../../src/protocols/trace"; +import type { AdapterEvent, OcxConfig, OcxUsage } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const ON = { protocols: { rollout: { directEncoders: true } } } as Pick; +const OFF = {} as Pick; +const single = (adapter: string) => ({ provider: { adapter } }); + +async function* replay(events: AdapterEvent[]): AsyncGenerator { + for (const event of events) yield event; +} + +describe("direct encoder gates", () => { + test("the ingress gate is off by default and only takes one non-Responses target", () => { + expect(directEncodersApply(OFF, single("anthropic"))).toBe(false); + expect(directEncodersApply(ON, null)).toBe(false); + expect(directEncodersApply(ON, single("anthropic"))).toBe(true); + expect(directEncodersApply(ON, single("google"))).toBe(true); + expect(directEncodersApply(ON, single("openai-responses"))).toBe(false); + expect(directEncodersApply(ON, { ...single("anthropic"), combo: { id: "c" } })).toBe(false); + expect(directEncodersApply(ON, { ...single("anthropic"), routeKind: "policy" })).toBe(false); + }); + + test("delivery re-checks the final route before encoding", () => { + const clientEncoder = { protocol: "chat" as const, stream: true, model: "m" }; + const none = {}; + expect(clientEncoderForDelivery({}, none, false, "anthropic")).toBeUndefined(); + expect(clientEncoderForDelivery({ clientEncoder }, none, false, "anthropic")).toBe(clientEncoder); + expect(clientEncoderForDelivery({ clientEncoder, comboAttempt: true }, none, false, "anthropic")).toBeUndefined(); + expect(clientEncoderForDelivery({ clientEncoder }, none, true, "anthropic")).toBeUndefined(); + expect(clientEncoderForDelivery({ clientEncoder }, none, false, "openai-responses")).toBeUndefined(); + const policy = { routeDecision: { routeKind: "policy" } } as unknown as Pick; + expect(clientEncoderForDelivery({ clientEncoder }, policy, false, "anthropic")).toBeUndefined(); + }); +}); + +describe("client-wire log channel", () => { + test("events recorded before the subscription replay in order, later ones go straight through", () => { + const log = createClientWireLog(); + const seen: ClientWireLogEvent["kind"][] = []; + log.record({ kind: "observe", payload: {} }); + log.record({ kind: "terminal", status: "completed", payload: {} }); + log.subscribe(event => { seen.push(event.kind); }); + log.record({ kind: "cancel" }); + expect(seen).toEqual(["observe", "terminal", "cancel"]); + }); + + test("the channel is attached per Response identity", () => { + const log = createClientWireLog(); + const response = attachClientWireLog(new Response("x"), log); + expect(clientWireLogOf(response)).toBe(log); + expect(clientWireLogOf(response.clone())).toBeUndefined(); + }); +}); + +describe("deferred request log of a client-wire response", () => { + const terminalPayload = (usage: Record | null) => ({ + type: "response.completed", + response: { id: "r", object: "response", status: "completed", model: "internal/model", output: [], usage }, + }); + + test("the terminal writes one row with the tap's status mapping; a later cancel is ignored", () => { + const rows: RequestLogEntry[] = []; + const logCtx: RequestLogContext = { model: "m", provider: "p" }; + const log = createClientWireLog(); + const response = attachClientWireLog(markClientWire(new Response("data: x\n\n", { + headers: { "Content-Type": "text/event-stream" }, + }), "chat"), log); + const wrapped = responseWithDeferredRequestLog(response, "cw-1", Date.now(), logCtx, entry => rows.push(entry)); + expect(wrapped).toBe(response); + expect(clientWireOf(wrapped)).toBe("chat"); + expect(rows).toHaveLength(0); + log.record({ + kind: "terminal", + status: "completed", + payload: terminalPayload({ input_tokens: 11, output_tokens: 3, total_tokens: 14 }), + }); + log.record({ kind: "cancel" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ status: 200, terminalStatus: "completed", closeReason: "terminal" }); + expect(logCtx.transportPhase).toBe("terminal_sse"); + expect(logCtx.usage).toMatchObject({ inputTokens: 11, outputTokens: 3 }); + }); + + test("a cancel before any terminal is a 499 client cancel", () => { + const rows: RequestLogEntry[] = []; + const log = createClientWireLog(); + const response = attachClientWireLog(markClientWire(new Response("x"), "messages"), log); + responseWithDeferredRequestLog(response, "cw-2", Date.now(), { model: "m", provider: "p" }, entry => rows.push(entry)); + log.record({ kind: "cancel" }); + log.record({ kind: "terminal", status: "completed", payload: terminalPayload(null) }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ status: 499, closeReason: "client_cancel" }); + }); +}); + +describe("deliverClientEncodedResponse", () => { + const usage: OcxUsage = { inputTokens: 9, outputTokens: 4 }; + const events: AdapterEvent[] = [ + { type: "text_delta", text: "hi" }, + { type: "done", usage }, + ]; + + function delivery(stream: boolean, logCtx: RequestLogContext) { + const calls: string[] = []; + const completed: Record[] = []; + const bound: (OcxUsage | undefined)[] = []; + return { + calls, + completed, + bound, + input: { + encoder: { protocol: "chat" as const, stream, model: "client-model" }, + events: replay(events), + logCtx, + translatorBudget: createTestTranslatorBudget(), + responseModelId: "internal/model", + adapterName: "anthropic", + fold: {}, + stopUpstream: () => { calls.push("stopUpstream"); }, + onStreamDone: () => { calls.push("streamDone"); }, + onCompletedResponse: (response: Record) => { completed.push(response); }, + bindUsage: (value: OcxUsage | undefined) => { bound.push(value); }, + }, + }; + } + + test("streams the client wire, runs the completion effects once and marks the response", async () => { + const logCtx: RequestLogContext = { model: "m", provider: "p" }; + beginInferenceAttempt(logCtx, { provider: "p", model: "m", adapter: "anthropic" }); + markProtocolEntry(logCtx, { inbound: "chat", lane: "bridge" }); + const run = delivery(true, logCtx); + const response = await deliverClientEncodedResponse(run.input); + expect(clientWireOf(response)).toBe("chat"); + expect(clientWireLogOf(response)).toBeDefined(); + const text = await response.text(); + expect(text).toContain("\"content\":\"hi\""); + expect(text.trimEnd().endsWith("data: [DONE]")).toBe(true); + expect(run.completed).toHaveLength(1); + expect(run.completed[0]).toMatchObject({ status: "completed", model: "internal/model" }); + expect(run.bound).toEqual([usage]); + expect(run.calls).toEqual(["stopUpstream", "streamDone"]); + + const trace = protocolTraceForRequest(logCtx, logCtx.attempts); + expect(trace).toMatchObject({ + inbound: "chat", + mode: "legacy-bridge", + upstream: "messages", + requestPath: ["chat", "responses-internal", "ir", "messages"], + responsePath: ["messages", "ir", "chat"], + }); + }); + + test("a non-streaming client receives the folded completion with its terminal already logged", async () => { + const rows: RequestLogEntry[] = []; + const logCtx: RequestLogContext = { model: "m", provider: "p" }; + const run = delivery(false, logCtx); + const response = await deliverClientEncodedResponse(run.input); + expect(response.status).toBe(200); + expect(clientWireOf(response)).toBe("chat"); + responseWithDeferredRequestLog(response, "cw-3", Date.now(), logCtx, entry => rows.push(entry)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ terminalStatus: "completed", closeReason: "terminal" }); + const body = await response.json() as { object: string; model: string; choices: { message: { content: string } }[] }; + expect(body.object).toBe("chat.completion"); + expect(body.model).toBe("client-model"); + expect(body.choices[0]!.message.content).toBe("hi"); + expect(run.completed).toHaveLength(1); + }); +}); From c804622f179f8d77bf8909718bf4448a10cb5f7d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:00:27 +0900 Subject: [PATCH 085/173] fix(protocols): key the argument repair on any mapped namespace, as the bridge does The bridge skips the bare-name schema lookup whenever the tool mapped to a namespace, even an empty one. The driver only did so for a non-empty namespace. --- src/protocols/encoders/adapter-events.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index 41ddac2ed02..b298e4114ae 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -632,7 +632,7 @@ export function encodeAdapterEventStream( const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`; const call: ClientToolCall = { itemId, callId: event.id, name, kind }; writer.toolStart(call); - currentToolCall = { ...call, args: "", argsBytes: 0, ...(mapped?.namespace ? { namespace: mapped.namespace } : {}) }; + currentToolCall = { ...call, args: "", argsBytes: 0, ...(mapped ? { namespace: mapped.namespace } : {}) }; budget.openCall(event.id); break; } From 1b9b3903e35ec115df4a1c09fd10c2c4d6e63a87 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:00:27 +0900 Subject: [PATCH 086/173] docs(structure): describe the direct Chat and Messages encoders Responses transport owns the delivery branch, its effect parity and the client-wire request log; Protocol Paths owns the encoders, the reused converter helpers and the traced path. The not-migrated inventory records what still returns through the internal Responses hop. --- .../040_acceptance_and_rollout.md | 1 + structure/data-planes/inbound-compat.md | 8 ++- structure/data-planes/protocol-paths.md | 37 ++++++++++++- structure/transports/responses.md | 52 +++++++++++++++++-- 4 files changed, 92 insertions(+), 6 deletions(-) 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 index 902a74b41f0..63819559160 100644 --- a/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md +++ b/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md @@ -39,6 +39,7 @@ 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 | +| Chat/Messages response encode (PF-09) | migrated behind `directEncoders` for one concrete non-Responses route in the streaming adapter delivery: `[upstream, ir, client]`. Still through `responses-internal`: combo and policy children, routed compaction, run-turn adapters (Cursor, Devin, coding-agent CLIs, CodeBuddy), sidecar turns, the buffered `parseResponse` branch (unused by these ingresses, which always stream internally), and every route while the switch is off. Responses-wire upstreams keep their existing codec path | | 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 | diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 7a2a5aba118..80a0c2b1a09 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -114,7 +114,13 @@ responses-wire upstreams; the responses-lane assembly for chat-wire upstreams ke attempt telemetry only. Combo/policy routes and requests that need Responses-only hosted tools, continuation, background, -or storage semantics retain the existing Chat -> Responses -> Chat bridge. +or storage semantics retain the existing Chat -> Responses -> Chat bridge. With +`protocols.rollout.directEncoders` on, the response half of that bridge is skipped for a single +non-Responses route: adapter delivery encodes the adapter events straight into Chat (or, on the +Messages ingress, Anthropic) frames and marks the response, and the ingress returns it without +the Responses-to-client conversion. The client-visible frames are the converter's; see +[Protocol Paths](protocol-paths.md#direct-client-encoders) and +[`responses.md`](../transports/responses.md#direct-client-encoders). On its streaming return path, typed `response.heartbeat` events become SSE comment-line keepalives. They preserve connection liveness without adding a Chat completion chunk, changing usage, or claiming semantic progress; see the diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 2ef936a6c62..bd0975c61c5 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -149,6 +149,38 @@ fixed at the bridge entry mark, before an effort override rewrites `thinking`. `tests/responses/protocol-envelope.test.ts`, `tests/responses/protocol-guard.test.ts` and `tests/responses/protocol-ingress-guard.test.ts` pin them. +## Direct client encoders + +`src/protocols/encoders/` turns `AdapterEvent` streams into the Chat Completions and Anthropic +Messages wires without the internal Responses SSE. It is server side and not a leaf. +`adapter-events.ts` is the driver: it ports the item state machine of `bridgeToResponsesSSE` +(item boundaries, signature grouping, hidden and redacted reasoning envelopes, tool naming and +argument gating, the integral-float repair, every terminal with its usage and durability rule, +the wire-silence heartbeat and stall watchdog, pull-based stepping, cancellation) and calls one +`ClientWireWriter` method wherever the bridge would emit a frame a client converter reads. +`chat.ts` (`encodeChatCompletionSse`, `foldChatCompletion`) and `messages.ts` +(`encodeAnthropicMessageSse`, `foldAnthropicMessage`) are the writers. They reuse the +converters' own helpers, exported from `src/chat/outbound.ts` and `src/claude/outbound.ts` +(ids, chunk and frame builders, usage mapping, `chatCompletionsStreamErrorPayload`, +`chatCompletionsFailedResponse`, `chatCompletionsIncompleteOutcome`, +`anthropicIncompleteOutcome`, `anthropicFailedStatus`, the message snapshot, the web-search pair), +so the frames a client receives are the converters' frames. A fold is the encoded stream read by +the existing collector. + +Deliberate equivalences, pinned by the parity tests: a Chat function call is delivered as one +complete tool-call chunk when it completes, as the converter always did; Messages streams +`input_json_delta` fragments; custom and tool-search calls and server-side search activity have +no Chat representation; a Messages thinking block is buffered until its item closes. The one +behavior that differs is backpressure: the Messages converter read the bridge eagerly, while the +encoder steps one event per pull. + +The server side is `src/server/inference/client-encoder-delivery.ts`, described with the +[Responses transport](../transports/responses.md#direct-client-encoders). A directly encoded +attempt is traced with `markAttemptProtocolPath`: the request path is still the bridge path +(`[inbound, "responses-internal", "ir", upstream]`, mode `legacy-bridge`) because the request +side still decodes through the Responses projection, and the response path is +`[upstream, "ir", inbound]`. + ## Settings `resolveApiSurfaceSettings` and `resolveProtocolSettings` in `src/protocols/settings.ts` are the @@ -157,8 +189,9 @@ always served. The Messages surface uses an explicit `apiSurfaces.messages.enabl 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. The Chat and Messages ingresses read the unrepresentable policy (above); no -request path reads the rollout switches yet. +the key-auth one. The Chat and Messages ingresses read the unrepresentable policy (above), and +`directEncodersApply` reads `directEncoders` on both; no request path reads the other rollout +switches yet. `claudeInboundDisabled` in `src/server/claude-messages.ts` is the Messages ingress reader: both `/v1/messages` and `/v1/messages/count_tokens` call it, so the two routes cannot disagree, and a diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2585be7b6c1..0d429facb37 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -428,7 +428,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `sidecar-execution.ts` | Image/video versus web-search execution and their shared rotation hook. | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | | `adapter-dispatch.ts` | Translated initial dispatch, bounded recovery and the shared continuation retry counter. | -| `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging. | +| `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; a streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, `core-combo-failure.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, @@ -471,7 +471,49 @@ reachable from `core.ts`. | `context.ts` | `createInferenceSendBudget(req, logCtx)` is the one construction of an ingress-owned send holder: the default guarded policy with this request's spend tracker as observer. `handleResponses` calls it only when no holder was inherited, because attaching the tracker parks it on `logCtx`. | | `final-log.ts` | `createFinalRequestLog(logIds, logCtx)` owns one request's final row: the first `finish(status, meta)` writes it, every later call is a no-op, and without log ids the claim settles with nothing written. The bridged Chat and Messages ingresses and native Chat finish through it. | | `attempt.ts` | `beginInferenceAttempt(logCtx, { provider, model, adapter })` opens the next attempt ordinal, makes it the active attempt with its start time, appends it to the request, and returns `seal(accountLabel?)` and `finish(status, usage?)`. | -| `client-wire.ts` | `markClientWire(response, protocol)` / `clientWireOf(response)` record, per `Response` identity, that a body is already in a client's wire. Nothing marks responses yet. | +| `client-wire.ts` | `markClientWire(response, protocol)` / `clientWireOf(response)` record, per `Response` identity, that a body is already in a client's wire. `createClientWireLog` / `attachClientWireLog` / `clientWireLogOf` carry the request-log facts of such a body (a start payload, one terminal, a cancel), buffered until the deferred log subscribes. | +| `client-wire-log.ts` | `recordClientWireRequestLog` is the deferred request log of a client-wire response: `responseWithDeferredRequestLog` calls it instead of tapping the body, and it applies the Responses SSE tap's rules to the reported facts (payload inspection until the terminal, `terminal_sse` phase, `httpStatusForRequestLogTerminal`, 499 for a cancel before any terminal, one row). | +| `client-encoder-delivery.ts` | Direct Chat/Messages delivery (PF-09), described below. | + +### Direct client encoders + +Behind `protocols.rollout.directEncoders` (default off). The Chat and Messages ingresses set +`HandleResponsesOptions.clientEncoder` (`{ protocol, stream, model, inputTokenFloor? }`) when +`directEncodersApply` holds for the route they settled: the switch is on and the route is one +concrete target whose adapter is not `openai-responses`. `clientEncoderForDelivery` re-checks at +delivery, because core can still change the route: combo children (`comboAttempt`), policy or +combo route decisions, routed compaction and Responses-wire adapters keep the bridged body. +Passthrough, run-turn adapters and sidecar turns never reach this branch. + +In the streaming adapter branch `deliverClientEncodedResponse` encodes the guarded event stream +with `encodeChatCompletionSse` or `encodeAnthropicMessageSse` (`src/protocols/encoders/`) +instead of `bridgeToResponsesSSE`, and preserves the bridge's effects: + +- Every event is also retained as a shallow copy on the request's translator budget + (`retainTranslatedEvent`). At any terminal the copies are folded with `buildResponseJSON` + (`recordBufferedDelivery: false`, declared-tool enforcement off as on the bridge for these + wires), which runs the replay-cache effects and releases the copies; overflow and client + cancel release them without folding. +- A `done` terminal hands the folded response to the same `onCompletedResponse` the bridge uses + (`commitReasoningReplayServingRoute`, the Kiro final-answer memo, `rememberResponseState` + unless compaction, `notifyResponseComplete`), after the thought-signature durability barrier + where the bridge awaited it. `bindKeyUsageFromBridge` gets the adapter usage under the bridge's + `onUsage` rules. +- The body goes through `trackStreamLifetime` with the same cleanup and admission lease; the + terminal and a client cancel call `cancelResponseCompletion` and abort the upstream once. +- Client frames are counted with `noteRelayedEvent`. +- The request log learns the bridge's `response.created` snapshot and a terminal payload with the + bridge's usage presence rules through the client-wire log channel. + +A non-streaming client gets the encoded stream folded by the existing collectors +(`collectChatCompletionResponse`, `collectAnthropicMessageResponse`), with the status mapping the +ingresses applied. The response is marked with `markClientWire`, and the ingress returns it +without the Responses-to-client conversion; the Chat ingress still wraps it with +`responseWithDeferredRequestLog`, which subscribes to the log channel. The attempt's trace is +marked with `markAttemptProtocolPath`: request path and mode stay the bridge's, the response path +becomes `[upstream, "ir", client]`. `tests/responses/protocol-direct-encoders-chat.test.ts`, +`tests/responses/protocol-direct-encoders-messages.test.ts` and +`tests/server/inference-client-encoder-delivery.test.ts` cover parity and wiring. Native Chat in `src/server/chat-native.ts` is split in two. `handleNativeChatCompletions` opens the attempt and owns the final log row; `runNativeChatAttempt(execution, attemptHandle)` runs @@ -485,7 +527,11 @@ its own spend tracker rather than a send holder. `src/bridge.ts` is a re-export facade; the implementation lives in `src/bridge/`. `src/bridge/sse.ts` (`bridgeToResponsesSSE`) turns adapter events into the Responses SSE stream, and `src/bridge/response-json.ts` (`buildResponseJSON`) builds the non-streaming Responses body -from the same events. `src/bridge/errors.ts` (`formatErrorResponse`) formats error responses and +from the same events. `buildResponseJSON` records a buffered delivery on the attempt unless the +caller passes `recordBufferedDelivery: false`, which the direct client encoders do because they +count their own frames. `src/protocols/encoders/adapter-events.ts` ports the bridge's item state +machine for those encoders, so a change to item boundaries, tool naming or terminal handling in +`sse.ts` has to be made there too; the parity tests fail when the two diverge. `src/bridge/errors.ts` (`formatErrorResponse`) formats error responses and keeps only allowlisted transport verdict codes. Adapter error events take a different path: `src/bridge/internal.ts` carries an event's own `code` into the SSE and JSON failure, after mapping cyber-policy codes to HTTP 400. The same file holds the shared usage shaping; `input_tokens_details` and From 8b590f7986b43ac6eecc615cc31857bdcc358273 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:20:00 +0900 Subject: [PATCH 087/173] test(protocols): count only tool-call delta frames in the Chat frame-shape case The finish chunk names tool_calls as its finish_reason, so a substring match counted it as a second tool-call frame. --- tests/responses/protocol-direct-encoders-chat.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/responses/protocol-direct-encoders-chat.test.ts b/tests/responses/protocol-direct-encoders-chat.test.ts index c1334980940..36c56cb1f5a 100644 --- a/tests/responses/protocol-direct-encoders-chat.test.ts +++ b/tests/responses/protocol-direct-encoders-chat.test.ts @@ -275,7 +275,8 @@ describe("direct Chat encoder matches bridge + converter (stream)", () => { const frames = await expectStreamParity(SCENARIOS["tool call with streamed arguments"]!.events); const roles = frames.filter(frame => JSON.stringify(frame).includes("\"role\":\"assistant\"")); expect(roles).toHaveLength(1); - const toolFrames = frames.filter(frame => JSON.stringify(frame).includes("tool_calls")); + // Frames carrying a tool-call delta; the finish chunk only names `tool_calls` as its reason. + const toolFrames = frames.filter(frame => JSON.stringify(frame).includes("\"tool_calls\":[")); expect(toolFrames).toHaveLength(1); expect(JSON.stringify(toolFrames[0])).toContain("\"arguments\":\"{\\\"q\\\":\\\"weather\\\"}\""); const finish = frames.at(-2) as { choices: { finish_reason: string }[]; usage: Record }; From 2153fe123c5c34aea73f30eab347243572242e9c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:57:06 +0900 Subject: [PATCH 088/173] feat(chat-native): let a caller hand the attempt a send budget and hooks A combo child must spend the combo's per-target budget instead of opening a second spend tracker, record its own first output and hold the parent's turn lease. All three are optional; without them the attempt behaves exactly as before. --- src/server/chat-native.ts | 60 +++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index c258f40899b..cac83fd5ac1 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -15,7 +15,9 @@ import { CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, isCyberPolicyMessage, + SEND_BUDGET_EXHAUSTED_CODE, } from "../lib/errors"; +import type { RequestExecutionBudget } from "../lib/request-execution-budget"; import type { AdmissionLease } from "../lib/admission"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { redactSecretString } from "../lib/redact"; @@ -31,6 +33,8 @@ import { REPLAY_REFUSAL_CLIENT_HEADERS, REPLAY_REFUSED_STATUS, retainReplayRefusal, + SendBudgetExhaustedError, + TRANSIENT_RETRY_MAX_ATTEMPTS, UpstreamRetryEvidenceError, type UpstreamSendRecovery, UPSTREAM_RESET_REPLAY_REFUSED_CODE, @@ -180,6 +184,18 @@ export type NativeChatFinishLog = ( export interface NativeChatExecution extends HandleNativeChatOptions { finishLog: NativeChatFinishLog; + /** + * A combo child's per-target budget (PF-07). With it the attempt opens no spend tracker of its + * own: the combo's hop reservation already booked the first send on the request's shared + * counter, and every later physical send is reported to that counter, whose observer is the + * request's one spend tracker. A second tracker would book each send twice and, merged into + * the parent row, replace the one the final log settles. + */ + sendBudget?: RequestExecutionBudget; + /** Replaces the request-relative first-output mark; a combo child records its own. */ + onFirstOutput?: () => void; + /** The lease a streamed body holds; defaults to `logIds.turnAdmissionLease`. */ + turnAdmissionLease?: AdmissionLease; } /** @@ -216,7 +232,10 @@ export async function runNativeChatAttempt( attemptHandle: InferenceAttempt, ): Promise { const { req, config, logCtx, logIds, route, requestedModel, requestedStream, translatorBudget, finishLog } = execution; + const { sendBudget } = execution; const { attempt } = attemptHandle; + const onFirstOutput = execution.onFirstOutput + ?? (logIds ? () => recordFirstOutput(logCtx, logIds.start) : undefined); const fail = (status: number, message: string, type?: string, code?: string | null): Response => { const safeMessage = redactSecretString(message); finishLog(status, safeMessage); @@ -235,7 +254,7 @@ export async function runNativeChatAttempt( // of adding another trackStreamLifetime wrapper (unsafe on bundled Bun#32111). let streamTurnRegistered = false; const transferTurnToStream = () => { - const lease = logIds?.turnAdmissionLease; + const lease = execution.turnAdmissionLease ?? logIds?.turnAdmissionLease; if (!lease || typeof (lease as { bindAbortController?: unknown }).bindAbortController !== "function") return; registerTurn(upstream, lease); streamTurnRegistered = true; @@ -256,7 +275,7 @@ export async function runNativeChatAttempt( if (proactiveKeyProvider) route.provider = proactiveKeyProvider; let activeProvider: OcxProviderConfig = route.provider; stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider); - const spendTracker = attachRequestSpendTracker(req, logCtx); + const spendTracker = sendBudget ? undefined : attachRequestSpendTracker(req, logCtx); let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider); let activeRequest: AdapterRequest; let retainedRequestBytes = 0; @@ -298,9 +317,21 @@ export async function runNativeChatAttempt( // key rotation so recovery cannot replace the ceiling along with the active credential. const requestTransientPolicy = transientRetryPolicyFor(activeProvider); let transientSendsUsed = 0; - const remainingTransientSends = (): number => requestTransientPolicy - ? Math.max(0, requestTransientPolicy.attempts - transientSendsUsed) - : Number.POSITIVE_INFINITY; + // A combo child also answers to the request's shared base allowance, at the cap its own ladder + // uses. Its first send is exempt: the combo reserved it before dispatching this target. + const sharedSendCap = requestTransientPolicy?.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS; + let physicalSends = 0; + const remainingSharedSends = (): number => { + if (!sendBudget) return Number.POSITIVE_INFINITY; + const remaining = sendBudget.remainingBaseSends(sharedSendCap); + return physicalSends === 0 ? Math.max(1, remaining) : remaining; + }; + const remainingTransientSends = (): number => Math.min( + requestTransientPolicy + ? Math.max(0, requestTransientPolicy.attempts - transientSendsUsed) + : Number.POSITIVE_INFINITY, + remainingSharedSends(), + ); const transientSendAvailable = (): boolean => remainingTransientSends() > 0; const send = async (request: AdapterRequest, recovery?: "rate-limit-429" | "key-429"): Promise => { @@ -308,6 +339,7 @@ export async function runNativeChatAttempt( // #2643: opted-in key-auth openai-chat providers retry pre-stream transient statuses on // the native chat lane too; everyone else keeps reset-only semantics. const remaining = remainingTransientSends(); + if (sendBudget && remaining <= 0) throw new SendBudgetExhaustedError(safeHostLabel(request.url)); if (requestTransientPolicy && remaining <= 0) { throw new Error("native Chat transient send budget exhausted before recovery dispatch"); } @@ -349,7 +381,15 @@ export async function runNativeChatAttempt( const encoding = new Headers(init.headers).get("accept-encoding"); if (!headers.has("accept-encoding") && encoding) headers.set("accept-encoding", encoding); if (init.signal?.aborted) throw init.signal.reason; - if (!spendTracker.charge()) throw new NativeChatSpendRefusal(); + if (sendBudget) { + // Backstop for sends the helper cannot see coming (a reset replay). The first + // report settles the combo's booking; each later one is charged and booked. + if (physicalSends > 0 && sendBudget.remainingBaseSends(sharedSendCap) <= 0) { + throw new SendBudgetExhaustedError(safeHostLabel(request.url)); + } + physicalSends += 1; + sendBudget.used += 1; + } else if (!spendTracker?.charge()) throw new NativeChatSpendRefusal(); noteProviderAttemptSend(logCtx, route.providerName, activeProvider, logCtx.usageLogInputTokens, transportRecovery ?? recovery); // A reselected provider transport is still a physical send: the connection policy // and manual-redirect ownership wrap the selected implementation (#4992). @@ -442,6 +482,10 @@ export async function runNativeChatAttempt( upstream.abort(); if (req.signal.aborted) return fail(499, "Client cancelled request", "client_cancelled"); const sendError = error instanceof UpstreamRetryEvidenceError ? error.cause : error; + if (sendBudget && sendError instanceof SendBudgetExhaustedError) { + // A decision this process made, answered as the Responses path answers it: 429, not 502. + return fail(429, sendError.message, SEND_BUDGET_EXHAUSTED_CODE, SEND_BUDGET_EXHAUSTED_CODE); + } if (sendError instanceof NativeChatSpendRefusal) { const refusal = workflowRefusalResponse("workflow-spend-exhausted", logCtx); finishLog(429); @@ -556,7 +600,7 @@ export async function runNativeChatAttempt( translatorBudget, signal: upstream.signal, stallTimeoutSec: config.stallTimeoutSec, - onFirstOutput: logIds ? () => recordFirstOutput(logCtx, logIds.start) : undefined, + onFirstOutput, onUsage: usage => { if (!recordKeyWireAttemptUsage(logCtx, usage)) { logCtx.usage = usage; @@ -656,7 +700,7 @@ export async function runNativeChatAttempt( attempt.usage = usage; } } - if (logIds) recordFirstOutput(logCtx, logIds.start); + onFirstOutput?.(); try { const serialized = requestedStream ? jsonCompletionSse(completion, requestedModel, translatorBudget) From 10214e6c1d48d172dd8e97880c4d9f3acb752b35 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:57:16 +0900 Subject: [PATCH 089/173] feat(protocols): append a reason to a request's entry trace mark A combo candidate skipped as unrepresentable leaves no attempt behind, so the skip has to be recorded on the request itself or the trace cannot say why it was passed over. --- src/protocols/trace.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/protocols/trace.ts b/src/protocols/trace.ts index 02a6cf4be35..73bb5cb3843 100644 --- a/src/protocols/trace.ts +++ b/src/protocols/trace.ts @@ -116,6 +116,20 @@ export function markProtocolBlocked( } } +/** + * Add a reason to the request's entry mark: a combo candidate skipped before any send (PF-07). + * No-op without an entry mark, and a blocked mark is never reopened. + */ +export function addProtocolEntryReason(logCtx: object, code: ProtocolReasonCode): void { + try { + const mark = requestMarks.get(logCtx); + if (mark?.kind !== "entry") return; + mark.reasonCodes = boundedReasons([...mark.reasonCodes, code]); + } catch { + /* a trace must never fail the request it describes */ + } +} + /** * Record the path one physical attempt actually took, overriding the lane-derived one. For a * send site that knows its path better than the ingress lane does. From 3cf911c3b9aff9b301d7ed39597c868924263467 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:58:49 +0900 Subject: [PATCH 090/173] feat(responses): send eligible Chat combo candidates on the native lane With a Chat protocol source on the options, a candidate whose settled route passes the native Chat rule runs runNativeChatAttempt on the attempt the combo opened, with the target budget and a fresh source body; under reject an unrepresentable one is never picked. --- src/server/responses/core-combo-native.ts | 323 ++++++++++++++++++++++ src/server/responses/core-combo.ts | 47 +++- src/server/responses/core-options.ts | 2 + tests/helpers/responses-core-source.ts | 1 + 4 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 src/server/responses/core-combo-native.ts diff --git a/src/server/responses/core-combo-native.ts b/src/server/responses/core-combo-native.ts new file mode 100644 index 00000000000..c70af618f51 --- /dev/null +++ b/src/server/responses/core-combo-native.ts @@ -0,0 +1,323 @@ +/** + * Native Chat candidates inside the combo loop (PF-07). + * + * The Chat ingress hands the combo a `ComboProtocolSource` only for a combo route with + * `protocols.rollout.nativeChatCombos` on; without one nothing here runs. For the candidate the + * combo is about to dispatch, the concrete route is settled the way the Chat ingress settles a + * single route, and the native Chat lane's own eligibility rule decides whether the child goes + * native. A native child runs `runNativeChatAttempt` (through the source) on the attempt the combo + * already opened, with the combo's per-target budget, the client's abort signal and the turn + * lease, built from a fresh copy of the source body; every other candidate keeps the bridge. + * Under `unrepresentable: "reject"` a candidate whose path would drop a requested feature is never + * picked, and a combo whose every enabled candidate is skipped answers the ingress refusal. + * + * No `./` sibling import on purpose: `core-options.ts` names this module's types, so an edge back + * into the owner graph would close a cycle. + */ +import type { OcxConfig } from "../../types"; +import type { RouteResult } from "../../router"; +import type { DataPlaneAdmission } from "../auth-cors"; +import type { AdmissionLease } from "../../lib/admission"; +import type { RequestExecutionBudget } from "../../lib/request-execution-budget"; +import type { TransientSendBudget } from "../../lib/upstream-retry"; +import type { ResponsesTerminalStatus } from "../../bridge"; +import type { NativeChatFinishLog } from "../chat-native"; +import type { InferenceAttempt } from "../inference/attempt"; +import type { ProtocolEnvelope } from "../../protocols/envelope"; +import type { ProtocolFeature } from "../../protocols/features"; +import type { PersistedUsageAttempt } from "../../usage/log"; +import { + finishRequestAttempt, + sealRequestAttemptIdentity, + type RequestLogContext, +} from "../request-log"; +import { captureRouteStaticPolicy, routeConcreteModel } from "../../router"; +import { comboDefaultEffort, concreteComboRequestBody, getCombo } from "../../combos"; +import { supportedLadderFor } from "../effort-policy"; +import { resolveWireProtocolOverride } from "../adapter-resolve"; +import { resolveOpenCodeGoTransport } from "../../providers/opencode-go-transport"; +import { getOrAllocateRequestSessionLane } from "../request-log-conversation"; +import { assertRouteAllowedByScope, resolveAdmissionModelScope } from "../admission-model-scope"; +import { isNativeChatRouteEligible } from "../chat-native-eligibility"; +import { chatCompletionsErrorResponse } from "../../chat/outbound"; +import { isRequestExecutionBudget } from "../../lib/request-execution-budget"; +import { markResponseNonReplayable } from "../../lib/upstream-retry"; +import { redactSecretString } from "../../lib/redact"; +import { upstreamWireForAdapter } from "../../protocols/contract"; +import { checkRepresentable, unrepresentableMessage } from "../../protocols/guard"; +import { deliveryModeForLane, requestPathForLane } from "../../protocols/path"; +import { resolveProtocolSettings } from "../../protocols/settings"; +import { addProtocolEntryReason, markAttemptProtocolPath, markProtocolBlocked } from "../../protocols/trace"; +import { markClientWire } from "../inference/client-wire"; + +type Rec = Record; +type ComboTarget = { provider: string; model: string }; + +/** What the source needs to run one native child on the combo's open attempt. */ +export interface NativeComboChildRun { + route: RouteResult; + /** A fresh copy of the source Chat body, never another candidate's rewritten one. */ + body: Rec; + childLog: RequestLogContext; + attemptHandle: InferenceAttempt; + sendBudget: RequestExecutionBudget; + turnAdmissionLease?: AdmissionLease; + onFirstOutput: () => void; + /** Reports to the combo's child callbacks; it never writes the parent's final row itself. */ + finishLog: NativeChatFinishLog; +} + +/** Supplied by the Chat ingress for a combo route when `nativeChatCombos` is on. */ +export interface ComboProtocolSource { + readonly inbound: "chat"; + readonly envelope: ProtocolEnvelope; + /** Runs `runNativeChatAttempt`; the response it returns is in the Chat wire. */ + dispatchNativeChild(input: NativeComboChildRun): Promise; +} + +/** The native plan for one dispatch: the settled route and its own fresh body. */ +export interface NativeComboChildPlan { + route: RouteResult; + body: Rec; + sendBudget: RequestExecutionBudget; +} + +export interface ComboProtocolLanes { + /** Pick predicate. False only for a candidate the `reject` policy skips. */ + pickable(target: ComboTarget): boolean; + /** The ingress refusal when every enabled candidate was skipped; otherwise undefined. */ + refusal(): Response | undefined; + /** The native plan for the candidate about to dispatch, or undefined to keep the bridge. */ + nativeChild( + target: ComboTarget, + targetRoute: RouteResult, + targetSendBudget: TransientSendBudget | undefined, + ): NativeComboChildPlan | undefined; +} + +interface CandidateVerdict { + skip?: ProtocolFeature[]; +} + +export function createComboProtocolLanes(input: { + source: ComboProtocolSource | undefined; + req: Request; + config: OcxConfig; + logCtx: RequestLogContext; + admission: DataPlaneAdmission | undefined; + comboId: string; + targets: readonly ComboTarget[]; +}): ComboProtocolLanes | undefined { + const { source, req, config, logCtx, admission, comboId, targets } = input; + if (!source) return undefined; + const envelope = source.envelope; + const reject = resolveProtocolSettings(config).unrepresentable === "reject"; + const selector = (target: ComboTarget): string => `${target.provider}/${target.model}`; + + // The Chat ingress's own settlement of a single route, applied to the concrete target: the + // key's model scope, the static policy captured for a Chat inbound, the wire override and the + // OpenCode Go transport. A refusal here keeps the bridge, which reports it in its own shape. + const settle = (target: ComboTarget, targetRoute: RouteResult): RouteResult | undefined => { + try { + const route: RouteResult = { ...targetRoute }; + assertRouteAllowedByScope(resolveAdmissionModelScope(config, admission), selector(target), route); + const routedProvider = route.provider; + route.staticPolicy = captureRouteStaticPolicy( + route.providerName, route.modelId, routedProvider, route.staticPolicy.effectiveAlias, "chat", + ); + const wireProvider = resolveWireProtocolOverride(route.providerName, route.modelId, routedProvider, "chat", route.staticPolicy); + route.provider = resolveOpenCodeGoTransport(wireProvider, getOrAllocateRequestSessionLane(req), routedProvider); + return route; + } catch { + return undefined; + } + }; + + // Eligibility reads a body, and a body is a charged copy, so the adapter is checked first: + // a candidate that can never go native costs no copy at all. + const nativeBody = (target: ComboTarget, route: RouteResult | undefined): Rec | undefined => { + if (!route || route.provider.adapter !== "openai-chat") return undefined; + const body = envelope.freshBody(); + // The concrete selector, as the bridge child's body carries it; the wire model comes from + // the route either way, and the pinned-effort lookup reads this. + body.model = selector(target); + return isNativeChatRouteEligible(route, body, config) ? body : undefined; + }; + + const verdicts = new Map(); + let skipRecorded = false; + const judge = (target: ComboTarget): CandidateVerdict => { + const key = selector(target); + const cached = verdicts.get(key); + if (cached) return cached; + let verdict: CandidateVerdict = {}; + let targetRoute: RouteResult | undefined; + try { + targetRoute = routeConcreteModel(config, key); + } catch { + // Not evidence about the path; dispatch keeps the existing routing failure surface. + targetRoute = undefined; + } + if (targetRoute) { + const settled = settle(target, targetRoute); + const native = nativeBody(target, settled) !== undefined; + const upstream = native ? "chat" : upstreamWireForAdapter((settled ?? targetRoute).provider.adapter); + const checked = checkRepresentable({ + inbound: "chat", + requestPath: requestPathForLane("chat", native ? "native" : "bridge", upstream), + features: envelope.features(), + policy: "reject", + }); + if (!checked.ok) { + verdict = { skip: checked.features }; + if (!skipRecorded) { + skipRecorded = true; + addProtocolEntryReason(logCtx, "feature-unrepresentable"); + } + } + } + verdicts.set(key, verdict); + return verdict; + }; + + return { + pickable: target => !reject || judge(target).skip === undefined, + refusal: () => { + if (!reject) return undefined; + const enabled = targets.filter(target => { + const provider = config.providers[target.provider]; + return provider !== undefined && provider.disabled !== true; + }); + if (enabled.length === 0) return undefined; + const skipped = enabled.map(judge); + if (skipped.some(verdict => verdict.skip === undefined)) return undefined; + const features = [...new Set(skipped.flatMap(verdict => verdict.skip ?? []))]; + // The same refusal the ingress gives a single route: 400 in the Chat shape, feature keys + // only, a blocked trace, and no upstream send. + markProtocolBlocked(logCtx, { inbound: "chat", reasonCodes: ["feature-unrepresentable"], features }); + logCtx.errorCode = "unsupported_feature"; + return markClientWire(chatCompletionsErrorResponse( + 400, unrepresentableMessage(features), "invalid_request_error", "unsupported_feature", + ), "chat"); + }, + nativeChild: (target, targetRoute, targetSendBudget) => { + // Without the request's execution budget a native child could not share its sends, so it + // keeps the bridge rather than opening a tracker of its own. + if (!isRequestExecutionBudget(targetSendBudget)) return undefined; + const route = settle(target, targetRoute); + const body = nativeBody(target, route); + if (!route || !body) return undefined; + applyComboEffort(body, config, comboId, target, targetRoute); + return { route, body, sendBudget: targetSendBudget }; + }, + }; +} + +/** + * The combo's reasoning-effort policy, applied to the Chat spelling of the same field. + * + * The bridge child gets it from `concreteComboRequestBody` on the Responses body; a native child + * must not lose a forced or default effort just because it skipped that body. The same function + * decides, through a one-field Responses view, so the two lanes cannot disagree. + */ +function applyComboEffort( + body: Rec, + config: OcxConfig, + comboId: string, + target: ComboTarget, + targetRoute: RouteResult, +): void { + const combo = getCombo(config, comboId); + if (!combo) return; + const hadEffort = Object.hasOwn(body, "reasoning_effort"); + const shaped = concreteComboRequestBody( + hadEffort ? { reasoning: { effort: body.reasoning_effort } } : {}, + target, + comboDefaultEffort(config, comboId), + supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }), + combo.reasoningEffortMode, + combo.defaultEffortMode, + ); + const reasoning = shaped.reasoning; + if (reasoning === undefined) { + if (hadEffort) delete body.reasoning_effort; + return; + } + const effort = (reasoning as { effort?: unknown }).effort; + if (typeof effort === "string") body.reasoning_effort = effort; +} + +/** The combo's child callbacks, gated so a discarded attempt never reaches the parent. */ +export interface ComboChildCallbacks { + onTerminal(status: ResponsesTerminalStatus): void; + onCancel(): void; + onResponseComplete(model: string): void; +} + +/** + * Run one native child on the attempt the combo opened, and mark its answer as Chat wire. + * + * The child's outcomes map onto the combo's existing child callbacks: a terminal or cancellation + * after the response was handed over publishes through the gate (and so reaches the parent's + * final-row owner only once the combo commits), while a failure answered before that is left to + * the combo's ordinary failure path. A non-OK answer produced after output was observed is marked + * non-replayable, so the combo stops instead of re-running a turn that already ran upstream. + */ +export async function dispatchNativeComboChild(input: { + source: ComboProtocolSource; + plan: NativeComboChildPlan; + logCtx: RequestLogContext; + childLog: RequestLogContext; + attempt: PersistedUsageAttempt; + startedAt: number; + turnAdmissionLease?: AdmissionLease; + onFirstOutput: () => void; + callbacks: ComboChildCallbacks; +}): Promise { + const { plan, logCtx, childLog, attempt, startedAt, callbacks } = input; + childLog.providerAdapter = plan.route.provider.adapter; + markAttemptProtocolPath(attempt, { + mode: deliveryModeForLane("chat", "native", "chat"), + requestPath: requestPathForLane("chat", "native", "chat"), + }); + let outputSeen = false; + const finishLog: NativeChatFinishLog = (status, message, closeReason = "non_stream") => { + if (message) childLog.upstreamError = redactSecretString(message).slice(0, 500); + if (closeReason !== "non_stream") { + // A streamed body ends after the combo merged this child into the parent row, so what the + // stream learned last is carried across here before the parent's row is written. + if (childLog.usage !== undefined) logCtx.usage = childLog.usage; + if (closeReason === "client_cancel") return callbacks.onCancel(); + } + if (status < 400) { + callbacks.onTerminal("completed"); + callbacks.onResponseComplete(plan.route.modelId); + return; + } + if (closeReason === "terminal") { + childLog.terminalHttpStatus = status; + callbacks.onTerminal("failed"); + } + }; + const attemptHandle: InferenceAttempt = { + attempt, + startedAt, + seal: label => sealRequestAttemptIdentity(attempt, childLog.provider, childLog.providerAdapter ?? attempt.adapter, label), + finish: (status, usage) => finishRequestAttempt(attempt, status, Date.now() - startedAt, usage), + }; + const response = await input.source.dispatchNativeChild({ + route: plan.route, + body: plan.body, + childLog, + attemptHandle, + sendBudget: plan.sendBudget, + ...(input.turnAdmissionLease ? { turnAdmissionLease: input.turnAdmissionLease } : {}), + onFirstOutput: () => { + outputSeen = true; + input.onFirstOutput(); + }, + finishLog, + }); + if (!response.ok && outputSeen) markResponseNonReplayable(response); + return markClientWire(response, "chat"); +} diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 7f285a89e7b..4feb3a69d60 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -75,6 +75,8 @@ import { preflightComboStreamResponse } from "./combo-stream-preflight"; import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow"; import { mandatoryResponsesReasoningReplayUnavailable } from "./core-replay"; import { settleOperatorReplacement } from "../../lib/upstream-retry"; +import { createComboProtocolLanes, dispatchNativeComboChild } from "./core-combo-native"; +import { clientWireOf } from "../inference/client-wire"; /** * Sends one combo target may run on its own before the ladder moves on. A target is a whole @@ -195,6 +197,17 @@ export async function executeComboResponses( // counter, but nothing read it as a limit across targets -- while its transition and // alternate-target ledgers come from the target list rather than from the single-target // account-move profile (#4546). + // PF-07: present only for a Chat combo with `nativeChatCombos` on; otherwise every child + // takes the bridge below exactly as before. + const protocolLanes = createComboProtocolLanes({ + source: options.protocolSource, + req, + config, + logCtx, + admission: options.admission, + comboId, + targets: combo.targets, + }); const comboSendScope = isRequestExecutionBudget(options.sendBudget) ? deriveSendBudgetScope(options.sendBudget, comboExecutionBudgetPolicy(combo.targets.length)) : undefined; @@ -298,7 +311,7 @@ export async function executeComboResponses( const payloadEligible = (target: (typeof combo.targets)[number]): boolean => comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target); const targetEligible = (target: (typeof combo.targets)[number]): boolean => - payloadEligible(target) && reasoningReplayEligible(target); + payloadEligible(target) && reasoningReplayEligible(target) && (protocolLanes?.pickable(target) ?? true); const onlyReplayIncompatibleTargetsRemain = (excluded: Iterable = []): boolean => { const excludedKeys = new Set(excluded); const remaining = combo.targets.filter(target => { @@ -398,6 +411,9 @@ export async function executeComboResponses( } if (!pick) { + // Every enabled candidate skipped as unrepresentable: the ingress refusal, with no send. + const protocolRefusal = protocolLanes?.refusal(); + if (protocolRefusal) return protocolRefusal; if (onlyReplayIncompatibleTargetsRemain()) return targetIncompatibleResponse(); return options.abortSignal?.aborted ? clientCancelledResponse() @@ -556,8 +572,31 @@ export async function executeComboResponses( && targetEligible(target) && !isComboTargetInCooldown(comboId, target), ); - response = await requestDispatchers.handleResponses(childRequest, config, childLog, { + const nativeChild = protocolLanes?.nativeChild(pick.target, targetRoute, targetSendBudget); + response = nativeChild ? await dispatchNativeComboChild({ + source: options.protocolSource!, + plan: nativeChild, + logCtx, + childLog, + attempt, + startedAt: started, + ...(options.turnAdmissionLease ? { turnAdmissionLease: options.turnAdmissionLease } : {}), + // Attempt-relative TTFT, recorded here for the same reason as the bridge child below. + onFirstOutput: () => { + if (attempt.firstOutputMs === undefined) { + attempt.firstOutputMs = Math.max(0, Date.now() - started); + } + options.onFirstOutput?.(); + }, + callbacks: { + onTerminal: callbackGate.onTerminal, + onCancel: callbackGate.onCancel, + onResponseComplete: callbackGate.onResponseComplete, + }, + }) : await requestDispatchers.handleResponses(childRequest, config, childLog, { ...options, + // A bridge child is a concrete route; the native source belongs to this loop only. + ...(options.protocolSource ? { protocolSource: undefined } : {}), // After the spread: the child must run on THIS target's ladder, not on the holder the // parent arrived with. sendBudget: targetSendBudget, @@ -597,7 +636,9 @@ export async function executeComboResponses( return clientCancelledResponse(); } - if (response.ok && !runTurnAdapterSseResponses.has(response)) { + // A native Chat child reports a pre-stream failure by status before any byte, so its body + // is never peeked; a non-OK one takes the ordinary failure path below. + if (response.ok && !runTurnAdapterSseResponses.has(response) && clientWireOf(response) !== "chat") { const nativePassthrough = isNativePassthroughSseResponse(response); const eagerRelay = isEagerRelaySseResponse(response); let preflight; diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index fff50864da8..47c2c982c40 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -106,6 +106,8 @@ export interface HandleResponsesOptions { * it. Omitted means a genuine Responses inbound. */ inboundWire?: InboundWire; + /** PF-07: the Chat source a combo child may send natively; set only by the Chat ingress. */ + protocolSource?: import("./core-combo-native").ComboProtocolSource; /** Internal transport identity for route-scoped upstream compatibility policy. */ inboundTransport?: "websocket"; /** diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index be7d246e0b4..2d3805ea0b7 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -28,6 +28,7 @@ export const RESPONSES_CORE_MODULES = [ "core-auth.ts", "core-normalize.ts", "core-combo.ts", + "core-combo-native.ts", "request-prepare.ts", "shadow-target-availability.ts", "compaction-routing.ts", From 2217b9dd8e24d3ddcdb2d554ee54ec5911797bc4 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:59:15 +0900 Subject: [PATCH 091/173] feat(chat): hand Chat combos a native source behind nativeChatCombos With the switch on, a combo route carries its source envelope and a native dispatcher into the Responses combo loop, and a Chat-wire answer passes through without the Responses-to-Chat conversion. Switch off leaves the request byte-for-byte unchanged. --- src/server/chat-completions.ts | 27 +++++++++++++++++++++------ src/server/chat-native.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index e92579902cd..46ce2a9f7d9 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -49,7 +49,7 @@ import { type RequestLogContext, } from "./request-log"; import { createFinalRequestLog } from "./inference/final-log"; -import { clientWireOf } from "./inference/client-wire"; +import { clientWireLogOf, clientWireOf } from "./inference/client-wire"; import { directEncodersApply } from "./inference/client-encoder-delivery"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; @@ -71,7 +71,7 @@ import { isTranslatorBudgetExceededError, type TranslatorBudget, } from "../lib/translator-budget"; -import { handleNativeChatCompletions, nativeChatDeclineReason } from "./chat-native"; +import { createNativeChatComboSource, handleNativeChatCompletions, nativeChatDeclineReason } from "./chat-native"; import { upstreamWireForAdapter, type ProtocolReasonCode } from "../protocols/contract"; import { createProtocolEnvelope } from "../protocols/envelope"; import { featuresFromChatBody } from "../protocols/features"; @@ -246,8 +246,13 @@ async function handleChatCompletionsWithBudget( /* unknown model: let handleResponses shape the 404 */ } - // Off by default: under the legacy policy nothing below is built and the request is unchanged. - const envelope = resolveProtocolSettings(config).unrepresentable === "reject" + // Off by default: under the legacy policy with `nativeChatCombos` off nothing below is built + // and the request is unchanged. An effort row keeps its effort on the Responses body only, so + // its combo stays on the bridge. + const protocolSettings = resolveProtocolSettings(config); + const nativeChatCombos = protocolSettings.rollout.nativeChatCombos + && settledRoute?.combo !== undefined && !effortRow; + const envelope = protocolSettings.unrepresentable === "reject" || nativeChatCombos ? createProtocolEnvelope({ inbound: "chat", body: chatBody, translatorBudget }) : undefined; // Combo and policy children are judged per candidate (PF-07); an unknown model has no route. @@ -428,6 +433,12 @@ async function handleChatCompletionsWithBudget( abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", + // PF-07: the combo sends eligible candidates natively from this envelope. + ...(envelope && nativeChatCombos ? { + protocolSource: createNativeChatComboSource({ + req, config, envelope, requestedModel, requestedStream: stream, translatorBudget, + }), + } : {}), // Terminal vision-describe marker (roadmap 180): the bridge rebuilds // headers from the FORWARD_HEADERS allowlist, which would drop the raw // header — so the fact is detected here and carried as an option flag. @@ -440,9 +451,13 @@ async function handleChatCompletionsWithBudget( ? { clientEncoder: { protocol: "chat" as const, stream, model: requestedModel } } : {}), }); - // Already in the Chat wire (direct encoder): no conversion, still the deferred request log. + // Already in the Chat wire: no conversion. A direct-encoder body (PF-09) reports its own log + // facts to the deferred request log. A native combo child (PF-07) carries none: its row is + // written by the terminal callbacks above, and the deferred log's Responses-shaped inspector + // would misread a Chat stream, so of those only a refusal is wrapped. if (clientWireOf(upstream) === "chat") { - return logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream; + if (!logIds || (upstream.ok && !clientWireLogOf(upstream))) return upstream; + return responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx); } // Rewrite non-2xx before deferred logging so /api/logs records the client-facing status diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index cac83fd5ac1..36a9477450b 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -74,6 +74,8 @@ import { createFinalRequestLog } from "./inference/final-log"; import { registerTurn, unregisterTurn } from "./lifecycle"; import { attachRequestSpendTracker } from "./responses/request-spend"; import { workflowRefusalResponse } from "./workflow-refusal"; +import type { ComboProtocolSource } from "./responses/core-combo-native"; +import type { ProtocolEnvelope } from "../protocols/envelope"; export { isNativeChatRouteEligible, nativeChatDeclineReason } from "./chat-native-eligibility"; @@ -222,6 +224,38 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio return runNativeChatAttempt({ ...options, finishLog }, attemptHandle); } +/** + * The Chat source a combo hands its native children (PF-07). A child runs on the attempt the + * combo opened and reports through the combo's callbacks, so the final row stays the parent's. + */ +export function createNativeChatComboSource(input: { + req: Request; + config: OcxConfig; + envelope: ProtocolEnvelope; + requestedModel: string; + requestedStream: boolean; + translatorBudget: TranslatorBudget; +}): ComboProtocolSource { + return { + inbound: "chat", + envelope: input.envelope, + dispatchNativeChild: child => runNativeChatAttempt({ + req: input.req, + config: input.config, + logCtx: child.childLog, + route: child.route, + chatBody: child.body, + requestedModel: input.requestedModel, + requestedStream: input.requestedStream, + translatorBudget: input.translatorBudget, + finishLog: child.finishLog, + sendBudget: child.sendBudget, + onFirstOutput: child.onFirstOutput, + ...(child.turnAdmissionLease ? { turnAdmissionLease: child.turnAdmissionLease } : {}), + }, child.attemptHandle), + }; +} + /** * One native Chat attempt on an already-open attempt row: effort normalization, the send * loop with key failover and 429 replay, relay and usage. Every outcome is reported through From 3fb6a1ba1ff8fa5547c8715c2e83944024390bc6 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:01:54 +0900 Subject: [PATCH 092/173] test(chat): pin native Chat candidates, failover and skips in combos Loopback upstreams show the native candidate receives the caller's own body (n and logprobs intact), a failed one hops within the shared budget, output is never re-sent, the switch-off path stays bridged, and reject skips or refuses without a send. --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + tests/responses/chat-native-combo.test.ts | 312 ++++++++++++++++++++++ 3 files changed, 314 insertions(+) create mode 100644 tests/responses/chat-native-combo.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ee9bb48f2e7..6bdf369a28a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -347,6 +347,7 @@ "protocol-ingress-guard.test.ts": "responses", "protocol-direct-encoders-chat.test.ts": "responses", "protocol-direct-encoders-messages.test.ts": "responses", + "chat-native-combo.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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 387c7cb41d7..a0b3a378291 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -173,6 +173,7 @@ "protocol-ingress-guard.test.ts": "responses", "protocol-direct-encoders-chat.test.ts": "responses", "protocol-direct-encoders-messages.test.ts": "responses", + "chat-native-combo.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/chat-native-combo.test.ts b/tests/responses/chat-native-combo.test.ts new file mode 100644 index 00000000000..a979edd1cb8 --- /dev/null +++ b/tests/responses/chat-native-combo.test.ts @@ -0,0 +1,312 @@ +/** + * Native Chat candidates inside a combo (PF-07). + * + * With `protocols.rollout.nativeChatCombos` on, a Chat combo's openai-chat candidate is sent on + * the native Chat lane from the caller's own body, while every other candidate keeps the + * Chat -> Responses bridge. The cases drive the real Chat ingress against loopback upstreams and + * read what each upstream received, because the point of the change is the body on the wire and + * the sends the combo is allowed to make, neither of which an in-process stub can show. + */ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { getRequestLogEntries } from "../../src/server/request-log"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearComboRecallForTests } from "../../src/server/responses/combo-session-recall"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearResponseStateForTests, flushResponseState } from "../../src/responses/state"; +import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; +import { chatErrorStream, chatStream, responsesSuccess } from "../helpers/combo-failover-upstream"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +type Rec = Record; + +// A transient ladder with backoff plus a loopback failover can pass 5s under suite load. +setDefaultTimeout(30_000); + +const MESSAGES = [{ role: "user", content: "fixture" }]; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let releaseSpendHome: (() => void) | undefined; +const servers: Array> = []; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-chat-native-combo-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-chat-native-combo-")); + process.env.OPENCODEX_HOME = testDir; + // Taken after the home is installed so the physical sends own this journal. + releaseSpendHome = acquireOwnedSpendHome(); + clearComboSelectionState(); + clearComboRecallForTests(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + clearResponseStateForTests(); +}); + +afterEach(async () => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + for (const server of servers.splice(0)) await server.stop(true); + await flushResponseState(); + clearResponseStateForTests(); + clearComboSelectionState(); + clearComboRecallForTests(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + resetProviderRequestPacingForTest(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); +}); + +/** A loopback upstream that records every body it receives. */ +function upstream(answer: (body: Rec, hit: number) => Response | Promise) { + const bodies: Rec[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const body = await request.json() as Rec; + bodies.push(body); + return answer(body, bodies.length); + }, + }); + servers.push(server); + return { bodies, baseUrl: new URL("/v1", server.url).href }; +} + +function provider(adapter: string, baseUrl: string, extra: Partial = {}): OcxProviderConfig { + return { adapter, baseUrl, allowPrivateNetwork: true, authMode: "key", apiKey: `key-${adapter}`, ...extra }; +} + +/** A completed Responses stream, which is what the bridge always asks a Responses upstream for. */ +function responsesStream(text: string): Response { + const response = responsesSuccess(text, "m2"); + return new Response([ + `event: response.output_text.delta\ndata: ${JSON.stringify({ type: "response.output_text.delta", delta: text, item_id: "msg_backup", output_index: 0, content_index: 0 })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response })}\n\n`, + ].join(""), { headers: { "content-type": "text/event-stream" } }); +} + +/** A Chat completion carrying two choices with logprobs: only the native lane can return it. */ +function twoChoiceCompletion(): Response { + const choice = (index: number) => ({ + index, + message: { role: "assistant", content: `choice ${index}` }, + logprobs: { content: [{ token: "choice", logprob: -0.1, top_logprobs: [] }] }, + finish_reason: "stop", + }); + return Response.json({ + id: "chatcmpl-native", + object: "chat.completion", + model: "m1", + choices: [choice(0), choice(1)], + usage: { prompt_tokens: 2, completion_tokens: 2, total_tokens: 4 }, + }); +} + +function comboConfig( + providers: Record, + targets: Array<{ provider: string; model: string }>, + protocols?: OcxConfig["protocols"], +): OcxConfig { + return { + port: 0, + defaultProvider: Object.keys(providers)[0]!, + providers, + combos: { pair: { strategy: "failover", targets } }, + ...(protocols ? { protocols } : {}), + }; +} + +const NATIVE_ON: OcxConfig["protocols"] = { rollout: { nativeChatCombos: true } }; + +async function send(config: OcxConfig, body: Rec) { + const requestId = `pf07-${crypto.randomUUID()}`; + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/pair", messages: MESSAGES, ...body }), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + const text = await response.text(); + const rows = getRequestLogEntries().filter(entry => entry.requestId === requestId); + return { response, text, rows }; +} + +describe("native Chat candidates in a combo", () => { + test("a native candidate receives the caller's own Chat body, n and logprobs included", async () => { + const a = upstream(() => twoChoiceCompletion()); + const b = upstream(() => responsesStream("bridge")); + const config = comboConfig( + { a: provider("openai-chat", a.baseUrl), b: provider("openai-responses", b.baseUrl) }, + [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }], + NATIVE_ON, + ); + + const { response, text, rows } = await send(config, { stream: false, n: 2, logprobs: true, top_logprobs: 1 }); + + expect(response.status).toBe(200); + const completion = JSON.parse(text) as { choices: unknown[] }; + // Both choices reach the client: the Responses bridge would have folded them into one. + expect(completion.choices).toHaveLength(2); + expect(a.bodies).toHaveLength(1); + expect(a.bodies[0]).toMatchObject({ model: "m1", messages: MESSAGES, n: 2, logprobs: true, top_logprobs: 1, stream: false }); + expect(b.bodies).toHaveLength(0); + expect(rows).toHaveLength(1); + expect(rows[0]!.status).toBe(200); + expect(rows[0]!.provider).toBe("combo"); + expect(rows[0]!.protocolTrace).toMatchObject({ + inbound: "chat", mode: "native", requestPath: ["chat", "chat"], + attempts: [{ ordinal: 1, mode: "native", requestPath: ["chat", "chat"] }], + }); + }); + + test("a failed native candidate fails over to the bridge within the shared send budget", async () => { + // Five sends on its own ladder, but the combo's per-target budget holds one back for the + // second declared target: the native child may reach its upstream three times, not five. + const a = upstream(() => Response.json({ error: { message: "fixture outage", type: "server_error" } }, { status: 503 })); + const b = upstream(() => responsesStream("recovered on the bridge")); + const config = comboConfig( + { + a: provider("openai-chat", a.baseUrl, { transientRetryOn5xx: { attempts: 5 } }), + b: provider("openai-responses", b.baseUrl), + }, + [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }], + NATIVE_ON, + ); + + const { response, text, rows } = await send(config, { stream: false, n: 2 }); + + expect(response.status).toBe(200); + expect(text).toContain("recovered on the bridge"); + expect(a.bodies).toHaveLength(3); + for (const body of a.bodies) expect(body).toMatchObject({ n: 2, messages: MESSAGES }); + expect(b.bodies).toHaveLength(1); + // The bridge candidate got a Responses body, built from the source rather than from A's. + expect(b.bodies[0]).toHaveProperty("input"); + expect(b.bodies[0]).not.toHaveProperty("messages"); + expect(rows).toHaveLength(1); + expect(rows[0]!.attempts?.map(attempt => attempt.status)).toEqual([503, 200]); + expect(rows[0]!.protocolTrace).toMatchObject({ + inbound: "chat", + requestPath: ["chat", "responses"], + attempts: [ + { ordinal: 1, mode: "native", requestPath: ["chat", "chat"] }, + { ordinal: 2, requestPath: ["chat", "responses"] }, + ], + }); + }); + + test("a streamed native answer that fails after output is not re-sent to the next target", async () => { + const a = upstream(() => chatErrorStream("fixture broke mid-stream", "partial answer")); + const b = upstream(() => responsesStream("must not run")); + const config = comboConfig( + { a: provider("openai-chat", a.baseUrl), b: provider("openai-responses", b.baseUrl) }, + [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }], + NATIVE_ON, + ); + + const { response, text } = await send(config, { stream: true }); + + expect(response.status).toBe(200); + expect(text).toContain("partial answer"); + expect(a.bodies).toHaveLength(1); + expect(b.bodies).toHaveLength(0); + }); + + test("a folded native answer that fails after output stops the combo instead of hopping", async () => { + // The caller asked for JSON, so the native lane folds the stream before answering. Output + // already left the upstream, so the failure must end the combo, as it does on the bridge. + const a = upstream(() => chatErrorStream("fixture broke mid-stream", "partial answer")); + const b = upstream(() => responsesStream("must not run")); + const config = comboConfig( + { a: provider("openai-chat", a.baseUrl), b: provider("openai-responses", b.baseUrl) }, + [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }], + NATIVE_ON, + ); + + const { response } = await send(config, { stream: false }); + + expect(response.status).not.toBe(200); + expect(a.bodies).toHaveLength(1); + expect(b.bodies).toHaveLength(0); + }); + + test("with the switch off the same combo keeps the Responses bridge", async () => { + const a = upstream(body => body.stream === true ? chatStream("bridged") : twoChoiceCompletion()); + const b = upstream(() => responsesStream("unused")); + const config = comboConfig( + { a: provider("openai-chat", a.baseUrl), b: provider("openai-responses", b.baseUrl) }, + [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }], + ); + + const { response, text, rows } = await send(config, { stream: false, n: 2 }); + + expect(response.status).toBe(200); + expect(text).toContain("bridged"); + expect(a.bodies).toHaveLength(1); + // The bridge always streams internally and cannot carry `n`. + expect(a.bodies[0]!.stream).toBe(true); + expect(a.bodies[0]).not.toHaveProperty("n"); + expect(rows[0]!.protocolTrace).toMatchObject({ mode: "legacy-bridge" }); + }); +}); + +describe("unrepresentable candidates under the reject policy", () => { + const REJECT: OcxConfig["protocols"] = { unrepresentable: "reject", rollout: { nativeChatCombos: true } }; + + test("a candidate whose path cannot carry n is skipped with its reason recorded", async () => { + const a = upstream(() => twoChoiceCompletion()); + const b = upstream(() => responsesStream("must not run")); + const config = comboConfig( + { a: provider("openai-chat", a.baseUrl), b: provider("openai-responses", b.baseUrl) }, + // The unrepresentable candidate is declared first, so only the skip can explain A serving. + [{ provider: "b", model: "m2" }, { provider: "a", model: "m1" }], + REJECT, + ); + + const { response, text, rows } = await send(config, { stream: false, n: 2 }); + + expect(response.status).toBe(200); + expect((JSON.parse(text) as { choices: unknown[] }).choices).toHaveLength(2); + expect(b.bodies).toHaveLength(0); + expect(a.bodies).toHaveLength(1); + expect(rows[0]!.attempts).toHaveLength(1); + expect(rows[0]!.protocolTrace).toMatchObject({ mode: "native" }); + expect(rows[0]!.protocolTrace!.reasonCodes).toContain("feature-unrepresentable"); + }); + + test("when every candidate is skipped the combo answers the ingress refusal with no send", async () => { + const b = upstream(() => responsesStream("must not run")); + const config = comboConfig( + { b: provider("openai-responses", b.baseUrl) }, + [{ provider: "b", model: "m2" }], + REJECT, + ); + + const { response, text, rows } = await send(config, { stream: false, n: 2 }); + + expect(response.status).toBe(400); + expect(JSON.parse(text)).toMatchObject({ error: { + type: "invalid_request_error", + code: "unsupported_feature", + message: "The selected route cannot carry these request features: request.multiple_choices", + } }); + expect(b.bodies).toHaveLength(0); + expect(rows).toHaveLength(1); + expect(rows[0]!.status).toBe(400); + expect(rows[0]!.protocolTrace).toMatchObject({ + inbound: "chat", mode: "blocked", requestPath: [], reasonCodes: ["feature-unrepresentable"], + }); + }); +}); From b252c0185b5b94b0236ac0fa09783d1b3eab8845 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:02:33 +0900 Subject: [PATCH 093/173] docs(structure): describe native Chat candidates in combos Records how a combo child is settled, judged and sent natively, how its sends and final row are owned, and why a marked child skips the stream preflight. --- structure/data-planes/protocol-paths.md | 22 ++++++++-- structure/transports/responses.md | 56 +++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index bd0975c61c5..a792eb3d4cb 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -140,7 +140,8 @@ Only when `resolveProtocolSettings(config).unrepresentable === "reject"` do the ingresses build an envelope and run the guard, after the route and its wire settle and before the request is sent: Chat on the native path when the native lane was chosen, otherwise on the bridge path to the settled adapter's wire; Messages on the bridge path. Combo and policy routes -and an unroutable model are not judged at ingress. A refusal answers 400 in the ingress's own +and an unroutable model are not judged at ingress; with `nativeChatCombos` on, a Chat combo's +candidates are judged one by one inside the combo loop (below). A refusal answers 400 in the ingress's own error shape (Chat `invalid_request_error` / `unsupported_feature`; Anthropic `invalid_request_error`) naming feature keys only, marks the trace blocked, and writes the final log row with no upstream send. Under the default `legacy` policy nothing is built and the @@ -181,6 +182,21 @@ attempt is traced with `markAttemptProtocolPath`: the request path is still the side still decodes through the Responses projection, and the response path is `[upstream, "ir", inbound]`. +## Native Chat candidates in combos + +With `protocols.rollout.nativeChatCombos` on, the Chat ingress hands a combo route its source +envelope, and `src/server/responses/core-combo-native.ts` sends each candidate that passes +`isNativeChatRouteEligible` on the native Chat lane from its own `freshBody()` copy, marking that +attempt `native` with `markAttemptProtocolPath`; other candidates keep the bridge and its +lane-derived path. The request's entry mark still says `bridge` with `combo-or-policy-route`, +because that is the lane the ingress chose; the final mode and paths follow the last attempt. +Under `reject` a candidate whose path cannot carry a requested feature is skipped before any send +and `feature-unrepresentable` is added to the entry mark; if every enabled candidate is skipped the +combo returns the ingress refusal and a blocked trace. With the switch off, combos are not judged +per candidate. Policy routes select a single candidate in the router and stay on the bridge. The +transport side (send budget, failover, logging) is in +[Responses transport](../transports/responses.md#native-chat-candidates-in-combos). + ## Settings `resolveApiSurfaceSettings` and `resolveProtocolSettings` in `src/protocols/settings.ts` are the @@ -190,8 +206,8 @@ 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. The Chat and Messages ingresses read the unrepresentable policy (above), and -`directEncodersApply` reads `directEncoders` on both; no request path reads the other rollout -switches yet. +`directEncodersApply` reads `directEncoders` on both, and the Chat ingress reads `nativeChatCombos` for +combo routes (above); no request path reads the other rollout switches yet. `claudeInboundDisabled` in `src/server/claude-messages.ts` is the Messages ingress reader: both `/v1/messages` and `/v1/messages/count_tokens` call it, so the two routes cannot disagree, and a diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 0d429facb37..b2344821351 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -431,7 +431,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; a streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, -`core-combo-failure.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, +`core-combo-failure.ts`, `core-combo-native.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, `core-opaque-recovery.ts` and `core-replay.ts`. `core-options.ts` owns the public option types and small composition contracts. Existing public helper names are re-exported by `core.ts`. Adapter construction remains with the existing registry; `fetch-helpers.ts` remains a leaf. @@ -471,7 +471,7 @@ reachable from `core.ts`. | `context.ts` | `createInferenceSendBudget(req, logCtx)` is the one construction of an ingress-owned send holder: the default guarded policy with this request's spend tracker as observer. `handleResponses` calls it only when no holder was inherited, because attaching the tracker parks it on `logCtx`. | | `final-log.ts` | `createFinalRequestLog(logIds, logCtx)` owns one request's final row: the first `finish(status, meta)` writes it, every later call is a no-op, and without log ids the claim settles with nothing written. The bridged Chat and Messages ingresses and native Chat finish through it. | | `attempt.ts` | `beginInferenceAttempt(logCtx, { provider, model, adapter })` opens the next attempt ordinal, makes it the active attempt with its start time, appends it to the request, and returns `seal(accountLabel?)` and `finish(status, usage?)`. | -| `client-wire.ts` | `markClientWire(response, protocol)` / `clientWireOf(response)` record, per `Response` identity, that a body is already in a client's wire. `createClientWireLog` / `attachClientWireLog` / `clientWireLogOf` carry the request-log facts of such a body (a start payload, one terminal, a cancel), buffered until the deferred log subscribes. | +| `client-wire.ts` | `markClientWire(response, protocol)` / `clientWireOf(response)` record, per `Response` identity, that a body is already in a client's wire. `createClientWireLog` / `attachClientWireLog` / `clientWireLogOf` carry the request-log facts of such a body (a start payload, one terminal, a cancel), buffered until the deferred log subscribes. The combo marks a native Chat child's answer and its refusal of an all-unrepresentable combo as `chat` (below). | | `client-wire-log.ts` | `recordClientWireRequestLog` is the deferred request log of a client-wire response: `responseWithDeferredRequestLog` calls it instead of tapping the body, and it applies the Responses SSE tap's rules to the reported facts (payload inspection until the terminal, `terminal_sse` phase, `httpStatusForRequestLogTerminal`, 499 for a cancel before any terminal, one row). | | `client-encoder-delivery.ts` | Direct Chat/Messages delivery (PF-09), described below. | @@ -519,8 +519,56 @@ Native Chat in `src/server/chat-native.ts` is split in two. `handleNativeChatCom the attempt and owns the final log row; `runNativeChatAttempt(execution, attemptHandle)` runs effort normalization, the send loop, key failover, 429 replay, relay and usage, and reports each outcome through the `finishLog` it is given. A caller that owns a different final row can -therefore run a native attempt without the attempt writing that row itself. Native Chat keeps -its own spend tracker rather than a send holder. +therefore run a native attempt without the attempt writing that row itself. A standalone native +Chat request keeps its own spend tracker rather than a send holder; a combo child is handed the +combo's per-target budget instead (below). + +### Native Chat candidates in combos + +`protocols.rollout.nativeChatCombos` (off by default) lets a Chat combo send an eligible +candidate on the native Chat lane. The Chat ingress supplies `HandleResponsesOptions.protocolSource` +(`ComboProtocolSource` in `core-combo-native.ts`: the source envelope and a native dispatcher) +only for a combo route with the switch on and no effort row; without it the combo loop runs +exactly as before, and a bridge child never inherits it. + +For the candidate about to dispatch, `core-combo-native.ts` settles the concrete route the way the +Chat ingress settles a single route (the key's model scope, the static policy for a Chat inbound, +the wire override, the OpenCode Go transport) and applies `isNativeChatRouteEligible` to a fresh +`envelope.freshBody()` copy whose `model` is the concrete selector. A candidate whose adapter is +not `openai-chat` is decided without a copy. An eligible child is dispatched through +`protocolSource.dispatchNativeChild`, which runs `runNativeChatAttempt` on the attempt the combo +already opened (its opening stays hand-rolled: the ordinal comes from the parent context while the +active attempt and requested effort land on the child context, which `beginInferenceAttempt` +does not express). The child gets the combo's per-target send budget, the client's abort signal +and the turn lease, and the combo's reasoning-effort policy mapped onto `reasoning_effort` +through `concreteComboRequestBody`, so the two lanes cannot disagree on effort. It records its +attempt path as native (`[chat, chat]`) and its answer is marked `chat`. + +Send accounting: the combo's hop reservation already booked the target's first send, so the native +child opens no spend tracker; it reports each physical send to the target budget (the first +settles the hop's booking, each later one is charged and booked by the request's one tracker), its +transient ladder and 429 replays are capped by the shared base allowance at the same cap its own +ladder uses, and a refusal answers 429 `request_send_budget_exhausted` in the Chat shape. + +A marked child skips `preflightComboStreamResponse`: native Chat reports a pre-stream failure by +HTTP status before any byte, and a non-OK answer goes through `consumeComboFailure` unchanged. A +non-OK answer produced after the child observed output (a folded non-streaming answer that failed +mid-stream) is marked non-replayable, so the combo stops rather than re-running a turn that +already ran upstream. The child's final-log callback never writes the parent's row: a terminal, +cancellation or folded success publishes through the combo's child callback gate, which reaches +the Chat ingress's final-row owner only after the combo commits, and usage learned after the +merge is carried to the parent first. The Chat ingress returns a `chat`-marked success as it is; +only a `chat`-marked refusal goes through `responseWithDeferredRequestLog`, whose SSE inspector +reads Responses events and would misread a Chat stream. + +Under `unrepresentable: "reject"`, each candidate's path (native, or the bridge to its settled +adapter's wire) is judged with `checkRepresentable`; a failing candidate is excluded from the pick +predicate and `feature-unrepresentable` is appended to the request's trace entry mark. When every +enabled candidate is skipped, the combo answers the ingress's refusal (400 `unsupported_feature` +in the Chat shape, blocked trace) with no send. `n > 1` is never emulated with several +inferences. Policy routes resolve one candidate in `routeModel` and never reach this loop, so they +are not migrated. `tests/responses/chat-native-combo.test.ts` pins the source body, failover +within the shared budget, no resend after output, the switch-off path and both reject outcomes. ## Adapter-to-Responses bridge From 4cb30da62576881389b491ddfa8d4cc96d7a2a8d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:02:33 +0900 Subject: [PATCH 094/173] docs(devlog): record what PF-07 leaves on the bridge Policy routes never reach the combo loop, effort-row combos keep the bridge, and a streamed native child commits without the zero-output preflight. --- .../040_acceptance_and_rollout.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 63819559160..d95a6b0cf3b 100644 --- a/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md +++ b/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md @@ -40,7 +40,10 @@ Kept current by each packet that migrates something. |---|---| | Chat/Messages request decode | still produces a Responses-shaped body before the IR (`responses-internal`); codecs are named entry points over the existing translators | | Chat/Messages response encode (PF-09) | migrated behind `directEncoders` for one concrete non-Responses route in the streaming adapter delivery: `[upstream, ir, client]`. Still through `responses-internal`: combo and policy children, routed compaction, run-turn adapters (Cursor, Devin, coding-agent CLIs, CodeBuddy), sidecar turns, the buffered `parseResponse` branch (unused by these ingresses, which always stream internally), and every route while the switch is off. Responses-wire upstreams keep their existing codec path | -| Policy-route children | native Chat only if dispatched through the combo child loop (PF-07 records the outcome) | +| Policy-route children | not migrated (PF-07): `routeModel` evaluates the policy and returns one concrete candidate, so a policy request never reaches the combo child loop and keeps the Chat bridge | +| Chat combos with `nativeChatCombos` off | bridge for every candidate, and not judged per candidate under `reject` (the ingress guard also skips combos) | +| Chat combos reached through an effort row | bridge (PF-07): the row's effort lives only on the Responses body, so the native source is not supplied | +| Native Chat combo child, streamed, zero-output in-band failure | no hop (PF-07): the child's 200 is committed without `preflightComboStreamResponse`, so a failure frame before any output reaches the client instead of the next target; the bridge child would have hopped | | 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 | From 8f359efe8c91610649c824553afd090e53ef049a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:03:34 +0900 Subject: [PATCH 095/173] feat(protocols): preview a Chat combo's candidates as the combo now sends them With nativeChatCombos on the combo loop judges each candidate as its concrete route, so the plan snapshot does too; otherwise preview and observed trace would disagree. --- src/protocols/plan-snapshot.ts | 5 ++++- structure/data-planes/protocol-paths.md | 4 +++- tests/responses/protocol-plan-snapshot.test.ts | 11 +++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/protocols/plan-snapshot.ts b/src/protocols/plan-snapshot.ts index 75eb207ef20..a45f6c5d66c 100644 --- a/src/protocols/plan-snapshot.ts +++ b/src/protocols/plan-snapshot.ts @@ -109,11 +109,14 @@ function candidateFor( let declineReasons: ProtocolReasonCode[] = []; let nativeEligible = false; if (inbound === "chat") { + // With `nativeChatCombos` on, the combo loop judges each candidate as the concrete route it + // is (PF-07), so the preview must too; a policy still resolves one candidate on the bridge. + const comboChildNative = routeKind === "combo" && resolveProtocolSettings(config).rollout.nativeChatCombos; const settled: RouteResult = { ...route, provider, staticPolicy, - ...(routeKind === "direct" ? {} : { routeKind }), + ...(routeKind === "direct" || comboChildNative ? {} : { routeKind }), }; const reason = effortRow ? "effort-row" : nativeChatDeclineReason(settled, chatBodyForFeatures(features), config); nativeEligible = reason === undefined; diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index a792eb3d4cb..a68d33c9c25 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -110,7 +110,9 @@ without side effects. Combo and policy selectors are expanded from their configu policy evaluator; every other selector goes through `routeModel`'s deterministic branches. Each candidate's wire is settled the way the ingress settles it (`captureRouteStaticPolicy` for the original inbound, then `resolveWireProtocolOverride`), and a Chat candidate's native lane is judged -by `nativeChatDeclineReason` against a structural body built from the requested features. Messages +by `nativeChatDeclineReason` against a structural body built from the requested features. With +`nativeChatCombos` on, a combo's candidates are judged as the concrete routes they are, as the +combo loop judges them; policy candidates keep `combo-or-policy-route`. Messages caller-forward passthrough depends on the caller's own credential, so it is reported as `caller-credential-required` and never assumed. The OpenCode Go session-lane transport is not modelled. `tests/responses/protocol-plan-snapshot.test.ts` pins the no-side-effect property against diff --git a/tests/responses/protocol-plan-snapshot.test.ts b/tests/responses/protocol-plan-snapshot.test.ts index e18283b9fd7..2e8ef5f43d5 100644 --- a/tests/responses/protocol-plan-snapshot.test.ts +++ b/tests/responses/protocol-plan-snapshot.test.ts @@ -84,6 +84,17 @@ describe("buildProtocolPlanSnapshot", () => { expect(plan.candidates.map(c => c.mode)).toEqual(["legacy-bridge", "translated"]); }); + test("with nativeChatCombos on, a combo's Chat candidate is judged as its concrete route", () => { + const config = baseConfig({ protocols: { rollout: { nativeChatCombos: true } } } as Partial); + const snapshot = buildProtocolPlanSnapshot(config, { model: "combo/mixed", inbound: "chat", features: [] }); + expect(snapshot.candidates.map(c => [c.provider, c.nativeEligible, c.declineReasons])).toEqual([ + ["a", true, []], + ["r", false, ["cross-wire-ir"]], + ]); + const plan = previewProtocolPlan(config, { model: "combo/mixed", inbound: "chat", features: [] }); + expect(plan.candidates.map(c => c.mode)).toEqual(["native", "translated"]); + }); + test("a policy alias expands its configured candidates", () => { const snapshot = buildProtocolPlanSnapshot(baseConfig(), { model: "ocx/fast", inbound: "responses", features: [] }); expect(snapshot.routeKind).toBe("policy"); From 0ccac368f9511aeda8548057563cd510b97f7448 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:04:33 +0900 Subject: [PATCH 096/173] refactor(responses): keep the combo send-scope comment beside its code The protocol lanes were created between the send-scope comment and the scope it describes; move them above so each comment sits on the statement it explains. --- src/server/responses/core-combo.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 4feb3a69d60..de0e0d101d2 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -192,11 +192,6 @@ export async function executeComboResponses( if (!combo) { return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`); } - // The ladder's own scope, derived from what this combo DECLARES. It shares the request-wide - // counter with the holder that arrived on options -- a combo child already inherited that - // counter, but nothing read it as a limit across targets -- while its transition and - // alternate-target ledgers come from the target list rather than from the single-target - // account-move profile (#4546). // PF-07: present only for a Chat combo with `nativeChatCombos` on; otherwise every child // takes the bridge below exactly as before. const protocolLanes = createComboProtocolLanes({ @@ -208,6 +203,11 @@ export async function executeComboResponses( comboId, targets: combo.targets, }); + // The ladder's own scope, derived from what this combo DECLARES. It shares the request-wide + // counter with the holder that arrived on options -- a combo child already inherited that + // counter, but nothing read it as a limit across targets -- while its transition and + // alternate-target ledgers come from the target list rather than from the single-target + // account-move profile (#4546). const comboSendScope = isRequestExecutionBudget(options.sendBudget) ? deriveSendBudgetScope(options.sendBudget, comboExecutionBudgetPolicy(combo.targets.length)) : undefined; From 685385f0fb38670f97934b49c0b726eecf8d90b6 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:54:26 +0900 Subject: [PATCH 097/173] refactor(anthropic): share the Messages URL, version and key-auth headers The adapter's endpoint, pinned anthropic-version and key placement move into exported helpers so a second Messages sender can reuse them instead of restating them. --- src/adapters/anthropic.ts | 49 ++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 8f17a2710b1..95839988c75 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -523,6 +523,39 @@ function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean { return provider.apiKeyTransport === "bearer"; } +/** The `anthropic-version` every Messages request from this proxy pins. */ +export const ANTHROPIC_API_VERSION = "2023-06-01"; + +/** + * The fixed headers of every Messages request this proxy builds, before credentials. Shared by + * the adapter and the managed native lane so both pin the same version and client identity. + */ +export function anthropicBaseRequestHeaders(stream: boolean | undefined): Record { + return { + "Content-Type": "application/json", + "anthropic-version": ANTHROPIC_API_VERSION, + "Accept": stream ? "text/event-stream" : "application/json", + "User-Agent": "@anthropic-ai/sdk/0.74.0", + }; +} + +/** Key-auth credential placement: `x-api-key`, or a bearer when the provider asks for one. */ +export function applyAnthropicKeyAuth(headers: Record, provider: OcxProviderConfig): void { + if (typeof provider.apiKey !== "string") return; + if (anthropicKeyUsesBearer(provider)) headers["Authorization"] = `Bearer ${provider.apiKey}`; + else headers["x-api-key"] = provider.apiKey; +} + +/** The provider's Messages endpoint, refusing a base URL with an unresolved `{placeholder}`. */ +export function resolveAnthropicMessagesUrl(provider: Pick): string { + const url = anthropicMessagesUrl(provider.baseUrl); + const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0]; + if (unresolvedPlaceholder) { + throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`); + } + return url; +} + /** Map a Responses reasoning effort to an Anthropic extended-thinking budget (tokens, >= 1024). */ function reasoningBudget(effort: string): number { switch (effort) { @@ -1129,22 +1162,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti body.tool_choice = { ...settledToolChoice, disable_parallel_tool_use: true }; } - const url = anthropicMessagesUrl(provider.baseUrl); - const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0]; - if (unresolvedPlaceholder) { - throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`); - } + const url = resolveAnthropicMessagesUrl(provider); // Anthropic fast mode: `speed` is only accepted beside its beta; without it the API // answers 400 "speed: Extra inputs are not permitted". The beta is merged below, after // any header override, so a request never carries one without the other. const fastSpeed = anthropicFastSpeed(parsed, provider); if (fastSpeed) body.speed = fastSpeed.value; - const headers: Record = { - "Content-Type": "application/json", - "anthropic-version": "2023-06-01", - "Accept": parsed.stream ? "text/event-stream" : "application/json", - "User-Agent": "@anthropic-ai/sdk/0.74.0", - }; + const headers = anthropicBaseRequestHeaders(parsed.stream); if (isOAuth) { headers["Authorization"] = `Bearer ${provider.apiKey}`; headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA; @@ -1155,8 +1179,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti headers["X-Claude-Code-Session-Id"] = claudeCodeSessionId(provider.apiKey); headers["x-client-request-id"] = crypto.randomUUID(); } else { - if (anthropicKeyUsesBearer(provider)) headers["Authorization"] = `Bearer ${provider.apiKey}`; - else headers["x-api-key"] = provider.apiKey; + applyAnthropicKeyAuth(headers, provider); } if (provider.headers) Object.assign(headers, provider.headers); mergeAnthropicBetaHeader(headers, fastSpeed?.betas ?? []); From f8ba968456e418d0a839774f222848b40d9ea1e4 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:54:26 +0900 Subject: [PATCH 098/173] feat(anthropic): build a managed native Messages request from the source body Allowlisted source fields, the wire model and the provider's own key; no caller header is read, so the managed lane cannot inherit caller-forward authority. --- src/adapters/anthropic/passthrough.ts | 87 +++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/adapters/anthropic/passthrough.ts diff --git a/src/adapters/anthropic/passthrough.ts b/src/adapters/anthropic/passthrough.ts new file mode 100644 index 00000000000..9c10de68001 --- /dev/null +++ b/src/adapters/anthropic/passthrough.ts @@ -0,0 +1,87 @@ +/** + * Managed native Messages request builder (PF-08). + * + * The request a proxy-managed Anthropic key sends when the client already spoke Messages: the + * caller's own body, cut to a fixed field allowlist, with the wire model and the provider's + * credential. URL, `anthropic-version`, client identity and credential placement come from the + * same helpers the Anthropic adapter uses, so the two lanes cannot drift apart. + * + * Authority: only the provider's configured key is ever placed on the request. No caller header + * is read here at all — the caller-forward passthrough in `src/server/claude-messages.ts` is the + * only place a caller's Anthropic credential may travel, and it does not come through here. + */ +import type { OcxConfig, OcxProviderConfig } from "../../types"; +import { anthropicBaseRequestHeaders, applyAnthropicKeyAuth, resolveAnthropicMessagesUrl } from "../anthropic"; + +/** + * Top-level Messages fields the native lane forwards. Everything else is dropped: an unknown or + * beta-gated field would otherwise reach the provider unchecked. None of the dropped fields has + * a name in the protocol feature vocabulary, so no feature effect is recorded for them. + */ +export const ANTHROPIC_MESSAGES_PASSTHROUGH_FIELDS = [ + "model", + "messages", + "system", + "max_tokens", + "metadata", + "stop_sequences", + "stream", + "temperature", + "top_p", + "top_k", + "tools", + "tool_choice", + "thinking", + "output_config", + "service_tier", +] as const; + +const PASSTHROUGH_FIELD_SET: ReadonlySet = new Set(ANTHROPIC_MESSAGES_PASSTHROUGH_FIELDS); + +export interface AnthropicMessagesPassthroughRequest { + url: string; + headers: Record; + /** The serialized wire body. */ + body: string; + /** The same body before serialization, for callers that count or inspect what is sent. */ + wireBody: Record; +} + +/** The allowlisted copy of `body` with `model` set to the wire model. Shallow: nothing is cloned. */ +export function anthropicMessagesPassthroughBody( + body: Readonly>, + modelId: string, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (PASSTHROUGH_FIELD_SET.has(key) && value !== undefined) out[key] = value; + } + out.model = modelId; + return out; +} + +/** + * Build the upstream request. Throws the adapter's own errors for a missing key or a malformed + * or unresolved base URL. `config` is accepted for parity with the other passthrough builders; + * no config key changes the wire today. + */ +export function buildAnthropicMessagesPassthroughRequest( + provider: OcxProviderConfig, + modelId: string, + body: Readonly>, + _config?: OcxConfig, +): AnthropicMessagesPassthroughRequest { + if (provider.authMode !== undefined && provider.authMode !== "key") { + throw new Error("managed native Messages requires a key-auth anthropic provider"); + } + if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") { + throw new Error("anthropic provider requires a non-empty apiKey (authMode: key)"); + } + const url = resolveAnthropicMessagesUrl(provider); + const wireBody = anthropicMessagesPassthroughBody(body, modelId); + const headers = anthropicBaseRequestHeaders(wireBody.stream === true); + applyAnthropicKeyAuth(headers, provider); + // Operator-configured provider headers apply exactly as the adapter applies them. + if (provider.headers) Object.assign(headers, provider.headers); + return { url, headers, body: JSON.stringify(wireBody), wireBody }; +} From 23fd503e649f566dcfe2a0437f36fe67429debde Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:54:32 +0900 Subject: [PATCH 099/173] feat(claude): name why a Messages route declines the managed native lane One pure rule for the ingress, count_tokens and the planner: switch, anthropic adapter, key auth, no combo or policy, no synthetic effort or fast row, no vision preprocessing. --- src/server/messages-native-eligibility.ts | 71 +++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/server/messages-native-eligibility.ts diff --git a/src/server/messages-native-eligibility.ts b/src/server/messages-native-eligibility.ts new file mode 100644 index 00000000000..1b1742b30a4 --- /dev/null +++ b/src/server/messages-native-eligibility.ts @@ -0,0 +1,71 @@ +/** + * Whether a settled Messages route may take the managed native Messages lane (PF-08). + * + * PURE: reads the route, the body's structure and config, and nothing else. The Messages ingress, + * `count_tokens` and the protocol planner (`src/protocols/plan-snapshot.ts`) all ask here, so a + * preview, a count and the real send cannot disagree about the lane. + */ +import type { ProtocolReasonCode } from "../protocols/contract"; +import { featuresFromMessagesBody } from "../protocols/features"; +import { resolveProtocolSettings } from "../protocols/settings"; +import type { RouteResult } from "../router"; +import type { OcxConfig } from "../types"; +import { requiresVisionPreprocessing } from "../vision"; + +/** Why the managed native Messages lane declines a route, as a protocol reason code. */ +export type NativeMessagesDeclineReason = Extract< + ProtocolReasonCode, + | "rollout-disabled" + | "cross-wire-ir" + | "auth-mode-not-native" + | "combo-or-policy-route" + | "effort-row" + | "fast-row" + | "vision-preprocessing" +>; + +/** Model-selector facts the body cannot carry: a synthetic effort or fast row was requested. */ +export interface NativeMessagesSelector { + effortRow?: boolean; + fastRow?: boolean; +} + +/** + * The first rule that keeps a Messages request off the managed native lane, or `undefined` when + * the route is eligible. + * + * - the `protocols.rollout.managedMessagesNative` switch is off; + * - the final adapter is not `anthropic`; + * - the credential is not a proxy-managed key (OAuth is PF-10; `forward` belongs to the caller); + * - a combo or policy route owns multi-candidate execution in the Responses pipeline; + * - a synthetic effort or fast row needs the adapter that owns its wire rewrite; + * - an image would reach a model the operator declared unable to read it. + */ +export function nativeMessagesDeclineReason( + route: RouteResult, + body: Readonly>, + config: OcxConfig, + selector: NativeMessagesSelector = {}, +): NativeMessagesDeclineReason | undefined { + if (!resolveProtocolSettings(config).rollout.managedMessagesNative) return "rollout-disabled"; + const provider = route.provider; + if (provider.adapter !== "anthropic") return "cross-wire-ir"; + if (provider.authMode !== undefined && provider.authMode !== "key") return "auth-mode-not-native"; + if (route.combo || route.routeKind === "combo" || route.routeKind === "policy") return "combo-or-policy-route"; + if (selector.effortRow) return "effort-row"; + if (selector.fastRow) return "fast-row"; + if (featuresFromMessagesBody(body).has("request.images") + && requiresVisionPreprocessing(config, provider, route.modelId, route.providerName)) { + return "vision-preprocessing"; + } + return undefined; +} + +export function isNativeMessagesRouteEligible( + route: RouteResult, + body: Readonly>, + config: OcxConfig, + selector?: NativeMessagesSelector, +): boolean { + return nativeMessagesDeclineReason(route, body, config, selector) === undefined; +} From 7024b5bcabc43276ec17917daa3ff518d7ab5a48 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:54:32 +0900 Subject: [PATCH 100/173] feat(claude): send eligible managed-key Messages natively Modelled on native Chat: shared attempt, final log, spend tracker, proactive key pick, 401/429 key failover and replay; SSE relays through the existing Anthropic log tap. --- src/server/claude-messages.ts | 4 +- src/server/messages-native.ts | 618 ++++++++++++++++++++++++++++++++++ 2 files changed, 620 insertions(+), 2 deletions(-) create mode 100644 src/server/messages-native.ts diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 996f67845ed..64924b7ffbb 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -254,7 +254,7 @@ function uuidFromHex(hex32: string): string { return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`; } -function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; outputTokens: number; cachedInputTokens?: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number } | undefined { +export function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; outputTokens: number; cachedInputTokens?: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number } | undefined { if (!usage) return undefined; const num = (v: unknown) => typeof v === "number" ? v : 0; const hasCache = usage.cache_read_input_tokens !== undefined || usage.cache_creation_input_tokens !== undefined; @@ -448,7 +448,7 @@ export function tapAnthropicSseForLog( * An empty id has no representable wire form, and forwarding `""` is what Anthropic * rejects (#1767), so the request fails locally with a 400 before any upstream fetch. */ -function sanitizePassthroughToolCallIds(messages: unknown[]): void { +export function sanitizePassthroughToolCallIds(messages: unknown[]): void { const blocks: Rec[] = []; for (const message of messages) { if (!isRec(message) || !Array.isArray(message.content)) continue; diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts new file mode 100644 index 00000000000..3fb7a499b3f --- /dev/null +++ b/src/server/messages-native.ts @@ -0,0 +1,618 @@ +/** + * Managed native Messages lane (PF-08, behind `protocols.rollout.managedMessagesNative`). + * + * A Messages request whose settled route is a proxy-managed key on the `anthropic` adapter is + * sent as Messages: the source body (after the ingress's managed-client steps) cut to a field + * allowlist, the wire model, and the provider's own key. Modelled on the native Chat lane and + * built on the same shared pieces — attempt row, finish-once final log, spend tracker, proactive + * key selection, key failover and 429 replay, connection policy — so nothing the Responses + * pipeline enforces is bypassed. + * + * Authority. This lane never reads a caller header. The caller-forward passthrough in + * `claude-messages.ts` (the caller's own Anthropic credential) is a different branch decided + * before this one, and nothing here can reach it or be reached from it. + * + * Loaded lazily by `claude-messages.ts` only when the switch is on and a route is eligible. + */ +import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard"; +import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; +import { formatAnthropicErrorBody } from "../adapters/anthropic"; +import { + buildAnthropicMessagesPassthroughRequest, + type AnthropicMessagesPassthroughRequest, +} from "../adapters/anthropic/passthrough"; +import { resolveInboundModel } from "../claude/inbound"; +import { anthropicErrorBody, anthropicErrorResponse, collectAnthropicMessage } from "../claude/outbound"; +import type { AdmissionLease } from "../lib/admission"; +import { readBoundedResponseBody } from "../lib/bounded-body"; +import { classifyError } from "../lib/errors"; +import { redactSecretString } from "../lib/redact"; +import { resolveClientRetryAfter } from "../lib/retry-after"; +import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget"; +import { + applyReplayRefusalClientHeaders, + applyUpstreamRecoveryInit, + fetchWithResetRetry, + fetchWithTransientRetry, + isNonReplayableResponse, + isReplayRefusalResponse, + isTransientUpstreamStatus, + prepareSameTarget429Wait, + REPLAY_REFUSED_STATUS, + retainReplayRefusal, + UpstreamRetryEvidenceError, + type UpstreamSendRecovery, + UPSTREAM_RESET_REPLAY_REFUSED_CODE, +} from "../lib/upstream-retry"; +import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection"; +import { + hasKeyPoolFailover, + rateLimitRetryDelayMs, + rateLimitRetryPolicyFor, + rotateProviderTransportOn401, + rotateProviderTransportOn429, + selectProactiveApiKeyTransport, + transientRetryPolicyFor, +} from "../providers/key-failover"; +import { stampApiKeyAccountLabel } from "../providers/label"; +import type { OcxProviderTransport } from "../providers/xai-transport"; +import { preservesPhysicalComboProvider, resolveComboId } from "../combos"; +import { captureRouteStaticPolicy, routeModel, type RouteResult } from "../router"; +import { POLICY_NAMESPACE, resolvePolicyProfileId } from "../routing/profile"; +import type { OcxConfig, OcxProviderConfig, OcxUsage } from "../types"; +import { resolveWireProtocolOverride } from "./adapter-resolve"; +import { + anthropicUsageToOcx, + estimateClaudeRequestTokens, + resolvePassthroughBodyGuard, + sanitizePassthroughToolCallIds, + tapAnthropicSseForLog, +} from "./claude-messages"; +import { beginInferenceAttempt } from "./inference/attempt"; +import { createFinalRequestLog, type FinalRequestLogMeta } from "./inference/final-log"; +import { registerTurn, unregisterTurn } from "./lifecycle"; +import { nativeMessagesDeclineReason, type NativeMessagesSelector } from "./messages-native-eligibility"; +import { + noteProviderAttemptSend, + recordAttemptCredentialSource, + recordFirstOutput, + recordKeyAttemptFailure, + recordKeyWireAttemptUsage, + type RequestLogContext, +} from "./request-log"; +import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers"; +import { linkAbortSignal } from "./responses/core-lifetime"; +import { attachRequestSpendTracker } from "./responses/request-spend"; +import { workflowRefusalResponse } from "./workflow-refusal"; + +export { + isNativeMessagesRouteEligible, + nativeMessagesDeclineReason, + type NativeMessagesDeclineReason, +} from "./messages-native-eligibility"; + +type Rec = Record; + +const MAX_NATIVE_MESSAGES_JSON_BYTES = 32 * 1024 * 1024; +const MAX_NATIVE_MESSAGES_ERROR_BYTES = 64 * 1024; + +class NativeMessagesSpendRefusal extends Error {} + +function isRec(value: unknown): value is Rec { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export interface HandleNativeMessagesOptions { + req: Request; + config: OcxConfig; + logCtx: RequestLogContext; + logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease }; + /** The settled, eligible route (see `isNativeMessagesRouteEligible`). */ + route: RouteResult; + /** + * The Messages body after the ingress's managed-client steps. Owned by this lane from here + * on: a fresh envelope copy when an envelope exists, otherwise the ingress's own body. + */ + body: Rec; + /** The selector the client sent, echoed on the request log. */ + requestedModel: string; + translatorBudget: TranslatorBudget; +} + +type FinishLog = (status: number, message?: string, closeReason?: FinalRequestLogMeta["closeReason"]) => void; + +/** Relay an upstream body unchanged, recording first output on its first non-empty chunk. */ +function observeFirstChunk(body: ReadableStream, onFirst: () => void): ReadableStream { + const reader = body.getReader(); + let seen = false; + return new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + if (!seen && value.byteLength > 0) { + seen = true; + onFirst(); + } + controller.enqueue(value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); +} + +/** A minimal valid Messages stream for a streaming caller whose upstream answered JSON. */ +function messageAsSse(message: Rec): string { + const frames: string[] = []; + const emit = (name: string, data: Rec) => frames.push(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`); + emit("message_start", { type: "message_start", message: { ...message, content: [], stop_reason: null } }); + const blocks = Array.isArray(message.content) ? message.content.filter(isRec) : []; + blocks.forEach((block, index) => { + emit("content_block_start", { type: "content_block_start", index, content_block: block }); + emit("content_block_stop", { type: "content_block_stop", index }); + }); + emit("message_delta", { + type: "message_delta", + delta: { stop_reason: message.stop_reason ?? "end_turn", stop_sequence: message.stop_sequence ?? null }, + usage: message.usage ?? {}, + }); + emit("message_stop", { type: "message_stop" }); + return frames.join(""); +} + +/** Reject a body the Messages API cannot carry before anything is sent. */ +async function prepareNativeBody(body: Rec, signal: AbortSignal): Promise { + if (!Array.isArray(body.messages)) return; + // The adapter normally owns these; this lane bypasses it, so it runs the same steps as the + // caller-forward passthrough: tier-normalize and guard images, then repair tool-call ids. + await normalizeAnthropicImages(body.messages, { abortSignal: signal }); + enforceAnthropicImageLimits(body.messages); + sanitizePassthroughToolCallIds(body.messages); +} + +/** + * Handle one eligible Messages request natively. Opens the attempt, owns the request's final + * log row, and answers in the Messages wire (SSE or JSON per the caller's own `stream`). + */ +export async function handleNativeMessages(options: HandleNativeMessagesOptions): Promise { + const { req, config, logCtx, logIds, route, body, requestedModel, translatorBudget } = options; + const requestedStream = body.stream === true; + logCtx.inboundProtocol = "messages"; + logCtx.model = route.modelId; + logCtx.provider = route.providerName; + logCtx.providerAdapter = route.provider.adapter; + logCtx.requestedModel = requestedModel; + if (route.routeReason === "model-alias" || route.modelId !== requestedModel) logCtx.requestedAlias = requestedModel; + logCtx.requestedServiceTier = typeof body.service_tier === "string" ? body.service_tier : undefined; + // Reserve spend the way native Chat does: an input estimate that never enters usage, and the + // caller's own output ceiling. + if (logCtx.usageLogInputTokens === undefined) { + logCtx.spendInputEstimateTokens = estimateClaudeRequestTokens(body, requestedModel); + } + if (typeof body.max_tokens === "number" && body.max_tokens > 0) { + logCtx.spendOutputCeilingTokens = Math.trunc(body.max_tokens); + } + + const attemptHandle = beginInferenceAttempt(logCtx, { + provider: route.providerName, + model: route.modelId, + adapter: "anthropic", + }); + attemptHandle.seal(logCtx.accountLogLabel); + const { attempt } = attemptHandle; + const finalLog = createFinalRequestLog(logIds, logCtx); + const finishLog: FinishLog = (status, message, closeReason = "non_stream") => { + if (finalLog.finished()) return; + if (message) logCtx.upstreamError = redactSecretString(message).slice(0, 500); + finalLog.finish(status, { closeReason }); + }; + const bindUsage = (usage: OcxUsage | undefined) => { + if (!usage) return; + if (!recordKeyWireAttemptUsage(logCtx, usage)) { + logCtx.usage = usage; + attempt.usage = usage; + } + }; + const fail = (status: number, message: string, type?: string, code?: string): Response => { + const safeMessage = redactSecretString(message); + finishLog(status, safeMessage); + return anthropicErrorResponse(status, safeMessage, type, code); + }; + + try { + await prepareNativeBody(body, req.signal); + } catch (error) { + if (req.signal.aborted) return fail(499, "Client cancelled request", "api_error"); + if (isTranslatorBudgetExceededError(error)) { + return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); + } + // An AnthropicRequestError (empty tool id) or an image the guard cannot carry. + return fail(400, error instanceof Error ? error.message : String(error), "invalid_request_error"); + } + + const upstream = new AbortController(); + const cleanupAbort = linkAbortSignal(upstream, req.signal); + let streamTurnRegistered = false; + const transferTurnToStream = () => { + const lease = logIds?.turnAdmissionLease; + if (!lease || typeof (lease as { bindAbortController?: unknown }).bindAbortController !== "function") return; + registerTurn(upstream, lease); + streamTurnRegistered = true; + }; + const releaseStreamTurn = () => { + if (!streamTurnRegistered) return; + streamTurnRegistered = false; + unregisterTurn(upstream); + }; + const connectMs = config.connectTimeoutMs ?? 200_000; + // Same pre-dispatch key preference every direct send path applies (see chat-native.ts). + const proactiveKeyProvider = selectProactiveApiKeyTransport(config, route.providerName, route.provider); + if (proactiveKeyProvider) route.provider = proactiveKeyProvider; + let activeProvider: OcxProviderConfig = route.provider; + stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider); + const spendTracker = attachRequestSpendTracker(req, logCtx); + let activeRequest: AnthropicMessagesPassthroughRequest; + let retainedRequestBytes = 0; + const releaseRetainedRequest = () => { + if (retainedRequestBytes === 0) return; + translatorBudget.releaseRetained(retainedRequestBytes, { kind: "request_copies" }); + retainedRequestBytes = 0; + }; + const retainRequest = (request: AnthropicMessagesPassthroughRequest) => { + const bytes = Buffer.byteLength(request.body); + translatorBudget.chargeRetained(bytes, { kind: "request_copies" }); + retainedRequestBytes = bytes; + }; + // Every rebuild reads the same `body`; the builder copies, so no credential or header from an + // earlier key survives into the next request. + const buildActiveRequest = () => { + recordAttemptCredentialSource(attempt, route.providerName, activeProvider, "anthropic"); + return buildAnthropicMessagesPassthroughRequest(activeProvider, route.modelId, body, config); + }; + const rebuildFor = (provider: OcxProviderConfig) => { + activeProvider = provider; + stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider); + releaseRetainedRequest(); + activeRequest = buildActiveRequest(); + retainRequest(activeRequest); + }; + try { + activeRequest = buildActiveRequest(); + retainRequest(activeRequest); + } catch (error) { + releaseRetainedRequest(); + cleanupAbort(); + upstream.abort(); + if (isTranslatorBudgetExceededError(error)) { + return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); + } + return fail(400, error instanceof Error ? error.message : String(error), "invalid_request_error"); + } + + // One inbound request owns one transient send allowance, captured before any key rotation. + const requestTransientPolicy = transientRetryPolicyFor(activeProvider); + let transientSendsUsed = 0; + const remainingTransientSends = (): number => requestTransientPolicy + ? Math.max(0, requestTransientPolicy.attempts - transientSendsUsed) + : Number.POSITIVE_INFINITY; + const transientSendAvailable = (): boolean => remainingTransientSends() > 0; + const selector: NativeMessagesSelector = {}; + + const send = async (recovery?: "rate-limit-429" | "key-429" | "key-401"): Promise => { + const remaining = remainingTransientSends(); + if (requestTransientPolicy && remaining <= 0) { + throw new Error("native Messages transient send budget exhausted before recovery dispatch"); + } + const fetchWithPolicy = requestTransientPolicy ? fetchWithTransientRetry : fetchWithResetRetry; + const request = activeRequest; + return await fetchWithPolicy( + (transportRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout( + request.url, + applyUpstreamRecoveryInit({ method: "POST", headers: request.headers, body: request.body }, transportRecovery), + upstream.signal, + connectMs, + requestedStream, + providerFetch(activeProvider, undefined, { + providerName: route.providerName, + modelId: route.modelId, + dispatchOverride: async (_input, init, execute) => { + if (!providerApiKeySelectionIsCurrent(config, route.providerName, activeProvider)) { + const current = resolveCurrentProviderApiKeyTransport(config, route.providerName, activeProvider); + if (!current || nativeMessagesDeclineReason({ ...route, provider: current }, body, config, selector) !== undefined) { + throw new Error("Provider key selection is no longer available for native Messages"); + } + rebuildFor(current); + } + // The retry closure may hold a pre-reselection request: send the current one whole. + const wire = activeRequest; + const headers = new Headers(wire.headers); + const encoding = new Headers(init.headers).get("accept-encoding"); + if (!headers.has("accept-encoding") && encoding) headers.set("accept-encoding", encoding); + if (init.signal?.aborted) throw init.signal.reason; + if (!spendTracker.charge()) throw new NativeMessagesSpendRefusal(); + noteProviderAttemptSend(logCtx, route.providerName, activeProvider, logCtx.usageLogInputTokens, transportRecovery ?? recovery); + const dispatched = await sendWithConnectionPolicy( + (activeProvider as OcxProviderTransport).fetch ?? execute, + wire.url, + applyUpstreamRecoveryInit({ ...init, method: "POST", headers, body: wire.body }, transportRecovery), + { providerName: route.providerName, provider: activeProvider }, + ); + if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal); + return dispatched; + }, + }), + ), + { + abortSignal: upstream.signal, + label: safeHostLabel(request.url), + ...(requestTransientPolicy + ? { + attempts: remaining, + onSendsConsumed: (sends: number) => { transientSendsUsed += Math.max(0, sends); }, + } + : {}), + }, + ); + }; + const discard = (response: Response) => { + try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } + }; + + let response: Response; + try { + response = await send(); + // A credential-scoped 401 on a static key pool says nothing about the sibling keys. + while (response.status === 401 && hasKeyPoolFailover(activeProvider) && transientSendAvailable()) { + const rotated = rotateProviderTransportOn401(config, route.providerName, activeProvider, { + now: Date.now(), + attemptedKey: activeProvider.apiKey, + }); + if (!rotated) break; + discard(response); + rebuildFor(rotated); + response = await send("key-401"); + } + const retryPolicy = rateLimitRetryPolicyFor(activeProvider); + let retries = 0; + // A refusal this proxy synthesized for a reset replay is not a provider rate limit. + while ( + response.status === 429 + && !isNonReplayableResponse(response) + && retryPolicy + && retries < retryPolicy.attempts + && transientSendAvailable() + ) { + retries += 1; + for await (const _ of prepareSameTarget429Wait({ + body: response.body, + signal: upstream.signal, + delayMs: rateLimitRetryDelayMs(retryPolicy, response.headers.get("retry-after"), Date.now()), + })) { /* pre-stream wait */ } + if (upstream.signal.aborted) throw upstream.signal.reason; + response = await send("rate-limit-429"); + } + while (response.status === 429 && !isNonReplayableResponse(response) && hasKeyPoolFailover(activeProvider)) { + const rotated = rotateProviderTransportOn429(config, route.providerName, activeProvider, { + retryAfter: response.headers.get("retry-after"), + now: Date.now(), + attemptedKey: activeProvider.apiKey, + attemptedSelection: activeProvider._apiKeyAttempt, + }); + if (!rotated) break; + // Rotation already recorded the cooldown; keep the terminal 429 when no send remains. + if (!transientSendAvailable()) break; + discard(response); + rebuildFor(rotated); + response = await send("key-429"); + } + } catch (error) { + releaseRetainedRequest(); + cleanupAbort(); + upstream.abort(); + if (req.signal.aborted) return fail(499, "Client cancelled request", "api_error"); + const sendError = error instanceof UpstreamRetryEvidenceError ? error.cause : error; + if (sendError instanceof NativeMessagesSpendRefusal) { + const refusal = workflowRefusalResponse("workflow-spend-exhausted", logCtx); + finishLog(429); + return refusal; + } + if (isTranslatorBudgetExceededError(error)) { + return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); + } + return fail(502, error instanceof Error ? error.message : String(error), "api_error"); + } + releaseRetainedRequest(); + + if (!response.ok) { + let bodyText = ""; + try { + const read = await readBoundedResponseBody(response, { signal: upstream.signal, maxBytes: MAX_NATIVE_MESSAGES_ERROR_BYTES }); + if (read.displaySafe) bodyText = read.text; + } catch { /* status-only fallback */ } + cleanupAbort(); + if (req.signal.aborted) { + upstream.abort(); + return fail(499, "Client cancelled request", "api_error"); + } + return nativeMessagesErrorResponse(response, bodyText, finishLog); + } + + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.includes("text/event-stream") && response.body) { + const bodyGuard = resolvePassthroughBodyGuard(config, req.signal); + const source = logIds ? observeFirstChunk(response.body, () => recordFirstOutput(logCtx, logIds.start)) : response.body; + if (requestedStream) { + transferTurnToStream(); + const relayed = tapAnthropicSseForLog(source, logCtx, (status, meta) => { + try { + cleanupAbort(); + bindUsage(logCtx.usage); + finishLog(status, undefined, meta.closeReason); + if (meta.closeReason !== "terminal") upstream.abort(); + } finally { + releaseStreamTurn(); + } + }, bodyGuard); + return new Response(relayed, { + status: 200, + headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive" }, + }); + } + // A non-streaming caller whose upstream streamed anyway: fold the stream into one message. + const tapState: { closeReason?: FinalRequestLogMeta["closeReason"] } = {}; + const tapped = tapAnthropicSseForLog(source, logCtx, (_status, meta) => { + tapState.closeReason = meta.closeReason; + }, bodyGuard); + try { + const message = await collectAnthropicMessage(tapped, requestedModel, translatorBudget); + cleanupAbort(); + if (tapState.closeReason === "client_cancel" || req.signal.aborted) { + return fail(499, "Client cancelled request", "api_error"); + } + bindUsage(logCtx.usage); + if (message.type === "error") { + const error = isRec(message.error) ? message.error : {}; + return fail(502, typeof error.message === "string" ? error.message : "upstream stream failed", "api_error"); + } + finishLog(200); + return Response.json(message); + } catch (error) { + cleanupAbort(); + upstream.abort(); + if (req.signal.aborted) return fail(499, "Client cancelled request", "api_error"); + if (isTranslatorBudgetExceededError(error)) { + return fail(413, "upstream translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); + } + return fail(502, error instanceof Error ? error.message : String(error), "api_error"); + } + } + + let read; + try { + read = await readBoundedResponseBody(response, { + signal: upstream.signal, + maxBytes: MAX_NATIVE_MESSAGES_JSON_BYTES, + totalTimeoutMs: Math.max(connectMs, 5_000), + inactivityTimeoutMs: Math.max(connectMs, 5_000), + }); + } catch (error) { + cleanupAbort(); + upstream.abort(); + if (req.signal.aborted) return fail(499, "Client cancelled request", "api_error"); + return fail(502, error instanceof Error ? error.message : String(error), "api_error"); + } + cleanupAbort(); + if (read.oversized) { + upstream.abort(); + return fail(502, "upstream response exceeded the safe limit", "api_error", "translation_buffer_limit"); + } + let message: unknown; + try { + message = JSON.parse(read.text); + } catch { + return fail(502, "upstream returned malformed Messages JSON", "api_error"); + } + if (!isRec(message) || message.type !== "message") { + return fail(502, "upstream response was not a Messages result", "api_error"); + } + bindUsage(anthropicUsageToOcx(isRec(message.usage) ? message.usage : undefined)); + if (logIds) recordFirstOutput(logCtx, logIds.start); + try { + translatorBudget.chargeRetained(Buffer.byteLength(read.text) * 2, { kind: "live_transient" }); + } catch (error) { + if (isTranslatorBudgetExceededError(error)) { + return fail(502, "upstream translation buffer exceeded the safe limit", "api_error", "translation_buffer_limit"); + } + throw error; + } + finishLog(200); + if (requestedStream) { + return new Response(messageAsSse(message), { + status: 200, + headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" }, + }); + } + return new Response(read.text, { status: 200, headers: { "Content-Type": "application/json" } }); +} + +/** + * A non-OK upstream answer in Anthropic shape. Status and retry policy follow the translated + * Messages path, so a client sees the same contract on either lane: transient 5xx become 529 + * with a retry hint, and a proxy-side replay refusal keeps its no-retry marking. + */ +function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog): Response { + let upstreamType: string | undefined; + let upstreamMessage: string | undefined; + try { + const parsed = JSON.parse(bodyText) as Rec; + const details = isRec(parsed.error) ? parsed.error : parsed; + if (typeof details.type === "string") upstreamType = details.type; + if (typeof details.message === "string" && details.message.trim()) { + upstreamMessage = redactSecretString(details.message.trim()); + } + } catch { /* keep generic classification */ } + const detail = formatAnthropicErrorBody(response.status, response.headers, bodyText); + const message = upstreamMessage + ?? (detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`); + const classified = classifyError( + response.status, + upstreamType ?? (response.status === 401 ? "authentication_error" + : response.status === 429 ? "rate_limit_error" + : response.status >= 500 ? "api_error" : "invalid_request_error"), + message, + ); + const safeMessage = redactSecretString(classified.message); + const replayRefusal = isReplayRefusalResponse(response); + const transient = !replayRefusal && isTransientUpstreamStatus(response.status); + const status = replayRefusal ? REPLAY_REFUSED_STATUS : transient ? 529 : response.status; + const upstreamRetryAfter = response.headers.get("retry-after"); + const retryAfter = replayRefusal + ? undefined + : resolveClientRetryAfter({ status: response.status, message: safeMessage, upstreamRetryAfter }) + ?? (upstreamRetryAfter?.trim() === "0" ? "0" : undefined); + finishLog(response.status, safeMessage); + const headers = new Headers({ "Content-Type": "application/json" }); + if (retryAfter) headers.set("Retry-After", retryAfter); + else if (transient) headers.set("Retry-After", "2"); + if (replayRefusal) applyReplayRefusalClientHeaders(headers); + const out = new Response(JSON.stringify(anthropicErrorBody( + status, + safeMessage, + transient ? "overloaded_error" : upstreamType, + replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : undefined, + )), { status, headers }); + return replayRefusal ? retainReplayRefusal(out) : out; +} + +/** + * The body `count_tokens` should count for this selector: what the native lane would send when + * the route is eligible, else `undefined` so the caller keeps its existing estimate. Reads config + * and the router's deterministic branches only; sends nothing. + */ +export function nativeMessagesCountBody( + config: OcxConfig, + cc: OcxConfig["claudeCode"], + body: Rec, + selector: NativeMessagesSelector, +): Rec | undefined { + if (typeof body.model !== "string") return undefined; + try { + const selectorId = resolveInboundModel(body.model, cc); + // Combo and policy selectors are never eligible, and routing them would advance round-robin + // state or run the policy evaluator for a request that sends nothing. + if (resolvePolicyProfileId(config, selectorId) !== null || selectorId.startsWith(`${POLICY_NAMESPACE}/`)) return undefined; + if (!preservesPhysicalComboProvider(config) && resolveComboId(config, selectorId) !== null) return undefined; + const route = routeModel(config, selectorId); + route.staticPolicy = captureRouteStaticPolicy( + route.providerName, route.modelId, route.provider, route.staticPolicy.effectiveAlias, "anthropic", + ); + route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy); + if (nativeMessagesDeclineReason(route, body, config, selector) !== undefined) return undefined; + return buildAnthropicMessagesPassthroughRequest(route.provider, route.modelId, body, config).wireBody; + } catch { + return undefined; + } +} From 8b6f97d3b297d1e5c96d735bc30015143ed99e6e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:55:17 +0900 Subject: [PATCH 101/173] feat(claude): route eligible managed-key Messages to the native lane Decided after route settlement and the managed-client steps; the caller-forward branch keeps its own earlier decision. The reject guard judges the native path when it applies. --- src/server/claude-messages.ts | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 64924b7ffbb..788b314b265 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -66,11 +66,12 @@ import type { ClientEncoderOption } from "./responses/core-options"; import { handleResponses } from "./responses"; import { upstreamWireForAdapter } from "../protocols/contract"; import { createProtocolEnvelope, type ProtocolEnvelope } from "../protocols/envelope"; -import { featuresFromMessagesBody } from "../protocols/features"; +import { featuresFromMessagesBody, type ProtocolFeature } from "../protocols/features"; import { checkRepresentable, unrepresentableMessage } from "../protocols/guard"; import { requestPathForLane } from "../protocols/path"; import { resolveApiSurfaceSettings, resolveProtocolSettings } from "../protocols/settings"; import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace"; +import { isNativeMessagesRouteEligible } from "./messages-native-eligibility"; import { isApiAuthRequired, isDataPlaneAdmissionSecret, @@ -749,6 +750,7 @@ async function handleClaudeMessagesWithBudget( let requestedModel = ""; // Built only under the reject policy; the legacy default leaves this request untouched. let envelope: ProtocolEnvelope | undefined; + let messagesFeatures: () => Iterable = () => []; try { anthropicBody = await readAnthropicBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes)); // Defensive [1m] strip (devlog 138): clients normally remove the context-variant @@ -822,7 +824,7 @@ async function handleClaudeMessagesWithBudget( const sourceEnvelope = envelope; // The bridge entry mark below reads these before an effort override rewrites `thinking`, // which also fixes the envelope's cached features on the caller's own settings. - const messagesFeatures = sourceEnvelope + messagesFeatures = sourceEnvelope ? () => sourceEnvelope.features() : () => featuresFromMessagesBody(messagesBody); if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model, cc)) { @@ -982,11 +984,17 @@ async function handleClaudeMessagesWithBudget( /* unknown model: let handleResponses shape the 404 */ } + // PF-08: a managed-key Anthropic route sends its Messages body natively. The caller-forward + // passthrough above was decided on the caller's own credential and never reaches this point. + const nativeMessagesRoute = settledRoute && isRec(anthropicBody) + && isNativeMessagesRouteEligible(settledRoute, anthropicBody, config, { effortRow: !!effortRow, fastRow: !!fastRow }) + ? settledRoute + : undefined; // Combo and policy children are judged per candidate (PF-07); an unknown model has no route. if (envelope && settledRoute && !settledRoute.combo && settledRoute.routeKind !== "policy") { const verdict = checkRepresentable({ inbound: "messages", - requestPath: requestPathForLane("messages", "bridge", upstreamWireForAdapter(settledRoute.provider.adapter)), + requestPath: requestPathForLane("messages", nativeMessagesRoute ? "native" : "bridge", upstreamWireForAdapter(settledRoute.provider.adapter)), features: envelope.features(), policy: "reject", }); @@ -997,6 +1005,23 @@ async function handleClaudeMessagesWithBudget( return anthropicErrorResponse(400, unrepresentableMessage(verdict.features), "invalid_request_error"); } } + if (nativeMessagesRoute) { + markProtocolEntry(logCtx, { inbound: "messages", lane: "native", features: messagesFeatures }); + let nativeBody: Rec; + try { + // Built from the source envelope when there is one, after the managed-client steps above. + nativeBody = envelope ? envelope.freshBody() : anthropicBody as Rec; + } catch (err) { + if (!isTranslatorBudgetExceededError(err)) throw err; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 413, { closeReason: "non_stream" }); + return anthropicErrorResponse(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); + } + const { handleNativeMessages } = await import("./messages-native"); + return await handleNativeMessages({ + req, config, logCtx, ...(logIds ? { logIds } : {}), + route: nativeMessagesRoute, body: nativeBody, requestedModel, translatorBudget, + }); + } const headers = new Headers({ "content-type": "application/json" }); let trustedClaudeMainAuth: { authorization: string; chatgptAccountId?: string } | undefined; From 716c117a33210d2eb8cdcd28b91762ccc5ab8db3 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:55:17 +0900 Subject: [PATCH 102/173] feat(claude): count the native Messages body in count_tokens With the switch on and an eligible route, count_tokens estimates the allowlisted body the native lane would send; otherwise the existing estimate is unchanged. --- src/server/claude-messages.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 788b314b265..5760b730192 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -1399,7 +1399,11 @@ export async function handleClaudeCountTokens( if (wantsNativePassthrough(req, config, requestPolicy, model, cc)) { return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens"); } - const inputTokens = estimateClaudeRequestTokens(raw, model); + // PF-08: an eligible managed-key route counts the body the native lane would send. + const nativeCountBody = resolveProtocolSettings(config).rollout.managedMessagesNative + ? (await import("./messages-native")).nativeMessagesCountBody(config, cc, raw, { fastRow: countFastRow !== null }) + : undefined; + const inputTokens = estimateClaudeRequestTokens(nativeCountBody ?? raw, model); return new Response(JSON.stringify({ input_tokens: inputTokens }), { status: 200, headers: { "Content-Type": "application/json" }, From f0ec1db5caa2b6f9d8e99278b7a1f5d90649a615 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:55:39 +0900 Subject: [PATCH 103/173] feat(protocols): preview managed native Messages candidates With the switch on, a Messages candidate is judged by the same decline rule the ingress uses; with it off the preview is unchanged. --- src/protocols/plan-snapshot.ts | 47 +++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/src/protocols/plan-snapshot.ts b/src/protocols/plan-snapshot.ts index a45f6c5d66c..e7a9684648f 100644 --- a/src/protocols/plan-snapshot.ts +++ b/src/protocols/plan-snapshot.ts @@ -22,6 +22,7 @@ import { captureRouteStaticPolicy, routeConcreteModel, routeModel, type RouteRes import { getRoutingProfile, POLICY_NAMESPACE, resolvePolicyProfileId } from "../routing/profile"; import { resolveWireProtocolOverride } from "../server/adapter-resolve"; import { nativeChatDeclineReason } from "../server/chat-native-eligibility"; +import { nativeMessagesDeclineReason } from "../server/messages-native-eligibility"; import { parseSyntheticRowId } from "../server/fast-row"; import type { OcxConfig } from "../types"; import { inboundWireForProtocol, type Protocol, type ProtocolReasonCode } from "./contract"; @@ -90,13 +91,26 @@ function chatBodyForFeatures(features: ReadonlySet): Record): Record { + const content = features.has("request.images") + ? [{ type: "image", source: { type: "base64", media_type: "image/png", data: "AA==" } }] + : ""; + return { messages: [{ role: "user", content }] }; +} + +interface SyntheticRows { + effortRow: boolean; + fastRow: boolean; +} + function candidateFor( config: OcxConfig, inbound: Protocol, route: RouteResult, routeKind: SettledRouteKind, features: ReadonlySet, - effortRow: boolean, + rows: SyntheticRows, ): ProtocolPlanCandidateInput { const wire = inboundWireForProtocol(inbound); // The same two steps every ingress runs: recapture static policy for the original inbound, @@ -108,17 +122,24 @@ function candidateFor( const adapter = provider.adapter ?? "openai-responses"; let declineReasons: ProtocolReasonCode[] = []; let nativeEligible = false; + // With `nativeChatCombos` on, the combo loop judges each Chat candidate as the concrete route it + // is (PF-07), so the preview must too; a policy still resolves one candidate on the bridge. + const comboChildNative = inbound === "chat" && routeKind === "combo" + && resolveProtocolSettings(config).rollout.nativeChatCombos; + const settled: RouteResult = { + ...route, + provider, + staticPolicy, + ...(routeKind === "direct" || comboChildNative ? {} : { routeKind }), + }; if (inbound === "chat") { - // With `nativeChatCombos` on, the combo loop judges each candidate as the concrete route it - // is (PF-07), so the preview must too; a policy still resolves one candidate on the bridge. - const comboChildNative = routeKind === "combo" && resolveProtocolSettings(config).rollout.nativeChatCombos; - const settled: RouteResult = { - ...route, - provider, - staticPolicy, - ...(routeKind === "direct" || comboChildNative ? {} : { routeKind }), - }; - const reason = effortRow ? "effort-row" : nativeChatDeclineReason(settled, chatBodyForFeatures(features), config); + const reason = rows.effortRow ? "effort-row" : nativeChatDeclineReason(settled, chatBodyForFeatures(features), config); + nativeEligible = reason === undefined; + if (reason) declineReasons = [reason]; + } else if (inbound === "messages" && resolveProtocolSettings(config).rollout.managedMessagesNative) { + // The runtime rule itself. With the switch off nothing is judged, so the default preview + // is exactly what it was before the managed native lane existed. + const reason = nativeMessagesDeclineReason(settled, messagesBodyForFeatures(features), config, rows); nativeEligible = reason === undefined; if (reason) declineReasons = [reason]; } @@ -153,6 +174,7 @@ export function buildProtocolPlanSnapshot( const reasonCodes: ProtocolReasonCode[] = []; let routeKey = request.model; let effortRow = false; + let fastRow = false; let syntheticRow = false; try { const parsed = parseSyntheticRowId(request.model, config); @@ -162,6 +184,7 @@ export function buildProtocolPlanSnapshot( syntheticRow = true; } else if (parsed.fastRow) { routeKey = parsed.fastRow.baseId; + fastRow = true; syntheticRow = true; } } catch { @@ -182,7 +205,7 @@ export function buildProtocolPlanSnapshot( return { ...base, routeKind: settled.routeKind, - candidates: settled.routes.map(route => candidateFor(config, request.inbound, route, settled.routeKind, features, effortRow)), + candidates: settled.routes.map(route => candidateFor(config, request.inbound, route, settled.routeKind, features, { effortRow, fastRow })), reasonCodes, }; } From ed858b53b68f6da92071aa18669eef1be708e26f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:59:12 +0900 Subject: [PATCH 104/173] test(claude): pin the native Messages builder, decline rule and preview The builder keeps only allowlisted fields and the adapter's headers; each decline reason is named; the planner reports native only with the switch on and a managed key. --- scripts/test-layout/layout.json | 2 + .../anthropic-messages-passthrough.test.ts | 105 ++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + .../messages-native-eligibility.test.ts | 128 ++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 tests/adapters/anthropic/anthropic-messages-passthrough.test.ts create mode 100644 tests/responses/messages-native-eligibility.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 6bdf369a28a..84e1b5f9fe4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -242,6 +242,7 @@ "anthropic-tail-guard.test.ts": "adapters/anthropic", "anthropic-thinking-signature.test.ts": "adapters/anthropic", "anthropic-tool-call-id.test.ts": "adapters/anthropic", + "anthropic-messages-passthrough.test.ts": "adapters/anthropic", "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "anthropic-tool-schema.test.ts": "adapters/anthropic", "antigravity-baseurl-override.test.ts": "adapters/google", @@ -348,6 +349,7 @@ "protocol-direct-encoders-chat.test.ts": "responses", "protocol-direct-encoders-messages.test.ts": "responses", "chat-native-combo.test.ts": "responses", + "messages-native-eligibility.test.ts": "responses", "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/adapters/anthropic/anthropic-messages-passthrough.test.ts b/tests/adapters/anthropic/anthropic-messages-passthrough.test.ts new file mode 100644 index 00000000000..e2184b2b170 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-messages-passthrough.test.ts @@ -0,0 +1,105 @@ +/** + * The managed native Messages builder (src/adapters/anthropic/passthrough.ts, PF-08): the + * allowlisted source body with the wire model, the adapter's own URL, pinned version and key + * placement, and nothing taken from the caller. + */ +import { describe, expect, test } from "bun:test"; +import { ANTHROPIC_API_VERSION, createAnthropicAdapter } from "../../../src/adapters/anthropic"; +import { + ANTHROPIC_MESSAGES_PASSTHROUGH_FIELDS, + buildAnthropicMessagesPassthroughRequest, +} from "../../../src/adapters/anthropic/passthrough"; +import { createTranslatorBudget } from "../../../src/lib/translator-budget"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "anthropic", + baseUrl: "https://anthropic.example/v1", + authMode: "key", + apiKey: "fixture-managed-key", + ...overrides, + } as OcxProviderConfig; +} + +const SOURCE = { + model: "client-selector", + max_tokens: 64, + top_k: 7, + temperature: 0.2, + stream: true, + thinking: { type: "enabled", budget_tokens: 2048 }, + system: [{ type: "text", text: "fixture system", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: [{ type: "text", text: "fixture", cache_control: { type: "ephemeral", ttl: "1h" } }] }], + metadata: { user_id: "fixture-user" }, + // Not on the allowlist: dropped rather than forwarded unchecked. + context_management: { edits: [] }, + mcp_servers: [{ type: "url", url: "https://mcp.example" }], + container: "fixture-container", +}; + +describe("buildAnthropicMessagesPassthroughRequest", () => { + test("keeps exactly the allowlisted source fields and swaps in the wire model", () => { + const built = buildAnthropicMessagesPassthroughRequest(provider(), "claude-wire", SOURCE); + const expected: Record = {}; + for (const [key, value] of Object.entries(SOURCE)) { + if ((ANTHROPIC_MESSAGES_PASSTHROUGH_FIELDS as readonly string[]).includes(key)) expected[key] = value; + } + expected.model = "claude-wire"; + expect(JSON.parse(built.body)).toEqual(expected); + expect(built.wireBody).toEqual(expected); + expect(built.wireBody).toMatchObject({ top_k: 7, thinking: SOURCE.thinking, system: SOURCE.system }); + expect(built.wireBody).not.toHaveProperty("context_management"); + expect(built.wireBody).not.toHaveProperty("mcp_servers"); + expect(built.wireBody).not.toHaveProperty("container"); + // The source is not mutated. + expect(SOURCE.model).toBe("client-selector"); + }); + + test("uses the adapter's endpoint, version and key placement", async () => { + const built = buildAnthropicMessagesPassthroughRequest(provider(), "claude-wire", SOURCE); + expect(built.url).toBe("https://anthropic.example/v1/messages"); + expect(built.headers).toMatchObject({ + "anthropic-version": ANTHROPIC_API_VERSION, + "x-api-key": "fixture-managed-key", + Accept: "text/event-stream", + "Content-Type": "application/json", + }); + expect(built.headers).not.toHaveProperty("Authorization"); + + const parsed = { + modelId: "claude-wire", + stream: true, + context: { messages: [{ role: "user", content: "fixture", timestamp: 0 }] }, + options: {}, + } as unknown as OcxParsedRequest; + const adapterRequest = await createAnthropicAdapter(provider()) + .buildRequest(parsed, { headers: new Headers(), translatorBudget: createTranslatorBudget() }); + expect(adapterRequest.url).toBe(built.url); + const adapterHeaders = adapterRequest.headers as Record; + for (const name of ["anthropic-version", "x-api-key", "Accept", "User-Agent"]) { + expect(built.headers[name]).toBe(adapterHeaders[name]); + } + }); + + test("a bearer key transport and operator headers apply as in the adapter", () => { + const built = buildAnthropicMessagesPassthroughRequest( + provider({ apiKeyTransport: "bearer", headers: { "x-operator": "fixture" } } as Partial), + "claude-wire", + { ...SOURCE, stream: false }, + ); + expect(built.headers.Authorization).toBe("Bearer fixture-managed-key"); + expect(built.headers).not.toHaveProperty("x-api-key"); + expect(built.headers["x-operator"]).toBe("fixture"); + expect(built.headers.Accept).toBe("application/json"); + }); + + test("refuses what the adapter refuses and never carries an OAuth or forwarded credential", () => { + expect(() => buildAnthropicMessagesPassthroughRequest(provider({ apiKey: "" }), "m", SOURCE)) + .toThrow("non-empty apiKey"); + expect(() => buildAnthropicMessagesPassthroughRequest(provider({ baseUrl: "https://{region}.example/v1" }), "m", SOURCE)) + .toThrow("unresolved {region}"); + expect(() => buildAnthropicMessagesPassthroughRequest(provider({ authMode: "oauth" }), "m", SOURCE)).toThrow(); + expect(() => buildAnthropicMessagesPassthroughRequest(provider({ authMode: "forward" }), "m", SOURCE)).toThrow(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a0b3a378291..95c608f81c8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -68,6 +68,7 @@ "anthropic-tail-guard.test.ts": "adapters/anthropic", "anthropic-thinking-signature.test.ts": "adapters/anthropic", "anthropic-tool-call-id.test.ts": "adapters/anthropic", + "anthropic-messages-passthrough.test.ts": "adapters/anthropic", "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "anthropic-tool-schema.test.ts": "adapters/anthropic", "antigravity-baseurl-override.test.ts": "adapters/google", @@ -174,6 +175,7 @@ "protocol-direct-encoders-chat.test.ts": "responses", "protocol-direct-encoders-messages.test.ts": "responses", "chat-native-combo.test.ts": "responses", + "messages-native-eligibility.test.ts": "responses", "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/messages-native-eligibility.test.ts b/tests/responses/messages-native-eligibility.test.ts new file mode 100644 index 00000000000..2bd3a0d6049 --- /dev/null +++ b/tests/responses/messages-native-eligibility.test.ts @@ -0,0 +1,128 @@ +/** + * Which Messages routes take the managed native lane (PF-08), and that the planner reports the + * same rule: `nativeMessagesDeclineReason` (src/server/messages-native-eligibility.ts) and the + * Messages candidates of `buildProtocolPlanSnapshot` (src/protocols/plan-snapshot.ts). + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildProtocolPlanSnapshot, previewProtocolPlan } from "../../src/protocols/plan-snapshot"; +import type { RouteResult } from "../../src/router"; +import { + isNativeMessagesRouteEligible, + nativeMessagesDeclineReason, +} from "../../src/server/messages-native-eligibility"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-messages-native-eligibility-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) removeTreeWithRetry(testDir); +}); + +const ON = { protocols: { rollout: { managedMessagesNative: true } } } as Partial; + +function config(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "anth", + providers: { + anth: { adapter: "anthropic", baseUrl: "https://anth.example/v1", authMode: "key", apiKey: "ka", models: ["claude-x"] }, + anthoauth: { adapter: "anthropic", baseUrl: "https://anth.example/v1", authMode: "oauth", apiKey: "ko", models: ["claude-o"] }, + chat: { adapter: "openai-chat", baseUrl: "https://chat.example/v1", apiKey: "kc", models: ["m1"] }, + }, + combos: { + pair: { strategy: "failover", targets: [{ provider: "anth", model: "claude-x" }, { provider: "chat", model: "m1" }] }, + }, + ...overrides, + } as OcxConfig; +} + +type RouteOverrides = Omit, "provider"> & { adapter?: string; authMode?: string; provider?: Record }; + +function route(overrides: RouteOverrides = {}): RouteResult { + const { adapter = "anthropic", authMode, provider, ...rest } = overrides; + return { + providerName: "anth", + modelId: "claude-x", + routeKind: "direct", + routeReason: "test", + provider: { adapter, baseUrl: "https://anth.example/v1", apiKey: "ka", ...(authMode ? { authMode } : {}), ...provider }, + ...rest, + } as unknown as RouteResult; +} + +const TEXT_BODY = { messages: [{ role: "user", content: "fixture" }] }; +const IMAGE_BODY = { + messages: [{ role: "user", content: [{ type: "image", source: { type: "base64", media_type: "image/png", data: "AA==" } }] }], +}; + +describe("native Messages decline reasons", () => { + test("the switch is checked first and defaults off", () => { + expect(nativeMessagesDeclineReason(route(), TEXT_BODY, config())).toBe("rollout-disabled"); + expect(isNativeMessagesRouteEligible(route(), TEXT_BODY, config())).toBe(false); + }); + + test("an eligible managed-key Anthropic route has no reason", () => { + expect(nativeMessagesDeclineReason(route(), TEXT_BODY, config(ON))).toBeUndefined(); + expect(nativeMessagesDeclineReason(route({ authMode: "key" }), TEXT_BODY, config(ON))).toBeUndefined(); + expect(isNativeMessagesRouteEligible(route(), TEXT_BODY, config(ON))).toBe(true); + }); + + test("each rule names its reason", () => { + const on = config(ON); + expect(nativeMessagesDeclineReason(route({ adapter: "openai-chat" }), TEXT_BODY, on)).toBe("cross-wire-ir"); + expect(nativeMessagesDeclineReason(route({ authMode: "oauth" }), TEXT_BODY, on)).toBe("auth-mode-not-native"); + expect(nativeMessagesDeclineReason(route({ authMode: "forward" }), TEXT_BODY, on)).toBe("auth-mode-not-native"); + expect(nativeMessagesDeclineReason(route({ routeKind: "policy" } as RouteOverrides), TEXT_BODY, on)).toBe("combo-or-policy-route"); + expect(nativeMessagesDeclineReason(route({ routeKind: "combo" } as RouteOverrides), TEXT_BODY, on)).toBe("combo-or-policy-route"); + expect(nativeMessagesDeclineReason(route(), TEXT_BODY, on, { effortRow: true })).toBe("effort-row"); + expect(nativeMessagesDeclineReason(route(), TEXT_BODY, on, { fastRow: true })).toBe("fast-row"); + }); + + test("an image for a model declared text-only needs vision preprocessing", () => { + const blind = route({ provider: { modelCapabilities: { "claude-x": { inputModalities: ["text"] } } } }); + expect(nativeMessagesDeclineReason(blind, IMAGE_BODY, config(ON))).toBe("vision-preprocessing"); + expect(nativeMessagesDeclineReason(blind, TEXT_BODY, config(ON))).toBeUndefined(); + expect(nativeMessagesDeclineReason(route(), IMAGE_BODY, config(ON))).toBeUndefined(); + }); +}); + +describe("planner mirrors the native Messages rule", () => { + test("switch off: the preview is unchanged (bridge, no decline reason)", () => { + const snapshot = buildProtocolPlanSnapshot(config(), { model: "anth/claude-x", inbound: "messages", features: [] }); + expect(snapshot.candidates).toEqual([ + { provider: "anth", model: "claude-x", adapter: "anthropic", nativeEligible: false, declineReasons: [] }, + ]); + expect(previewProtocolPlan(config(), { model: "anth/claude-x", inbound: "messages", features: [] }).mode).toBe("legacy-bridge"); + }); + + test("switch on: a managed-key Anthropic candidate is native", () => { + const snapshot = buildProtocolPlanSnapshot(config(ON), { model: "anth/claude-x", inbound: "messages", features: ["request.top_k"] }); + expect(snapshot.candidates).toEqual([ + { provider: "anth", model: "claude-x", adapter: "anthropic", nativeEligible: true, declineReasons: [] }, + ]); + const plan = previewProtocolPlan(config(ON), { model: "anth/claude-x", inbound: "messages", features: ["request.top_k"] }); + expect(plan.mode).toBe("native"); + expect(plan.candidates[0]).toMatchObject({ requestPath: ["messages", "messages"], eligible: true }); + expect(plan.guaranteedFeatures).toEqual(["request.top_k"]); + }); + + test("switch on: OAuth and combo candidates report the rule that declined them", () => { + const oauth = buildProtocolPlanSnapshot(config(ON), { model: "anthoauth/claude-o", inbound: "messages", features: [] }); + expect(oauth.candidates[0]).toMatchObject({ nativeEligible: false, declineReasons: ["auth-mode-not-native"] }); + const combo = buildProtocolPlanSnapshot(config(ON), { model: "combo/pair", inbound: "messages", features: [] }); + expect(combo.candidates[0]).toMatchObject({ provider: "anth", nativeEligible: false, declineReasons: ["combo-or-policy-route"] }); + }); +}); From 5218c5215ab43122d31daf566c7af3bdc2809096 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:59:12 +0900 Subject: [PATCH 105/173] test(claude): exercise managed native Messages against a fake upstream Allowlisted body and provider key on the wire, 401/429 key failover, stream relay and usage, JSON callers, switch-off bridge, caller-forward precedence and count_tokens. --- scripts/test-layout/layout.json | 1 + .../messages-native.test.ts | 265 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 3 files changed, 267 insertions(+) create mode 100644 tests/claude-integration/messages-native.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 84e1b5f9fe4..f9a3b2f8a40 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -426,6 +426,7 @@ "claude-management-api.test.ts": "claude-integration", "claude-manual-env.test.ts": "gui", "claude-messages-endpoint.test.ts": "claude-integration", + "messages-native.test.ts": "claude-integration", "messages-surface-matrix.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", diff --git a/tests/claude-integration/messages-native.test.ts b/tests/claude-integration/messages-native.test.ts new file mode 100644 index 00000000000..24eb3551070 --- /dev/null +++ b/tests/claude-integration/messages-native.test.ts @@ -0,0 +1,265 @@ +/** + * Managed native Messages lane (PF-08) against fake upstreams. With + * `protocols.rollout.managedMessagesNative` on, a direct route to a key-auth `anthropic` + * provider receives the caller's Messages body itself (allowlisted, wire model, the provider's + * key) instead of the Responses replay. Everything here is a local fixture: no real credential + * or service is reached. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { estimateClaudeRequestTokens, handleClaudeCountTokens, handleClaudeMessages } from "../../src/server/claude-messages"; +import { getRequestLogEntries } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +interface Seen { + path: string; + headers: Headers; + body: Record; +} + +type Reply = (seen: Seen, index: number) => Response; + +let upstream: ReturnType | undefined; +let callerForward: ReturnType | undefined; +let seen: Seen[] = []; +let callerForwardSeen: Seen[] = []; +let releaseSpendHome: (() => void) | undefined; +let testDir = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-messages-native-")); + process.env.OPENCODEX_HOME = testDir; + clearKeyCooldowns(); + seen = []; + callerForwardSeen = []; +}); + +afterEach(async () => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + await upstream?.stop(true); + upstream = undefined; + await callerForward?.stop(true); + callerForward = undefined; + clearKeyCooldowns(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) removeTreeWithRetry(testDir); +}); + +const MESSAGE_JSON = { + id: "msg_fixture", + type: "message", + role: "assistant", + model: "claude-x", + content: [{ type: "text", text: "fixture reply" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 11, output_tokens: 3 }, +}; + +const SSE_FRAMES = [ + { event: "message_start", data: { type: "message_start", message: { ...MESSAGE_JSON, content: [], stop_reason: null, usage: { input_tokens: 5, output_tokens: 0, cache_read_input_tokens: 2 } } } }, + { event: "content_block_start", data: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } }, + { event: "content_block_delta", data: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "streamed" } } }, + { event: "content_block_stop", data: { type: "content_block_stop", index: 0 } }, + { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 7 } } }, + { event: "message_stop", data: { type: "message_stop" } }, +]; +const SSE_TEXT = SSE_FRAMES.map(frame => `event: ${frame.event}\ndata: ${JSON.stringify(frame.data)}\n\n`).join(""); + +function ok(seenRequest: Seen): Response { + if (seenRequest.body.stream === true) { + return new Response(SSE_TEXT, { headers: { "content-type": "text/event-stream" } }); + } + return Response.json(MESSAGE_JSON); +} + +function startUpstream(reply: Reply = ok): number { + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { + const entry = { path: new URL(req.url).pathname, headers: req.headers, body: await req.json() as Record }; + seen.push(entry); + return reply(entry, seen.length - 1); + } }); + return upstream.port!; +} + +function fixtureConfig(port: number, options: { on?: boolean; pool?: boolean; claudeCode?: OcxConfig["claudeCode"] } = {}): OcxConfig { + const { on = true, pool = false } = options; + releaseSpendHome ??= acquireOwnedSpendHome(); + const config = { + port: 0, + defaultProvider: "anth", + providers: { anth: { + adapter: "anthropic", + baseUrl: `http://127.0.0.1:${port}`, + authMode: "key", + apiKey: "fixture-key-alpha", + allowPrivateNetwork: true, + models: ["claude-x"], + ...(pool ? { apiKeyPool: [ + { id: "k1", key: "fixture-key-alpha", addedAt: 1 }, + { id: "k2", key: "fixture-key-beta", addedAt: 2 }, + ] } : {}), + } }, + ...(on ? { protocols: { rollout: { managedMessagesNative: true } } } : {}), + ...(options.claudeCode ? { claudeCode: options.claudeCode } : {}), + } as OcxConfig; + saveConfig(config); + return config; +} + +const SOURCE_BODY = { + model: "anth/claude-x", + max_tokens: 64, + top_k: 5, + temperature: 0.3, + thinking: { type: "enabled", budget_tokens: 1024 }, + metadata: { user_id: "fixture-user" }, + system: [{ type: "text", text: "fixture system", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: [{ type: "text", text: "fixture question", cache_control: { type: "ephemeral" } }] }], + tools: [{ name: "lookup", description: "fixture tool", input_schema: { type: "object", properties: {} } }], + // Not on the allowlist: must not reach the provider. + context_management: { edits: [] }, +}; + +function messagesRequest(body: Record, headers: Record = {}): Request { + return new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); +} + +function rowFor(requestId: string) { + const rows = getRequestLogEntries().filter(entry => entry.requestId === requestId); + expect(rows).toHaveLength(1); + return rows[0]!; +} + +async function send(config: OcxConfig, body: Record, headers?: Record) { + const requestId = `pf08-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(messagesRequest(body, headers), config, { model: "", provider: "" }, + { requestId, start: Date.now() }); + const text = await response.text(); + return { requestId, response, text }; +} + +describe("managed native Messages", () => { + test("sends exactly the allowlisted source body with the provider key and no caller credential", async () => { + const config = fixtureConfig(startUpstream()); + const { requestId, response, text } = await send(config, { ...SOURCE_BODY, stream: false }, { + authorization: "Bearer fixture-admission-token", + "x-api-key": "fixture-caller-key", + "anthropic-beta": "fixture-beta", + }); + expect(response.status).toBe(200); + expect(JSON.parse(text)).toEqual(MESSAGE_JSON); + expect(seen).toHaveLength(1); + const sent = seen[0]!; + expect(sent.path).toBe("/v1/messages"); + const { context_management: _dropped, ...allowlisted } = SOURCE_BODY; + expect(sent.body).toEqual({ ...allowlisted, stream: false, model: "claude-x" }); + expect(sent.headers.get("x-api-key")).toBe("fixture-key-alpha"); + expect(sent.headers.get("authorization")).toBeNull(); + expect(sent.headers.get("anthropic-beta")).toBeNull(); + expect(sent.headers.get("anthropic-version")).toBe("2023-06-01"); + + const row = rowFor(requestId); + expect(row.status).toBe(200); + expect(row.usage).toMatchObject({ inputTokens: 11, outputTokens: 3 }); + expect(row.protocolTrace).toMatchObject({ inbound: "messages", mode: "native", requestPath: ["messages", "messages"] }); + expect(JSON.stringify(row)).not.toContain("fixture-key-alpha"); + }); + + test("relays the upstream stream unchanged and records its usage", async () => { + const config = fixtureConfig(startUpstream()); + const { requestId, response, text } = await send(config, { ...SOURCE_BODY, stream: true }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect(text).toBe(SSE_TEXT); + expect(seen[0]!.body.stream).toBe(true); + const row = rowFor(requestId); + expect(row.status).toBe(200); + expect(row.usage).toMatchObject({ inputTokens: 7, outputTokens: 7, cacheReadInputTokens: 2 }); + }); + + test("a 401 on the first pooled key fails over to the next key", async () => { + const config = fixtureConfig(startUpstream((entry, index) => index === 0 + ? Response.json({ type: "error", error: { type: "authentication_error", message: "invalid x-api-key" } }, { status: 401 }) + : ok(entry)), { pool: true }); + const { response } = await send(config, { ...SOURCE_BODY, stream: false }); + expect(response.status).toBe(200); + expect(seen.map(entry => entry.headers.get("x-api-key"))).toEqual(["fixture-key-alpha", "fixture-key-beta"]); + expect(seen[1]!.body).toEqual(seen[0]!.body); + }); + + test("a 429 on the first pooled key fails over to the next key", async () => { + const config = fixtureConfig(startUpstream((entry, index) => index === 0 + ? Response.json({ type: "error", error: { type: "rate_limit_error", message: "slow down" } }, + { status: 429, headers: { "retry-after": "30" } }) + : ok(entry)), { pool: true }); + const { response } = await send(config, { ...SOURCE_BODY, stream: false }); + expect(response.status).toBe(200); + expect(seen.map(entry => entry.headers.get("x-api-key"))).toEqual(["fixture-key-alpha", "fixture-key-beta"]); + }); + + test("an upstream error answers in Anthropic shape without the key", async () => { + const config = fixtureConfig(startUpstream(() => Response.json( + { type: "error", error: { type: "invalid_request_error", message: "bad fixture" } }, { status: 400 }))); + const { response, text } = await send(config, { ...SOURCE_BODY, stream: false }); + expect(response.status).toBe(400); + expect(JSON.parse(text)).toMatchObject({ type: "error", error: { type: "invalid_request_error", message: "bad fixture" } }); + expect(text).not.toContain("fixture-key-alpha"); + }); + + test("switch off: the same request still takes the Responses bridge", async () => { + const config = fixtureConfig(startUpstream(), { on: false }); + const { requestId, response } = await send(config, { ...SOURCE_BODY, stream: false }); + expect(response.status).toBe(200); + expect(seen).toHaveLength(1); + // The bridge rebuilds the body through the adapter, which has no top_k. + expect(seen[0]!.body).not.toHaveProperty("top_k"); + expect(seen[0]!.body.stream).toBe(true); + expect(rowFor(requestId).protocolTrace).toMatchObject({ inbound: "messages", mode: "legacy-bridge" }); + }); + + test("caller-forward passthrough is decided first and keeps the caller's credential", async () => { + callerForward = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { + callerForwardSeen.push({ path: new URL(req.url).pathname, headers: req.headers, body: await req.json() as Record }); + return Response.json(MESSAGE_JSON); + } }); + const config = fixtureConfig(startUpstream(), { + claudeCode: { anthropicBaseUrl: `http://127.0.0.1:${callerForward.port}` } as OcxConfig["claudeCode"], + }); + const { response } = await send(config, { model: "claude-haiku-4-5", max_tokens: 16, stream: false, + messages: [{ role: "user", content: "fixture" }] }, { "x-api-key": "sk-ant-fixture-caller" }); + expect(response.status).toBe(200); + expect(seen).toHaveLength(0); + expect(callerForwardSeen).toHaveLength(1); + expect(callerForwardSeen[0]!.headers.get("x-api-key")).toBe("sk-ant-fixture-caller"); + }); + + test("count_tokens counts the body the native lane sends", async () => { + const config = fixtureConfig(startUpstream()); + await send(config, { ...SOURCE_BODY, stream: false }); + const sentBody = seen[0]!.body; + const response = await handleClaudeCountTokens(new Request("http://localhost/v1/messages/count_tokens", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(SOURCE_BODY), + }), config); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ input_tokens: estimateClaudeRequestTokens(sentBody, "anth/claude-x") }); + // Counting sends nothing. + expect(seen).toHaveLength(1); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 95c608f81c8..fcbd73d8c93 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -252,6 +252,7 @@ "claude-management-api.test.ts": "claude-integration", "claude-manual-env.test.ts": "gui", "claude-messages-endpoint.test.ts": "claude-integration", + "messages-native.test.ts": "claude-integration", "messages-surface-matrix.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", From 97cfd0c36e8a3704209e1c602769232b93e59759 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:59:49 +0900 Subject: [PATCH 106/173] docs(structure): describe the managed native Messages lane Protocol paths owns the rule, builder and authority split; the Responses transport doc lists the lane among the native senders; the devlog inventory records what stays bridged. --- .../040_acceptance_and_rollout.md | 3 + structure/data-planes/protocol-paths.md | 68 ++++++++++++++++--- structure/transports/responses.md | 7 +- 3 files changed, 67 insertions(+), 11 deletions(-) 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 index d95a6b0cf3b..c0f2c82648e 100644 --- a/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md +++ b/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md @@ -48,3 +48,6 @@ Kept current by each packet that migrates something. | Responses-only features on Chat/Messages | `previous_response_id`, `store`, `background`, compaction stay on the bridge | | Non-public-wire adapters (`other`) | translated through the IR; no feature claims | | OAuth native Chat | not planned in this unit | +| Messages → key-auth Anthropic | native behind `managedMessagesNative` (PF-08); bridge while off. Caller `anthropic-beta` is not forwarded (PF-10 allowlist); top-level fields outside the allowlist are dropped with no feature effect; the response `model` is the upstream's wire id | +| Messages → Anthropic OAuth | bridge until `managedMessagesNativeOAuth` (PF-10) | +| Messages native lane, translated-only steps | skill elision, `stabilizePromptCache`, pinned route effort and the Claude web-search/vision sidecars run only on the bridge; routes needing vision preprocessing stay there | diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index a68d33c9c25..a0e04c500e8 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -23,7 +23,9 @@ Existing spellings keep their names and map through explicit functions in the sa `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. +or trace reports cannot disagree. `nativeMessagesDeclineReason` in +`src/server/messages-native-eligibility.ts` does the same for the managed native Messages lane +(below). `contract.ts`, `src/protocols/features.ts`, `src/protocols/baseline.ts`, `src/protocols/path.ts`, `src/protocols/dto.ts`, `src/protocols/plan.ts` and `src/protocols/guard.ts` are leaf modules: the dashboard imports them directly, so they import @@ -59,7 +61,8 @@ path the protocol-first-class work targets. Today Chat to Chat and Responses to 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`. +non-streaming client. No target cell contains `responses-internal`. `current` describes the +default configuration, with every rollout switch off. `tests/responses/protocol-baseline.test.ts` pins both sides. ## Plan and trace shapes @@ -75,8 +78,8 @@ exposes, within fixed limits, and both validators reject anything that is not ex `src/protocols/trace.ts` is server side and is not a leaf. The Chat Completions ingress (`src/server/chat-completions.ts`) marks the lane it chose, the reason code that declined the native lane, and the request's features; the Messages ingress (`src/server/claude-messages.ts`) -marks caller-forward passthrough as the native lane, the translated path as the bridge, and a -disabled surface or a compatibility reject as blocked. The Responses ingress needs no mark: its +marks caller-forward passthrough and the managed native lane as native, the translated path as +the bridge, and a disabled surface or a compatibility reject as blocked. The Responses ingress needs no mark: its path follows the final adapter's wire. Marks live in WeakMaps keyed by the request log context and the live attempt objects, and no mark function throws into the request. @@ -112,7 +115,10 @@ candidate's wire is settled the way the ingress settles it (`captureRouteStaticP original inbound, then `resolveWireProtocolOverride`), and a Chat candidate's native lane is judged by `nativeChatDeclineReason` against a structural body built from the requested features. With `nativeChatCombos` on, a combo's candidates are judged as the concrete routes they are, as the -combo loop judges them; policy candidates keep `combo-or-policy-route`. Messages +combo loop judges them; policy candidates keep `combo-or-policy-route`. With +`protocols.rollout.managedMessagesNative` on, a Messages candidate is judged the same way by +`nativeMessagesDeclineReason`; with it off Messages candidates carry no decline reason, exactly as +before the lane existed. Messages caller-forward passthrough depends on the caller's own credential, so it is reported as `caller-credential-required` and never assumed. The OpenCode Go session-lane transport is not modelled. `tests/responses/protocol-plan-snapshot.test.ts` pins the no-side-effect property against @@ -141,7 +147,8 @@ hop (the internal Responses body) still does. Only when `resolveProtocolSettings(config).unrepresentable === "reject"` do the Chat and Messages ingresses build an envelope and run the guard, after the route and its wire settle and before the request is sent: Chat on the native path when the native lane was chosen, otherwise on the -bridge path to the settled adapter's wire; Messages on the bridge path. Combo and policy routes +bridge path to the settled adapter's wire; Messages on the native path when the managed native +lane was chosen, otherwise on the bridge path. Combo and policy routes and an unroutable model are not judged at ingress; with `nativeChatCombos` on, a Chat combo's candidates are judged one by one inside the combo loop (below). A refusal answers 400 in the ingress's own error shape (Chat `invalid_request_error` / `unsupported_feature`; Anthropic @@ -199,6 +206,47 @@ per candidate. Policy routes select a single candidate in the router and stay on transport side (send budget, failover, logging) is in [Responses transport](../transports/responses.md#native-chat-candidates-in-combos). +## Managed native Messages + +Behind `protocols.rollout.managedMessagesNative` (default off). A Messages request whose settled +route is a direct, key-auth `anthropic` provider is sent as Messages instead of replaying through +Responses. `nativeMessagesDeclineReason` names the first rule that keeps a route off the lane: +`rollout-disabled`, `cross-wire-ir` (another adapter), `auth-mode-not-native` (OAuth, which is +PF-10, or `forward`), `combo-or-policy-route`, `effort-row` / `fast-row` (synthetic rows need the +adapter's wire rewrite), `vision-preprocessing` (an image for a model declared unable to read +it). The ingress, `count_tokens` and the planner all ask it. + +`src/server/claude-messages.ts` decides the lane after the route and its wire settle and after the +managed-client steps already applied to the body (alias/modelMap resolution, `ocx-route`, effort +directives). The caller-forward passthrough is decided earlier, on the caller's own credential, +and returns before this point; the two branches share no credential and no header. The body sent +is `envelope.freshBody()` when a source envelope exists, otherwise the ingress's own body. +`src/server/messages-native.ts` is imported lazily, only for an eligible route. + +`buildAnthropicMessagesPassthroughRequest` in `src/adapters/anthropic/passthrough.ts` builds the +request from that body: the top-level allowlist (`model, messages, system, max_tokens, metadata, +stop_sequences, stream, temperature, top_p, top_k, tools, tool_choice, thinking, output_config, +service_tier`), the wire model, and the URL, `anthropic-version`, client identity and key placement +the Anthropic adapter uses (`resolveAnthropicMessagesUrl`, `anthropicBaseRequestHeaders`, +`applyAnthropicKeyAuth`), plus the provider's configured headers. No caller header is read, so the +caller's `Authorization`, `x-api-key` and `anthropic-beta` never reach the provider; a beta +allowlist is PF-10. A dropped field has no name in the feature vocabulary, so it records no +feature effect. + +`handleNativeMessages` mirrors native Chat on the shared pieces: `beginInferenceAttempt`, +`createFinalRequestLog`, the request spend tracker charged per physical send, proactive key +selection, 401 and 429 key-pool rotation, same-target 429 replay, the reset/transient retry +policy and `sendWithConnectionPolicy`. Before sending it runs the image normalizer, the image +guard and the tool-call-id repair the caller-forward passthrough runs. A streaming caller gets the +upstream SSE relayed byte for byte through `tapAnthropicSseForLog`, which records usage and the +terminal and applies the body stall and size guards; a non-streaming caller gets the upstream JSON +(or a folded stream). Upstream errors answer in Anthropic shape with the translated lane's status +policy (transient 5xx as 529, replay refusals kept non-retryable). `count_tokens` estimates the +body the builder would send when the route is eligible, and sends nothing. +`tests/adapters/anthropic/anthropic-messages-passthrough.test.ts`, +`tests/responses/messages-native-eligibility.test.ts` and +`tests/claude-integration/messages-native.test.ts` pin the builder, the rule and the lane. + ## Settings `resolveApiSurfaceSettings` and `resolveProtocolSettings` in `src/protocols/settings.ts` are the @@ -207,9 +255,11 @@ always served. The Messages surface uses an explicit `apiSurfaces.messages.enabl 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. The Chat and Messages ingresses read the unrepresentable policy (above), and -`directEncodersApply` reads `directEncoders` on both, and the Chat ingress reads `nativeChatCombos` for -combo routes (above); no request path reads the other rollout switches yet. +the key-auth one. The Chat and Messages ingresses read the unrepresentable policy (above); +`directEncodersApply` reads `directEncoders` on both; the Chat ingress reads `nativeChatCombos` +for combo routes (above); `managedMessagesNative` is read through `nativeMessagesDeclineReason` +by the Messages ingress, `count_tokens` and the planner (below). No request path reads the +other rollout switches yet. `claudeInboundDisabled` in `src/server/claude-messages.ts` is the Messages ingress reader: both `/v1/messages` and `/v1/messages/count_tokens` call it, so the two routes cannot disagree, and a diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b2344821351..822bffed11d 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -253,7 +253,8 @@ pick after the pin would leave the first attempt on the cooled key. Native Chat Completions is a separate entry path: `src/server/chat-completions.ts` routes eligible `openai-chat` requests to `src/server/chat-native.ts` and never through Responses -core, so that file repeats the same call before it binds the adapter. Native compact +core, so that file repeats the same call before it binds the adapter; the managed native +Messages lane (`src/server/messages-native.ts`) does the same. Native compact (`src/server/responses/compact.ts`) and the keyed Images relay (`src/server/images.ts`) do the same for the same reason. Request paths assign the Transport variant, not the bare `selectProactiveApiKey` snapshot: the snapshot is the persisted row, so it carries none of the @@ -469,7 +470,7 @@ reachable from `core.ts`. | Module | Contract | | --- | --- | | `context.ts` | `createInferenceSendBudget(req, logCtx)` is the one construction of an ingress-owned send holder: the default guarded policy with this request's spend tracker as observer. `handleResponses` calls it only when no holder was inherited, because attaching the tracker parks it on `logCtx`. | -| `final-log.ts` | `createFinalRequestLog(logIds, logCtx)` owns one request's final row: the first `finish(status, meta)` writes it, every later call is a no-op, and without log ids the claim settles with nothing written. The bridged Chat and Messages ingresses and native Chat finish through it. | +| `final-log.ts` | `createFinalRequestLog(logIds, logCtx)` owns one request's final row: the first `finish(status, meta)` writes it, every later call is a no-op, and without log ids the claim settles with nothing written. The bridged Chat and Messages ingresses, native Chat and native Messages finish through it. | | `attempt.ts` | `beginInferenceAttempt(logCtx, { provider, model, adapter })` opens the next attempt ordinal, makes it the active attempt with its start time, appends it to the request, and returns `seal(accountLabel?)` and `finish(status, usage?)`. | | `client-wire.ts` | `markClientWire(response, protocol)` / `clientWireOf(response)` record, per `Response` identity, that a body is already in a client's wire. `createClientWireLog` / `attachClientWireLog` / `clientWireLogOf` carry the request-log facts of such a body (a start payload, one terminal, a cancel), buffered until the deferred log subscribes. The combo marks a native Chat child's answer and its refusal of an all-unrepresentable combo as `chat` (below). | | `client-wire-log.ts` | `recordClientWireRequestLog` is the deferred request log of a client-wire response: `responseWithDeferredRequestLog` calls it instead of tapping the body, and it applies the Responses SSE tap's rules to the reported facts (payload inspection until the terminal, `terminal_sse` phase, `httpStatusForRequestLogTerminal`, 499 for a cancel before any terminal, one row). | @@ -569,6 +570,8 @@ in the Chat shape, blocked trace) with no send. `n > 1` is never emulated with s inferences. Policy routes resolve one candidate in `routeModel` and never reach this loop, so they are not migrated. `tests/responses/chat-native-combo.test.ts` pins the source body, failover within the shared budget, no resend after output, the switch-off path and both reject outcomes. +Managed native Messages ([Protocol Paths](../data-planes/protocol-paths.md#managed-native-messages)) +reuses `beginInferenceAttempt` and `createFinalRequestLog` with its own 401/429 key-rotation loop. ## Adapter-to-Responses bridge From d328dc73160d27f4e68ec5dba70bc45c20e44e6d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:03:00 +0900 Subject: [PATCH 107/173] feat(protocols): name operator policy that only the bridge applies A native lane that would skip a pinned effort, skill elision or a sidecar declines with bridge-only-policy. New reason code, so the contract version moves to 2026-09-25.1. --- src/protocols/contract.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/protocols/contract.ts b/src/protocols/contract.ts index 19c501fada7..375f72d5f55 100644 --- a/src/protocols/contract.ts +++ b/src/protocols/contract.ts @@ -9,7 +9,7 @@ */ /** Bumped when a reason code, hop, mode, or feature disposition changes meaning. */ -export const PROTOCOL_CONTRACT_VERSION = "2026-09-24.1"; +export const PROTOCOL_CONTRACT_VERSION = "2026-09-25.1"; export const PROTOCOLS = ["responses", "chat", "messages"] as const; /** A public inference API a client can speak to this proxy. */ @@ -72,6 +72,8 @@ export const PROTOCOL_REASON_CODES = [ "unknown-model", "upstream-other", "rollout-disabled", + /** Operator policy that only the bridge applies (pinned effort, skill elision, a sidecar). */ + "bridge-only-policy", ] as const; export type ProtocolReasonCode = (typeof PROTOCOL_REASON_CODES)[number]; From 6ece218ce146c07ba903fdc91fee36373cac3f6e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:03:14 +0900 Subject: [PATCH 108/173] feat(claude): tell whether translation would elide a blocked skill A pure predicate over the same inputs the translator uses, so a caller can keep a request on the path that applies claudeCode.blockedSkills. --- src/claude/inbound.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 547a8a1c9b7..318db951b6e 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -177,6 +177,28 @@ function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly str return ids; } +/** + * Whether translating this Messages body would elide a blocked skill bundle: a user text block + * `maybeElideSkillText` would stub, or a tool_result answering a blocked Skill call. Pure; the + * managed native Messages lane asks it so a request whose bundle the operator blocked keeps the + * translated path that applies the block. + */ +export function anthropicBodyElidesBlockedSkill(body: unknown, cc?: Pick): boolean { + if (!isRec(body) || !Array.isArray(body.messages)) return false; + const names = effectiveBlockedSkillNames(cc); + if (names.length === 0) return false; + const callIds = blockedSkillCallIds(body.messages, names); + for (const msg of body.messages) { + if (!isRec(msg) || msg.role !== "user" || !Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (!isRec(block)) continue; + if (block.type === "text" && typeof block.text === "string" && maybeElideSkillText(block.text, names) !== block.text) return true; + if (block.type === "tool_result" && typeof block.tool_use_id === "string" && callIds.has(block.tool_use_id)) return true; + } + } + return false; +} + /** * Claude Code (observed 2026-07-11, real CLI smoke) sends `role:"system"` entries in * `messages` despite the published API having no system role. They are emitted as From a2a827dedf063582862391427b61453c0fd55ae4 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:03:40 +0900 Subject: [PATCH 109/173] feat(claude): keep Messages on the bridge when bridge-only policy applies A pinned route effort, a blocked-skill bundle the translator would elide, or a web-search tool the sidecar could serve now declines the native lane with bridge-only-policy. --- src/server/messages-native-eligibility.ts | 59 ++++++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/server/messages-native-eligibility.ts b/src/server/messages-native-eligibility.ts index 1b1742b30a4..91ba350f203 100644 --- a/src/server/messages-native-eligibility.ts +++ b/src/server/messages-native-eligibility.ts @@ -5,12 +5,15 @@ * `count_tokens` and the protocol planner (`src/protocols/plan-snapshot.ts`) all ask here, so a * preview, a count and the real send cannot disagree about the lane. */ +import { anthropicBodyElidesBlockedSkill } from "../claude/inbound"; +import { isClaudeWebSearchToolName } from "../claude/outbound"; import type { ProtocolReasonCode } from "../protocols/contract"; import { featuresFromMessagesBody } from "../protocols/features"; import { resolveProtocolSettings } from "../protocols/settings"; import type { RouteResult } from "../router"; import type { OcxConfig } from "../types"; import { requiresVisionPreprocessing } from "../vision"; +import { resolvePinnedEffort } from "./effort-policy"; /** Why the managed native Messages lane declines a route, as a protocol reason code. */ export type NativeMessagesDeclineReason = Extract< @@ -22,12 +25,61 @@ export type NativeMessagesDeclineReason = Extract< | "effort-row" | "fast-row" | "vision-preprocessing" + | "bridge-only-policy" >; -/** Model-selector facts the body cannot carry: a synthetic effort or fast row was requested. */ +/** Request facts the body cannot carry. */ export interface NativeMessagesSelector { + /** A synthetic effort row was requested. */ effortRow?: boolean; + /** A synthetic fast row was requested. */ fastRow?: boolean; + /** The model id the bridge would parse (the translated body's `model`), for the effort pin. */ + routeSelector?: string; + /** The Claude settings this ingress reads (intercept bindings applied); defaults to config's. */ + claudeCode?: OcxConfig["claudeCode"]; +} + +type Rec = Record; +function isRec(value: unknown): value is Rec { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Whether the bridge could hand this request to the web-search sidecar: a `web_search*` server + * tool the tool choice does not exclude, with the sidecar not disabled in the Claude replay config + * (`buildClaudeReplayConfig` merges `claudeCode.webSearchSidecar` over the global block). Whether + * a backend credential then exists is decided at dispatch and is not read here, so this errs + * toward the bridge, which is today's behavior for every such request. + */ +function webSearchSidecarMayEngage(body: Readonly, config: OcxConfig): boolean { + const sidecar = { ...config.webSearchSidecar, ...config.claudeCode?.webSearchSidecar }; + if (sidecar.enabled === false) return false; + if (!Array.isArray(body.tools)) return false; + const declared = body.tools.some(tool => isRec(tool) && typeof tool.type === "string" && tool.type.startsWith("web_search")); + if (!declared) return false; + const choice = body.tool_choice; + if (isRec(choice)) { + if (choice.type === "none") return false; + if (choice.type === "tool") return typeof choice.name === "string" && isClaudeWebSearchToolName(choice.name); + } + return true; +} + +/** + * Operator policy the translated path applies and the native lane would skip: a pinned + * reasoning effort for the route, a blocked-skill bundle the translator would elide, or the + * web-search sidecar. Any of them keeps the request on the bridge. + */ +function bridgeOnlyPolicyApplies( + route: RouteResult, + body: Readonly, + config: OcxConfig, + selector: NativeMessagesSelector, +): boolean { + if (resolvePinnedEffort(route, selector.routeSelector, config) !== undefined) return true; + if (anthropicBodyElidesBlockedSkill(body, selector.claudeCode ?? config.claudeCode)) return true; + return webSearchSidecarMayEngage(body, config); } /** @@ -39,7 +91,9 @@ export interface NativeMessagesSelector { * - the credential is not a proxy-managed key (OAuth is PF-10; `forward` belongs to the caller); * - a combo or policy route owns multi-candidate execution in the Responses pipeline; * - a synthetic effort or fast row needs the adapter that owns its wire rewrite; - * - an image would reach a model the operator declared unable to read it. + * - an image would reach a model the operator declared unable to read it; + * - operator policy only the bridge applies would engage (pinned effort, blocked-skill elision, + * the web-search sidecar). */ export function nativeMessagesDeclineReason( route: RouteResult, @@ -58,6 +112,7 @@ export function nativeMessagesDeclineReason( && requiresVisionPreprocessing(config, provider, route.modelId, route.providerName)) { return "vision-preprocessing"; } + if (bridgeOnlyPolicyApplies(route, body, config, selector)) return "bridge-only-policy"; return undefined; } From 109db7e3e40770eae5b25d8891925b23c00480d0 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:04:11 +0900 Subject: [PATCH 110/173] feat(claude): judge native Messages with the bridge's selector and Claude settings The ingress, count_tokens and the key-reselection recheck pass the translated model id and the ingress's Claude view, so the effort pin and blocked skills read what the bridge reads. --- src/server/claude-messages.ts | 19 ++++++++++++------- src/server/messages-native.ts | 7 +++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 5760b730192..b4f227b96b9 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -71,7 +71,7 @@ import { checkRepresentable, unrepresentableMessage } from "../protocols/guard"; import { requestPathForLane } from "../protocols/path"; import { resolveApiSurfaceSettings, resolveProtocolSettings } from "../protocols/settings"; import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace"; -import { isNativeMessagesRouteEligible } from "./messages-native-eligibility"; +import { nativeMessagesDeclineReason, type NativeMessagesSelector } from "./messages-native-eligibility"; import { isApiAuthRequired, isDataPlaneAdmissionSecret, @@ -824,9 +824,11 @@ async function handleClaudeMessagesWithBudget( const sourceEnvelope = envelope; // The bridge entry mark below reads these before an effort override rewrites `thinking`, // which also fixes the envelope's cached features on the caller's own settings. + // Scanned once, like the envelope's cache, so a later mark reports the same features. + let scannedFeatures: ReadonlySet | undefined; messagesFeatures = sourceEnvelope ? () => sourceEnvelope.features() - : () => featuresFromMessagesBody(messagesBody); + : () => (scannedFeatures ??= featuresFromMessagesBody(messagesBody)); if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model, cc)) { markProtocolEntry(logCtx, { inbound: "messages", lane: "native", features: messagesFeatures }); return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); @@ -986,10 +988,13 @@ async function handleClaudeMessagesWithBudget( // PF-08: a managed-key Anthropic route sends its Messages body natively. The caller-forward // passthrough above was decided on the caller's own credential and never reaches this point. - const nativeMessagesRoute = settledRoute && isRec(anthropicBody) - && isNativeMessagesRouteEligible(settledRoute, anthropicBody, config, { effortRow: !!effortRow, fastRow: !!fastRow }) - ? settledRoute - : undefined; + const nativeSelector: NativeMessagesSelector = { + effortRow: !!effortRow, fastRow: !!fastRow, routeSelector: String(internalBody.model ?? ""), claudeCode: cc, + }; + const nativeDecline = settledRoute && isRec(anthropicBody) + ? nativeMessagesDeclineReason(settledRoute, anthropicBody, config, nativeSelector) + : "rollout-disabled"; + const nativeMessagesRoute = settledRoute && nativeDecline === undefined ? settledRoute : undefined; // Combo and policy children are judged per candidate (PF-07); an unknown model has no route. if (envelope && settledRoute && !settledRoute.combo && settledRoute.routeKind !== "policy") { const verdict = checkRepresentable({ @@ -1019,7 +1024,7 @@ async function handleClaudeMessagesWithBudget( const { handleNativeMessages } = await import("./messages-native"); return await handleNativeMessages({ req, config, logCtx, ...(logIds ? { logIds } : {}), - route: nativeMessagesRoute, body: nativeBody, requestedModel, translatorBudget, + route: nativeMessagesRoute, body: nativeBody, requestedModel, translatorBudget, selector: nativeSelector, }); } diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index 3fb7a499b3f..11fc675b587 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -117,6 +117,8 @@ export interface HandleNativeMessagesOptions { /** The selector the client sent, echoed on the request log. */ requestedModel: string; translatorBudget: TranslatorBudget; + /** The facts the ingress judged eligibility with; re-applied if key selection changes. */ + selector?: NativeMessagesSelector; } type FinishLog = (status: number, message?: string, closeReason?: FinalRequestLogMeta["closeReason"]) => void; @@ -299,7 +301,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) ? Math.max(0, requestTransientPolicy.attempts - transientSendsUsed) : Number.POSITIVE_INFINITY; const transientSendAvailable = (): boolean => remainingTransientSends() > 0; - const selector: NativeMessagesSelector = {}; + const selector: NativeMessagesSelector = options.selector ?? {}; const send = async (recovery?: "rate-limit-429" | "key-429" | "key-401"): Promise => { const remaining = remainingTransientSends(); @@ -596,7 +598,7 @@ export function nativeMessagesCountBody( config: OcxConfig, cc: OcxConfig["claudeCode"], body: Rec, - selector: NativeMessagesSelector, + rows: Pick, ): Rec | undefined { if (typeof body.model !== "string") return undefined; try { @@ -610,6 +612,7 @@ export function nativeMessagesCountBody( route.providerName, route.modelId, route.provider, route.staticPolicy.effectiveAlias, "anthropic", ); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy); + const selector: NativeMessagesSelector = { ...rows, routeSelector: selectorId, claudeCode: cc }; if (nativeMessagesDeclineReason(route, body, config, selector) !== undefined) return undefined; return buildAnthropicMessagesPassthroughRequest(route.provider, route.modelId, body, config).wireBody; } catch { From 629be510a2e4f3db39f7f66d63f538bddcf94808 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:04:13 +0900 Subject: [PATCH 111/173] feat(claude): record why a Messages route declined the native lane With the switch on, the bridge entry mark carries the decline reason, so the trace explains the bridge; features are scanned once so both marks report the caller's own settings. --- src/server/claude-messages.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index b4f227b96b9..c9b58a77fd2 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -995,6 +995,15 @@ async function handleClaudeMessagesWithBudget( ? nativeMessagesDeclineReason(settledRoute, anthropicBody, config, nativeSelector) : "rollout-disabled"; const nativeMessagesRoute = settledRoute && nativeDecline === undefined ? settledRoute : undefined; + // With the switch on, a declined route says why on its bridge mark, as native Chat does. + if (nativeDecline && nativeDecline !== "rollout-disabled") { + markProtocolEntry(logCtx, { + inbound: "messages", + lane: "bridge", + reasonCodes: [...(effortRow ? ["effort-row" as const] : fastRow ? ["fast-row" as const] : []), nativeDecline], + features: messagesFeatures, + }); + } // Combo and policy children are judged per candidate (PF-07); an unknown model has no route. if (envelope && settledRoute && !settledRoute.combo && settledRoute.routeKind !== "policy") { const verdict = checkRepresentable({ From 384d785201470c9cb350fb9d0a06f8d02a9337c9 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:04:30 +0900 Subject: [PATCH 112/173] feat(protocols): preview the bridge-only decline for Messages candidates The planner passes the resolved selector, so a pinned route effort reports bridge-only-policy; body-dependent policy (skills, web search) is not predictable from features. --- src/protocols/plan-snapshot.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/protocols/plan-snapshot.ts b/src/protocols/plan-snapshot.ts index e7a9684648f..aaadb435e88 100644 --- a/src/protocols/plan-snapshot.ts +++ b/src/protocols/plan-snapshot.ts @@ -102,6 +102,8 @@ function messagesBodyForFeatures(features: ReadonlySet): Record interface SyntheticRows { effortRow: boolean; fastRow: boolean; + /** The resolved selector the bridge would parse; the effort pin reads it. */ + routeSelector: string; } function candidateFor( @@ -139,7 +141,12 @@ function candidateFor( } else if (inbound === "messages" && resolveProtocolSettings(config).rollout.managedMessagesNative) { // The runtime rule itself. With the switch off nothing is judged, so the default preview // is exactly what it was before the managed native lane existed. - const reason = nativeMessagesDeclineReason(settled, messagesBodyForFeatures(features), config, rows); + // Pinned effort is judged from config and the route; blocked-skill elision and the web-search + // sidecar depend on body content no feature describes, so a preview cannot predict them. + const reason = nativeMessagesDeclineReason(settled, messagesBodyForFeatures(features), config, { + ...rows, + claudeCode: config.claudeCode, + }); nativeEligible = reason === undefined; if (reason) declineReasons = [reason]; } @@ -205,7 +212,7 @@ export function buildProtocolPlanSnapshot( return { ...base, routeKind: settled.routeKind, - candidates: settled.routes.map(route => candidateFor(config, request.inbound, route, settled.routeKind, features, { effortRow, fastRow })), + candidates: settled.routes.map(route => candidateFor(config, request.inbound, route, settled.routeKind, features, { effortRow, fastRow, routeSelector: routeKey })), reasonCodes, }; } From eb8a6250b4b17faacd88eabf5dc904517928afab Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:05:01 +0900 Subject: [PATCH 113/173] feat(claude): echo the requested model on native Messages responses The translated lane answers with the client's selector; the native lane now rewrites message_start (stream) or the message (JSON) the same way instead of the wire id. --- src/server/messages-native.ts | 85 ++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index 11fc675b587..51a1ce3550b 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -84,6 +84,7 @@ import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectio import { linkAbortSignal } from "./responses/core-lifetime"; import { attachRequestSpendTracker } from "./responses/request-spend"; import { workflowRefusalResponse } from "./workflow-refusal"; +import { sseFieldValue } from "../lib/sse-decoder"; export { isNativeMessagesRouteEligible, @@ -146,6 +147,82 @@ function observeFirstChunk(body: ReadableStream, onFirst: () => void }); } +/** How far into a stream the `message_start` frame is looked for before relaying untouched. */ +const MODEL_ECHO_SCAN_BYTES = 64 * 1024; + +/** The `message_start` frame with `message.model` set to `model`, or undefined for any other frame. */ +function messageStartWithModel(frame: string, model: string): string | undefined { + const data = frame + .split("\n") + .map(line => sseFieldValue(line, "data")) + .filter((value): value is string => value !== null) + .join(""); + if (!data) return undefined; + let parsed: unknown; + try { parsed = JSON.parse(data); } catch { return undefined; } + if (!isRec(parsed) || parsed.type !== "message_start" || !isRec(parsed.message)) return undefined; + parsed.message.model = model; + return `event: message_start\ndata: ${JSON.stringify(parsed)}\n\n`; +} + +/** + * Echo the client's selector in `message_start.message.model`, as the translated lane does + * (`responsesSseToAnthropicSse` is given the requested model). Works on bytes: frames before and + * including `message_start` are split on the blank line, and everything after is relayed as is. + */ +function echoRequestedModel(body: ReadableStream, model: string): ReadableStream { + const reader = body.getReader(); + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + let pending = new Uint8Array(0); + let scanning = true; + let scanned = 0; + const frameEnd = (bytes: Uint8Array): number => { + for (let i = 0; i + 1 < bytes.length; i++) if (bytes[i] === 10 && bytes[i + 1] === 10) return i + 2; + return -1; + }; + return new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + if (pending.length > 0) controller.enqueue(pending); + pending = new Uint8Array(0); + controller.close(); + return; + } + if (!scanning) { + controller.enqueue(value); + return; + } + scanned += value.byteLength; + const joined = new Uint8Array(pending.length + value.byteLength); + joined.set(pending); + joined.set(value, pending.length); + pending = joined; + let end: number; + while (scanning && (end = frameEnd(pending)) !== -1) { + const frame = pending.subarray(0, end); + pending = pending.subarray(end); + const rewritten = messageStartWithModel(decoder.decode(frame), model); + if (rewritten !== undefined) { + controller.enqueue(encoder.encode(rewritten)); + scanning = false; + } else { + controller.enqueue(frame); + } + } + if (scanning && scanned > MODEL_ECHO_SCAN_BYTES) scanning = false; + if (!scanning && pending.length > 0) { + controller.enqueue(pending); + pending = new Uint8Array(0); + } + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); +} + /** A minimal valid Messages stream for a streaming caller whose upstream answered JSON. */ function messageAsSse(message: Rec): string { const frames: string[] = []; @@ -445,7 +522,8 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; if (contentType.includes("text/event-stream") && response.body) { const bodyGuard = resolvePassthroughBodyGuard(config, req.signal); - const source = logIds ? observeFirstChunk(response.body, () => recordFirstOutput(logCtx, logIds.start)) : response.body; + const observed = logIds ? observeFirstChunk(response.body, () => recordFirstOutput(logCtx, logIds.start)) : response.body; + const source = echoRequestedModel(observed, requestedModel); if (requestedStream) { transferTurnToStream(); const relayed = tapAnthropicSseForLog(source, logCtx, (status, meta) => { @@ -530,6 +608,9 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) } throw error; } + // The client's selector, not the wire id, as the translated lane answers. + message.model = requestedModel; + const serialized = JSON.stringify(message); finishLog(200); if (requestedStream) { return new Response(messageAsSse(message), { @@ -537,7 +618,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" }, }); } - return new Response(read.text, { status: 200, headers: { "Content-Type": "application/json" } }); + return new Response(serialized, { status: 200, headers: { "Content-Type": "application/json" } }); } /** From a11c3d4a7f339f62f41ec93bb711200fb9b6f5d2 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:06:05 +0900 Subject: [PATCH 114/173] test(claude): expect the requested model on native Messages responses JSON and stream answers now carry the client's selector, matching the translated lane. --- tests/claude-integration/messages-native.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/claude-integration/messages-native.test.ts b/tests/claude-integration/messages-native.test.ts index 24eb3551070..37b19deddaf 100644 --- a/tests/claude-integration/messages-native.test.ts +++ b/tests/claude-integration/messages-native.test.ts @@ -74,7 +74,12 @@ const SSE_FRAMES = [ { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 7 } } }, { event: "message_stop", data: { type: "message_stop" } }, ]; -const SSE_TEXT = SSE_FRAMES.map(frame => `event: ${frame.event}\ndata: ${JSON.stringify(frame.data)}\n\n`).join(""); +const sseText = (frames: readonly { event: string; data: unknown }[]) => frames.map(frame => `event: ${frame.event}\ndata: ${JSON.stringify(frame.data)}\n\n`).join(""); +const SSE_TEXT = sseText(SSE_FRAMES); +/** What the client receives: the upstream stream with its selector echoed in message_start. */ +const ECHOED_SSE_TEXT = sseText(SSE_FRAMES.map((frame, index) => index === 0 + ? { ...frame, data: { ...frame.data, message: { ...(frame.data as { message: Record }).message, model: "anth/claude-x" } } } + : frame)); function ok(seenRequest: Seen): Response { if (seenRequest.body.stream === true) { @@ -162,7 +167,8 @@ describe("managed native Messages", () => { "anthropic-beta": "fixture-beta", }); expect(response.status).toBe(200); - expect(JSON.parse(text)).toEqual(MESSAGE_JSON); + // The client's selector is echoed, as on the translated lane. + expect(JSON.parse(text)).toEqual({ ...MESSAGE_JSON, model: "anth/claude-x" }); expect(seen).toHaveLength(1); const sent = seen[0]!; expect(sent.path).toBe("/v1/messages"); @@ -180,12 +186,12 @@ describe("managed native Messages", () => { expect(JSON.stringify(row)).not.toContain("fixture-key-alpha"); }); - test("relays the upstream stream unchanged and records its usage", async () => { + test("relays the upstream stream (selector echoed in message_start) and records its usage", async () => { const config = fixtureConfig(startUpstream()); const { requestId, response, text } = await send(config, { ...SOURCE_BODY, stream: true }); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain("text/event-stream"); - expect(text).toBe(SSE_TEXT); + expect(text).toBe(ECHOED_SSE_TEXT); expect(seen[0]!.body.stream).toBe(true); const row = rowFor(requestId); expect(row.status).toBe(200); From fa673ec9aadea3ca377b14ae4be380cdc4d29483 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:06:05 +0900 Subject: [PATCH 115/173] test(claude): pin bridge-only-policy declines and the decline trace Pinned effort, blocked-skill elision and the web-search sidecar decline the native lane; the planner reports the pin; a declined route's bridge trace names the reason only when on. --- scripts/test-layout/layout.json | 2 + .../messages-native-decline-trace.test.ts | 102 +++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + .../messages-native-bridge-policy.test.ts | 122 ++++++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 tests/claude-integration/messages-native-decline-trace.test.ts create mode 100644 tests/responses/messages-native-bridge-policy.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f9a3b2f8a40..5c7e53671a1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -350,6 +350,7 @@ "protocol-direct-encoders-messages.test.ts": "responses", "chat-native-combo.test.ts": "responses", "messages-native-eligibility.test.ts": "responses", + "messages-native-bridge-policy.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", @@ -427,6 +428,7 @@ "claude-manual-env.test.ts": "gui", "claude-messages-endpoint.test.ts": "claude-integration", "messages-native.test.ts": "claude-integration", + "messages-native-decline-trace.test.ts": "claude-integration", "messages-surface-matrix.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", diff --git a/tests/claude-integration/messages-native-decline-trace.test.ts b/tests/claude-integration/messages-native-decline-trace.test.ts new file mode 100644 index 00000000000..0a439614182 --- /dev/null +++ b/tests/claude-integration/messages-native-decline-trace.test.ts @@ -0,0 +1,102 @@ +/** + * With `protocols.rollout.managedMessagesNative` on, a Messages route that declines the native + * lane stays on the bridge and its trace names the rule that declined it. With the switch off + * the trace is unchanged: no decline reason is recorded. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { getRequestLogEntries } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let upstream: ReturnType | undefined; +let bodies: Record[] = []; +let releaseSpendHome: (() => void) | undefined; +let testDir = ""; +let previousHome: string | undefined; + +const SSE = [ + { type: "message_start", message: { id: "msg_fixture", type: "message", role: "assistant", model: "claude-x", content: [], + stop_reason: null, stop_sequence: null, usage: { input_tokens: 3, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "fixture" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, +].map(data => `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`).join(""); + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-messages-decline-trace-")); + process.env.OPENCODEX_HOME = testDir; + bodies = []; + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { + bodies.push(await req.json() as Record); + return new Response(SSE, { headers: { "content-type": "text/event-stream" } }); + } }); +}); + +afterEach(async () => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + await upstream?.stop(true); + upstream = undefined; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) removeTreeWithRetry(testDir); +}); + +function fixtureConfig(on: boolean): OcxConfig { + releaseSpendHome ??= acquireOwnedSpendHome(); + const config = { + port: 0, + defaultProvider: "anth", + providers: { anth: { + adapter: "anthropic", baseUrl: `http://127.0.0.1:${upstream!.port}`, authMode: "key", apiKey: "fixture-key", + allowPrivateNetwork: true, models: ["claude-x"], + // Operator policy only the bridge applies. + pinnedReasoningEffort: "high", + } }, + ...(on ? { protocols: { rollout: { managedMessagesNative: true } } } : {}), + } as OcxConfig; + saveConfig(config); + return config; +} + +async function send(config: OcxConfig) { + const requestId = `pf08-decline-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "anth/claude-x", max_tokens: 32, top_k: 4, stream: false, + messages: [{ role: "user", content: "fixture" }] }), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + await response.text(); + const rows = getRequestLogEntries().filter(entry => entry.requestId === requestId); + expect(rows).toHaveLength(1); + return { response, row: rows[0]! }; +} + +describe("native Messages decline in the trace", () => { + test("switch on: a pinned effort keeps the bridge and the trace says why", async () => { + const { response, row } = await send(fixtureConfig(true)); + expect(response.status).toBe(200); + expect(bodies).toHaveLength(1); + // The bridge rebuilt the request through the adapter: no top_k, internal streaming. + expect(bodies[0]).not.toHaveProperty("top_k"); + expect(row.protocolTrace).toMatchObject({ inbound: "messages", mode: "legacy-bridge" }); + expect(row.protocolTrace?.reasonCodes).toContain("bridge-only-policy"); + }); + + test("switch off: no decline reason is recorded", async () => { + const { row } = await send(fixtureConfig(false)); + expect(row.protocolTrace).toMatchObject({ inbound: "messages", mode: "legacy-bridge" }); + expect(row.protocolTrace?.reasonCodes).not.toContain("bridge-only-policy"); + expect(row.protocolTrace?.reasonCodes).not.toContain("rollout-disabled"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fcbd73d8c93..aa1dff3a754 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -176,6 +176,7 @@ "protocol-direct-encoders-messages.test.ts": "responses", "chat-native-combo.test.ts": "responses", "messages-native-eligibility.test.ts": "responses", + "messages-native-bridge-policy.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", @@ -253,6 +254,7 @@ "claude-manual-env.test.ts": "gui", "claude-messages-endpoint.test.ts": "claude-integration", "messages-native.test.ts": "claude-integration", + "messages-native-decline-trace.test.ts": "claude-integration", "messages-surface-matrix.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", diff --git a/tests/responses/messages-native-bridge-policy.test.ts b/tests/responses/messages-native-bridge-policy.test.ts new file mode 100644 index 00000000000..61ab7c4815b --- /dev/null +++ b/tests/responses/messages-native-bridge-policy.test.ts @@ -0,0 +1,122 @@ +/** + * Operator policy only the bridge applies keeps a Messages request off the managed native lane + * (`bridge-only-policy`): a pinned route effort, a blocked-skill bundle the translator would + * elide, and a web-search tool the sidecar could serve. The planner reports the config-only part + * of the same rule. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { anthropicBodyElidesBlockedSkill } from "../../src/claude/inbound"; +import { buildProtocolPlanSnapshot } from "../../src/protocols/plan-snapshot"; +import type { RouteResult } from "../../src/router"; +import { nativeMessagesDeclineReason } from "../../src/server/messages-native-eligibility"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-messages-bridge-policy-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) removeTreeWithRetry(testDir); +}); + +function config(overrides: Partial = {}, provider: Record = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "anth", + providers: { + anth: { adapter: "anthropic", baseUrl: "https://anth.example/v1", authMode: "key", apiKey: "ka", models: ["claude-x"], ...provider }, + }, + protocols: { rollout: { managedMessagesNative: true } }, + ...overrides, + } as OcxConfig; +} + +function route(provider: Record = {}): RouteResult { + return { + providerName: "anth", + modelId: "claude-x", + routeKind: "direct", + routeReason: "test", + provider: { adapter: "anthropic", baseUrl: "https://anth.example/v1", apiKey: "ka", ...provider }, + } as unknown as RouteResult; +} + +const TEXT_BODY = { messages: [{ role: "user", content: "fixture" }] }; + +const SKILL_BUNDLE = `Base directory for this skill: /fixture/skills/claude-api\n${"x".repeat(12_000)}`; + +describe("pinned route effort", () => { + test("a provider or model pin keeps the request on the bridge", () => { + expect(nativeMessagesDeclineReason(route({ pinnedReasoningEffort: "high" }), TEXT_BODY, config())) + .toBe("bridge-only-policy"); + expect(nativeMessagesDeclineReason(route({ modelPinnedReasoningEfforts: { "claude-x": "low" } }), TEXT_BODY, config())) + .toBe("bridge-only-policy"); + }); + + test("a global pin is read through the bridge's selector", () => { + const pinned = config({ modelPinnedEfforts: { "anth/claude-x": "medium" } } as Partial); + expect(nativeMessagesDeclineReason(route(), TEXT_BODY, pinned, { routeSelector: "anth/claude-x" })) + .toBe("bridge-only-policy"); + expect(nativeMessagesDeclineReason(route(), TEXT_BODY, config(), { routeSelector: "anth/claude-x" })).toBeUndefined(); + }); + + test("the planner reports the pin as the decline reason", () => { + const snapshot = buildProtocolPlanSnapshot(config({}, { pinnedReasoningEffort: "high" }), + { model: "anth/claude-x", inbound: "messages", features: [] }); + expect(snapshot.candidates[0]).toMatchObject({ nativeEligible: false, declineReasons: ["bridge-only-policy"] }); + }); +}); + +describe("blocked-skill elision", () => { + const bundleBody = { messages: [{ role: "user", content: [{ type: "text", text: SKILL_BUNDLE }] }] }; + const resultBody = { + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "toolu_skill", name: "Skill", input: { skill: "claude-api" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_skill", content: "Launching skill" }] }, + ], + }; + + test("a bundle or a blocked Skill result the translator would stub declines the native lane", () => { + expect(anthropicBodyElidesBlockedSkill(bundleBody)).toBe(true); + expect(anthropicBodyElidesBlockedSkill(resultBody)).toBe(true); + expect(nativeMessagesDeclineReason(route(), bundleBody, config())).toBe("bridge-only-policy"); + expect(nativeMessagesDeclineReason(route(), resultBody, config())).toBe("bridge-only-policy"); + }); + + test("nothing to elide, or no blocked skills, stays native", () => { + expect(anthropicBodyElidesBlockedSkill(TEXT_BODY)).toBe(false); + const short = { messages: [{ role: "user", content: [{ type: "text", text: "Base directory for this skill: /x/claude-api" }] }] }; + expect(anthropicBodyElidesBlockedSkill(short)).toBe(false); + const unblocked = config({ claudeCode: { blockedSkills: [] } } as Partial); + expect(nativeMessagesDeclineReason(route(), bundleBody, unblocked)).toBeUndefined(); + }); +}); + +describe("web-search sidecar", () => { + const searchBody = { ...TEXT_BODY, tools: [{ type: "web_search_20250305", name: "web_search" }] }; + + test("a web_search server tool the sidecar could serve declines the native lane", () => { + expect(nativeMessagesDeclineReason(route(), searchBody, config())).toBe("bridge-only-policy"); + expect(nativeMessagesDeclineReason(route(), { ...searchBody, tool_choice: { type: "tool", name: "web_search" } }, config())) + .toBe("bridge-only-policy"); + }); + + test("a disabled sidecar or a tool choice that excludes search stays native", () => { + const off = config({ claudeCode: { webSearchSidecar: { enabled: false } } } as Partial); + expect(nativeMessagesDeclineReason(route(), searchBody, off)).toBeUndefined(); + expect(nativeMessagesDeclineReason(route(), { ...searchBody, tool_choice: { type: "none" } }, config())).toBeUndefined(); + expect(nativeMessagesDeclineReason(route(), { ...searchBody, tool_choice: { type: "tool", name: "lookup" } }, config())) + .toBeUndefined(); + }); +}); From e18270090055e13ef98d1f06e2683d9bb6eda0e5 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:06:28 +0900 Subject: [PATCH 116/173] docs(structure): record bridge-only declines, the decline trace and model echo States which operator policy keeps Messages on the bridge, that stabilizePromptCache is a recorded gap rather than a decline rule, and that native answers echo the client's selector. --- .../040_acceptance_and_rollout.md | 4 +-- structure/data-planes/protocol-paths.md | 28 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) 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 index c0f2c82648e..d234573565b 100644 --- a/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md +++ b/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md @@ -48,6 +48,6 @@ Kept current by each packet that migrates something. | Responses-only features on Chat/Messages | `previous_response_id`, `store`, `background`, compaction stay on the bridge | | Non-public-wire adapters (`other`) | translated through the IR; no feature claims | | OAuth native Chat | not planned in this unit | -| Messages → key-auth Anthropic | native behind `managedMessagesNative` (PF-08); bridge while off. Caller `anthropic-beta` is not forwarded (PF-10 allowlist); top-level fields outside the allowlist are dropped with no feature effect; the response `model` is the upstream's wire id | +| Messages → key-auth Anthropic | native behind `managedMessagesNative` (PF-08); bridge while off. Caller `anthropic-beta` is not forwarded (PF-10 allowlist); top-level fields outside the allowlist are dropped with no feature effect | | Messages → Anthropic OAuth | bridge until `managedMessagesNativeOAuth` (PF-10) | -| Messages native lane, translated-only steps | skill elision, `stabilizePromptCache`, pinned route effort and the Claude web-search/vision sidecars run only on the bridge; routes needing vision preprocessing stay there | +| Messages native lane, translated-only steps | a pinned route effort, blocked-skill elision, the web-search sidecar and vision preprocessing keep the request on the bridge (`bridge-only-policy` / `vision-preprocessing`); `stabilizePromptCache` is a recorded gap — the native lane does not apply it | diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index a0e04c500e8..37d88cbec73 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -214,7 +214,20 @@ Responses. `nativeMessagesDeclineReason` names the first rule that keeps a route `rollout-disabled`, `cross-wire-ir` (another adapter), `auth-mode-not-native` (OAuth, which is PF-10, or `forward`), `combo-or-policy-route`, `effort-row` / `fast-row` (synthetic rows need the adapter's wire rewrite), `vision-preprocessing` (an image for a model declared unable to read -it). The ingress, `count_tokens` and the planner all ask it. +it), and `bridge-only-policy` when operator policy that only the translated path applies would +engage: a pinned reasoning effort for the route (`resolvePinnedEffort`, read with the translated +body's model id as the bridge reads it), a blocked-skill bundle the translator would elide +(`anthropicBodyElidesBlockedSkill` in `src/claude/inbound.ts`), or a `web_search*` server tool the +web-search sidecar could serve (not excluded by `tool_choice`, sidecar not disabled in the Claude +replay config; backend credentials are decided at dispatch, so this errs toward the bridge). The +ingress, `count_tokens` and the planner all ask it. The planner can judge only the config-and-route +parts: skill elision and web search depend on body content no feature describes. + +With the switch on, a declined route re-marks its bridge entry with the decline reason (after +any `effort-row` / `fast-row`), so the trace says why the bridge was taken; with it off nothing is +added. `claudeCode.stabilizePromptCache` is not a decline rule: it is a Claude-app cache +optimization rather than routing policy, applies only on the translated path, and is a recorded +gap of the native lane. `src/server/claude-messages.ts` decides the lane after the route and its wire settle and after the managed-client steps already applied to the body (alias/modelMap resolution, `ocx-route`, effort @@ -238,14 +251,19 @@ feature effect. selection, 401 and 429 key-pool rotation, same-target 429 replay, the reset/transient retry policy and `sendWithConnectionPolicy`. Before sending it runs the image normalizer, the image guard and the tool-call-id repair the caller-forward passthrough runs. A streaming caller gets the -upstream SSE relayed byte for byte through `tapAnthropicSseForLog`, which records usage and the +upstream SSE relayed through `tapAnthropicSseForLog`, which records usage and the terminal and applies the body stall and size guards; a non-streaming caller gets the upstream JSON -(or a folded stream). Upstream errors answer in Anthropic shape with the translated lane's status +(or a folded stream). Either way `model` is rewritten to the selector the client sent, as the +translated lane answers (`message_start.message.model` on a stream, found within the first 64 KiB; +everything else is relayed as is). Upstream errors answer in Anthropic shape with the translated lane's status policy (transient 5xx as 529, replay refusals kept non-retryable). `count_tokens` estimates the body the builder would send when the route is eligible, and sends nothing. `tests/adapters/anthropic/anthropic-messages-passthrough.test.ts`, -`tests/responses/messages-native-eligibility.test.ts` and -`tests/claude-integration/messages-native.test.ts` pin the builder, the rule and the lane. +`tests/responses/messages-native-eligibility.test.ts`, +`tests/responses/messages-native-bridge-policy.test.ts`, +`tests/claude-integration/messages-native.test.ts` and +`tests/claude-integration/messages-native-decline-trace.test.ts` pin the builder, the rule, the +lane and the decline trace. ## Settings From eff984acf112ca2e1dcee3c405fd101c6715c398 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:51:02 +0900 Subject: [PATCH 117/173] feat(protocols): add the provider wire summary DTO and validator The dashboard's provider panel validates the ?provider block with the shared leaf, so an older or newer server's record is refused instead of half-rendered. --- src/protocols/dto.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/protocols/dto.ts b/src/protocols/dto.ts index 246835bffd8..3d96365879d 100644 --- a/src/protocols/dto.ts +++ b/src/protocols/dto.ts @@ -104,6 +104,36 @@ export interface ProtocolTraceV1 { contractVersion: string; } +/** Who decided the wire a provider (or one of its models) receives. */ +export const PROTOCOL_ADAPTER_SOURCES = ["hard-pin", "operator", "registry", "provider-default"] as const; +export type ProtocolAdapterSource = (typeof PROTOCOL_ADAPTER_SOURCES)[number]; + +/** Upper bound on the per-model overrides one provider summary lists. */ +export const PROTOCOL_PROVIDER_OVERRIDE_LIMIT = 64; + +/** One model whose upstream wire differs from, or was decided apart from, the provider's. */ +export interface ProtocolProviderModelOverrideV1 { + model: string; + adapter: string; + source: ProtocolAdapterSource; +} + +/** + * The `provider` block of `GET /api/protocols?provider=`: the upstream wire a provider + * receives and who decided it. Static config only; no credential, base URL or header. + */ +export interface ProtocolProviderSummaryV1 { + name: string; + adapter: string; + adapterSource: ProtocolAdapterSource; + /** Resolved auth mode (`key`, `oauth`, `forward`, `local`), or null when none resolves. */ + authMode: string | null; + upstream: UpstreamWire; + modelOverrides: ProtocolProviderModelOverrideV1[]; + /** Set when more overrides exist than `PROTOCOL_PROVIDER_OVERRIDE_LIMIT` allows to list. */ + modelOverridesTruncated?: true; +} + type Rec = Record; function isRec(value: unknown): value is Rec { return !!value && typeof value === "object" && !Array.isArray(value); @@ -215,3 +245,23 @@ export function parseProtocolTraceV1(value: unknown): ProtocolTraceV1 | undefine contractVersion: value.contractVersion, }; } + +const ADAPTER_SOURCES = new Set(PROTOCOL_ADAPTER_SOURCES); +function isAdapterSource(value: unknown): value is ProtocolAdapterSource { + return typeof value === "string" && ADAPTER_SOURCES.has(value); +} + +function isModelOverride(value: unknown): value is ProtocolProviderModelOverrideV1 { + return isRec(value) && isIdentifier(value.model) && isIdentifier(value.adapter) && isAdapterSource(value.source); +} + +export function isProtocolProviderSummaryV1(value: unknown): value is ProtocolProviderSummaryV1 { + return isRec(value) + && isIdentifier(value.name) + && isIdentifier(value.adapter) + && isAdapterSource(value.adapterSource) + && (value.authMode === null || isIdentifier(value.authMode)) + && isUpstreamWire(value.upstream) + && boundedArray(value.modelOverrides, PROTOCOL_PROVIDER_OVERRIDE_LIMIT, isModelOverride) + && (value.modelOverridesTruncated === undefined || value.modelOverridesTruncated === true); +} From cc28b2de3ee8cabfd5461f0f8661231e442139e6 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:51:02 +0900 Subject: [PATCH 118/173] feat(protocols): answer GET /api/protocols?provider with the resolved wire The provider panel needs the adapter, who decided it and per-model overrides; it is read from captureRouteStaticPolicy so no second copy of the adapter rules exists. --- src/protocols/provider-summary.ts | 96 ++++++++++++++++++++++++ src/server/management/protocol-routes.ts | 24 +++++- 2 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 src/protocols/provider-summary.ts diff --git a/src/protocols/provider-summary.ts b/src/protocols/provider-summary.ts new file mode 100644 index 00000000000..0f4cdd5d602 --- /dev/null +++ b/src/protocols/provider-summary.ts @@ -0,0 +1,96 @@ +/** + * The `provider` block of `GET /api/protocols?provider=`: which upstream wire one + * provider receives and who decided it. + * + * SERVER SIDE (not a leaf): it reads the provider registry through + * `captureRouteStaticPolicy`, the same resolver every ingress uses, so the answer is the + * resolved static policy and never a second copy of the adapter rules. It is free of side + * effects: no fetch, no model-cache refresh, no config write. Only the adapter id, its + * provenance, the auth mode and model ids leave this module; no credential, base URL or header. + * + * Model overrides are resolved for a Responses client, the Codex default. A registry wire + * default declared for another client API only shows up in that API's plan preview. + */ +import { PROVIDER_REGISTRY } from "../providers/registry"; +import type { StaticPolicySource } from "../providers/resolved-model-policy"; +import { captureRouteStaticPolicy } from "../router"; +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { upstreamWireForAdapter } from "./contract"; +import { + PROTOCOL_DTO_LIMITS, + PROTOCOL_PROVIDER_OVERRIDE_LIMIT, + type ProtocolAdapterSource, + type ProtocolProviderModelOverrideV1, + type ProtocolProviderSummaryV1, +} from "./dto"; + +/** Upper bound on the model ids examined, so a provider with a huge static list stays cheap. */ +const MODEL_SCAN_LIMIT = 1024; + +/** Collapse the resolver's provenance vocabulary onto the four public decision sources. */ +export function protocolAdapterSource(source: StaticPolicySource | undefined): ProtocolAdapterSource { + switch (source) { + case "hard-pin": + return "hard-pin"; + case "operator": + case "operator-capability": + return "operator"; + case "registry": + return "registry"; + default: + return "provider-default"; + } +} + +function usableModelId(model: unknown): model is string { + return typeof model === "string" && model.length > 0 && model.length <= PROTOCOL_DTO_LIMITS.identifierLength + && !/[\u0000-\u001f\u007f]/.test(model); +} + +/** Model ids whose wire could differ from the provider's: explicit overrides, registry defaults, listed models. */ +function candidateModelIds(name: string, provider: Readonly): string[] { + const registry = PROVIDER_REGISTRY.find(entry => entry.id === name); + const ids = new Set(); + const add = (model: unknown) => { + if (ids.size < MODEL_SCAN_LIMIT && usableModelId(model)) ids.add(model); + }; + for (const model of Object.keys(provider.modelAdapters ?? {})) add(model); + for (const model of Object.keys(registry?.modelWireDefaults ?? {})) add(model); + add(provider.defaultModel); + for (const model of provider.models ?? []) add(model); + return [...ids].sort((left, right) => left.localeCompare(right)); +} + +/** + * Summarize one configured provider, or `undefined` when no provider has that name. The + * provider-level adapter is resolved without a model; each override is resolved for its model. + */ +export function buildProtocolProviderSummary(config: Readonly, name: string): ProtocolProviderSummaryV1 | undefined { + if (!Object.hasOwn(config.providers ?? {}, name)) return undefined; + const provider = config.providers[name]; + if (!provider) return undefined; + const base = captureRouteStaticPolicy(name, "", provider); + const adapter = base.provider.adapter; + const overrides: ProtocolProviderModelOverrideV1[] = []; + let truncated = false; + for (const model of candidateModelIds(name, provider)) { + const policy = captureRouteStaticPolicy(name, model, provider); + const source = protocolAdapterSource(policy.provenance.model.adapter); + if (source === "provider-default" && policy.model.adapter === adapter) continue; + if (overrides.length >= PROTOCOL_PROVIDER_OVERRIDE_LIMIT) { + truncated = true; + break; + } + overrides.push({ model, adapter: policy.model.adapter, source }); + } + const authMode = base.provider.authMode; + return { + name, + adapter, + adapterSource: protocolAdapterSource(base.provenance.provider.adapter), + authMode: typeof authMode === "string" && authMode.length > 0 ? authMode : null, + upstream: upstreamWireForAdapter(adapter), + modelOverrides: overrides, + ...(truncated ? { modelOverridesTruncated: true as const } : {}), + }; +} diff --git a/src/server/management/protocol-routes.ts b/src/server/management/protocol-routes.ts index f3dfb00bca9..53e025eed8d 100644 --- a/src/server/management/protocol-routes.ts +++ b/src/server/management/protocol-routes.ts @@ -5,7 +5,8 @@ * the planner reaches the router and the ingress eligibility rules, and a static import * would put them on every dashboard request. * - * GET and the plan preview are read-only. The preview is computed from config alone + * GET (optionally with `?provider=`, src/protocols/provider-summary.ts) and the plan + * preview are read-only. The preview is computed from config alone * (src/protocols/plan-snapshot.ts): it sends nothing upstream, advances no combo state, and * never logs its input. PATCH /api/protocols/settings is the one writer; it validates in * src/server/management/protocol-settings-patch.ts, persists through the locked @@ -16,6 +17,7 @@ import { jsonResponse } from "../auth-cors"; import { isProtocol, PROTOCOL_CONTRACT_VERSION } from "../../protocols/contract"; import { isProtocolFeature, PROTOCOL_FEATURES, type ProtocolFeature } from "../../protocols/features"; import { previewProtocolPlan, type ProtocolPlanRequest } from "../../protocols/plan-snapshot"; +import { buildProtocolProviderSummary } from "../../protocols/provider-summary"; import { protocolPolicyRevision, resolveApiSurfaceSettings, resolveProtocolSettings } from "../../protocols/settings"; import type { OcxConfig } from "../../types"; import type { ManagementContext } from "./context"; @@ -28,6 +30,7 @@ import { } from "./protocol-settings-patch"; export const PROTOCOL_PLAN_LIMITS = { modelLength: 200, features: 24 } as const; +export const PROTOCOL_PROVIDER_QUERY_LIMIT = 200; const PLAN_BODY_KEYS = new Set(["model", "inbound", "features"]); const INVALID_BODY = Symbol("invalid-body"); @@ -72,6 +75,22 @@ function protocolInfo(config: OcxConfig) { }; } +/** + * `GET /api/protocols?provider=`: the usual body plus the provider's wire summary. The + * name is bounded and must name a configured provider; neither error echoes it back. + */ +function protocolInfoForProvider(ctx: ManagementContext, values: string[]): Response { + const { req, config } = ctx; + const name = values.length === 1 ? values[0]!.trim() : ""; + if (!name || name.length > PROTOCOL_PROVIDER_QUERY_LIMIT || /[\u0000-\u001f\u007f]/.test(name)) { + const message = `provider must be one non-empty name of at most ${PROTOCOL_PROVIDER_QUERY_LIMIT} characters`; + return jsonResponse({ error: { code: "invalid_provider", message } }, 400, req, config); + } + const provider = buildProtocolProviderSummary(config, name); + if (!provider) return jsonResponse({ error: { code: "unknown_provider", message: "no provider with that name" } }, 404, req, config); + return jsonResponse({ ...protocolInfo(config), provider }, 200, req, config); +} + /** Only SQLITE_BUSY is contention worth retrying; any other lock failure repeats forever. */ function isConfigLockContention(error: unknown): boolean { if (!error || typeof error !== "object") return false; @@ -117,7 +136,8 @@ export async function handleProtocolRoutes(ctx: ManagementContext): Promise Date: Fri, 25 Sep 2026 12:52:13 +0900 Subject: [PATCH 119/173] feat(protocols): list exact-id hard pins among provider model overrides A pinned model the operator never listed still receives another wire; leaving it out would make the provider panel claim the provider adapter applies to it. --- src/protocols/provider-summary.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/protocols/provider-summary.ts b/src/protocols/provider-summary.ts index 0f4cdd5d602..c4fbc383729 100644 --- a/src/protocols/provider-summary.ts +++ b/src/protocols/provider-summary.ts @@ -14,7 +14,7 @@ import { PROVIDER_REGISTRY } from "../providers/registry"; import type { StaticPolicySource } from "../providers/resolved-model-policy"; import { captureRouteStaticPolicy } from "../router"; -import type { OcxConfig, OcxProviderConfig } from "../types"; +import { captureWireAdapterHardPins, type OcxConfig, type OcxProviderConfig } from "../types"; import { upstreamWireForAdapter } from "./contract"; import { PROTOCOL_DTO_LIMITS, @@ -47,7 +47,11 @@ function usableModelId(model: unknown): model is string { && !/[\u0000-\u001f\u007f]/.test(model); } -/** Model ids whose wire could differ from the provider's: explicit overrides, registry defaults, listed models. */ +/** + * Model ids whose wire could differ from the provider's: explicit overrides, registry wire + * defaults, exact-id hard pins and the listed models. Prefix pins cannot be enumerated; a + * listed model they match is still found. + */ function candidateModelIds(name: string, provider: Readonly): string[] { const registry = PROVIDER_REGISTRY.find(entry => entry.id === name); const ids = new Set(); @@ -56,6 +60,7 @@ function candidateModelIds(name: string, provider: Readonly): }; for (const model of Object.keys(provider.modelAdapters ?? {})) add(model); for (const model of Object.keys(registry?.modelWireDefaults ?? {})) add(model); + for (const model of Object.keys(captureWireAdapterHardPins(name))) add(model); add(provider.defaultModel); for (const model of provider.models ?? []) add(model); return [...ids].sort((left, right) => left.localeCompare(right)); From 8bbc9355a34d044255ab41fa3edf05c4296621c5 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:52:13 +0900 Subject: [PATCH 120/173] test(protocols): pin the ?provider summary bounds, 404 and source mapping Covers the provenance collapse, the 64-override cap, a non-echoing 404 and 400s for empty, over-long, control-character and repeated provider parameters. --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + .../server/protocol-provider-summary.test.ts | 182 ++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/server/protocol-provider-summary.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5c7e53671a1..d46e43309a4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1121,6 +1121,7 @@ "management-route-registry.test.ts": "server", "protocol-routes.test.ts": "server", "protocol-settings-route.test.ts": "server", + "protocol-provider-summary.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "managing-cli.test.ts": "service", "memory-watchdog.test.ts": "server", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index aa1dff3a754..e1a2141ce56 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -947,6 +947,7 @@ "management-route-registry.test.ts": "server", "protocol-routes.test.ts": "server", "protocol-settings-route.test.ts": "server", + "protocol-provider-summary.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "managing-cli.test.ts": "service", "memory-watchdog.test.ts": "server", diff --git a/tests/server/protocol-provider-summary.test.ts b/tests/server/protocol-provider-summary.test.ts new file mode 100644 index 00000000000..463746b42d2 --- /dev/null +++ b/tests/server/protocol-provider-summary.test.ts @@ -0,0 +1,182 @@ +/** + * GET /api/protocols?provider= (src/server/management/protocol-routes.ts) and its + * builder (src/protocols/provider-summary.ts): the resolved upstream wire, who decided it, + * bounded per-model overrides, and a bounded, non-echoing query parameter. + */ +import { describe, expect, test } from "bun:test"; +import { isProtocolProviderSummaryV1, PROTOCOL_PROVIDER_OVERRIDE_LIMIT, type ProtocolProviderSummaryV1 } from "../../src/protocols/dto"; +import { buildProtocolProviderSummary, protocolAdapterSource } from "../../src/protocols/provider-summary"; +import type { ManagementContext } from "../../src/server/management/context"; +import { handleProtocolRoutes, PROTOCOL_PROVIDER_QUERY_LIMIT } from "../../src/server/management/protocol-routes"; +import type { OcxConfig } from "../../src/types"; + +const manyModels = Array.from({ length: PROTOCOL_PROVIDER_OVERRIDE_LIMIT + 10 }, (_, index) => `m${String(index).padStart(3, "0")}`); + +const config = { + port: 10100, + defaultProvider: "custom", + providers: { + custom: { + adapter: "openai-chat", + baseUrl: "https://custom.example/v1", + apiKey: "secret-key-custom", + models: ["plain", "wide"], + modelAdapters: { wide: "openai-responses" }, + }, + "opencode-go": { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/go/v1", + authMode: "key", + apiKey: "secret-key-go", + models: ["minimax-m2.5", "gpt-5.6-luna", "kimi-k2.7-code", "plain"], + modelAdapters: { "kimi-k2.7-code": "openai-responses" }, + }, + crowded: { + adapter: "openai-chat", + baseUrl: "https://crowded.example/v1", + apiKey: "secret-key-crowded", + models: manyModels, + modelAdapters: Object.fromEntries(manyModels.map(model => [model, "openai-responses"])), + }, + }, +} as unknown as OcxConfig; + +function ctx(path: string): ManagementContext { + const url = new URL(`http://127.0.0.1:10100${path}`); + return { req: new Request(url), url, config, deps: {}, version: "test" } as unknown as ManagementContext; +} + +async function get(path: string): Promise { + const res = await handleProtocolRoutes(ctx(path)); + if (!res) throw new Error("route did not answer"); + return res; +} + +describe("protocolAdapterSource", () => { + test.each([ + ["hard-pin", "hard-pin"], + ["operator", "operator"], + ["operator-capability", "operator"], + ["registry", "registry"], + ["provider-default", "provider-default"], + ["captured-auth", "provider-default"], + ["unknown", "provider-default"], + [undefined, "provider-default"], + ] as const)("maps %s to %s", (source, expected) => { + expect(protocolAdapterSource(source)).toBe(expected); + }); +}); + +describe("buildProtocolProviderSummary", () => { + test("an operator provider lists only the model whose wire was decided apart from it", () => { + const summary = buildProtocolProviderSummary(config, "custom"); + expect(isProtocolProviderSummaryV1(summary)).toBe(true); + expect(summary).toEqual({ + name: "custom", + adapter: "openai-chat", + adapterSource: "operator", + authMode: null, + upstream: "chat", + modelOverrides: [{ model: "wide", adapter: "openai-responses", source: "operator" }], + }); + }); + + test("a registry provider reports hard pins, registry wire defaults and operator overrides", () => { + const summary = buildProtocolProviderSummary(config, "opencode-go")!; + expect(summary).toMatchObject({ adapter: "openai-chat", adapterSource: "registry", authMode: "key", upstream: "chat" }); + const byModel = new Map(summary.modelOverrides.map(row => [row.model, row])); + expect(byModel.get("minimax-m2.5")).toEqual({ model: "minimax-m2.5", adapter: "anthropic", source: "hard-pin" }); + expect(byModel.get("gpt-5.6-luna")).toEqual({ model: "gpt-5.6-luna", adapter: "openai-responses", source: "registry" }); + expect(byModel.get("kimi-k2.7-code")).toEqual({ model: "kimi-k2.7-code", adapter: "openai-responses", source: "operator" }); + expect(byModel.has("plain")).toBe(false); + const models = summary.modelOverrides.map(row => row.model); + expect(models).toEqual([...models].sort((left, right) => left.localeCompare(right))); + }); + + test("overrides are capped and the cap is reported", () => { + const summary = buildProtocolProviderSummary(config, "crowded")!; + expect(summary.modelOverrides).toHaveLength(PROTOCOL_PROVIDER_OVERRIDE_LIMIT); + expect(summary.modelOverridesTruncated).toBe(true); + expect(isProtocolProviderSummaryV1(summary)).toBe(true); + }); + + test("an unknown or inherited name has no summary", () => { + expect(buildProtocolProviderSummary(config, "missing")).toBeUndefined(); + expect(buildProtocolProviderSummary(config, "constructor")).toBeUndefined(); + expect(buildProtocolProviderSummary(config, "__proto__")).toBeUndefined(); + }); + + test("the summary carries no credential or endpoint", () => { + const text = JSON.stringify(buildProtocolProviderSummary(config, "opencode-go")); + expect(text).not.toContain("secret-key"); + expect(text).not.toContain("opencode.ai"); + }); +}); + +describe("GET /api/protocols?provider=", () => { + test("adds the provider block to the usual body", async () => { + const res = await get("/api/protocols?provider=custom"); + expect(res.status).toBe(200); + const body = await res.json() as { schemaVersion: number; features: unknown[]; provider: ProtocolProviderSummaryV1 }; + expect(body.schemaVersion).toBe(1); + expect(Array.isArray(body.features)).toBe(true); + expect(isProtocolProviderSummaryV1(body.provider)).toBe(true); + expect(body.provider.name).toBe("custom"); + }); + + test("the plain GET keeps its shape without a provider block", async () => { + const body = await (await get("/api/protocols")).json() as Record; + expect(body.provider).toBeUndefined(); + }); + + test("an unknown provider answers 404 without echoing the name", async () => { + const res = await get("/api/protocols?provider=no-such-provider"); + expect(res.status).toBe(404); + const text = await res.text(); + expect(text).toContain("unknown_provider"); + expect(text).not.toContain("no-such-provider"); + }); + + test.each([ + ["an empty name", "/api/protocols?provider="], + ["a blank name", "/api/protocols?provider=%20%20"], + ["an over-long name", `/api/protocols?provider=${"p".repeat(PROTOCOL_PROVIDER_QUERY_LIMIT + 1)}`], + ["a control character", "/api/protocols?provider=custom%01"], + ["a repeated parameter", "/api/protocols?provider=custom&provider=opencode-go"], + ])("rejects %s with 400", async (_label, path) => { + const res = await get(path); + expect(res.status).toBe(400); + const payload = await res.json() as { error: { code: string } }; + expect(payload.error.code).toBe("invalid_provider"); + }); + + test("a name at the length bound is looked up, not rejected", async () => { + const res = await get(`/api/protocols?provider=${"p".repeat(PROTOCOL_PROVIDER_QUERY_LIMIT)}`); + expect(res.status).toBe(404); + }); +}); + +describe("isProtocolProviderSummaryV1", () => { + const valid: ProtocolProviderSummaryV1 = { + name: "p", + adapter: "openai-chat", + adapterSource: "operator", + authMode: "key", + upstream: "chat", + modelOverrides: [], + }; + + test("accepts a minimal summary and rejects drift", () => { + expect(isProtocolProviderSummaryV1(valid)).toBe(true); + expect(isProtocolProviderSummaryV1({ ...valid, adapterSource: "captured-auth" })).toBe(false); + expect(isProtocolProviderSummaryV1({ ...valid, upstream: "grpc" })).toBe(false); + expect(isProtocolProviderSummaryV1({ ...valid, authMode: undefined })).toBe(false); + expect(isProtocolProviderSummaryV1({ ...valid, modelOverridesTruncated: false })).toBe(false); + const tooMany = Array.from({ length: PROTOCOL_PROVIDER_OVERRIDE_LIMIT + 1 }, (_, index) => ({ + model: `m${index}`, + adapter: "openai-responses", + source: "operator", + })); + expect(isProtocolProviderSummaryV1({ ...valid, modelOverrides: tooMany })).toBe(false); + }); +}); From 1ba483b188d44d92c028f5eb495f6cf46431d41a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:52:28 +0900 Subject: [PATCH 121/173] feat(gui): fetch the provider wire summary from GET /api/protocols?provider An older server answers 404 or ignores the parameter; both read as unavailable so the provider panel hides quietly rather than showing an error. --- gui/src/protocol-api.ts | 42 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/gui/src/protocol-api.ts b/gui/src/protocol-api.ts index 2a33b30fd89..0e906520741 100644 --- a/gui/src/protocol-api.ts +++ b/gui/src/protocol-api.ts @@ -1,6 +1,6 @@ /** - * Client for the protocol routes (`GET /api/protocols`, `POST /api/protocols/plan`, - * `PATCH /api/protocols/settings`). + * Client for the protocol routes (`GET /api/protocols`, `GET /api/protocols?provider=`, + * `POST /api/protocols/plan`, `PATCH /api/protocols/settings`). * * The dashboard never computes a plan itself; it asks the server and validates the answer * with the shared leaf validator, so a record from an older or newer server is refused rather @@ -8,7 +8,12 @@ * the preview off quietly instead of showing an error. */ import { isProtocol, type Protocol } from "../../src/protocols/contract"; -import { isProtocolPlanV1, type ProtocolPlanV1 } from "../../src/protocols/dto"; +import { + isProtocolPlanV1, + isProtocolProviderSummaryV1, + type ProtocolPlanV1, + type ProtocolProviderSummaryV1, +} from "../../src/protocols/dto"; import { isProtocolFeature, type ProtocolFeature } from "../../src/protocols/features"; export interface ProtocolPlanQuery { @@ -151,3 +156,34 @@ export async function patchProtocolSettings( return { kind: "error" }; } } + +export type ProtocolProviderSummaryResult = + | { kind: "summary"; summary: ProtocolProviderSummaryV1 } + /** The server predates the provider block, or no longer has this provider. */ + | { kind: "unavailable" } + | { kind: "error" }; + +/** + * The upstream wire one provider receives, from `GET /api/protocols?provider=`. A 404 + * (older server without the routes, or a provider removed meanwhile) and a 200 without a + * `provider` block (a server that ignores the parameter) both read as unavailable, so the + * panel hides instead of guessing. + */ +export async function fetchProtocolProviderSummary( + apiBase: string, + provider: string, + signal?: AbortSignal, +): Promise { + try { + const res = await fetch(`${apiBase}/api/protocols?${new URLSearchParams({ provider }).toString()}`, { signal }); + if (res.status === 404) return { kind: "unavailable" }; + if (!res.ok) return { kind: "error" }; + const payload: unknown = await res.json(); + if (!isRec(payload) || payload.provider === undefined) return { kind: "unavailable" }; + if (!isProtocolProviderSummaryV1(payload.provider) || payload.provider.name !== provider) return { kind: "error" }; + return { kind: "summary", summary: payload.provider }; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + return { kind: "error" }; + } +} From eef484a10d7c088a45793db1b62b5e31e388d62c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:53:11 +0900 Subject: [PATCH 122/173] feat(gui): let providers and compatibility hashes carry a query Protocol deep links keep their target in the hash so Back/Forward restores it; routing reads the path alone and drops a query on any route that does not own one. --- gui/src/app-routing.ts | 21 ++++++++++++++++++--- gui/src/hash-routing.ts | 9 +++++++++ gui/src/pages/models-tab.ts | 5 +++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index f31583e9044..5a9eb8b635b 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -1,6 +1,6 @@ /** Pure hash → page resolution used by App route state. */ -import { normalizeHashPath } from "./hash-routing"; +import { normalizeHashPath, splitHashQuery } from "./hash-routing"; export type Page = | "dashboard" @@ -32,9 +32,9 @@ export const VALID_PAGES = new Set([ ]); export function readPageFromHash(hash?: string): Page { - const raw = normalizeHashPath( + const raw = splitHashQuery(normalizeHashPath( hash ?? (typeof window !== "undefined" ? window.location.hash : ""), - ); + )).path; // Sub-views use a "/" suffix (e.g. #logs/debug); the first segment is the page id. const pageId = raw.split("/")[0] as Page; // Legacy: Debug used to be a standalone page; it now lives as a tab on Logs. @@ -109,6 +109,13 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/cline", ] as const; +/** + * Routes that own a `?query` suffix: provider settings for one provider + * (`#providers?provider=`) and a protocol-pair prefilter on the compatibility matrix + * (`#models/compatibility?inbound=chat&upstream=messages`). Anywhere else the query is dropped. + */ +export const QUERY_HASH_PATHS: readonly string[] = ["providers", "models/compatibility"]; + export function hashBelongsToPage(rawHash: string, page: Page): boolean { return rawHash === page || (page === "logs" && rawHash === "logs/debug") @@ -133,6 +140,14 @@ export type AppHashChangeAction = { * push, so Back is never trapped on a hash the router immediately corrects. */ export function resolveAppHashChange(rawHash: string): AppHashChangeAction { + const { path, query } = splitHashQuery(rawHash); + if (query || path !== rawHash) { + // Route on the path alone; keep the query only where a page owns it, so Back/Forward + // restores it and an unrelated page never carries a stale one. + const action = resolveAppHashChange(path); + if (action.replaceTo === null && QUERY_HASH_PATHS.includes(path)) return action; + return { page: action.page, replaceTo: action.replaceTo ?? path }; + } const nextPage = readPageFromHash(rawHash); // Legacy: Debug used to be a standalone page. diff --git a/gui/src/hash-routing.ts b/gui/src/hash-routing.ts index 5579f7d8a26..f7e61793f5e 100644 --- a/gui/src/hash-routing.ts +++ b/gui/src/hash-routing.ts @@ -5,6 +5,15 @@ export function normalizeHashPath(hash: string): string { return hash.replace(/^#\/?/, ""); } +/** + * Split a normalized hash into its route path and an optional `?query`. The query is page-owned + * state (a prefilter, a selected provider); routing decisions read the path alone. + */ +export function splitHashQuery(raw: string): { path: string; query: string } { + const index = raw.indexOf("?"); + return index < 0 ? { path: raw, query: "" } : { path: raw.slice(0, index), query: raw.slice(index + 1) }; +} + /** * Passive URL correction: replace the current history entry. * Does not emit `hashchange` — callers must update React state themselves when needed. diff --git a/gui/src/pages/models-tab.ts b/gui/src/pages/models-tab.ts index 952d37f5c35..ae5a417946b 100644 --- a/gui/src/pages/models-tab.ts +++ b/gui/src/pages/models-tab.ts @@ -6,7 +6,7 @@ * is already large and because the tests want to import this directly. */ -import { navigateHash, normalizeHashPath } from "../hash-routing"; +import { navigateHash, normalizeHashPath, splitHashQuery } from "../hash-routing"; /** * `catalog` rather than `models` for the first tab: the page is Models and its first @@ -31,7 +31,8 @@ export function modelsTabHash(tab: ModelsTab): string { * on the catalog while the URL claimed Combos. */ export function readModelsTab(hash = window.location.hash): ModelsTab { - const raw = normalizeHashPath(hash); + // A compatibility prefilter rides in `?query` (protocol-deep-links.ts); the tab is the path. + const raw = splitHashQuery(normalizeHashPath(hash)).path; if (raw === "models/combos" || raw === "combos" || raw.startsWith("combos/")) return "combos"; if (raw === "models/routing" || raw === "routing" || raw.startsWith("routing/")) return "routing"; if (raw === "models/compatibility" || raw === "lab" || raw.startsWith("lab/")) return "compatibility"; From 86ad004f40d0fb47641c006c855d46fa423243a9 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:53:11 +0900 Subject: [PATCH 123/173] feat(gui): add protocol deep-link hash helpers One place builds and reads the compatibility pair and provider settings hashes, so the plan panel, the Logs trace and the target pages agree on the spelling. --- gui/src/protocol-deep-links.ts | 77 ++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 gui/src/protocol-deep-links.ts diff --git a/gui/src/protocol-deep-links.ts b/gui/src/protocol-deep-links.ts new file mode 100644 index 00000000000..3d0f94b5234 --- /dev/null +++ b/gui/src/protocol-deep-links.ts @@ -0,0 +1,77 @@ +/** + * Hash deep links between the protocol views: the plan preview and the Logs trace open the + * compatibility matrix prefiltered to one protocol pair, and the plan preview opens one + * provider's settings. + * + * The state lives in the hash query (`#models/compatibility?inbound=chat&upstream=messages`, + * `#providers?provider=`), which `app-routing.ts` keeps for exactly these two routes. A + * link is a deliberate navigation (`navigateHash` pushes a history entry), so Back returns to + * the view the link was followed from and Forward restores the prefilter. + */ +import { isProtocol, type Protocol, type UpstreamWire } from "../../src/protocols/contract"; +import { navigateHash, normalizeHashPath, splitHashQuery } from "./hash-routing"; + +export const COMPATIBILITY_HASH = "models/compatibility"; +export const PROVIDERS_HASH = "providers"; + +/** Longest provider name a deep link carries; the server bounds the same parameter at 200. */ +const PROVIDER_NAME_LIMIT = 200; + +export interface ProtocolPairFilter { + inbound: Protocol | ""; + upstream: Protocol | ""; +} + +export const EMPTY_PROTOCOL_PAIR: ProtocolPairFilter = { inbound: "", upstream: "" }; + +/** A plan or trace upstream as a filter value; `other` has no Lab protocol identity. */ +export function protocolPairUpstream(upstream: UpstreamWire | undefined): Protocol | "" { + return upstream !== undefined && isProtocol(upstream) ? upstream : ""; +} + +export function compatibilityPairHash(pair: Partial): string { + const query = new URLSearchParams(); + if (pair.inbound) query.set("inbound", pair.inbound); + if (pair.upstream) query.set("upstream", pair.upstream); + const text = query.toString(); + return text ? `${COMPATIBILITY_HASH}?${text}` : COMPATIBILITY_HASH; +} + +function readQuery(hash: string, path: string): URLSearchParams | null { + const parts = splitHashQuery(normalizeHashPath(hash)); + return parts.path === path ? new URLSearchParams(parts.query) : null; +} + +/** + * The pair a compatibility hash asks for, or `null` when the hash is not the compatibility + * route (another tab's hash must not clear a filter the matrix still shows). Unknown values + * read as "any". + */ +export function readCompatibilityPair(hash: string = window.location.hash): ProtocolPairFilter | null { + const query = readQuery(hash, COMPATIBILITY_HASH); + if (!query) return null; + const inbound = query.get("inbound"); + const upstream = query.get("upstream"); + return { + inbound: isProtocol(inbound) ? inbound : "", + upstream: isProtocol(upstream) ? upstream : "", + }; +} + +export function providerSettingsHash(provider: string): string { + return `${PROVIDERS_HASH}?${new URLSearchParams({ provider }).toString()}`; +} + +/** The provider a providers hash names, or `null`. */ +export function readProviderSettingsTarget(hash: string = window.location.hash): string | null { + const name = readQuery(hash, PROVIDERS_HASH)?.get("provider")?.trim() ?? ""; + return name && name.length <= PROVIDER_NAME_LIMIT ? name : null; +} + +export function openCompatibilityPair(pair: Partial): void { + navigateHash(compatibilityPairHash(pair)); +} + +export function openProviderSettings(provider: string): void { + navigateHash(providerSettingsHash(provider)); +} From 87658b32bab47ad83748a4e32622d84e9e3cc89f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:55:23 +0900 Subject: [PATCH 124/173] feat(gui): add the provider upstream wire panel Shows the wire a provider receives, who decided it and per-model overrides from the ?provider summary; it is read-only so it can never pass for an API exposure switch. --- .../ProviderProtocolPanel.tsx | 136 ++++++++++++++++++ gui/src/i18n/de.ts | 16 +++ gui/src/i18n/en.ts | 16 +++ gui/src/i18n/fr.ts | 16 +++ gui/src/i18n/ja.ts | 16 +++ gui/src/i18n/ko.ts | 16 +++ gui/src/i18n/ru.ts | 16 +++ gui/src/i18n/tr.ts | 16 +++ gui/src/i18n/vi.ts | 16 +++ gui/src/i18n/zh-TW.ts | 16 +++ gui/src/i18n/zh.ts | 16 +++ gui/src/main.tsx | 1 + gui/src/styles/protocol-evidence.css | 27 ++++ 13 files changed, 324 insertions(+) create mode 100644 gui/src/components/provider-workspace/ProviderProtocolPanel.tsx create mode 100644 gui/src/styles/protocol-evidence.css diff --git a/gui/src/components/provider-workspace/ProviderProtocolPanel.tsx b/gui/src/components/provider-workspace/ProviderProtocolPanel.tsx new file mode 100644 index 00000000000..9b253484061 --- /dev/null +++ b/gui/src/components/provider-workspace/ProviderProtocolPanel.tsx @@ -0,0 +1,136 @@ +/** + * The upstream wire one provider receives, and who decided it — a section of provider + * settings, read from `GET /api/protocols?provider=`. + * + * This is the format opencodex speaks TO the provider. It opens or closes no client API + * (Responses, Chat Completions, Messages); those are the API page's cards, so nothing here is + * a switch. The one editable input stays the adapter field of the settings form, which saves + * through `onUpdateProvider` → `PATCH /api/providers` like every other provider field; this + * panel only says what an unsaved change would do. An older server (404 or no `provider` + * block) hides the panel. + */ +import { useEffect, useState } from "react"; +import { upstreamWireForAdapter } from "../../../../src/protocols/contract"; +import type { ProtocolAdapterSource, ProtocolProviderSummaryV1 } from "../../../../src/protocols/dto"; +import { useT, type TKey } from "../../i18n/shared"; +import { fetchProtocolProviderSummary } from "../../protocol-api"; +import { protocolHopLabel } from "../protocols/protocol-labels"; + +const SOURCE_KEYS: Record = { + "hard-pin": "pws.protocol.source.hardPin", + operator: "pws.protocol.source.operator", + registry: "pws.protocol.source.registry", + "provider-default": "pws.protocol.source.providerDefault", +}; + +type LoadState = + | { key: string; kind: "summary"; summary: ProtocolProviderSummaryV1 } + | { key: string; kind: "hidden" } + | { key: string; kind: "error" }; + +function WireText({ adapter }: { adapter: string }) { + const t = useT(); + return ( + + {adapter} + {protocolHopLabel(upstreamWireForAdapter(adapter), t)} + + ); +} + +export function ProviderProtocolPanel({ + apiBase, + providerName, + savedAdapter, + draftAdapter, + refreshKey, +}: { + apiBase?: string; + providerName: string; + /** The saved adapter; a change after a PATCH refetches the summary. */ + savedAdapter: string; + /** The settings form's unsaved adapter choice, when it differs from the saved one. */ + draftAdapter?: string; + /** Any other saved field that can move the resolved wire (base URL, auth mode). */ + refreshKey?: string; +}) { + const t = useT(); + const requestKey = JSON.stringify([apiBase ?? "", providerName, savedAdapter, refreshKey ?? ""]); + const [state, setState] = useState(null); + + useEffect(() => { + if (!apiBase) return; + const controller = new AbortController(); + fetchProtocolProviderSummary(apiBase, providerName, controller.signal) + .then(result => { + if (controller.signal.aborted) return; + setState(result.kind === "summary" + ? { key: requestKey, kind: "summary", summary: result.summary } + : { key: requestKey, kind: result.kind === "unavailable" ? "hidden" : "error" }); + }) + .catch(() => { /* aborted by a newer request or by unmount */ }); + return () => controller.abort(); + }, [apiBase, providerName, requestKey]); + + const current = state?.key === requestKey ? state : null; + if (!apiBase || !current || current.kind === "hidden") return null; + + const pendingAdapter = draftAdapter && draftAdapter !== savedAdapter ? draftAdapter : null; + return ( +
    +

    {t("pws.protocol.title")}

    +

    {t("pws.protocol.note")}

    + {current.kind === "error" ? ( +

    {t("pws.protocol.loadFailed")}

    + ) : ( + <> +
    +
    +
    {t("pws.protocol.adapterLabel")}
    +
    +
    +
    +
    {t("pws.protocol.source")}
    +
    {t(SOURCE_KEYS[current.summary.adapterSource])}
    +
    + {current.summary.authMode && ( +
    +
    {t("pws.authMode")}
    +
    {current.summary.authMode}
    +
    + )} +
    + {pendingAdapter && ( +

    {t("pws.protocol.pending", { adapter: pendingAdapter })}

    + )} +

    {t("pws.protocol.overrides")}

    + {current.summary.modelOverrides.length === 0 ? ( +

    {t("pws.protocol.noOverrides")}

    + ) : ( + + + + + + + + + + {current.summary.modelOverrides.map(row => ( + + + + + + ))} + +
    {t("pws.protocol.col.model")}{t("pws.protocol.col.wire")}{t("pws.protocol.col.source")}
    {row.model}{t(SOURCE_KEYS[row.source])}
    + )} + {current.summary.modelOverridesTruncated && ( +

    {t("pws.protocol.overridesTruncated", { count: current.summary.modelOverrides.length })}

    + )} + + )} +
    + ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c454d45529c..e5c2ba6ab6e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3391,4 +3391,20 @@ export const de: Record = { "quotaSummary.critical": "Über 90 % genutzt", "quotaSummary.credits": "Guthaben", "quotaSummary.refreshFailed": "Letzte Aktualisierung fehlgeschlagen; vorheriger Stand wird angezeigt", + "pws.protocol.title": "Upstream-Protokoll", + "pws.protocol.note": "Das API-Format, das opencodex mit diesem Anbieter spricht. Es schaltet keine Client-API ein oder aus; das steuert die API-Seite.", + "pws.protocol.adapterLabel": "Upstream-Protokoll, das dieser Anbieter empfängt", + "pws.protocol.source": "Festgelegt durch", + "pws.protocol.source.hardPin": "Von opencodex für dieses Modell festgelegt", + "pws.protocol.source.operator": "Ihre Konfiguration", + "pws.protocol.source.registry": "Anbieterkatalog", + "pws.protocol.source.providerDefault": "Anbieter-Standard", + "pws.protocol.pending": "Nach dem Speichern empfängt dieser Anbieter {adapter}.", + "pws.protocol.overrides": "Modelle mit anderem Protokoll", + "pws.protocol.noOverrides": "Alle Modelle verwenden das Protokoll des Anbieters.", + "pws.protocol.overridesTruncated": "Die ersten {count} Modelle werden angezeigt.", + "pws.protocol.col.model": "Modell", + "pws.protocol.col.wire": "Protokoll", + "pws.protocol.col.source": "Festgelegt durch", + "pws.protocol.loadFailed": "Das Upstream-Protokoll konnte nicht geladen werden.", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ce56fab25e2..4f73e247753 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3425,6 +3425,22 @@ export const en = { "quotaSummary.critical": "90%+ used", "quotaSummary.credits": "Credits", "quotaSummary.refreshFailed": "Last refresh failed; showing the previous reading", + "pws.protocol.title": "Upstream wire", + "pws.protocol.note": "The API format opencodex speaks to this provider. It does not turn any client API on or off; the API page controls those.", + "pws.protocol.adapterLabel": "Upstream wire this provider receives", + "pws.protocol.source": "Decided by", + "pws.protocol.source.hardPin": "Fixed by opencodex for this model", + "pws.protocol.source.operator": "Your configuration", + "pws.protocol.source.registry": "Provider catalog", + "pws.protocol.source.providerDefault": "Provider default", + "pws.protocol.pending": "After you save, this provider receives {adapter}.", + "pws.protocol.overrides": "Models on another wire", + "pws.protocol.noOverrides": "Every model uses the provider wire.", + "pws.protocol.overridesTruncated": "Showing the first {count} models.", + "pws.protocol.col.model": "Model", + "pws.protocol.col.wire": "Wire", + "pws.protocol.col.source": "Decided by", + "pws.protocol.loadFailed": "The upstream wire could not be loaded.", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 56a982b9fa4..70c0bd19494 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3380,4 +3380,20 @@ export const fr: Record = { "quotaSummary.critical": "Plus de 90 % utilisés", "quotaSummary.credits": "Crédits", "quotaSummary.refreshFailed": "Échec de la dernière actualisation ; affichage de la lecture précédente", + "pws.protocol.title": "Protocole amont", + "pws.protocol.note": "Le format d'API qu'opencodex utilise avec ce fournisseur. Il n'active ni ne désactive aucune API cliente ; c'est la page API qui les contrôle.", + "pws.protocol.adapterLabel": "Protocole amont reçu par ce fournisseur", + "pws.protocol.source": "Décidé par", + "pws.protocol.source.hardPin": "Imposé par opencodex pour ce modèle", + "pws.protocol.source.operator": "Votre configuration", + "pws.protocol.source.registry": "Catalogue des fournisseurs", + "pws.protocol.source.providerDefault": "Valeur par défaut du fournisseur", + "pws.protocol.pending": "Après l'enregistrement, ce fournisseur recevra {adapter}.", + "pws.protocol.overrides": "Modèles sur un autre protocole", + "pws.protocol.noOverrides": "Tous les modèles utilisent le protocole du fournisseur.", + "pws.protocol.overridesTruncated": "Affichage des {count} premiers modèles.", + "pws.protocol.col.model": "Modèle", + "pws.protocol.col.wire": "Protocole", + "pws.protocol.col.source": "Décidé par", + "pws.protocol.loadFailed": "Impossible de charger le protocole amont.", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4de59d7e296..5c195d6c75e 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3413,4 +3413,20 @@ export const ja: Record = { "quotaSummary.critical": "90%以上使用", "quotaSummary.credits": "クレジット", "quotaSummary.refreshFailed": "最新の更新に失敗しました。前回の値を表示しています", + "pws.protocol.title": "アップストリームのワイヤー形式", + "pws.protocol.note": "opencodex がこのプロバイダーと通信する API 形式です。クライアント API のオン/オフは切り替えません。それは API ページで設定します。", + "pws.protocol.adapterLabel": "このプロバイダーが受け取るワイヤー形式", + "pws.protocol.source": "決定元", + "pws.protocol.source.hardPin": "opencodex がこのモデル用に固定", + "pws.protocol.source.operator": "ユーザー設定", + "pws.protocol.source.registry": "プロバイダーカタログ", + "pws.protocol.source.providerDefault": "プロバイダーの既定", + "pws.protocol.pending": "保存すると、このプロバイダーは {adapter} を受け取ります。", + "pws.protocol.overrides": "別のワイヤー形式を使うモデル", + "pws.protocol.noOverrides": "すべてのモデルがプロバイダーのワイヤー形式を使います。", + "pws.protocol.overridesTruncated": "最初の {count} 件のモデルを表示しています。", + "pws.protocol.col.model": "モデル", + "pws.protocol.col.wire": "ワイヤー形式", + "pws.protocol.col.source": "決定元", + "pws.protocol.loadFailed": "アップストリームのワイヤー形式を読み込めませんでした。", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b859b1fc431..5c81cc0461c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3413,4 +3413,20 @@ export const ko: Record = { "quotaSummary.critical": "90% 이상 사용", "quotaSummary.credits": "크레딧", "quotaSummary.refreshFailed": "최근 갱신에 실패해 이전 값을 표시합니다", + "pws.protocol.title": "업스트림 와이어", + "pws.protocol.note": "opencodex가 이 프로바이더에 보내는 API 형식입니다. 클라이언트 API를 켜거나 끄지 않으며, 그 설정은 API 페이지에 있습니다.", + "pws.protocol.adapterLabel": "이 프로바이더가 받는 업스트림 와이어", + "pws.protocol.source": "결정 주체", + "pws.protocol.source.hardPin": "opencodex가 이 모델에 고정", + "pws.protocol.source.operator": "사용자 설정", + "pws.protocol.source.registry": "프로바이더 카탈로그", + "pws.protocol.source.providerDefault": "프로바이더 기본값", + "pws.protocol.pending": "저장하면 이 프로바이더는 {adapter}를 받습니다.", + "pws.protocol.overrides": "다른 와이어를 쓰는 모델", + "pws.protocol.noOverrides": "모든 모델이 프로바이더 와이어를 사용합니다.", + "pws.protocol.overridesTruncated": "처음 {count}개 모델만 표시합니다.", + "pws.protocol.col.model": "모델", + "pws.protocol.col.wire": "와이어", + "pws.protocol.col.source": "결정 주체", + "pws.protocol.loadFailed": "업스트림 와이어를 불러오지 못했습니다.", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index cb23ef3e984..b3bd929f197 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3414,4 +3414,20 @@ export const ru: Record = { "quotaSummary.critical": "Использовано более 90%", "quotaSummary.credits": "Кредиты", "quotaSummary.refreshFailed": "Последнее обновление не удалось; показаны предыдущие данные", + "pws.protocol.title": "Протокол к провайдеру", + "pws.protocol.note": "Формат API, которым opencodex обращается к этому провайдеру. Он не включает и не выключает клиентские API — это настраивается на странице API.", + "pws.protocol.adapterLabel": "Протокол, который получает этот провайдер", + "pws.protocol.source": "Кем определено", + "pws.protocol.source.hardPin": "Зафиксировано opencodex для этой модели", + "pws.protocol.source.operator": "Ваша конфигурация", + "pws.protocol.source.registry": "Каталог провайдеров", + "pws.protocol.source.providerDefault": "По умолчанию для провайдера", + "pws.protocol.pending": "После сохранения этот провайдер будет получать {adapter}.", + "pws.protocol.overrides": "Модели с другим протоколом", + "pws.protocol.noOverrides": "Все модели используют протокол провайдера.", + "pws.protocol.overridesTruncated": "Показаны первые {count} моделей.", + "pws.protocol.col.model": "Модель", + "pws.protocol.col.wire": "Протокол", + "pws.protocol.col.source": "Кем определено", + "pws.protocol.loadFailed": "Не удалось загрузить протокол к провайдеру.", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 466b0485d34..67d66d6f04e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3414,4 +3414,20 @@ export const tr: Record = { "quotaSummary.critical": "%90+ kullanıldı", "quotaSummary.credits": "Krediler", "quotaSummary.refreshFailed": "Son yenileme başarısız; önceki değer gösteriliyor", + "pws.protocol.title": "Yukarı akış protokolü", + "pws.protocol.note": "opencodex'in bu sağlayıcıyla konuştuğu API biçimi. Hiçbir istemci API'sini açıp kapatmaz; bunlar API sayfasından yönetilir.", + "pws.protocol.adapterLabel": "Bu sağlayıcının aldığı yukarı akış protokolü", + "pws.protocol.source": "Belirleyen", + "pws.protocol.source.hardPin": "opencodex bu model için sabitledi", + "pws.protocol.source.operator": "Sizin yapılandırmanız", + "pws.protocol.source.registry": "Sağlayıcı kataloğu", + "pws.protocol.source.providerDefault": "Sağlayıcı varsayılanı", + "pws.protocol.pending": "Kaydettikten sonra bu sağlayıcı {adapter} alır.", + "pws.protocol.overrides": "Başka protokol kullanan modeller", + "pws.protocol.noOverrides": "Tüm modeller sağlayıcının protokolünü kullanır.", + "pws.protocol.overridesTruncated": "İlk {count} model gösteriliyor.", + "pws.protocol.col.model": "Model", + "pws.protocol.col.wire": "Protokol", + "pws.protocol.col.source": "Belirleyen", + "pws.protocol.loadFailed": "Yukarı akış protokolü yüklenemedi.", }; diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 581f9398856..1f86f080fe3 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3383,4 +3383,20 @@ export const vi: Record = { "quotaSummary.critical": "Đã dùng trên 90%", "quotaSummary.credits": "Tín dụng", "quotaSummary.refreshFailed": "Lần làm mới gần nhất thất bại; đang hiển thị số liệu trước đó", + "pws.protocol.title": "Giao thức upstream", + "pws.protocol.note": "Định dạng API mà opencodex dùng để gọi nhà cung cấp này. Nó không bật hay tắt API phía client nào; các API đó nằm ở trang API.", + "pws.protocol.adapterLabel": "Giao thức upstream mà nhà cung cấp này nhận", + "pws.protocol.source": "Do ai quyết định", + "pws.protocol.source.hardPin": "opencodex cố định cho mô hình này", + "pws.protocol.source.operator": "Cấu hình của bạn", + "pws.protocol.source.registry": "Danh mục nhà cung cấp", + "pws.protocol.source.providerDefault": "Mặc định của nhà cung cấp", + "pws.protocol.pending": "Sau khi lưu, nhà cung cấp này sẽ nhận {adapter}.", + "pws.protocol.overrides": "Mô hình dùng giao thức khác", + "pws.protocol.noOverrides": "Mọi mô hình đều dùng giao thức của nhà cung cấp.", + "pws.protocol.overridesTruncated": "Đang hiển thị {count} mô hình đầu tiên.", + "pws.protocol.col.model": "Mô hình", + "pws.protocol.col.wire": "Giao thức", + "pws.protocol.col.source": "Do ai quyết định", + "pws.protocol.loadFailed": "Không tải được giao thức upstream.", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 03122163cfc..d2de869a239 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3377,4 +3377,20 @@ export const zhTW: Record = { "quotaSummary.critical": "已用 90% 以上", "quotaSummary.credits": "額度", "quotaSummary.refreshFailed": "最近一次重新整理失敗,顯示上次資料", + "pws.protocol.title": "上游協定", + "pws.protocol.note": "opencodex 與此供應商溝通時使用的 API 格式。它不會開啟或關閉任何用戶端 API;那些在 API 頁面設定。", + "pws.protocol.adapterLabel": "此供應商接收的上游協定", + "pws.protocol.source": "決定來源", + "pws.protocol.source.hardPin": "opencodex 為此模型固定", + "pws.protocol.source.operator": "你的設定", + "pws.protocol.source.registry": "供應商目錄", + "pws.protocol.source.providerDefault": "供應商預設", + "pws.protocol.pending": "儲存後,此供應商將接收 {adapter}。", + "pws.protocol.overrides": "使用其他協定的模型", + "pws.protocol.noOverrides": "所有模型都使用供應商的協定。", + "pws.protocol.overridesTruncated": "僅顯示前 {count} 個模型。", + "pws.protocol.col.model": "模型", + "pws.protocol.col.wire": "協定", + "pws.protocol.col.source": "決定來源", + "pws.protocol.loadFailed": "無法載入上游協定。", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 9caed8a5614..b341c1e2447 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3412,4 +3412,20 @@ export const zh: Record = { "quotaSummary.critical": "已用 90% 以上", "quotaSummary.credits": "额度", "quotaSummary.refreshFailed": "最近一次刷新失败,显示上次数据", + "pws.protocol.title": "上游协议", + "pws.protocol.note": "opencodex 与此提供商通信时使用的 API 格式。它不会开启或关闭任何客户端 API;这些在 API 页面设置。", + "pws.protocol.adapterLabel": "此提供商接收的上游协议", + "pws.protocol.source": "决定来源", + "pws.protocol.source.hardPin": "opencodex 为此模型固定", + "pws.protocol.source.operator": "你的配置", + "pws.protocol.source.registry": "提供商目录", + "pws.protocol.source.providerDefault": "提供商默认", + "pws.protocol.pending": "保存后,此提供商将接收 {adapter}。", + "pws.protocol.overrides": "使用其他协议的模型", + "pws.protocol.noOverrides": "所有模型都使用提供商的协议。", + "pws.protocol.overridesTruncated": "仅显示前 {count} 个模型。", + "pws.protocol.col.model": "模型", + "pws.protocol.col.wire": "协议", + "pws.protocol.col.source": "决定来源", + "pws.protocol.loadFailed": "无法加载上游协议。", }; diff --git a/gui/src/main.tsx b/gui/src/main.tsx index f1bdb6533f6..a0ee419b435 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -18,6 +18,7 @@ import "./styles/claude-first-party-bindings.css"; import "./styles/claude-desktop-picker.css"; import "./styles/anthropic-reset-grants.css"; import "./styles/star-onboarding.css"; +import "./styles/protocol-evidence.css"; import "./pages/tray.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/gui/src/styles/protocol-evidence.css b/gui/src/styles/protocol-evidence.css new file mode 100644 index 00000000000..81bbe6e2232 --- /dev/null +++ b/gui/src/styles/protocol-evidence.css @@ -0,0 +1,27 @@ +/* Protocol evidence views (PF-11): the provider wire panel, compatibility protocol filters, + combo candidate paths and the deep links between them. Kept out of styles.css, which is at + its file-size cap. */ + +/* ── Provider settings: upstream wire ─────────────────────── */ +.ppp-card { + display: flex; flex-direction: column; gap: 10px; padding: 14px; + border: 1px solid var(--border); border-radius: var(--radius-sm); + background: var(--surface); +} +.ppp-card h3, .ppp-card h4 { margin: 0; color: var(--text); font-size: var(--text-control); } +.ppp-kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin: 0; } +.ppp-kv div { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.ppp-kv dt { color: var(--muted); font-size: var(--text-caption); } +.ppp-kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.ppp-wire { display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 6px; } +.ppp-pending { margin: 0; color: var(--text); font-size: var(--text-caption); } +.ppp-overrides { width: 100%; border-collapse: collapse; font-size: var(--text-label); } +.ppp-overrides th { color: var(--muted); font-weight: 500; text-align: left; } +.ppp-overrides th, .ppp-overrides td { padding: 6px 8px; border-bottom: 1px solid var(--border-soft); vertical-align: top; } +.ppp-overrides code { overflow-wrap: anywhere; } + +@media (max-width: 760px) { + .ppp-overrides thead { display: none; } + .ppp-overrides tr { display: grid; grid-template-columns: 1fr; padding: 6px 0; border-bottom: 1px solid var(--border-soft); } + .ppp-overrides td { padding: 2px 0; border-bottom: 0; } +} From 96c924ea80f7c83a4283f20d60cf79b1cd83244b Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:55:23 +0900 Subject: [PATCH 125/173] feat(gui): show the upstream wire panel under the provider adapter field The adapter field stays the only editor and still saves through onUpdateProvider; the panel beside it says which wire an unsaved choice would send. --- .../provider-workspace/ProviderSettings.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index ebfa5a20e02..ae5203747fc 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -1,7 +1,8 @@ /** * ProviderSettings — adapter/baseUrl/defaultModel/authMode/note editing form * for the workspace Settings tab (WP091). Uses PATCH /api/providers via an - * onUpdateProvider prop. May fetch `/api/provider-presets` once per provider + * onUpdateProvider prop; the upstream wire panel below the adapter field is read-only + * and never saves on its own. May fetch `/api/provider-presets` once per provider * to discover `baseUrlChoices` (e.g. Qwen Cloud endpoint picker). * * Parent should remount on provider change (`key={item.name}`) so choice-loading @@ -20,6 +21,7 @@ import { openAiAccountProviderState } from "../../provider-payload"; import { providerSupportsLiveModelDiscovery } from "../../provider-workspace/catalog"; import type { CatalogPreset } from "../provider-catalog/provider-presets"; import { authModeLabel } from "./ProviderRail"; +import { ProviderProtocolPanel } from "./ProviderProtocolPanel"; import type { WorkspaceItem, ProviderUpdatePatch, ProviderUpdateResult } from "./types"; const ADAPTERS = ["openai-responses", "openai-chat", "anthropic", "google", "azure-openai", "cursor"] as const; @@ -356,6 +358,13 @@ export default function ProviderSettings({ )} + {hasEndpointPicker ? ( <>
+ + {matrixRows.length === 0 ? ( @@ -564,7 +618,7 @@ export default function CompatibilityMatrix({ apiBase, active = true, onCountCha - {allVerdicts.map(verdict => { + {visibleVerdicts.map(verdict => { const selected = visibleSelection?.projectionKey === verdict.projectionKey; return ( From 58778a0566ff43c605de89bbab2b4fcb07f3d842 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:58:25 +0900 Subject: [PATCH 129/173] feat(gui): link a traced log row to the compatibility matrix for its pair The trace says what a request did; whether that pair is verified is a Lab question, so the row opens the matrix prefiltered to its client API and upstream wire. --- .../protocols/ProtocolTracePanel.tsx | 107 ++++++++++-------- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/vi.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/styles/protocol-evidence.css | 4 + 12 files changed, 83 insertions(+), 48 deletions(-) diff --git a/gui/src/components/protocols/ProtocolTracePanel.tsx b/gui/src/components/protocols/ProtocolTracePanel.tsx index 9975105737a..80c336c8ab5 100644 --- a/gui/src/components/protocols/ProtocolTracePanel.tsx +++ b/gui/src/components/protocols/ProtocolTracePanel.tsx @@ -1,12 +1,14 @@ import { parseProtocolTraceV1 } from "../../../../src/protocols/dto"; import type { TFn } from "../../i18n/shared"; +import { openCompatibilityPair, protocolPairUpstream } from "../../protocol-deep-links"; import { FEATURE_DISPOSITION_KEYS, PROTOCOL_MODE_KEYS, protocolHopLabel, protocolPathLabel } from "./protocol-labels"; /** * The protocol section of the Logs detail dialog: what this request actually did, attempt by * attempt. Reason codes and feature ids are fixed machine identifiers and render as code; the * internal Responses hop is labelled as internal. A row without a valid trace says so instead - * of inferring a path from other fields. + * of inferring a path from other fields. A traced row links to the compatibility matrix + * prefiltered to its client API and upstream wire, where the Lab verdicts (not this trace) live. */ export function ProtocolTracePanel({ trace, t }: { trace: unknown; t: TFn }) { const parsed = parseProtocolTraceV1(trace); @@ -14,53 +16,62 @@ export function ProtocolTracePanel({ trace, t }: { trace: unknown; t: TFn }) {

{t("logs.detail.protocol.section")}

{parsed ? ( -
- {t("logs.detail.protocol.inbound")} - {protocolHopLabel(parsed.inbound, t)} - {t("logs.detail.protocol.mode")} - {t(PROTOCOL_MODE_KEYS[parsed.mode])} - {parsed.upstream && ( - <> - {t("logs.detail.protocol.upstream")} - {protocolHopLabel(parsed.upstream, t)} - - )} - {parsed.requestPath.length > 0 && ( - <> - {t("logs.detail.protocol.requestPath")} - {protocolPathLabel(parsed.requestPath, t)} - {t("logs.detail.protocol.responsePath")} - {protocolPathLabel(parsed.responsePath, t)} - - )} - {t("logs.detail.protocol.reasons")} - {parsed.reasonCodes.join(", ") || "–"} - {parsed.featureEffects && parsed.featureEffects.length > 0 && ( - <> - {t("logs.detail.protocol.features")} - - {parsed.featureEffects.map(effect => ( - - {effect.feature}: {t(FEATURE_DISPOSITION_KEYS[effect.disposition])} - - ))} - - - )} - {parsed.attempts && parsed.attempts.length > 0 && ( - <> - {t("logs.detail.protocol.attempts")} - - {parsed.attempts.map(attempt => ( - - {t("logs.detail.protocol.attempt", { ordinal: attempt.ordinal })}:{" "} - {protocolPathLabel(attempt.requestPath, t)} · {t(PROTOCOL_MODE_KEYS[attempt.mode])} - - ))} - - - )} -
+ <> +
+ {t("logs.detail.protocol.inbound")} + {protocolHopLabel(parsed.inbound, t)} + {t("logs.detail.protocol.mode")} + {t(PROTOCOL_MODE_KEYS[parsed.mode])} + {parsed.upstream && ( + <> + {t("logs.detail.protocol.upstream")} + {protocolHopLabel(parsed.upstream, t)} + + )} + {parsed.requestPath.length > 0 && ( + <> + {t("logs.detail.protocol.requestPath")} + {protocolPathLabel(parsed.requestPath, t)} + {t("logs.detail.protocol.responsePath")} + {protocolPathLabel(parsed.responsePath, t)} + + )} + {t("logs.detail.protocol.reasons")} + {parsed.reasonCodes.join(", ") || "–"} + {parsed.featureEffects && parsed.featureEffects.length > 0 && ( + <> + {t("logs.detail.protocol.features")} + + {parsed.featureEffects.map(effect => ( + + {effect.feature}: {t(FEATURE_DISPOSITION_KEYS[effect.disposition])} + + ))} + + + )} + {parsed.attempts && parsed.attempts.length > 0 && ( + <> + {t("logs.detail.protocol.attempts")} + + {parsed.attempts.map(attempt => ( + + {t("logs.detail.protocol.attempt", { ordinal: attempt.ordinal })}:{" "} + {protocolPathLabel(attempt.requestPath, t)} · {t(PROTOCOL_MODE_KEYS[attempt.mode])} + + ))} + + + )} +
+ + ) : (

{t("logs.detail.protocol.none")}

)} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index dcc5d01e988..a8eaa53a640 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3415,4 +3415,6 @@ export const de: Record = { "compatProtocol.unverified": "Noch keine Lab-Nachweise für {pair}. Das Paar ist ungeprüft, nicht fehlgeschlagen.", "compatProtocol.unresolved": "Das Protokollpaar von {count} Subjekten konnte nicht gelesen werden; sie sind in diesem Filter nicht enthalten.", "compatProtocol.axisNote": "Dies sind nur Lab-Urteile. Wie eine Anfrage zugestellt wird (nativ oder übersetzt), zeigt die Anfragepfad-Vorschau auf der API-Seite.", + "protocolLinks.labEvidence": "Lab-Nachweise für dieses Paar", + "protocolLinks.providerSettings": "Anbietereinstellungen", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 18fc20c2646..b80e3b4fe4a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3449,6 +3449,8 @@ export const en = { "compatProtocol.unverified": "No Lab evidence for {pair} yet. The pair is unverified, not failed.", "compatProtocol.unresolved": "The protocol pair of {count} subjects could not be read; they are left out of this filter.", "compatProtocol.axisNote": "These are Lab verdicts only. How a request is delivered (native or translated) is shown by the request path preview on the API page.", + "protocolLinks.labEvidence": "Lab evidence for this pair", + "protocolLinks.providerSettings": "Provider settings", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 39f2e8ae9b4..5c10f6152d6 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3404,4 +3404,6 @@ export const fr: Record = { "compatProtocol.unverified": "Aucune preuve Lab pour {pair} pour l'instant. La paire n'est pas vérifiée, elle n'a pas échoué.", "compatProtocol.unresolved": "La paire de protocoles de {count} sujets n'a pas pu être lue ; ils sont exclus de ce filtre.", "compatProtocol.axisNote": "Il s'agit uniquement de verdicts Lab. Le mode d'acheminement d'une requête (natif ou traduit) est affiché par l'aperçu du chemin de requête sur la page API.", + "protocolLinks.labEvidence": "Preuves Lab pour cette paire", + "protocolLinks.providerSettings": "Paramètres du fournisseur", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index f9a48237e81..b4814164f09 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3437,4 +3437,6 @@ export const ja: Record = { "compatProtocol.unverified": "{pair} の Lab エビデンスはまだありません。この組は未検証であり、失敗ではありません。", "compatProtocol.unresolved": "{count} 件のサブジェクトはプロトコルの組を読み取れず、このフィルターから除外しています。", "compatProtocol.axisNote": "これは Lab の判定のみです。リクエストの配送方法(ネイティブまたは変換)は API ページのリクエスト経路プレビューに表示されます。", + "protocolLinks.labEvidence": "この組の Lab エビデンス", + "protocolLinks.providerSettings": "プロバイダー設定", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b829949a30f..9f5547a6dc4 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3437,4 +3437,6 @@ export const ko: Record = { "compatProtocol.unverified": "{pair}에 대한 Lab 근거가 아직 없습니다. 이 조합은 실패가 아니라 미검증 상태입니다.", "compatProtocol.unresolved": "{count}개 대상의 프로토콜 쌍을 읽지 못해 이 필터에서 제외했습니다.", "compatProtocol.axisNote": "Lab 판정만 표시합니다. 요청이 어떻게 전달되는지(네이티브 또는 변환)는 API 페이지의 요청 경로 미리보기에 나옵니다.", + "protocolLinks.labEvidence": "이 조합의 Lab 근거", + "protocolLinks.providerSettings": "프로바이더 설정", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9fdad15b95b..bbb2dbd534e 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3438,4 +3438,6 @@ export const ru: Record = { "compatProtocol.unverified": "Для {pair} пока нет данных Lab. Пара не проверена, а не провалена.", "compatProtocol.unresolved": "Не удалось прочитать пару протоколов для {count} субъектов; они не учитываются в этом фильтре.", "compatProtocol.axisNote": "Здесь только вердикты Lab. Способ доставки запроса (нативно или с переводом) показывает предпросмотр пути запроса на странице API.", + "protocolLinks.labEvidence": "Данные Lab для этой пары", + "protocolLinks.providerSettings": "Настройки провайдера", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index cdcdea8fd77..4b67be4102b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3438,4 +3438,6 @@ export const tr: Record = { "compatProtocol.unverified": "{pair} için henüz Lab kanıtı yok. Bu çift başarısız değil, doğrulanmamış.", "compatProtocol.unresolved": "{count} öznenin protokol çifti okunamadı; bu filtrenin dışında bırakıldılar.", "compatProtocol.axisNote": "Bunlar yalnızca Lab kararlarıdır. Bir isteğin nasıl iletildiği (yerel ya da çevrilmiş) API sayfasındaki istek yolu önizlemesinde gösterilir.", + "protocolLinks.labEvidence": "Bu çift için Lab kanıtı", + "protocolLinks.providerSettings": "Sağlayıcı ayarları", }; diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index e07030ee769..8e58a631e80 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3407,4 +3407,6 @@ export const vi: Record = { "compatProtocol.unverified": "Chưa có bằng chứng Lab cho {pair}. Cặp này chưa được xác minh, không phải thất bại.", "compatProtocol.unresolved": "Không đọc được cặp giao thức của {count} đối tượng; chúng bị loại khỏi bộ lọc này.", "compatProtocol.axisNote": "Đây chỉ là kết luận của Lab. Cách một request được chuyển đi (native hay được dịch) hiển thị trong phần xem trước đường đi request trên trang API.", + "protocolLinks.labEvidence": "Bằng chứng Lab cho cặp này", + "protocolLinks.providerSettings": "Cài đặt nhà cung cấp", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 88c772af1d7..fa130321d33 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3401,4 +3401,6 @@ export const zhTW: Record = { "compatProtocol.unverified": "{pair} 尚無 Lab 證據。此組合是未驗證,而非失敗。", "compatProtocol.unresolved": "無法讀取 {count} 個主體的協定組合,已從此篩選中排除。", "compatProtocol.axisNote": "這裡只有 Lab 判定。請求如何傳遞(原生或轉譯)請見 API 頁面的請求路徑預覽。", + "protocolLinks.labEvidence": "此組合的 Lab 證據", + "protocolLinks.providerSettings": "供應商設定", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 551373481b5..c2a2a62de6e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3436,4 +3436,6 @@ export const zh: Record = { "compatProtocol.unverified": "{pair} 尚无 Lab 证据。此组合是未验证,而非失败。", "compatProtocol.unresolved": "无法读取 {count} 个主体的协议组合,已从此筛选中排除。", "compatProtocol.axisNote": "这里只有 Lab 判定。请求如何传递(原生或转译)请见 API 页面的请求路径预览。", + "protocolLinks.labEvidence": "此组合的 Lab 证据", + "protocolLinks.providerSettings": "提供商设置", }; diff --git a/gui/src/styles/protocol-evidence.css b/gui/src/styles/protocol-evidence.css index be51d3ed762..fe2da1c04c0 100644 --- a/gui/src/styles/protocol-evidence.css +++ b/gui/src/styles/protocol-evidence.css @@ -33,3 +33,7 @@ padding: 8px 10px; border: 1px dashed var(--border); border-radius: var(--radius-xs); color: var(--text); font-size: var(--text-label); } + +/* ── Deep links between protocol views ────────────────────── */ +.protocol-deep-link { align-self: flex-start; margin-top: 6px; } +.protocol-plan-links { display: flex; flex-wrap: wrap; gap: 6px; } From a11c831daf6b6b6ceaeeb457eb953d82c5ff0a5a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:58:45 +0900 Subject: [PATCH 130/173] feat(gui): link plan candidates to provider settings and Lab evidence A candidate's wire is decided in provider settings and its pair is verified in the matrix; the preview links both instead of restating either. --- .../protocols/ProtocolPlanPanel.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/gui/src/components/protocols/ProtocolPlanPanel.tsx b/gui/src/components/protocols/ProtocolPlanPanel.tsx index 6659293ab92..99804a5b747 100644 --- a/gui/src/components/protocols/ProtocolPlanPanel.tsx +++ b/gui/src/components/protocols/ProtocolPlanPanel.tsx @@ -14,6 +14,7 @@ import { FEATURE_SOURCES, PROTOCOL_FEATURES, type ProtocolFeature } from "../../ import type { ExternalModelRow, GatewayInboundProtocol } from "../../api-access-models"; import { useT, type TKey } from "../../i18n/shared"; import { fetchProtocolPlan } from "../../protocol-api"; +import { openCompatibilityPair, openProviderSettings, protocolPairUpstream } from "../../protocol-deep-links"; import { FeatureDispositionList } from "./FeatureDispositionList"; const INBOUNDS: readonly Protocol[] = ["responses", "chat", "messages"]; @@ -62,7 +63,12 @@ function FeatureNames({ features }: { features: readonly ProtocolFeature[] }) { ); } -function PlanResult({ plan }: { plan: ProtocolPlanV1 }) { +/** + * One plan, candidate by candidate. Each candidate links to its provider's settings (which + * wire it receives) and to the compatibility matrix for its pair (what the Lab has verified); + * the plan itself never claims verification. Shared with the combo detail panel. + */ +export function PlanResult({ plan }: { plan: ProtocolPlanV1 }) { const t = useT(); return (
@@ -128,6 +134,18 @@ function PlanResult({ plan }: { plan: ProtocolPlanV1 }) { )} +
+ + +
))} From ab32557e36ea8bc69d9c83657e07750182c2b821 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 12:59:44 +0900 Subject: [PATCH 131/173] feat(gui): open a provider's settings from #providers?provider= The plan preview links a candidate to the panel that says which wire it receives; the hash is re-read on Back/Forward and dropped once the user selects another provider. --- .../provider-workspace/ProviderDetails.tsx | 14 ++++ gui/src/pages/Providers.tsx | 9 +++ gui/src/pages/providers-deep-link.ts | 64 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 gui/src/pages/providers-deep-link.ts diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index aa843b86fc7..7ea18634a7c 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -50,6 +50,8 @@ export default function ProviderDetails({ accountLoadState, accountsFocusToken = 0, accountsFocusProvider = null, + settingsFocusToken = 0, + settingsFocusProvider = null, switchingAccountId, keys, busyProvider, @@ -90,6 +92,9 @@ export default function ProviderDetails({ accountsFocusToken?: number; /** Provider that owns the current accountsFocusToken; other providers ignore it. */ accountsFocusProvider?: string | null; + /** When this token increases for settingsFocusProvider, switch to the Settings tab (deep link). */ + settingsFocusToken?: number; + settingsFocusProvider?: string | null; switchingAccountId?: string | null; keys?: ApiKeyRow[]; busyProvider?: string | null; @@ -115,6 +120,7 @@ export default function ProviderDetails({ // Seed 0 so a mount-time token from revealProviderAccounts stays pending until // authSurface exists; seeding with the prop would treat it as already seen. const [seenAccountsFocusToken, setSeenAccountsFocusToken] = useState(0); + const [seenSettingsFocusToken, setSeenSettingsFocusToken] = useState(0); const registerSettingsSave = useCallback((save: (() => Promise) | null) => { settingsSaveRef.current = save; }, []); @@ -167,6 +173,14 @@ export default function ProviderDetails({ } } + // Same render-time adjustment for a `#providers?provider=` deep link. Leaving Settings + // is what needs the unsaved-changes guard, so opening it needs none. + const scopedSettingsFocusToken = settingsFocusProvider === item.name ? settingsFocusToken : 0; + if (scopedSettingsFocusToken !== seenSettingsFocusToken) { + setSeenSettingsFocusToken(scopedSettingsFocusToken); + if (scopedSettingsFocusToken) setTab("settings"); + } + const requestDeselect = useCallback(() => { if (settingsDirty && tab === "settings") { setPendingLeave("deselect"); diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 87bf848cd20..e2bfd75f159 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -23,6 +23,7 @@ import { buildAccountLoginStatus, buildAddModalAccountRows } from "./providers-p import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; import { useProviderModelsNotice } from "./use-provider-models-notice"; import { navigateHash } from "../hash-routing"; +import { useProviderSettingsDeepLink } from "./providers-deep-link"; /** The page's real refresh tickets: only the captured report epoch and account read can settle them. */ // oxlint-disable-next-line react/only-export-components -- keep the page-owned coordinator and its direct race tests in the authorized owner. @@ -286,6 +287,12 @@ export default function Providers({ apiBase }: { apiBase: string }) { setAccountsFocus(previous => ({ token: previous.token + 1, provider })); }, []); // Providers hash sync is owned by App (passive replaceHash / deliberate navigateHash). + // The one query it keeps here, `#providers?provider=`, opens that provider's settings. + const settingsFocus = useProviderSettingsDeepLink( + config ? Object.keys(config.providers) : null, + workspaceSelected, + setWorkspaceSelected, + ); // Warm the Add Provider catalog cache while the page is open so opening the // modal does not wait on a cold /api/provider-presets round-trip (~same key as @@ -624,6 +631,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { accountLoadState={accountLoadStates[item.name] ?? (item.authMode === "oauth" ? "idle" : "ready")} accountsFocusToken={accountsFocus.token} accountsFocusProvider={accountsFocus.provider} + settingsFocusToken={settingsFocus.token} + settingsFocusProvider={settingsFocus.provider} switchingAccountId={switchingAccount?.provider === item.name ? switchingAccount.accountId : null} busyProvider={busy} loginHint={loginInfo} diff --git a/gui/src/pages/providers-deep-link.ts b/gui/src/pages/providers-deep-link.ts new file mode 100644 index 00000000000..ccf0e038008 --- /dev/null +++ b/gui/src/pages/providers-deep-link.ts @@ -0,0 +1,64 @@ +/** + * `#providers?provider=` opens that provider's settings tab. The plan preview links here + * (protocol-deep-links.ts) so "which wire does this candidate receive" lands on the panel that + * answers it. + * + * The hash is the source of truth: it is read on mount and on every hashchange/popstate, so + * Back/Forward re-apply it. A name that is not configured (yet) waits for the provider list and + * is ignored if it never appears. Selecting another provider, or closing this one, drops the + * query with a passive replace, so a refresh does not reopen a provider the user moved away from. + */ +import { useEffect, useRef, useState } from "react"; +import { replaceHash } from "../hash-routing"; +import { PROVIDERS_HASH, readProviderSettingsTarget } from "../protocol-deep-links"; + +export interface ProviderSettingsFocus { + /** Increases each time a deep link asks for `provider`'s settings. */ + token: number; + provider: string | null; +} + +export function useProviderSettingsDeepLink( + providerNames: readonly string[] | null, + selected: string | null, + select: (name: string) => void, +): ProviderSettingsFocus { + // `seq` makes a repeated hash event for the same name a new request. + const [request, setRequest] = useState(() => ({ name: readProviderSettingsTarget(), seq: 0 })); + const [focus, setFocus] = useState({ token: 0, provider: null }); + const appliedSeqRef = useRef(-1); + const previousSelectedRef = useRef(selected); + const namesKey = providerNames ? providerNames.join("\n") : null; + + useEffect(() => { + const sync = () => setRequest(current => ({ name: readProviderSettingsTarget(), seq: current.seq + 1 })); + window.addEventListener("hashchange", sync); + window.addEventListener("popstate", sync); + return () => { + window.removeEventListener("hashchange", sync); + window.removeEventListener("popstate", sync); + }; + }, []); + + useEffect(() => { + const { name, seq } = request; + if (!name || namesKey === null || appliedSeqRef.current === seq) return; + if (!namesKey.split("\n").includes(name)) return; + appliedSeqRef.current = seq; + select(name); + setFocus(current => ({ token: current.token + 1, provider: name })); + }, [namesKey, request, select]); + + useEffect(() => { + const previous = previousSelectedRef.current; + previousSelectedRef.current = selected; + const linked = readProviderSettingsTarget(); + if (!linked || appliedSeqRef.current !== request.seq || request.name !== linked) return; + const movedAway = selected !== null ? selected !== linked : previous === linked; + if (movedAway) replaceHash(PROVIDERS_HASH); + // `request` is deliberately not a dependency: only a selection change can move away. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected]); + + return focus; +} From 5af82a206b6299d7e7d7da810071488f8d22df96 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:00:38 +0900 Subject: [PATCH 132/173] feat(gui): add the combo candidate path preview Asks POST /api/protocols/plan for every feature the client API can express, so the guaranteed/partial split covers the vocabulary; it runs only on an explicit click. --- .../protocols/ComboProtocolPlan.tsx | 89 +++++++++++++++++++ gui/src/i18n/de.ts | 4 + gui/src/i18n/en.ts | 4 + gui/src/i18n/fr.ts | 4 + gui/src/i18n/ja.ts | 4 + gui/src/i18n/ko.ts | 4 + gui/src/i18n/ru.ts | 4 + gui/src/i18n/tr.ts | 4 + gui/src/i18n/vi.ts | 4 + gui/src/i18n/zh-TW.ts | 4 + gui/src/i18n/zh.ts | 4 + gui/src/styles/protocol-evidence.css | 4 + 12 files changed, 133 insertions(+) create mode 100644 gui/src/components/protocols/ComboProtocolPlan.tsx diff --git a/gui/src/components/protocols/ComboProtocolPlan.tsx b/gui/src/components/protocols/ComboProtocolPlan.tsx new file mode 100644 index 00000000000..ad2e43c2c35 --- /dev/null +++ b/gui/src/components/protocols/ComboProtocolPlan.tsx @@ -0,0 +1,89 @@ +/** + * Combo detail: what each target would do with a request, and which features every target + * keeps versus only some of them — asked of `POST /api/protocols/plan`, never computed here. + * + * The preview runs on demand (one button, like the API page's preview), for every feature the + * chosen client API can express, so the guaranteed/partial split covers the whole vocabulary. + * It reads the SAVED combo: an unsaved target edit is not in the config the server plans from. + * An older server without the route hides the section. + */ +import { useEffect, useMemo, useRef, useState } from "react"; +import { PROTOCOLS, type Protocol } from "../../../../src/protocols/contract"; +import type { ProtocolPlanV1 } from "../../../../src/protocols/dto"; +import { FEATURE_SOURCES, PROTOCOL_FEATURES } from "../../../../src/protocols/features"; +import { useT } from "../../i18n/shared"; +import { fetchProtocolPlan } from "../../protocol-api"; +import { protocolHopLabel } from "./protocol-labels"; +import { PlanResult } from "./ProtocolPlanPanel"; + +export function ComboProtocolPlan({ apiBase, model, dirty }: { + apiBase: string; + /** The combo's public model id, as a client would send it. */ + model: string; + /** The editor holds unsaved changes the preview cannot see. */ + dirty: boolean; +}) { + const t = useT(); + const [inbound, setInbound] = useState("responses"); + const [plan, setPlan] = useState(null); + const [pending, setPending] = useState(false); + const [failed, setFailed] = useState(false); + const [unavailable, setUnavailable] = useState(false); + const controllerRef = useRef(null); + + useEffect(() => () => controllerRef.current?.abort(), []); + + const features = useMemo( + () => PROTOCOL_FEATURES.filter(feature => FEATURE_SOURCES[feature].includes(inbound)), + [inbound], + ); + + const run = async () => { + if (pending) return; + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + setPending(true); + setFailed(false); + try { + const result = await fetchProtocolPlan(apiBase, { model, inbound, features }, controller.signal); + if (controller.signal.aborted) return; + if (result.kind === "unavailable") setUnavailable(true); + else if (result.kind === "error") setFailed(true); + else setPlan(result.plan); + } catch { + // Aborted by a newer preview or by leaving the combo. + } finally { + if (controllerRef.current === controller) setPending(false); + } + }; + + if (unavailable) return null; + return ( +
+

{t("cws.plan.title")}

+

{t("cws.plan.description")}

+

{t("api.plan.modeNote")}

+ {dirty &&

{t("cws.plan.savedOnly")}

} +
+ +
+
+ +
+ {failed &&

{t("api.plan.failed")}

} + {plan && plan.inbound === inbound && } +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a8eaa53a640..50a1493108b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3417,4 +3417,8 @@ export const de: Record = { "compatProtocol.axisNote": "Dies sind nur Lab-Urteile. Wie eine Anfrage zugestellt wird (nativ oder übersetzt), zeigt die Anfragepfad-Vorschau auf der API-Seite.", "protocolLinks.labEvidence": "Lab-Nachweise für dieses Paar", "protocolLinks.providerSettings": "Anbietereinstellungen", + "cws.plan.title": "Pfade der Ziele", + "cws.plan.description": "Fragt den Proxy, wie jedes Ziel eine Anfrage mit allen Funktionen der Client-API übertragen würde und welche Funktionen alle Ziele erhalten. Es wird nichts an den Anbieter gesendet.", + "cws.plan.run": "Pfade anzeigen", + "cws.plan.savedOnly": "Zeigt die gespeicherte Kombination. Speichern Sie Ihre Änderungen, um sie in der Vorschau zu sehen.", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b80e3b4fe4a..6c75e9feedf 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3451,6 +3451,10 @@ export const en = { "compatProtocol.axisNote": "These are Lab verdicts only. How a request is delivered (native or translated) is shown by the request path preview on the API page.", "protocolLinks.labEvidence": "Lab evidence for this pair", "protocolLinks.providerSettings": "Provider settings", + "cws.plan.title": "Candidate paths", + "cws.plan.description": "Asks the proxy how each target would carry a request with every feature the client API can express, and which features all targets keep. Nothing is sent upstream.", + "cws.plan.run": "Show candidate paths", + "cws.plan.savedOnly": "Shows the saved combo. Save your changes to preview them.", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5c10f6152d6..3db3aabb4f9 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3406,4 +3406,8 @@ export const fr: Record = { "compatProtocol.axisNote": "Il s'agit uniquement de verdicts Lab. Le mode d'acheminement d'une requête (natif ou traduit) est affiché par l'aperçu du chemin de requête sur la page API.", "protocolLinks.labEvidence": "Preuves Lab pour cette paire", "protocolLinks.providerSettings": "Paramètres du fournisseur", + "cws.plan.title": "Chemins des cibles", + "cws.plan.description": "Demande au proxy comment chaque cible transporterait une requête utilisant toutes les fonctionnalités de l'API cliente, et lesquelles toutes les cibles conservent. Rien n'est envoyé en amont.", + "cws.plan.run": "Afficher les chemins", + "cws.plan.savedOnly": "Affiche le combo enregistré. Enregistrez vos modifications pour les prévisualiser.", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index b4814164f09..0af5286e31e 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3439,4 +3439,8 @@ export const ja: Record = { "compatProtocol.axisNote": "これは Lab の判定のみです。リクエストの配送方法(ネイティブまたは変換)は API ページのリクエスト経路プレビューに表示されます。", "protocolLinks.labEvidence": "この組の Lab エビデンス", "protocolLinks.providerSettings": "プロバイダー設定", + "cws.plan.title": "ターゲットごとの経路", + "cws.plan.description": "クライアント API が表現できるすべての機能を含むリクエストを各ターゲットがどう運ぶか、すべてのターゲットで保たれる機能は何かをプロキシに問い合わせます。上流には何も送信しません。", + "cws.plan.run": "経路を表示", + "cws.plan.savedOnly": "保存済みのコンボを表示しています。変更をプレビューするには保存してください。", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9f5547a6dc4..a3ccbefbae2 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3439,4 +3439,8 @@ export const ko: Record = { "compatProtocol.axisNote": "Lab 판정만 표시합니다. 요청이 어떻게 전달되는지(네이티브 또는 변환)는 API 페이지의 요청 경로 미리보기에 나옵니다.", "protocolLinks.labEvidence": "이 조합의 Lab 근거", "protocolLinks.providerSettings": "프로바이더 설정", + "cws.plan.title": "대상별 경로", + "cws.plan.description": "클라이언트 API가 표현할 수 있는 모든 기능을 담은 요청을 각 대상이 어떻게 전달하는지, 모든 대상이 유지하는 기능은 무엇인지 프록시에 묻습니다. 업스트림으로는 아무것도 보내지 않습니다.", + "cws.plan.run": "경로 보기", + "cws.plan.savedOnly": "저장된 콤보를 보여 줍니다. 변경 사항을 미리 보려면 먼저 저장하세요.", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index bbb2dbd534e..833a4ae4058 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3440,4 +3440,8 @@ export const ru: Record = { "compatProtocol.axisNote": "Здесь только вердикты Lab. Способ доставки запроса (нативно или с переводом) показывает предпросмотр пути запроса на странице API.", "protocolLinks.labEvidence": "Данные Lab для этой пары", "protocolLinks.providerSettings": "Настройки провайдера", + "cws.plan.title": "Пути по целям", + "cws.plan.description": "Спрашивает прокси, как каждая цель передаст запрос со всеми возможностями клиентского API и какие возможности сохраняют все цели. Ничего не отправляется провайдеру.", + "cws.plan.run": "Показать пути", + "cws.plan.savedOnly": "Показан сохранённый комбо. Сохраните изменения, чтобы увидеть их в предпросмотре.", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4b67be4102b..69717e0b5a2 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3440,4 +3440,8 @@ export const tr: Record = { "compatProtocol.axisNote": "Bunlar yalnızca Lab kararlarıdır. Bir isteğin nasıl iletildiği (yerel ya da çevrilmiş) API sayfasındaki istek yolu önizlemesinde gösterilir.", "protocolLinks.labEvidence": "Bu çift için Lab kanıtı", "protocolLinks.providerSettings": "Sağlayıcı ayarları", + "cws.plan.title": "Hedef yolları", + "cws.plan.description": "Proxy'ye her hedefin, istemci API'sinin ifade edebildiği tüm özellikleri içeren bir isteği nasıl taşıyacağını ve hangi özellikleri tüm hedeflerin koruduğunu sorar. Yukarı akışa hiçbir şey gönderilmez.", + "cws.plan.run": "Yolları göster", + "cws.plan.savedOnly": "Kaydedilmiş kombo gösteriliyor. Değişikliklerinizi önizlemek için kaydedin.", }; diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 8e58a631e80..762269381ba 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3409,4 +3409,8 @@ export const vi: Record = { "compatProtocol.axisNote": "Đây chỉ là kết luận của Lab. Cách một request được chuyển đi (native hay được dịch) hiển thị trong phần xem trước đường đi request trên trang API.", "protocolLinks.labEvidence": "Bằng chứng Lab cho cặp này", "protocolLinks.providerSettings": "Cài đặt nhà cung cấp", + "cws.plan.title": "Đường đi của từng đích", + "cws.plan.description": "Hỏi proxy xem mỗi đích sẽ chuyển một request có mọi tính năng mà API phía client biểu diễn được như thế nào, và tính năng nào được mọi đích giữ lại. Không gửi gì lên upstream.", + "cws.plan.run": "Hiển thị đường đi", + "cws.plan.savedOnly": "Đang hiển thị combo đã lưu. Hãy lưu thay đổi để xem trước chúng.", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index fa130321d33..e0b66d84a61 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3403,4 +3403,8 @@ export const zhTW: Record = { "compatProtocol.axisNote": "這裡只有 Lab 判定。請求如何傳遞(原生或轉譯)請見 API 頁面的請求路徑預覽。", "protocolLinks.labEvidence": "此組合的 Lab 證據", "protocolLinks.providerSettings": "供應商設定", + "cws.plan.title": "各目標的路徑", + "cws.plan.description": "詢問代理每個目標會如何傳遞帶有用戶端 API 所有可表達功能的請求,以及哪些功能所有目標都保留。不會向上游傳送任何內容。", + "cws.plan.run": "顯示路徑", + "cws.plan.savedOnly": "顯示的是已儲存的組合。請先儲存變更再預覽。", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c2a2a62de6e..2b127afa302 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3438,4 +3438,8 @@ export const zh: Record = { "compatProtocol.axisNote": "这里只有 Lab 判定。请求如何传递(原生或转译)请见 API 页面的请求路径预览。", "protocolLinks.labEvidence": "此组合的 Lab 证据", "protocolLinks.providerSettings": "提供商设置", + "cws.plan.title": "各目标的路径", + "cws.plan.description": "询问代理每个目标会如何传递带有客户端 API 所有可表达功能的请求,以及哪些功能所有目标都保留。不会向上游发送任何内容。", + "cws.plan.run": "显示路径", + "cws.plan.savedOnly": "显示的是已保存的组合。请先保存更改再预览。", }; diff --git a/gui/src/styles/protocol-evidence.css b/gui/src/styles/protocol-evidence.css index fe2da1c04c0..d6efb35372f 100644 --- a/gui/src/styles/protocol-evidence.css +++ b/gui/src/styles/protocol-evidence.css @@ -37,3 +37,7 @@ /* ── Deep links between protocol views ────────────────────── */ .protocol-deep-link { align-self: flex-start; margin-top: 6px; } .protocol-plan-links { display: flex; flex-wrap: wrap; gap: 6px; } + +/* ── Combo detail: candidate paths ────────────────────────── */ +.combo-protocol-plan { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; } +.combo-protocol-plan p { margin: 0; } From be126f3fbb4855e194b38e981650deef16db6436 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:00:38 +0900 Subject: [PATCH 133/173] feat(gui): show candidate paths in the saved combo detail apiBase is threaded from the Combos page so the preview targets the same machine or hub as the editor; a new, unsaved combo has nothing to plan and shows no preview. --- gui/src/components/ComboWorkspace.tsx | 2 ++ gui/src/components/combo-workspace-detail-panel.tsx | 5 +++++ gui/src/components/combo-workspace-types.ts | 2 ++ gui/src/pages/Combos.tsx | 1 + 4 files changed, 10 insertions(+) diff --git a/gui/src/components/ComboWorkspace.tsx b/gui/src/components/ComboWorkspace.tsx index fcceaa11796..b2a30fe6d6c 100644 --- a/gui/src/components/ComboWorkspace.tsx +++ b/gui/src/components/ComboWorkspace.tsx @@ -17,6 +17,7 @@ import type { ComboWorkspaceProps } from "./combo-workspace-types"; export type { ModelOption, ProviderOption, ComboWorkspaceProps } from "./combo-workspace-types"; export default function ComboWorkspace({ + apiBase, combos, providerQuotaStates, providers, @@ -173,6 +174,7 @@ export default function ComboWorkspace({ {baseline ? ( `cws-detail-tab-${tab}`; const detailPanelDomId = (tab: DetailTab) => `cws-detail-panel-${tab}`; export function DetailPanel({ + apiBase, baseline, isCreate = false, otherIds, @@ -45,6 +47,8 @@ export function DetailPanel({ onSave, onDirtyChange, }: { + /** Management API target; without it the candidate path preview is not offered. */ + apiBase?: string; baseline: ComboItem; isCreate?: boolean; /** Ids of all OTHER combos — rename collisions validate against these. */ @@ -377,6 +381,7 @@ export function DetailPanel({ />
)} + {!isCreate && apiBase && } {/* diff --git a/gui/src/components/combo-workspace-types.ts b/gui/src/components/combo-workspace-types.ts index 44325225e91..41fe52a129a 100644 --- a/gui/src/components/combo-workspace-types.ts +++ b/gui/src/components/combo-workspace-types.ts @@ -17,6 +17,8 @@ export type ModelOption = { }; export interface ComboWorkspaceProps { + /** Management API target; enables the per-candidate path preview in the detail panel. */ + apiBase?: string; combos: ComboItem[]; providerQuotaStates: ProviderQuotaStates; providers: ProviderOption[]; diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index 6d299c6c992..98946ef9c04 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -370,6 +370,7 @@ export default function Combos({ {state.refreshing ? t("common.loading") : ""} Date: Fri, 25 Sep 2026 13:01:54 +0900 Subject: [PATCH 134/173] refactor(gui): move subject pair resolution out of the filter components Fast refresh needs component-only modules, and keying the fetch on the missing ids removes the exhaustive-deps suppressions that made the React Compiler skip the hook. --- gui/src/pages/CompatibilityMatrix.tsx | 3 +- .../pages/compatibility-protocol-filter.tsx | 103 +----------------- gui/src/pages/compatibility-protocol-pairs.ts | 85 +++++++++++++++ 3 files changed, 91 insertions(+), 100 deletions(-) create mode 100644 gui/src/pages/compatibility-protocol-pairs.ts diff --git a/gui/src/pages/CompatibilityMatrix.tsx b/gui/src/pages/CompatibilityMatrix.tsx index c08b9435f29..da31b6b97ff 100644 --- a/gui/src/pages/CompatibilityMatrix.tsx +++ b/gui/src/pages/CompatibilityMatrix.tsx @@ -12,7 +12,8 @@ import { readCompatibilityPair, type ProtocolPairFilter, } from "../protocol-deep-links"; -import { ProtocolPairFilters, ProtocolPairStatus, useSubjectProtocolPairs } from "./compatibility-protocol-filter"; +import { ProtocolPairFilters, ProtocolPairStatus } from "./compatibility-protocol-filter"; +import { useSubjectProtocolPairs } from "./compatibility-protocol-pairs"; import { fetchLabPageData, fetchMoreVerdicts, diff --git a/gui/src/pages/compatibility-protocol-filter.tsx b/gui/src/pages/compatibility-protocol-filter.tsx index 25a31a5ab72..53721e677c3 100644 --- a/gui/src/pages/compatibility-protocol-filter.tsx +++ b/gui/src/pages/compatibility-protocol-filter.tsx @@ -1,112 +1,17 @@ /** - * Inbound / upstream protocol filters for the compatibility matrix. - * - * The Lab subject list carries only ids and kinds; the protocol pair lives in each subject's - * detail. Details are read only while a protocol filter is active, for the subjects the matrix - * shows, a few at a time and at most `SUBJECT_DETAIL_LIMIT` of them, and cached per target - * because a subject id is a digest of the subject and never changes meaning. + * Inbound / upstream protocol filters for the compatibility matrix, and the line that says + * what the Lab knows about the filtered pair. Pair resolution is compatibility-protocol-pairs.ts. * * The filter reads Lab verdicts only. Delivery mode (native, translated) is how a request * travels and belongs to the path preview; the two are never folded into one badge here. */ -import { useEffect, useMemo, useState } from "react"; import { PROTOCOLS, type Protocol } from "../../../src/protocols/contract"; import { protocolHopLabel } from "../components/protocols/protocol-labels"; import { useT } from "../i18n/shared"; import { Select } from "../ui"; import type { ProtocolPairFilter } from "../protocol-deep-links"; -import { fetchSubjectDetail } from "./compatibility-matrix-api"; -import { - protocolFilterActive, - subjectProtocolPair, - type ProtocolPairEvidence, - type SubjectProtocolPair, -} from "./compatibility-matrix-shared"; - -export const SUBJECT_DETAIL_LIMIT = 200; -const DETAIL_CONCURRENCY = 6; -const CACHE_LIMIT = 2000; - -/** `null` records a subject whose detail could not be read, so it is not retried every render. */ -const pairCache = new Map(); - -function cacheKey(apiBase: string, subjectId: string): string { - return `${apiBase}\u0000${subjectId}`; -} - -/** Test seam. */ -export function clearSubjectProtocolPairCache(): void { - pairCache.clear(); -} - -function remember(key: string, value: SubjectProtocolPair | null): void { - pairCache.set(key, value); - while (pairCache.size > CACHE_LIMIT) { - const oldest = pairCache.keys().next().value; - if (oldest === undefined) break; - pairCache.delete(oldest); - } -} - -export interface SubjectProtocolPairs { - pairs: ReadonlyMap; - loading: boolean; - /** Subjects whose pair is unknown: unreadable detail, or past the detail limit. */ - unresolved: number; -} - -function collect(apiBase: string, subjectIds: readonly string[]): SubjectProtocolPairs { - const pairs = new Map(); - let unresolved = 0; - subjectIds.forEach((subjectId, index) => { - const cached = index < SUBJECT_DETAIL_LIMIT ? pairCache.get(cacheKey(apiBase, subjectId)) : null; - if (cached) pairs.set(subjectId, cached); - else if (cached === null) unresolved += 1; - }); - return { pairs, loading: false, unresolved }; -} - -export function useSubjectProtocolPairs(apiBase: string, subjectIds: readonly string[], enabled: boolean): SubjectProtocolPairs { - const idsKey = subjectIds.join("\n"); - const [revision, setRevision] = useState(0); - const missing = useMemo( - () => enabled - ? subjectIds.slice(0, SUBJECT_DETAIL_LIMIT).filter(subjectId => !pairCache.has(cacheKey(apiBase, subjectId))) - : [], - // `revision` re-reads the cache after a batch lands. - // eslint-disable-next-line react-hooks/exhaustive-deps -- idsKey stands in for subjectIds - [apiBase, enabled, idsKey, revision], - ); - - useEffect(() => { - if (missing.length === 0) return; - const controller = new AbortController(); - const queue = [...missing]; - const worker = async () => { - for (let subjectId = queue.shift(); subjectId !== undefined; subjectId = queue.shift()) { - try { - const detail = await fetchSubjectDetail(apiBase, subjectId, controller.signal); - remember(cacheKey(apiBase, subjectId), subjectProtocolPair(detail)); - } catch { - if (controller.signal.aborted) return; - remember(cacheKey(apiBase, subjectId), null); - } - } - }; - void Promise.all(Array.from({ length: Math.min(DETAIL_CONCURRENCY, queue.length) }, worker)).then(() => { - if (!controller.signal.aborted) setRevision(value => value + 1); - }); - return () => controller.abort(); - }, [apiBase, missing]); - - return useMemo(() => { - if (!enabled) return { pairs: new Map(), loading: false, unresolved: 0 }; - const collected = collect(apiBase, subjectIds); - const pastLimit = Math.max(0, subjectIds.length - SUBJECT_DETAIL_LIMIT); - return { ...collected, loading: missing.length > 0, unresolved: collected.unresolved + pastLimit }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- idsKey stands in for subjectIds - }, [apiBase, enabled, idsKey, missing, revision]); -} +import { protocolFilterActive, type ProtocolPairEvidence } from "./compatibility-matrix-shared"; +import type { SubjectProtocolPairs } from "./compatibility-protocol-pairs"; function useProtocolName() { const t = useT(); diff --git a/gui/src/pages/compatibility-protocol-pairs.ts b/gui/src/pages/compatibility-protocol-pairs.ts new file mode 100644 index 00000000000..7023e174a4c --- /dev/null +++ b/gui/src/pages/compatibility-protocol-pairs.ts @@ -0,0 +1,85 @@ +/** + * The protocol pair of each Lab subject the compatibility matrix shows, for its protocol + * filters. + * + * The Lab subject list carries only ids and kinds; the pair lives in each subject's detail. + * Details are read only while a protocol filter is active, for the subjects the matrix shows, + * a few at a time and at most `SUBJECT_DETAIL_LIMIT` of them, and cached per target because a + * subject id is a digest of the subject and never changes meaning. + */ +import { useEffect, useState } from "react"; +import { fetchSubjectDetail } from "./compatibility-matrix-api"; +import { subjectProtocolPair, type SubjectProtocolPair } from "./compatibility-matrix-shared"; + +export const SUBJECT_DETAIL_LIMIT = 200; +const DETAIL_CONCURRENCY = 6; +const CACHE_LIMIT = 2000; +const LIST_SEPARATOR = "\n"; + +/** `null` records a subject whose detail could not be read, so it is not retried every render. */ +const pairCache = new Map(); + +function cacheKey(apiBase: string, subjectId: string): string { + return JSON.stringify([apiBase, subjectId]); +} + +/** Test seam. */ +export function clearSubjectProtocolPairCache(): void { + pairCache.clear(); +} + +function remember(key: string, value: SubjectProtocolPair | null): void { + pairCache.set(key, value); + while (pairCache.size > CACHE_LIMIT) { + const oldest = pairCache.keys().next().value; + if (oldest === undefined) break; + pairCache.delete(oldest); + } +} + +export interface SubjectProtocolPairs { + pairs: ReadonlyMap; + loading: boolean; + /** Subjects whose pair is unknown: an unreadable detail, or past the detail limit. */ + unresolved: number; +} + +const IDLE: SubjectProtocolPairs = { pairs: new Map(), loading: false, unresolved: 0 }; + +export function useSubjectProtocolPairs(apiBase: string, subjectIds: readonly string[], enabled: boolean): SubjectProtocolPairs { + // Bumped when a batch lands, so the render re-reads the cache. + const [, setLanded] = useState(0); + const readable = enabled ? subjectIds.slice(0, SUBJECT_DETAIL_LIMIT) : []; + const missingKey = readable.filter(subjectId => !pairCache.has(cacheKey(apiBase, subjectId))).join(LIST_SEPARATOR); + + useEffect(() => { + if (!missingKey) return; + const controller = new AbortController(); + const queue = missingKey.split(LIST_SEPARATOR); + const worker = async () => { + for (let subjectId = queue.shift(); subjectId !== undefined; subjectId = queue.shift()) { + try { + const detail = await fetchSubjectDetail(apiBase, subjectId, controller.signal); + remember(cacheKey(apiBase, subjectId), subjectProtocolPair(detail)); + } catch { + if (controller.signal.aborted) return; + remember(cacheKey(apiBase, subjectId), null); + } + } + }; + void Promise.all(Array.from({ length: Math.min(DETAIL_CONCURRENCY, queue.length) }, worker)).then(() => { + if (!controller.signal.aborted) setLanded(value => value + 1); + }); + return () => controller.abort(); + }, [apiBase, missingKey]); + + if (!enabled) return IDLE; + const pairs = new Map(); + let unresolved = Math.max(0, subjectIds.length - SUBJECT_DETAIL_LIMIT); + for (const subjectId of readable) { + const cached = pairCache.get(cacheKey(apiBase, subjectId)); + if (cached) pairs.set(subjectId, cached); + else if (cached === null) unresolved += 1; + } + return { pairs, loading: missingKey.length > 0, unresolved }; +} From aac16ff38398efee1f6e9ee5b3c9856dbdab3842 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:01:54 +0900 Subject: [PATCH 135/173] refactor(gui): apply a provider deep link while rendering, not in an effect Setting focus inside an effect cascaded a second render; adjusting own state during render opens the provider and its Settings tab in one paint and satisfies the compiler. --- gui/src/pages/providers-deep-link.ts | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/gui/src/pages/providers-deep-link.ts b/gui/src/pages/providers-deep-link.ts index ccf0e038008..0e86c5d4b96 100644 --- a/gui/src/pages/providers-deep-link.ts +++ b/gui/src/pages/providers-deep-link.ts @@ -18,6 +18,11 @@ export interface ProviderSettingsFocus { provider: string | null; } +/** + * `select` must be a state setter of the component calling this hook: a matching link is + * applied while that component renders (React's "adjust state when a prop changes"), so the + * provider and its Settings tab appear in the same paint. + */ export function useProviderSettingsDeepLink( providerNames: readonly string[] | null, selected: string | null, @@ -25,10 +30,8 @@ export function useProviderSettingsDeepLink( ): ProviderSettingsFocus { // `seq` makes a repeated hash event for the same name a new request. const [request, setRequest] = useState(() => ({ name: readProviderSettingsTarget(), seq: 0 })); - const [focus, setFocus] = useState({ token: 0, provider: null }); - const appliedSeqRef = useRef(-1); + const [applied, setApplied] = useState<{ seq: number; provider: string } | null>(null); const previousSelectedRef = useRef(selected); - const namesKey = providerNames ? providerNames.join("\n") : null; useEffect(() => { const sync = () => setRequest(current => ({ name: readProviderSettingsTarget(), seq: current.seq + 1 })); @@ -40,25 +43,22 @@ export function useProviderSettingsDeepLink( }; }, []); - useEffect(() => { - const { name, seq } = request; - if (!name || namesKey === null || appliedSeqRef.current === seq) return; - if (!namesKey.split("\n").includes(name)) return; - appliedSeqRef.current = seq; - select(name); - setFocus(current => ({ token: current.token + 1, provider: name })); - }, [namesKey, request, select]); + // A name that is not configured (yet) waits here until the provider list contains it. + if (request.name && applied?.seq !== request.seq && providerNames?.includes(request.name)) { + setApplied({ seq: request.seq, provider: request.name }); + select(request.name); + } useEffect(() => { const previous = previousSelectedRef.current; previousSelectedRef.current = selected; + // Only a selection change can move away; a new request alone must not drop its own hash. + if (previous === selected) return; const linked = readProviderSettingsTarget(); - if (!linked || appliedSeqRef.current !== request.seq || request.name !== linked) return; + if (!linked || applied?.seq !== request.seq || request.name !== linked) return; const movedAway = selected !== null ? selected !== linked : previous === linked; if (movedAway) replaceHash(PROVIDERS_HASH); - // `request` is deliberately not a dependency: only a selection change can move away. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selected]); + }, [applied, request, selected]); - return focus; + return applied ? { token: applied.seq + 1, provider: applied.provider } : { token: 0, provider: null }; } From 64d822f9a9babe56d1513857dcf3afe56c25223d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:04:47 +0900 Subject: [PATCH 136/173] test(gui): pin the provider wire panel labels, no-control rule and old-server hide The panel must name the upstream wire and its decider, expose no switch, and render nothing on a 404 or a server that ignores ?provider. --- gui/tests/provider-protocol-panel.test.tsx | 144 +++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 gui/tests/provider-protocol-panel.test.tsx diff --git a/gui/tests/provider-protocol-panel.test.tsx b/gui/tests/provider-protocol-panel.test.tsx new file mode 100644 index 00000000000..fc195deb3d9 --- /dev/null +++ b/gui/tests/provider-protocol-panel.test.tsx @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import type { ProtocolProviderSummaryV1 } from "../../src/protocols/dto"; +import { ProviderProtocolPanel } from "../src/components/provider-workspace/ProviderProtocolPanel"; +import ProviderSettings from "../src/components/provider-workspace/ProviderSettings"; +import { LanguageProvider } from "../src/i18n/provider"; +import { DICTS } from "../src/i18n/shared"; +import { fetchProtocolProviderSummary } from "../src/protocol-api"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; +let calls: string[] = []; + +const SUMMARY: ProtocolProviderSummaryV1 = { + name: "custom", + adapter: "openai-chat", + adapterSource: "operator", + authMode: "key", + upstream: "chat", + modelOverrides: [ + { model: "wide", adapter: "openai-responses", source: "operator" }, + { model: "pinned", adapter: "anthropic", source: "hard-pin" }, + ], +}; + +function serve(respond: (url: URL) => Response) { + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push(String(input)); + return respond(new URL(String(input))); + }) as typeof fetch; +} + +function info(extra: Record = {}): Response { + return Response.json({ schemaVersion: 1, policyRevision: "p1", features: [], surfaces: {}, ...extra }); +} + +beforeEach(() => { + calls = []; + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#providers" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); +}); + +async function renderPanel(props: Parameters[0]) { + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + // Let the summary fetch settle. + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + return { container, unmount: () => act(async () => { root.unmount(); }) }; +} + +test("labels the adapter as the upstream wire and shows who decided it", async () => { + serve(() => info({ provider: SUMMARY })); + const { container, unmount } = await renderPanel({ apiBase: "http://hub", providerName: "custom", savedAdapter: "openai-chat" }); + const text = container.textContent ?? ""; + expect(text).toContain(DICTS.en["pws.protocol.adapterLabel"]); + expect(text).toContain(DICTS.en["pws.protocol.source.operator"]); + expect(text).toContain(DICTS.en["pws.protocol.source.hardPin"]); + expect(text).toContain("wide"); + expect(text).toContain("pinned"); + expect(calls).toEqual(["http://hub/api/protocols?provider=custom"]); + await unmount(); +}); + +test("offers no control, so it cannot pass for an API exposure switch", async () => { + serve(() => info({ provider: SUMMARY })); + const { container, unmount } = await renderPanel({ apiBase: "http://hub", providerName: "custom", savedAdapter: "openai-chat" }); + const panel = container.querySelector('[data-testid="provider-protocol-panel"]'); + expect(panel).not.toBeNull(); + expect(panel!.querySelectorAll("input, select, button, [role='switch']")).toHaveLength(0); + await unmount(); +}); + +test("says what an unsaved adapter choice would send", async () => { + serve(() => info({ provider: SUMMARY })); + const { container, unmount } = await renderPanel({ + apiBase: "http://hub", + providerName: "custom", + savedAdapter: "openai-chat", + draftAdapter: "anthropic", + }); + expect(container.textContent).toContain("After you save, this provider receives anthropic."); + await unmount(); +}); + +test("hides quietly for an older server: 404 or no provider block", async () => { + serve(() => new Response("{}", { status: 404 })); + const missing = await renderPanel({ apiBase: "http://old", providerName: "custom", savedAdapter: "openai-chat" }); + expect(missing.container.innerHTML).toBe(""); + await missing.unmount(); + + serve(() => info()); + const ignored = await renderPanel({ apiBase: "http://older", providerName: "custom", savedAdapter: "openai-chat" }); + expect(ignored.container.innerHTML).toBe(""); + await ignored.unmount(); +}); + +test("fetchProtocolProviderSummary refuses a block for another provider", async () => { + serve(() => info({ provider: { ...SUMMARY, name: "other" } })); + expect(await fetchProtocolProviderSummary("http://hub", "custom")).toEqual({ kind: "error" }); + serve(() => info({ provider: { ...SUMMARY, adapterSource: "captured-auth" } })); + expect(await fetchProtocolProviderSummary("http://hub", "custom")).toEqual({ kind: "error" }); +}); + +test("provider settings without an API target fetch nothing for the panel", async () => { + serve(() => info({ provider: SUMMARY })); + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( ({ ok: true })} + />); + }); + expect(calls).toEqual([]); + expect(container.querySelector('[data-testid="provider-protocol-panel"]')).toBeNull(); + await act(async () => { root.unmount(); }); +}); From a85a2531192407381de096fbad43c586f3271f93 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:04:48 +0900 Subject: [PATCH 137/173] test(gui): pin protocol pair filters and unverified absent evidence Lab identities map through protocolFromLabProtocol, an unknown pair is left out, and a pair with no rows reads unverified with no verdict or delivery-mode badge. --- .../compatibility-protocol-filter.test.tsx | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 gui/tests/compatibility-protocol-filter.test.tsx diff --git a/gui/tests/compatibility-protocol-filter.test.tsx b/gui/tests/compatibility-protocol-filter.test.tsx new file mode 100644 index 00000000000..43e10224294 --- /dev/null +++ b/gui/tests/compatibility-protocol-filter.test.tsx @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { DICTS, I18nContext, interpolate, type TFn } from "../src/i18n/shared"; +import { + buildMatrixRows, + filterMatrixRowsByProtocol, + protocolPairEvidence, + subjectProtocolPair, + type SubjectProtocolPair, + type VerdictDto, +} from "../src/pages/compatibility-matrix-shared"; +import { ProtocolPairStatus } from "../src/pages/compatibility-protocol-filter"; +import { EMPTY_PROTOCOL_PAIR, type ProtocolPairFilter } from "../src/protocol-deep-links"; + +const t: TFn = (key, vars) => interpolate(DICTS.en[key], vars); +function LanguageProvider({ children }: { children: ReactNode }) { + return {}, t }}>{children}; +} + +function verdict(subjectId: string, value: VerdictDto["verdict"] = "VERIFIED"): VerdictDto { + return { + projectionKey: `${subjectId}:protocol_conformance`, + subjectId, + evidenceLayer: "protocol_conformance", + suiteId: "suite", + suiteVersion: "1", + suiteManifestDigest: "d", + projectionSpecVersion: "1", + verdict: value, + asOf: 1, + scenarioManifestDigests: [], + claimSourceDigest: null, + contributingEventIds: [], + contradictingEventIds: [], + notes: [], + }; +} + +const rows = buildMatrixRows([verdict("chat-to-messages"), verdict("responses-native")], []); +const pairs = new Map([ + ["chat-to-messages", { inbound: "chat", upstream: "messages" }], + ["responses-native", { inbound: "responses", upstream: "responses" }], +]); + +describe("subjectProtocolPair", () => { + test("maps Lab protocol identities onto the public vocabulary", () => { + expect(subjectProtocolPair({ subjectKind: "protocol", inboundProtocol: "openai-chat", upstreamProtocol: "anthropic-messages" })) + .toEqual({ inbound: "chat", upstream: "messages" }); + expect(subjectProtocolPair({ subjectKind: "route", inboundProtocol: "openai-responses", upstreamProtocol: "openai-responses" })) + .toEqual({ inbound: "responses", upstream: "responses" }); + }); + + test("an identity with no public protocol stays unknown instead of guessed", () => { + expect(subjectProtocolPair({ subjectKind: "protocol", inboundProtocol: "gemini-native", upstreamProtocol: 3 })).toEqual({}); + expect(subjectProtocolPair(null)).toEqual({}); + }); +}); + +describe("filterMatrixRowsByProtocol", () => { + test("an empty filter keeps every row", () => { + expect(filterMatrixRowsByProtocol(rows, pairs, EMPTY_PROTOCOL_PAIR)).toBe(rows); + }); + + test("filters by inbound, upstream, or both", () => { + const ids = (filter: ProtocolPairFilter) => filterMatrixRowsByProtocol(rows, pairs, filter).map(row => row.subjectId); + expect(ids({ inbound: "chat", upstream: "" })).toEqual(["chat-to-messages"]); + expect(ids({ inbound: "", upstream: "responses" })).toEqual(["responses-native"]); + expect(ids({ inbound: "chat", upstream: "messages" })).toEqual(["chat-to-messages"]); + expect(ids({ inbound: "messages", upstream: "chat" })).toEqual([]); + }); + + test("a subject whose pair is unknown is left out of an active filter", () => { + expect(filterMatrixRowsByProtocol(rows, new Map(), { inbound: "chat", upstream: "" })).toEqual([]); + }); +}); + +describe("absent Lab evidence", () => { + test("a filtered pair with no rows is unverified", () => { + expect(protocolPairEvidence({ inbound: "messages", upstream: "chat" }, [])).toBe("unverified"); + expect(protocolPairEvidence({ inbound: "chat", upstream: "messages" }, rows.slice(0, 1))).toBe("evidence"); + expect(protocolPairEvidence(EMPTY_PROTOCOL_PAIR, [])).toBe("any"); + }); + + test("the status line says unverified, never failed or unsupported", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain('data-pair-evidence="unverified"'); + expect(html).toContain("unverified, not failed"); + expect(html).not.toContain(DICTS.en["lab.verdict.UNSUPPORTED"]); + expect(html).not.toContain(DICTS.en["lab.verdict.BLOCKED"]); + expect(html).not.toContain("notice-err"); + }); + + test("delivery mode never shares the status line with a verdict", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).not.toContain(DICTS.en["api.plan.mode.native"]); + expect(html).not.toContain(DICTS.en["lab.verdict.VERIFIED"]); + expect(html).toContain(DICTS.en["compatProtocol.axisNote"]); + }); + + test("no filter renders nothing", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toBe(""); + }); +}); From 5de4101d1cdb17a26f0bc17d3ee6509bbf2e47cf Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:04:48 +0900 Subject: [PATCH 138/173] test(gui): pin protocol deep-link hashes and their Back/Forward behavior Queries survive only on providers and compatibility, a provider link waits for the list and re-applies on hash events, and moving away drops it. --- gui/tests/protocol-deep-links.test.ts | 66 ++++++++++++++++++ gui/tests/providers-deep-link.test.tsx | 95 ++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 gui/tests/protocol-deep-links.test.ts create mode 100644 gui/tests/providers-deep-link.test.tsx diff --git a/gui/tests/protocol-deep-links.test.ts b/gui/tests/protocol-deep-links.test.ts new file mode 100644 index 00000000000..5fc1b26ab56 --- /dev/null +++ b/gui/tests/protocol-deep-links.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { readPageFromHash, resolveAppHashChange } from "../src/app-routing"; +import { splitHashQuery } from "../src/hash-routing"; +import { readModelsTab } from "../src/pages/models-tab"; +import { + compatibilityPairHash, + protocolPairUpstream, + providerSettingsHash, + readCompatibilityPair, + readProviderSettingsTarget, +} from "../src/protocol-deep-links"; + +describe("compatibility pair hash", () => { + test("round-trips a pair and reads unknown values as any", () => { + const hash = compatibilityPairHash({ inbound: "chat", upstream: "messages" }); + expect(hash).toBe("models/compatibility?inbound=chat&upstream=messages"); + expect(readCompatibilityPair(`#${hash}`)).toEqual({ inbound: "chat", upstream: "messages" }); + expect(readCompatibilityPair("#models/compatibility?inbound=anthropic&upstream=grpc")).toEqual({ inbound: "", upstream: "" }); + expect(compatibilityPairHash({ inbound: "", upstream: "" })).toBe("models/compatibility"); + }); + + test("another route is not a pair, so it cannot clear the matrix filter", () => { + expect(readCompatibilityPair("#models")).toBeNull(); + expect(readCompatibilityPair("#logs?inbound=chat")).toBeNull(); + }); + + test("an upstream with no Lab identity links as any", () => { + expect(protocolPairUpstream("other")).toBe(""); + expect(protocolPairUpstream(undefined)).toBe(""); + expect(protocolPairUpstream("messages")).toBe("messages"); + }); +}); + +describe("provider settings hash", () => { + test("encodes the name and reads it back", () => { + const hash = providerSettingsHash("my provider/1"); + expect(splitHashQuery(hash).path).toBe("providers"); + expect(readProviderSettingsTarget(`#${hash}`)).toBe("my provider/1"); + }); + + test("an empty, over-long or foreign name is no target", () => { + expect(readProviderSettingsTarget("#providers")).toBeNull(); + expect(readProviderSettingsTarget("#providers?provider=%20")).toBeNull(); + expect(readProviderSettingsTarget(`#providers?provider=${"p".repeat(201)}`)).toBeNull(); + expect(readProviderSettingsTarget("#models?provider=x")).toBeNull(); + }); +}); + +describe("routing keeps the query only where a page owns it", () => { + test("providers and compatibility keep their query without a rewrite", () => { + expect(resolveAppHashChange("providers?provider=x")).toEqual({ page: "providers", replaceTo: null }); + expect(resolveAppHashChange("models/compatibility?inbound=chat")).toEqual({ page: "models", replaceTo: null }); + }); + + test("any other route drops the query passively", () => { + expect(resolveAppHashChange("logs?inbound=chat")).toEqual({ page: "logs", replaceTo: "logs" }); + expect(resolveAppHashChange("models/combos?x=1")).toEqual({ page: "models", replaceTo: "models/combos" }); + expect(resolveAppHashChange("lab?inbound=chat")).toEqual({ page: "models", replaceTo: "models/compatibility" }); + }); + + test("page and tab resolution read the path alone", () => { + expect(readPageFromHash("#providers?provider=x")).toBe("providers"); + expect(readPageFromHash("#models/compatibility?inbound=chat")).toBe("models"); + expect(readModelsTab("#models/compatibility?inbound=chat&upstream=messages")).toBe("compatibility"); + }); +}); diff --git a/gui/tests/providers-deep-link.test.tsx b/gui/tests/providers-deep-link.test.tsx new file mode 100644 index 00000000000..cd32be8c0c8 --- /dev/null +++ b/gui/tests/providers-deep-link.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useState } from "react"; +import type { Root } from "react-dom/client"; +import { useProviderSettingsDeepLink } from "../src/pages/providers-deep-link"; + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#providers?provider=beta" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); +}); + +type Snapshot = { selected: string | null; token: number; provider: string | null }; + +async function mount(names: string[] | null) { + const seen: Snapshot[] = []; + let choose!: (name: string | null) => void; + function Harness({ providerNames }: { providerNames: string[] | null }) { + const [selected, setSelected] = useState(null); + choose = setSelected; + const focus = useProviderSettingsDeepLink(providerNames, selected, setSelected); + seen.push({ selected, ...focus }); + return null; + } + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + return { + last: () => seen[seen.length - 1]!, + rerender: (next: string[] | null) => act(async () => { root.render(); }), + choose: (name: string | null) => act(async () => { choose(name); }), + unmount: () => act(async () => { root.unmount(); }), + }; +} + +async function hash(next: string) { + await act(async () => { + testWindow.location.hash = next; + testWindow.dispatchEvent(new testWindow.HashChangeEvent("hashchange")); + }); +} + +test("a provider link selects that provider and focuses its settings", async () => { + const view = await mount(["alpha", "beta"]); + expect(view.last()).toMatchObject({ selected: "beta", provider: "beta" }); + expect(view.last().token).toBeGreaterThan(0); + await view.unmount(); +}); + +test("a link waits for the provider list and ignores a name that never appears", async () => { + const view = await mount(null); + expect(view.last()).toMatchObject({ selected: null, token: 0 }); + await view.rerender(["alpha"]); + expect(view.last()).toMatchObject({ selected: null, token: 0 }); + await view.rerender(["alpha", "beta"]); + expect(view.last()).toMatchObject({ selected: "beta", provider: "beta" }); + await view.unmount(); +}); + +test("Back/Forward to another provider link re-applies it", async () => { + const view = await mount(["alpha", "beta"]); + const first = view.last().token; + await hash("providers?provider=alpha"); + expect(view.last()).toMatchObject({ selected: "alpha", provider: "alpha" }); + expect(view.last().token).toBeGreaterThan(first); + await hash("providers?provider=beta"); + expect(view.last()).toMatchObject({ selected: "beta", provider: "beta" }); + await view.unmount(); +}); + +test("choosing another provider drops the link so a refresh does not reopen it", async () => { + const view = await mount(["alpha", "beta"]); + await view.choose("alpha"); + expect(testWindow.location.hash).toBe("#providers"); + await view.unmount(); +}); From eb1dffa1e2ca1c82e804aadf566a65bb9c52821d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:04:48 +0900 Subject: [PATCH 139/173] test(gui): pin the on-demand combo candidate path preview No fetch before the click, the full expressible feature set in the plan body, both candidates rendered, and a quiet hide on an older server. --- gui/tests/combo-protocol-plan.test.tsx | 118 +++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 gui/tests/combo-protocol-plan.test.tsx diff --git a/gui/tests/combo-protocol-plan.test.tsx b/gui/tests/combo-protocol-plan.test.tsx new file mode 100644 index 00000000000..b36b29faf73 --- /dev/null +++ b/gui/tests/combo-protocol-plan.test.tsx @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { planProtocol } from "../../src/protocols/plan"; +import { FEATURE_SOURCES, PROTOCOL_FEATURES } from "../../src/protocols/features"; +import { ComboProtocolPlan } from "../src/components/protocols/ComboProtocolPlan"; +import { LanguageProvider } from "../src/i18n/provider"; +import { DICTS } from "../src/i18n/shared"; +import { clearProtocolPlanCache } from "../src/protocol-api"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; +let calls: Array<{ url: string; body?: unknown }> = []; + +const INFO = { + schemaVersion: 1, + contractVersion: "x", + policyRevision: "p1-00000001", + surfaces: { responses: { enabled: true }, chat: { enabled: true }, messages: { enabled: true } }, + settings: { unrepresentable: "legacy" }, + features: [...PROTOCOL_FEATURES], +}; +const RESPONSES_FEATURES = PROTOCOL_FEATURES.filter(feature => FEATURE_SOURCES[feature].includes("responses")); +const PLAN = planProtocol({ + inbound: "responses", + requestedModel: "combo/pair", + routeKind: "combo", + candidates: [ + { provider: "a", model: "m1", adapter: "openai-responses", nativeEligible: true, declineReasons: [] }, + { provider: "b", model: "m2", adapter: "openai-chat", nativeEligible: false, declineReasons: [] }, + ], + features: RESPONSES_FEATURES, + surfaces: INFO.surfaces, + settings: { unrepresentable: "legacy" }, + policyRevision: INFO.policyRevision, + basis: "preview", +}); + +function serve(routes: Record Response>) { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}) }); + return routes[new URL(url).pathname]?.() ?? new Response("{}", { status: 404 }); + }) as typeof fetch; +} + +beforeEach(() => { + calls = []; + clearProtocolPlanCache(); + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#models/combos" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); +}); + +async function render(dirty = false) { + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + const click = async () => { + await act(async () => { + container.querySelector(".combo-protocol-plan button")!.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + }; + return { container, click, unmount: () => act(async () => { root.unmount(); }) }; +} + +test("fetches nothing until asked, then plans every feature the client API can express", async () => { + serve({ "/api/protocols": () => Response.json(INFO), "/api/protocols/plan": () => Response.json(PLAN) }); + const view = await render(); + expect(calls).toEqual([]); + await view.click(); + expect(calls.map(call => new URL(call.url).pathname)).toEqual(["/api/protocols", "/api/protocols/plan"]); + expect(calls[1]!.body).toEqual({ model: "combo/pair", inbound: "responses", features: RESPONSES_FEATURES }); + const text = view.container.textContent ?? ""; + expect(text).toContain(DICTS.en["api.plan.guaranteed"]); + expect(text).toContain(DICTS.en["api.plan.partial"]); + expect(text).toContain("a/m1"); + expect(text).toContain("b/m2"); + expect(view.container.querySelectorAll(".protocol-plan-candidate")).toHaveLength(2); + await view.unmount(); +}); + +test("an older server hides the section quietly", async () => { + serve({}); + const view = await render(); + await view.click(); + expect(view.container.innerHTML).toBe(""); + await view.unmount(); +}); + +test("says the preview reads the saved combo while edits are unsaved", async () => { + serve({}); + const view = await render(true); + expect(view.container.textContent).toContain(DICTS.en["cws.plan.savedOnly"]); + await view.unmount(); +}); From 062dd5011230782b67e05595dd1b6ed59fbb9fad Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:05:38 +0900 Subject: [PATCH 140/173] docs(structure): describe the provider wire summary and protocol evidence views protocol-paths owns the ?provider builder and its source mapping; the dashboard doc records the three new panels, the hash-query deep links and their tests. --- structure/dashboard-and-usage.md | 34 +++++++++++++++++++++++++ structure/data-planes/protocol-paths.md | 20 +++++++++++++++ structure/gui-and-management-api.md | 2 +- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index f8c715b6ffe..de4268df77d 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -68,6 +68,40 @@ path, delivery mode, fidelity, reasons and feature effects (`FeatureDispositionL features every eligible candidate guarantees apart from those only some keep. Delivery mode is not a verification verdict, so the panel shows no Lab badge and does not read `ExternalModelRow.native`. Tests live in `gui/tests/protocol-api.test.ts` and `tests/server/protocol-routes.test.ts`. + +The same vocabulary appears on three more screens, each answering one question and each hiding +quietly when the server predates its route: + +- Provider settings: `gui/src/components/provider-workspace/ProviderProtocolPanel.tsx` sits under + the adapter field and reads `GET /api/protocols?provider=` on the settings `apiBase` + (`fetchProtocolProviderSummary`; a 404 or a body without `provider` hides it). It names the + adapter as the upstream wire the provider receives, the decision source and the per-model + overrides, and has no control of its own: the adapter field above it still saves through + `onUpdateProvider` → `PATCH /api/providers`, and the panel only says what an unsaved choice would + send. It is not an API exposure switch; those are the API page's cards. +- Compatibility matrix: inbound and upstream protocol filters + (`gui/src/pages/compatibility-protocol-filter.tsx`). The Lab subject list has no protocol, so + while a filter is active `gui/src/pages/compatibility-protocol-pairs.ts` reads the listed + subjects' details (at most 200, six at a time, cached per target) and maps their Lab identities + with `protocolFromLabProtocol`. A subject whose pair is unknown is left out; a pair with no + matching row reads "unverified, not failed", never failed or unsupported. The matrix shows Lab + verdicts only; delivery mode stays on the path preview, so the two never share a badge. +- Combo detail: `gui/src/components/protocols/ComboProtocolPlan.tsx` in the saved combo's config + tab runs `POST /api/protocols/plan` on an explicit click for every feature the chosen client API + can express, and renders the shared `PlanResult`: each target's path and feature effects, and the + guaranteed/partial split. It reads the saved combo, and says so while edits are unsaved. + +Deep links (`gui/src/protocol-deep-links.ts`) carry their target in the hash query, which +`resolveAppHashChange` keeps only on `#providers` and `#models/compatibility` (`QUERY_HASH_PATHS`) +and drops elsewhere. Each plan candidate links to `#providers?provider=` +(`gui/src/pages/providers-deep-link.ts` selects that provider and opens its Settings tab, and drops +the query once another provider is chosen) and to `#models/compatibility?inbound=…&upstream=…`; a +traced Logs row links to the compatibility pair it took. Links push history, the matrix replaces +the entry when its filter is edited, and both targets re-read the hash on `hashchange`/`popstate`, +so Back and Forward restore the prefilter. Tests live in `gui/tests/provider-protocol-panel.test.tsx`, +`gui/tests/compatibility-protocol-filter.test.tsx`, `gui/tests/protocol-deep-links.test.ts`, +`gui/tests/providers-deep-link.test.tsx` and `gui/tests/combo-protocol-plan.test.tsx`. + The API workspace gives `gui/src/components/section-tabs.tsx` its mobile reading line so scroll-spy and the top-bar offset agree; other consumers keep their existing reading line. The section strip stays one row at every width. diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 37d88cbec73..a0b3a61956e 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -124,6 +124,26 @@ caller-forward passthrough depends on the caller's own credential, so it is repo modelled. `tests/responses/protocol-plan-snapshot.test.ts` pins the no-side-effect property against combo selection state. +## Provider wire summary + +`buildProtocolProviderSummary` in `src/protocols/provider-summary.ts` (server side, side-effect +free) answers the `provider` block of `GET /api/protocols?provider=`: the adapter the provider +receives, its `upstream` wire (`upstreamWireForAdapter`), the resolved auth mode, and the models +whose wire was decided apart from the provider. Everything comes from `captureRouteStaticPolicy`, the +same resolver every ingress uses, so there is no second copy of the adapter rules. The resolver's +provenance collapses onto four public sources in `protocolAdapterSource`: `hard-pin`, `operator` +(including `operator-capability`), `registry`, and `provider-default` for everything else. + +Model overrides are resolved for a Responses client. The candidates are the explicit +`modelAdapters` keys, the registry's `modelWireDefaults` keys, the exact-id hard pins +(`captureWireAdapterHardPins`), the default model and the listed models, at most 1024 of them; a +model is listed when its source is not `provider-default` or its adapter differs from the +provider's. The list is sorted, capped at `PROTOCOL_PROVIDER_OVERRIDE_LIMIT` (64) and marked +`modelOverridesTruncated` past it. Prefix pins cannot be enumerated, so only a listed model they +match appears. No credential, base URL or header leaves the module. The shape and its validator, +`isProtocolProviderSummaryV1`, live in the leaf `dto.ts`. `tests/server/protocol-provider-summary.test.ts` +covers the source mapping, the cap, the 404 and the parameter bounds. + ## Source envelope, codecs and guard `src/protocols/envelope.ts` (server side; it charges the translator budget) wraps the body an diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 53c1d2f4103..9248fee1343 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -200,7 +200,7 @@ this document owns is which module holds which area and what invariant that area | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Claude reset grants | `src/server/management/anthropic-reset-grant-routes.ts` — `GET /api/anthropic/reset-grants`, `POST /api/anthropic/reset-grants/consume` (lazy-loaded). Wire and fail-closed parsing live in `src/providers/anthropic-reset-grants.ts` (the Claude Code 2.1.278 `cedar_ember` contract, sent with `CLAUDE_CLI_USER_AGENT` from `src/providers/claude-cli-identity.ts`); the journal is `src/providers/anthropic-reset-grant-ledger.ts`: a cross-process `BEGIN IMMEDIATE` lock around every synchronous read-modify-write, a 90 s lease, the operation id reused as the upstream `request_id`, same-id retry only inside the vendor's ten-minute window, no settlement inferred from a re-read, and a fail-closed `500 journal_write_failed` when an answer cannot be recorded. Spending requires the `gui-session` principal. The dashboard owner is `gui/src/hooks/useAnthropicResetGrants.ts` with `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` on the Anthropic OAuth rows of `ProviderAuthPanel`; after an unknown outcome the dialog only retries the same id. Design and audit record: [`../devlog/_plan/260923_claude_reset_grants/010_plan.md`](../devlog/_plan/260923_claude_reset_grants/010_plan.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. `PUT` keeps a stored field the body omits (`cooldownMs`, `waitForCooldownMs`, `defaultEffortMode`, `reasoningEffortMode`, `imageInput`, `cooldownWaitPolicy`, per-target `lastResort`); explicit values replace it and defaults are stored sparse. | -| Protocol paths | `src/server/management/protocol-routes.ts` (lazy-loaded) — `GET /api/protocols` returns the contract version, the resolved API surfaces and protocol settings, the policy revision and the feature vocabulary; `POST /api/protocols/plan` takes `{ model, inbound, features? }` (model at most 200 characters, at most 24 features, any other key refused with 400) and returns a `ProtocolPlanV1` with `basis: "preview"` from `src/protocols/plan-snapshot.ts`. Both are read-only, never log their input, and send nothing upstream. `PATCH /api/protocols/settings` takes `{ messagesEnabled?, unrepresentable?, rollout? }` (strict: unknown keys and wrong types are 400), validates and applies through `src/server/management/protocol-settings-patch.ts`, persists with `saveConfigPreservingClaudeCode`, restores the live config if the save fails (409 on lock contention, 500 otherwise), and answers with the fresh `GET /api/protocols` body; closing Messages also writes `claudeCode.enabled = false` through `commitClaudeCodeBlock` ([Protocol Paths](data-planes/protocol-paths.md#settings)). All three are `deferred-verb` in the route registry, owned by PF-12 in [`../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md`](../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md). | +| Protocol paths | `src/server/management/protocol-routes.ts` (lazy-loaded) — `GET /api/protocols` returns the contract version, the resolved API surfaces and protocol settings, the policy revision and the feature vocabulary; with `?provider=` (one non-empty name of at most 200 characters without control characters, else 400 `invalid_provider`; an unconfigured name is 404 `unknown_provider`, neither echoing the name) it adds a `provider` block from `src/protocols/provider-summary.ts` ([Protocol Paths](data-planes/protocol-paths.md#provider-wire-summary)); `POST /api/protocols/plan` takes `{ model, inbound, features? }` (model at most 200 characters, at most 24 features, any other key refused with 400) and returns a `ProtocolPlanV1` with `basis: "preview"` from `src/protocols/plan-snapshot.ts`. Both are read-only, never log their input, and send nothing upstream. `PATCH /api/protocols/settings` takes `{ messagesEnabled?, unrepresentable?, rollout? }` (strict: unknown keys and wrong types are 400), validates and applies through `src/server/management/protocol-settings-patch.ts`, persists with `saveConfigPreservingClaudeCode`, restores the live config if the save fails (409 on lock contention, 500 otherwise), and answers with the fresh `GET /api/protocols` body; closing Messages also writes `claudeCode.enabled = false` through `commitClaudeCodeBlock` ([Protocol Paths](data-planes/protocol-paths.md#settings)). All three are `deferred-verb` in the route registry, owned by PF-12 in [`../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md`](../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md). | | Workflow budget | `src/server/management/workflow-budget-routes.ts` — `GET /api/workflow-budget` reads the tracked roots or one root, and `POST /api/workflow-budget/clear` clears exactly one. The clear moves the windowed send ring and the child map and nothing else: `active` belongs to turns still in flight, the spend ledger is a token budget an operator did not ask to forgive, and the lifetime send total survives so a clear cannot launder the record. A refusal event carries `spendScope` and `spendLimit` when a token ceiling fired, so the reason is readable without the config open beside it; no scope id is ever attached, because root ids are client thread headers and identity ids are credentials. Both are `deferred-verb` in the route registry — they are owed CLI verbs, and because the ledger is process memory there is no local projection the CLI could read instead. See [`../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md`](../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md). | | Codex accounts | `src/codex/auth-api/routes.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Per-account quota activation uses the existing `GET/PUT /api/settings` surface and `src/codex/quota-auto-refresh.ts`, keeping scheduled spending separate from credential/authentication mutation. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. New-account config commits add UI-managed selector bindings in the same config save; deletion deliberately retains existing bindings for fail-closed exact routing and re-add stability. Account mutations request catalog convergence only after config durability and expose only the boolean `catalogRefreshPending` completion projection. | | Sidebar | `src/server/management/sidebar-routes.ts` — `GET/POST /api/github/star`, `GET /api/update/badge`, and `POST /api/update/desktop-snapshot`. The snapshot POST accepts the raw admin-token principal or the dedicated `local-desktop-snapshot-capability`; GUI sessions and requests carrying `Origin` cannot publish desktop state. A capability's bounded raw body is verified against its authenticated digest before JSON parsing or storage. Badge state is cosmetic and a failed poll degrades silently. | From 1d0a1ec08e133d8e1d5d8e245d958a581f4a39a0 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:05:38 +0900 Subject: [PATCH 141/173] docs(api): document GET /api/protocols?provider in the management reference Operators reading the reference see the provider block, its bounds and that it reports the upstream wire without exposing or toggling any client API. --- docs-site/src/content/docs/reference/management-api.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 813e906eb9e..06f4f817003 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -514,6 +514,7 @@ keys are not returned to dashboard clients. | Method and path | Purpose | Notable errors | | --- | --- | --- | | `GET /api/protocols` | Return the protocol contract version, which APIs are served, the protocol settings, the current policy revision, and the request features a preview understands | — | +| `GET /api/protocols?provider=` | The same body plus `provider`: `{ name, adapter, adapterSource, authMode, upstream, modelOverrides: [{ model, adapter, source }] }` — the wire the provider receives and who decided it (`hard-pin`, `operator`, `registry`, `provider-default`), with up to 64 models on a different wire (`modelOverridesTruncated: true` past that). No credential or base URL | 400 empty, repeated, over 200 characters, or control characters; 404 no provider with that name | | `POST /api/protocols/plan` | Preview the path a request would take: `{ "model": "...", "inbound": "responses" \| "chat" \| "messages", "features": [...] }` returns each route candidate's request and response path, delivery mode, fidelity, feature effects, and reasons | 400 invalid JSON, unknown field, model over 200 characters, unknown inbound, or more than 24 / unknown features | | `PATCH /api/protocols/settings` | Change protocol settings: `{ "messagesEnabled"?: boolean, "unrepresentable"?: "legacy" \| "reject", "rollout"?: { ...boolean switches } }`. Returns the same body as `GET /api/protocols`. Closing Messages also sets `claudeCode.enabled` to `false` in the same save; opening it writes only `apiSurfaces.messages.enabled` | 400 invalid JSON, empty body, unknown field, wrong type, or `rollout.managedMessagesNativeOAuth` without `rollout.managedMessagesNative`; 409 configuration busy; 500 save failed (nothing changes) | @@ -522,6 +523,10 @@ does not advance combo rotation, and is not logged. The API page in the dashboar preview under **Request path preview**. A delivery mode of `native` describes how the request travels; it is not a compatibility verification. +The `?provider=` form backs the **Upstream wire** section of a provider's settings. It reports +the format opencodex sends to that provider; it does not open or close any client API. To change +the provider-wide adapter, save the provider's settings as usual (`PATCH /api/providers`). + `PATCH /api/protocols/settings` is the only writer here and backs the Messages toggle on the API page. The rollout switches are staged and default off; see [API surfaces](/reference/configuration/server/#api-surfaces-apisurfaces) for how the Messages From 9062f691887637e06c8895925413a82630fb2138 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:31:36 +0900 Subject: [PATCH 142/173] fix(gui): load the provider wire panel and combo paths on the same-origin target The dashboard served by the proxy uses an empty API base, which both views read as no target, so the panel never fetched and the combo candidate paths never rendered. Only an absent base means no target. --- gui/src/components/combo-workspace-detail-panel.tsx | 2 +- .../provider-workspace/ProviderProtocolPanel.tsx | 4 ++-- gui/tests/provider-protocol-panel.test.tsx | 10 +++++++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx index d4fd4237538..4fb2fda9f7b 100644 --- a/gui/src/components/combo-workspace-detail-panel.tsx +++ b/gui/src/components/combo-workspace-detail-panel.tsx @@ -381,7 +381,7 @@ export function DetailPanel({ /> )} - {!isCreate && apiBase && } + {!isCreate && apiBase !== undefined && } {/* diff --git a/gui/src/components/provider-workspace/ProviderProtocolPanel.tsx b/gui/src/components/provider-workspace/ProviderProtocolPanel.tsx index 9b253484061..c5da3ff7998 100644 --- a/gui/src/components/provider-workspace/ProviderProtocolPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderProtocolPanel.tsx @@ -59,7 +59,7 @@ export function ProviderProtocolPanel({ const [state, setState] = useState(null); useEffect(() => { - if (!apiBase) return; + if (apiBase === undefined) return; const controller = new AbortController(); fetchProtocolProviderSummary(apiBase, providerName, controller.signal) .then(result => { @@ -73,7 +73,7 @@ export function ProviderProtocolPanel({ }, [apiBase, providerName, requestKey]); const current = state?.key === requestKey ? state : null; - if (!apiBase || !current || current.kind === "hidden") return null; + if (apiBase === undefined || !current || current.kind === "hidden") return null; const pendingAdapter = draftAdapter && draftAdapter !== savedAdapter ? draftAdapter : null; return ( diff --git a/gui/tests/provider-protocol-panel.test.tsx b/gui/tests/provider-protocol-panel.test.tsx index fc195deb3d9..12e674dac9d 100644 --- a/gui/tests/provider-protocol-panel.test.tsx +++ b/gui/tests/provider-protocol-panel.test.tsx @@ -30,7 +30,7 @@ const SUMMARY: ProtocolProviderSummaryV1 = { function serve(respond: (url: URL) => Response) { globalThis.fetch = (async (input: RequestInfo | URL) => { calls.push(String(input)); - return respond(new URL(String(input))); + return respond(new URL(String(input), "http://localhost")); }) as typeof fetch; } @@ -85,6 +85,14 @@ test("labels the adapter as the upstream wire and shows who decided it", async ( await unmount(); }); +test("the dashboard's own same-origin target (an empty base) still loads the panel", async () => { + serve(() => info({ provider: SUMMARY })); + const { container, unmount } = await renderPanel({ apiBase: "", providerName: "custom", savedAdapter: "openai-chat" }); + expect(calls).toEqual(["/api/protocols?provider=custom"]); + expect(container.textContent ?? "").toContain(DICTS.en["pws.protocol.adapterLabel"]); + await unmount(); +}); + test("offers no control, so it cannot pass for an API exposure switch", async () => { serve(() => info({ provider: SUMMARY })); const { container, unmount } = await renderPanel({ apiBase: "http://hub", providerName: "custom", savedAdapter: "openai-chat" }); From 5ad42bfe1a8d8099f0dc9455080bf719c6fec8fb Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:15:31 +0900 Subject: [PATCH 143/173] feat(protocols): name OAuth pools, dropped betas and stripped opaque state The native Messages lane needs fixed codes for a pooled OAuth decline, a caller beta it refused to forward and opaque thinking state it removed. New codes: contract 2026-09-25.2. --- src/protocols/contract.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/protocols/contract.ts b/src/protocols/contract.ts index 375f72d5f55..16dc967735e 100644 --- a/src/protocols/contract.ts +++ b/src/protocols/contract.ts @@ -9,7 +9,7 @@ */ /** Bumped when a reason code, hop, mode, or feature disposition changes meaning. */ -export const PROTOCOL_CONTRACT_VERSION = "2026-09-25.1"; +export const PROTOCOL_CONTRACT_VERSION = "2026-09-25.2"; export const PROTOCOLS = ["responses", "chat", "messages"] as const; /** A public inference API a client can speak to this proxy. */ @@ -74,6 +74,12 @@ export const PROTOCOL_REASON_CODES = [ "rollout-disabled", /** Operator policy that only the bridge applies (pinned effort, skill elision, a sidecar). */ "bridge-only-policy", + /** A pooled Anthropic OAuth account set: the bridge owns rotation and affinity. */ + "oauth-account-pool", + /** Caller `anthropic-beta` values outside the native lane's allowlist were not forwarded. */ + "anthropic-beta-dropped", + /** Thinking signatures or `redacted_thinking` removed for a destination that cannot verify them. */ + "opaque-state-stripped", ] as const; export type ProtocolReasonCode = (typeof PROTOCOL_REASON_CODES)[number]; From 04321a65bdce1ab09077edbc7a6f3943b91e3799 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:15:52 +0900 Subject: [PATCH 144/173] feat(anthropic): allowlist caller betas for the native Messages lane A caller's anthropic-beta can change what the provider accepts and bills. Only listed values survive, re-spelled from the list, per first-party or compatible destination. --- src/adapters/anthropic/beta-allowlist.ts | 80 ++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/adapters/anthropic/beta-allowlist.ts diff --git a/src/adapters/anthropic/beta-allowlist.ts b/src/adapters/anthropic/beta-allowlist.ts new file mode 100644 index 00000000000..249976b4a35 --- /dev/null +++ b/src/adapters/anthropic/beta-allowlist.ts @@ -0,0 +1,80 @@ +/** + * The `anthropic-beta` values a managed native Messages request may carry from its caller (PF-10). + * + * A beta header changes what the provider accepts and bills, so the native lane never forwards + * the caller's header as written. Each value is compared, case-insensitively, against a fixed + * list for the destination's provider class; a match is re-emitted in this file's own spelling, + * so no caller byte ever reaches the wire, and everything else is dropped. The caller learns + * nothing about which value was dropped, and the trace records only that something was + * (`anthropic-beta-dropped`), never the value. + * + * Proxy-owned betas (the OAuth pair, the fast-mode beta) are not listed here: the lane sets them + * itself from the provider's own configuration, exactly as the adapter does. + * + * LEAF MODULE: no runtime import, so the builder, the lane and tests can read it freely. + */ + +/** Where the native lane sends: Anthropic's own API, or an Anthropic-compatible third party. */ +export type AnthropicProviderClass = "first-party" | "compatible"; + +/** + * Betas a caller may forward to `api.anthropic.com`. Deliberately short: only a value whose + * effect is limited to how the model uses the body it already sent, with no new body field, + * no billing tier and no retained server state, belongs here. + */ +const FIRST_PARTY_BETAS: readonly string[] = [ + // Lets thinking blocks appear between tool calls in one assistant turn. No request field; + // the thinking and tool blocks it affects are already on the lane's field allowlist, and the + // proxy's own OAuth quota probe sends it (`src/providers/quota/vendor-probes-oauth.ts`). + "interleaved-thinking-2025-05-14", +]; + +/** + * Betas a caller may forward to an Anthropic-compatible third party. Empty: a third party's + * beta semantics are its own, an unknown value can fail the request outright, and none is + * needed for the fields the lane forwards. + */ +const COMPATIBLE_BETAS: readonly string[] = []; + +const ALLOWLISTS: Readonly>> = { + "first-party": new Map(FIRST_PARTY_BETAS.map(beta => [beta.toLowerCase(), beta])), + compatible: new Map(COMPATIBLE_BETAS.map(beta => [beta.toLowerCase(), beta])), +}; + +/** A caller header longer than this is not parsed at all and counts as dropped. */ +const MAX_CALLER_BETA_HEADER_CHARS = 2048; + +export interface AllowlistedAnthropicBetas { + /** Allowlisted values in this file's spelling, first-seen order, no duplicates. */ + betas: string[]; + /** Whether any non-empty caller value was left out. Never says which. */ + dropped: boolean; +} + +/** + * Filter the caller's comma-separated `anthropic-beta` header for one provider class. An absent + * or blank header yields nothing and drops nothing. + */ +export function allowlistAnthropicBetas( + callerHeader: string | null | undefined, + providerClass: AnthropicProviderClass, +): AllowlistedAnthropicBetas { + if (typeof callerHeader !== "string" || callerHeader.trim() === "") return { betas: [], dropped: false }; + if (callerHeader.length > MAX_CALLER_BETA_HEADER_CHARS) return { betas: [], dropped: true }; + const allowed = ALLOWLISTS[providerClass]; + const betas: string[] = []; + let dropped = false; + for (const part of callerHeader.split(",")) { + const token = part.trim().toLowerCase(); + if (!token) continue; + const canonical = allowed.get(token); + if (canonical === undefined) dropped = true; + else if (!betas.includes(canonical)) betas.push(canonical); + } + return { betas, dropped }; +} + +/** The allowlist for one class, for documentation and tests. */ +export function anthropicBetaAllowlist(providerClass: AnthropicProviderClass): readonly string[] { + return [...ALLOWLISTS[providerClass].values()]; +} From bc8483983d8c2de2266e806f3e4b12782a65be1d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:16:36 +0900 Subject: [PATCH 145/173] feat(protocols): keep opaque thinking state inside Anthropic's own domain Signatures and redacted_thinking can only be verified where they were minted. A native send keeps them for api.anthropic.com and removes them, copy-on-write, everywhere else. --- src/protocols/opaque-state.ts | 132 ++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/protocols/opaque-state.ts diff --git a/src/protocols/opaque-state.ts b/src/protocols/opaque-state.ts new file mode 100644 index 00000000000..e14c40402a1 --- /dev/null +++ b/src/protocols/opaque-state.ts @@ -0,0 +1,132 @@ +/** + * Opaque reasoning state and credential domains for native Messages sends (PF-10). + * + * A thinking block's `signature` and a `redacted_thinking` block's `data` are opaque to this + * proxy: they were minted by some Anthropic deployment for some credential, and only that side + * can verify or decrypt them. The native Messages lane therefore forwards them only to Anthropic + * itself (`api.anthropic.com` over HTTPS). For any other destination they are removed from the + * body that is sent — the signature field is dropped and the thinking text kept, a + * `redacted_thinking` block is dropped whole — and the caller's source body is left untouched, + * so the next build for the next destination starts from the full state again. + * + * A credential domain is the pair a destination is trusted as: the provider's base host and the + * class of credential it is reached with. Two sends share a domain only when both match; a key + * rotation within one provider keeps it, a move to another host or another credential class does + * not. Every build decides from its own domain, never from an earlier build's output. + * + * LEAF MODULE: type-only imports, no side effects, never logs or returns what it removed. + */ +import type { AnthropicProviderClass } from "../adapters/anthropic/beta-allowlist"; +import type { OcxProviderConfig } from "../types"; + +type Rec = Record; +function isRec(value: unknown): value is Rec { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Anthropic's own API host. Nothing else is first-party, including look-alike subdomains. */ +export const FIRST_PARTY_ANTHROPIC_HOST = "api.anthropic.com"; + +export type CredentialAuthClass = "key" | "oauth" | "forward" | "local" | "unknown"; + +export interface CredentialDomain { + /** Lower-cased `host` (with a non-default port) of the provider's base URL. */ + readonly host: string; + readonly authClass: CredentialAuthClass; + /** HTTPS to `api.anthropic.com` on the default port, with no userinfo. */ + readonly firstPartyAnthropic: boolean; +} + +function authClassOf(authMode: OcxProviderConfig["authMode"]): CredentialAuthClass { + switch (authMode) { + case undefined: + case "key": + return "key"; + case "oauth": + case "forward": + case "local": + return authMode; + default: + return "unknown"; + } +} + +/** The credential domain a provider is reached in, or `undefined` for a malformed base URL. */ +export function credentialDomainFor( + provider: Pick, +): CredentialDomain | undefined { + let url: URL; + try { + url = new URL(provider.baseUrl); + } catch { + return undefined; + } + const firstPartyAnthropic = url.protocol === "https:" + && url.hostname.toLowerCase() === FIRST_PARTY_ANTHROPIC_HOST + && url.port === "" + && url.username === "" + && url.password === ""; + return { host: url.host.toLowerCase(), authClass: authClassOf(provider.authMode), firstPartyAnthropic }; +} + +/** Whether two sends share one credential domain. An unknown domain shares none. */ +export function sameCredentialDomain(a: CredentialDomain | undefined, b: CredentialDomain | undefined): boolean { + return a !== undefined && b !== undefined && a.host === b.host && a.authClass === b.authClass; +} + +/** The beta-allowlist class of a provider: first-party only for Anthropic's own API. */ +export function anthropicProviderClass(provider: Pick): AnthropicProviderClass { + return credentialDomainFor(provider)?.firstPartyAnthropic ? "first-party" : "compatible"; +} + +function isOpaqueBlock(block: unknown): boolean { + if (!isRec(block)) return false; + if (block.type === "redacted_thinking") return true; + return block.type === "thinking" && Object.hasOwn(block, "signature"); +} + +/** Whether a Messages body carries any thinking signature or `redacted_thinking` block. */ +export function messagesBodyHasOpaqueState(body: Readonly): boolean { + if (!Array.isArray(body.messages)) return false; + for (const message of body.messages) { + if (isRec(message) && Array.isArray(message.content) && message.content.some(isOpaqueBlock)) return true; + } + return false; +} + +export interface OpaqueStateResult { + /** The body to send. The input itself when nothing had to change. */ + body: Rec; + /** Whether opaque state was removed for this destination. */ + stripped: boolean; +} + +/** + * The body a destination may receive. First-party Anthropic keeps every signature and redacted + * block; any other or unknown destination gets a copy without them. Copy-on-write: only the + * messages that change are copied, and the input is never mutated. A message left with no + * content is dropped rather than sent empty. + */ +export function opaqueStateForDestination(body: Rec, domain: CredentialDomain | undefined): OpaqueStateResult { + if (domain?.firstPartyAnthropic || !messagesBodyHasOpaqueState(body)) return { body, stripped: false }; + const messages: unknown[] = []; + for (const message of body.messages as unknown[]) { + if (!isRec(message) || !Array.isArray(message.content) || !message.content.some(isOpaqueBlock)) { + messages.push(message); + continue; + } + const content: unknown[] = []; + for (const block of message.content) { + if (!isOpaqueBlock(block)) { + content.push(block); + continue; + } + const opaque = block as Rec; + if (opaque.type === "redacted_thinking") continue; + const { signature: _signature, ...visible } = opaque; + content.push(visible); + } + if (content.length > 0) messages.push({ ...message, content }); + } + return { body: { ...body, messages }, stripped: true }; +} From 17a7fbfa99c787e59d6e155d664a38ce39675490 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:16:51 +0900 Subject: [PATCH 146/173] refactor(anthropic): export the OAuth header placement the adapter applies The native Messages lane must authenticate an OAuth account exactly as the adapter does. Moving the block into applyAnthropicOAuthAuth changes no header and no order. --- src/adapters/anthropic.ts | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 95839988c75..286cef31253 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -546,6 +546,21 @@ export function applyAnthropicKeyAuth(headers: Record, provider: else headers["x-api-key"] = provider.apiKey; } +/** + * OAuth (Claude Pro/Max) credential placement: the bearer, the OAuth beta pair and the Claude + * Code client fingerprint. Shared by the adapter and the managed native lane. + */ +export function applyAnthropicOAuthAuth(headers: Record, accessToken: string): void { + headers["Authorization"] = `Bearer ${accessToken}`; + headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA; + // Match the real Claude Code CLI request fingerprint: a valid OAuth token with an empty + // header set is a non-first-party signature. (cch billing-header signing is intentionally + // out of scope — brittle and version-coupled.) + Object.assign(headers, CLAUDE_CODE_HEADERS); + headers["X-Claude-Code-Session-Id"] = claudeCodeSessionId(accessToken); + headers["x-client-request-id"] = crypto.randomUUID(); +} + /** The provider's Messages endpoint, refusing a base URL with an unresolved `{placeholder}`. */ export function resolveAnthropicMessagesUrl(provider: Pick): string { const url = anthropicMessagesUrl(provider.baseUrl); @@ -1169,18 +1184,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti const fastSpeed = anthropicFastSpeed(parsed, provider); if (fastSpeed) body.speed = fastSpeed.value; const headers = anthropicBaseRequestHeaders(parsed.stream); - if (isOAuth) { - headers["Authorization"] = `Bearer ${provider.apiKey}`; - headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA; - // Match the real Claude Code CLI request fingerprint: a valid OAuth token with an empty - // header set is a non-first-party signature. (cch billing-header signing is intentionally - // out of scope — brittle and version-coupled.) - Object.assign(headers, CLAUDE_CODE_HEADERS); - headers["X-Claude-Code-Session-Id"] = claudeCodeSessionId(provider.apiKey); - headers["x-client-request-id"] = crypto.randomUUID(); - } else { - applyAnthropicKeyAuth(headers, provider); - } + if (isOAuth) applyAnthropicOAuthAuth(headers, provider.apiKey); + else applyAnthropicKeyAuth(headers, provider); if (provider.headers) Object.assign(headers, provider.headers); mergeAnthropicBetaHeader(headers, fastSpeed?.betas ?? []); From a3cb8b8a3e78e8f061073953e51d8e9b809deb92 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:18:03 +0900 Subject: [PATCH 147/173] feat(anthropic): build native Messages for OAuth, allowlisted betas and opaque state The builder takes the caller's beta header explicitly, keeps signatures for first-party Anthropic only, and shapes an OAuth request as the adapter does, to api.anthropic.com only. --- src/adapters/anthropic/passthrough.ts | 166 +++++++++++++++++++++++--- 1 file changed, 150 insertions(+), 16 deletions(-) diff --git a/src/adapters/anthropic/passthrough.ts b/src/adapters/anthropic/passthrough.ts index 9c10de68001..7e226144d97 100644 --- a/src/adapters/anthropic/passthrough.ts +++ b/src/adapters/anthropic/passthrough.ts @@ -1,17 +1,33 @@ /** - * Managed native Messages request builder (PF-08). + * Managed native Messages request builder (PF-08, PF-10). * - * The request a proxy-managed Anthropic key sends when the client already spoke Messages: the - * caller's own body, cut to a fixed field allowlist, with the wire model and the provider's + * The request a proxy-managed Anthropic credential sends when the client already spoke Messages: + * the caller's own body, cut to a fixed field allowlist, with the wire model and the provider's * credential. URL, `anthropic-version`, client identity and credential placement come from the * same helpers the Anthropic adapter uses, so the two lanes cannot drift apart. * - * Authority: only the provider's configured key is ever placed on the request. No caller header - * is read here at all — the caller-forward passthrough in `src/server/claude-messages.ts` is the - * only place a caller's Anthropic credential may travel, and it does not come through here. + * Authority: only the provider's own credential is ever placed on the request — a configured + * key, or (PF-10) the access token of the OAuth account the lane resolved, which is sent only to + * `api.anthropic.com`. The one caller header this builder sees is `anthropic-beta`, handed over + * explicitly and reduced to `beta-allowlist.ts`; no other caller header is read here at all. The + * caller-forward passthrough in `src/server/claude-messages.ts` is the only place a caller's + * Anthropic credential may travel, and it does not come through here. + * + * Opaque state: thinking signatures and `redacted_thinking` blocks reach only first-party + * Anthropic (`src/protocols/opaque-state.ts`). The source body is never mutated, so every build + * for every destination decides from the full source again. */ +import { mergeAnthropicBetaHeader } from "../../providers/anthropic-fast"; +import { applyClaudeToolPrefix, CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../../oauth/anthropic"; +import { credentialDomainFor, opaqueStateForDestination } from "../../protocols/opaque-state"; import type { OcxConfig, OcxProviderConfig } from "../../types"; -import { anthropicBaseRequestHeaders, applyAnthropicKeyAuth, resolveAnthropicMessagesUrl } from "../anthropic"; +import { + anthropicBaseRequestHeaders, + applyAnthropicKeyAuth, + applyAnthropicOAuthAuth, + resolveAnthropicMessagesUrl, +} from "../anthropic"; +import { allowlistAnthropicBetas } from "./beta-allowlist"; /** * Top-level Messages fields the native lane forwards. Everything else is dropped: an unknown or @@ -45,6 +61,20 @@ export interface AnthropicMessagesPassthroughRequest { body: string; /** The same body before serialization, for callers that count or inspect what is sent. */ wireBody: Record; + /** Caller `anthropic-beta` values were left out. Which ones is never recorded. */ + droppedBetas: boolean; + /** Thinking signatures or `redacted_thinking` blocks were removed for this destination. */ + strippedOpaqueState: boolean; + /** + * OAuth only: wire tool name to the caller's name, for every tool the OAuth prefix renamed. + * The lane maps `tool_use` names in the answer back through it. + */ + oauthToolNames?: ReadonlyMap; +} + +export interface AnthropicMessagesPassthroughOptions { + /** The caller's `anthropic-beta` header, handed over by the ingress. */ + callerAnthropicBeta?: string | null; } /** The allowlisted copy of `body` with `model` set to the wire model. Shallow: nothing is cloned. */ @@ -60,28 +90,132 @@ export function anthropicMessagesPassthroughBody( return out; } +type Rec = Record; +function isRec(value: unknown): value is Rec { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** A tool the caller executes (no `type`, or `custom`), as opposed to a typed server tool. */ +function isClientTool(tool: unknown): tool is Rec & { name: string } { + return isRec(tool) && typeof tool.name === "string" && (tool.type === undefined || tool.type === "custom"); +} + +/** + * The Claude OAuth request shape the adapter produces, applied to a Messages body: the Claude + * Code identity as the first system block, and declared client tool names under the OAuth + * prefix — in `tools`, a named `tool_choice` and the history's `tool_use` blocks. Copy-on-write; + * the input is not mutated. Two caller names that meet under the prefix are refused rather than + * guessed at. + */ +export function anthropicOAuthWireBody(body: Rec): { body: Rec; toolNames: Map } { + const out: Rec = { ...body }; + const toolNames = new Map(); + const owners = new Map(); + const wireName = (name: string): string => { + const wire = applyClaudeToolPrefix(name); + const owner = owners.get(wire); + if (owner !== undefined && owner !== name) throw new Error("tool names collide under the Claude OAuth tool prefix"); + owners.set(wire, name); + if (wire !== name) toolNames.set(wire, name); + return wire; + }; + const identity = { type: "text", text: CLAUDE_CODE_SYSTEM_INSTRUCTION }; + if (typeof body.system === "string" && body.system.length > 0) { + out.system = [identity, { type: "text", text: body.system }]; + } else if (Array.isArray(body.system)) { + const first = body.system[0]; + const present = isRec(first) && first.type === "text" && first.text === CLAUDE_CODE_SYSTEM_INSTRUCTION; + out.system = present ? body.system : [identity, ...body.system]; + } else { + out.system = [identity]; + } + // Only names the caller declared as its own tools are renamed. A typed tool (a server tool, + // or a client-executed builtin such as `bash_*`) keeps the name its type fixes, and so do + // its calls in the history. + const declared = new Set(); + if (Array.isArray(body.tools)) { + out.tools = body.tools.map(tool => { + if (!isClientTool(tool)) return tool; + declared.add(tool.name); + return { ...tool, name: wireName(tool.name) }; + }); + } + const renames = (name: unknown): name is string => typeof name === "string" && declared.has(name); + if (isRec(body.tool_choice) && body.tool_choice.type === "tool" && renames(body.tool_choice.name)) { + out.tool_choice = { ...body.tool_choice, name: wireName(body.tool_choice.name) }; + } + const isRenamedUse = (block: unknown): block is Rec & { name: string } => isRec(block) && block.type === "tool_use" && renames(block.name); + if (Array.isArray(body.messages)) { + out.messages = body.messages.map(message => { + if (!isRec(message) || !Array.isArray(message.content) || !message.content.some(isRenamedUse)) return message; + return { + ...message, + content: message.content.map(block => isRenamedUse(block) ? { ...block, name: wireName(block.name) } : block), + }; + }); + } + return { body: out, toolNames }; +} + /** - * Build the upstream request. Throws the adapter's own errors for a missing key or a malformed - * or unresolved base URL. `config` is accepted for parity with the other passthrough builders; - * no config key changes the wire today. + * What the native lane sends for this provider, without a credential: the allowlisted body, + * opaque state kept only for first-party Anthropic, and the OAuth request shape for an OAuth + * provider. `count_tokens` counts this; the builder below sends it. + */ +export function anthropicMessagesNativeWireBody( + provider: Pick, + modelId: string, + body: Readonly>, +): { wireBody: Rec; strippedOpaqueState: boolean; oauthToolNames?: Map } { + const allowlisted = anthropicMessagesPassthroughBody(body, modelId); + const opaque = opaqueStateForDestination(allowlisted, credentialDomainFor(provider)); + if (provider.authMode !== "oauth") return { wireBody: opaque.body, strippedOpaqueState: opaque.stripped }; + const oauth = anthropicOAuthWireBody(opaque.body); + return { wireBody: oauth.body, strippedOpaqueState: opaque.stripped, oauthToolNames: oauth.toolNames }; +} + +/** + * Build the upstream request. Throws the adapter's own errors for a missing credential or a + * malformed or unresolved base URL, and refuses an OAuth credential for any destination other + * than first-party Anthropic. `config` is accepted for parity with the other passthrough + * builders; no config key changes the wire today. */ export function buildAnthropicMessagesPassthroughRequest( provider: OcxProviderConfig, modelId: string, body: Readonly>, _config?: OcxConfig, + options: AnthropicMessagesPassthroughOptions = {}, ): AnthropicMessagesPassthroughRequest { - if (provider.authMode !== undefined && provider.authMode !== "key") { - throw new Error("managed native Messages requires a key-auth anthropic provider"); + const oauth = provider.authMode === "oauth"; + if (provider.authMode !== undefined && provider.authMode !== "key" && !oauth) { + throw new Error("managed native Messages requires a key-auth or OAuth anthropic provider"); + } + const domain = credentialDomainFor(provider); + if (oauth && !domain?.firstPartyAnthropic) { + throw new Error("managed native Messages sends Anthropic OAuth credentials only to api.anthropic.com"); } if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") { - throw new Error("anthropic provider requires a non-empty apiKey (authMode: key)"); + throw new Error(oauth + ? "anthropic oauth token missing — run ocx login anthropic" + : "anthropic provider requires a non-empty apiKey (authMode: key)"); } const url = resolveAnthropicMessagesUrl(provider); - const wireBody = anthropicMessagesPassthroughBody(body, modelId); + const { wireBody, strippedOpaqueState, oauthToolNames } = anthropicMessagesNativeWireBody(provider, modelId, body); const headers = anthropicBaseRequestHeaders(wireBody.stream === true); - applyAnthropicKeyAuth(headers, provider); + if (oauth) applyAnthropicOAuthAuth(headers, provider.apiKey); + else applyAnthropicKeyAuth(headers, provider); // Operator-configured provider headers apply exactly as the adapter applies them. if (provider.headers) Object.assign(headers, provider.headers); - return { url, headers, body: JSON.stringify(wireBody), wireBody }; + const betas = allowlistAnthropicBetas(options.callerAnthropicBeta, domain?.firstPartyAnthropic ? "first-party" : "compatible"); + mergeAnthropicBetaHeader(headers, betas.betas); + return { + url, + headers, + body: JSON.stringify(wireBody), + wireBody, + droppedBetas: betas.dropped, + strippedOpaqueState, + ...(oauthToolNames ? { oauthToolNames } : {}), + }; } From e3bda7ac5770c7bb647ae1857c5e1657938d4966 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:18:27 +0900 Subject: [PATCH 148/173] feat(messages): admit unpooled Anthropic OAuth routes to the native lane Behind managedMessagesNativeOAuth, the anthropic OAuth provider to api.anthropic.com is eligible. A pooled account set declines: its rotation and affinity stay in the bridge. --- src/server/messages-native-eligibility.ts | 39 +++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/server/messages-native-eligibility.ts b/src/server/messages-native-eligibility.ts index 91ba350f203..7fead1d9ccf 100644 --- a/src/server/messages-native-eligibility.ts +++ b/src/server/messages-native-eligibility.ts @@ -7,8 +7,10 @@ */ import { anthropicBodyElidesBlockedSkill } from "../claude/inbound"; import { isClaudeWebSearchToolName } from "../claude/outbound"; +import { isAnthropicAccountPoolEnabled } from "../oauth/anthropic-routing"; import type { ProtocolReasonCode } from "../protocols/contract"; import { featuresFromMessagesBody } from "../protocols/features"; +import { credentialDomainFor } from "../protocols/opaque-state"; import { resolveProtocolSettings } from "../protocols/settings"; import type { RouteResult } from "../router"; import type { OcxConfig } from "../types"; @@ -21,6 +23,7 @@ export type NativeMessagesDeclineReason = Extract< | "rollout-disabled" | "cross-wire-ir" | "auth-mode-not-native" + | "oauth-account-pool" | "combo-or-policy-route" | "effort-row" | "fast-row" @@ -38,6 +41,13 @@ export interface NativeMessagesSelector { routeSelector?: string; /** The Claude settings this ingress reads (intercept bindings applied); defaults to config's. */ claudeCode?: OcxConfig["claudeCode"]; + /** + * Runtime fact, supplied by a caller that is about to send: two or more usable Anthropic OAuth + * accounts are stored, so the bridge would rotate accounts on a 429 + * (`hasAnthropicFailoverQuorum`). It reads the account store, so the planner never supplies it + * and judges OAuth from config alone. + */ + oauthFailoverQuorum?: boolean; } type Rec = Record; @@ -82,13 +92,37 @@ function bridgeOnlyPolicyApplies( return webSearchSidecarMayEngage(body, config); } +/** + * The credential rule. A proxy-managed key is native since PF-08. An Anthropic OAuth account + * is native only with `managedMessagesNativeOAuth` on (itself effective only with + * `managedMessagesNative`), only for the `anthropic` provider the OAuth store serves, and only + * to `api.anthropic.com`. A pooled account set declines: rotation, session affinity and quota + * ranking live in the Responses pipeline's transport and are not replicated here. `forward` + * belongs to the caller. + */ +function credentialDeclineReason( + route: RouteResult, + config: OcxConfig, + selector: NativeMessagesSelector, +): NativeMessagesDeclineReason | undefined { + const provider = route.provider; + if (provider.authMode === undefined || provider.authMode === "key") return undefined; + if (provider.authMode !== "oauth") return "auth-mode-not-native"; + if (!resolveProtocolSettings(config).rollout.managedMessagesNativeOAuth) return "auth-mode-not-native"; + if (route.providerName !== "anthropic") return "auth-mode-not-native"; + if (!credentialDomainFor(provider)?.firstPartyAnthropic) return "auth-mode-not-native"; + if (isAnthropicAccountPoolEnabled(config) || selector.oauthFailoverQuorum === true) return "oauth-account-pool"; + return undefined; +} + /** * The first rule that keeps a Messages request off the managed native lane, or `undefined` when * the route is eligible. * * - the `protocols.rollout.managedMessagesNative` switch is off; * - the final adapter is not `anthropic`; - * - the credential is not a proxy-managed key (OAuth is PF-10; `forward` belongs to the caller); + * - the credential is neither a proxy-managed key nor an eligible Anthropic OAuth account + * (`credentialDeclineReason`); a pooled OAuth account set is `oauth-account-pool`; * - a combo or policy route owns multi-candidate execution in the Responses pipeline; * - a synthetic effort or fast row needs the adapter that owns its wire rewrite; * - an image would reach a model the operator declared unable to read it; @@ -104,7 +138,8 @@ export function nativeMessagesDeclineReason( if (!resolveProtocolSettings(config).rollout.managedMessagesNative) return "rollout-disabled"; const provider = route.provider; if (provider.adapter !== "anthropic") return "cross-wire-ir"; - if (provider.authMode !== undefined && provider.authMode !== "key") return "auth-mode-not-native"; + const credentialDecline = credentialDeclineReason(route, config, selector); + if (credentialDecline) return credentialDecline; if (route.combo || route.routeKind === "combo" || route.routeKind === "policy") return "combo-or-policy-route"; if (selector.effortRow) return "effort-row"; if (selector.fastRow) return "fast-row"; From 49fefb6cd9c8176103293a3bad99ddb12712e984 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:19:16 +0900 Subject: [PATCH 149/173] feat(messages): resolve the Anthropic OAuth account for a native send Mirrors the unpooled transport steps: capture, resolve, commit against the capture, and re-check before each send. A pooled set fails closed; renamed tool names map back. --- src/server/messages-native-oauth.ts | 149 ++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/server/messages-native-oauth.ts diff --git a/src/server/messages-native-oauth.ts b/src/server/messages-native-oauth.ts new file mode 100644 index 00000000000..0369ce55a7b --- /dev/null +++ b/src/server/messages-native-oauth.ts @@ -0,0 +1,149 @@ +/** + * Anthropic OAuth on the managed native Messages lane (PF-10, behind + * `protocols.rollout.managedMessagesNativeOAuth`). + * + * Credential. The account is the one the existing OAuth selection chooses for this request, by + * the same steps the Responses pipeline's transport takes for an unpooled Anthropic OAuth route + * (`prepareResponsesTransport`): capture the committed selection, resolve the active account's + * access snapshot (refreshing it through the OAuth owner when it is due), and commit that + * proposal against the captured selection so a concurrent manual switch wins. Nothing here picks + * an account of its own, and nothing runs at planning time: the planner and eligibility read + * config only, and this module is reached from the lane's dispatch alone. + * + * Pools. A pooled account set (the opt-in Anthropic account pool, or two or more usable accounts, + * which turns on reactive 429 rotation) is declined before this lane is chosen + * (`oauth-account-pool`). Should one appear between that decision and dispatch, resolution fails + * closed rather than serve a pooled account without the pool's rotation and affinity. + * + * Tool names. An OAuth request carries client tool names under the Claude OAuth prefix, as the + * adapter sends them; the answer's `tool_use` names are mapped back here, for exactly the names + * the builder renamed. + * + * No token, account id or body content is logged or returned in an error message. + */ +import { getValidAccessTokenSnapshot, type OAuthAccessSnapshot } from "../oauth"; +import { + commitAnthropicSelectionRouting, + getAnthropicPoolAccessSnapshot, + hasAnthropicFailoverQuorum, + isAnthropicAccountPoolEnabled, +} from "../oauth/anthropic-routing"; +import { + captureOAuthAccountSelection, + commitOAuthAccountSelection, + credentialGeneration, + getAccountCredentialWithStatus, +} from "../oauth/store"; +import type { TranslatorBudget } from "../lib/translator-budget"; +import type { OcxConfig } from "../types"; +import { relaySseWithPayloadRewrite } from "./sse-payload-rewrite"; + +const PROVIDER = "anthropic"; +const MAX_SELECTION_ATTEMPTS = 3; + +type Selection = NonNullable>; +type Rec = Record; + +function isRec(value: unknown): value is Rec { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** The committed selection and the credential snapshot one native request is served with. */ +export interface NativeOAuthBinding { + readonly selection: Selection; + readonly snapshot: OAuthAccessSnapshot; +} + +/** The selection moved or became pooled while it was being resolved. Maps to a 409 retry. */ +export class NativeOAuthSelectionChangedError extends Error { + constructor() { + super("OAuth account selection changed; retry the request"); + this.name = "NativeOAuthSelectionChangedError"; + } +} + +function pooled(config: OcxConfig): boolean { + return isAnthropicAccountPoolEnabled(config) || hasAnthropicFailoverQuorum(); +} + +/** + * Resolve and commit the account for one native request. Throws the OAuth owner's own errors + * (login required, refresh failure) unchanged, and `NativeOAuthSelectionChangedError` when the + * selection could not be committed. + */ +export async function resolveNativeOAuthBinding(config: OcxConfig): Promise { + if (pooled(config)) throw new NativeOAuthSelectionChangedError(); + let selection: Selection | null = captureOAuthAccountSelection(PROVIDER); + let candidate = await getValidAccessTokenSnapshot(PROVIDER); + for (let attempt = 0; attempt < MAX_SELECTION_ATTEMPTS; attempt++) { + if (!selection) break; + if (candidate.accountId !== selection.accountId) { + // The active account moved between capture and resolution: serve the committed one. + selection = captureOAuthAccountSelection(PROVIDER); + if (!selection) break; + candidate = await getAnthropicPoolAccessSnapshot(selection.accountId); + } + const committed = await commitOAuthAccountSelection(PROVIDER, candidate.accountId, { + expectedSelection: selection, + expectedCredentialGeneration: candidate.generation, + requireUsableAccount: true, + }); + if (committed) { + if (!commitAnthropicSelectionRouting(candidate.accountId, selection, committed, { + config, + sessionKey: null, + expectedCredentialGeneration: candidate.generation, + })) break; + if (pooled(config)) break; + return { selection: committed, snapshot: candidate }; + } + // A newer manual choice wins over this request's proposal. + selection = captureOAuthAccountSelection(PROVIDER); + if (!selection) break; + candidate = await getAnthropicPoolAccessSnapshot(selection.accountId); + } + throw new NativeOAuthSelectionChangedError(); +} + +/** + * Whether a binding may still be sent: the same committed selection, and the same usable, + * unexpired credential generation. Checked immediately before every physical send. + */ +export function nativeOAuthBindingIsCurrent(binding: NativeOAuthBinding): boolean { + const selected = captureOAuthAccountSelection(PROVIDER); + const row = getAccountCredentialWithStatus(PROVIDER, binding.snapshot.accountId); + return selected?.accountId === binding.selection.accountId + && selected?.revision === binding.selection.revision + && !!row && !row.needsReauth && row.credential.expires > Date.now() + && credentialGeneration(row.credential) === binding.snapshot.generation; +} + +/** Map a `tool_use` block's wire name back to the caller's name; other blocks are untouched. */ +function restoredBlock(block: unknown, names: ReadonlyMap): unknown { + if (!isRec(block) || block.type !== "tool_use" || typeof block.name !== "string") return block; + const original = names.get(block.name); + return original === undefined ? block : { ...block, name: original }; +} + +/** A Messages result with renamed `tool_use` names mapped back. Returns the input when unchanged. */ +export function restoreOAuthToolNamesInMessage(message: Rec, names: ReadonlyMap): Rec { + if (names.size === 0 || !Array.isArray(message.content)) return message; + return { ...message, content: message.content.map(block => restoredBlock(block, names)) }; +} + +/** The upstream Messages stream with renamed `tool_use` names mapped back in `content_block_start`. */ +export function restoreOAuthToolNamesInSse( + body: ReadableStream, + names: ReadonlyMap, + translatorBudget: TranslatorBudget, +): ReadableStream { + if (names.size === 0) return body; + return relaySseWithPayloadRewrite(body, (payload) => { + if (!payload.includes("content_block_start")) return payload; + let parsed: unknown; + try { parsed = JSON.parse(payload); } catch { return payload; } + if (!isRec(parsed) || parsed.type !== "content_block_start") return payload; + const restored = restoredBlock(parsed.content_block, names); + return restored === parsed.content_block ? payload : JSON.stringify({ ...parsed, content_block: restored }); + }, translatorBudget); +} From 2457ee98365a598b980226ea09f53c83529867ba Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:20:47 +0900 Subject: [PATCH 150/173] feat(messages): send native Messages with OAuth, allowlisted betas and opaque guard The lane resolves an OAuth account at dispatch only, re-checks it before each send, maps renamed tools back, and records dropped betas and stripped opaque state by fixed code. --- src/server/messages-native.ts | 122 +++++++++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 17 deletions(-) diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index 51a1ce3550b..5a88dc50aa3 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -3,12 +3,15 @@ * * A Messages request whose settled route is a proxy-managed key on the `anthropic` adapter is * sent as Messages: the source body (after the ingress's managed-client steps) cut to a field - * allowlist, the wire model, and the provider's own key. Modelled on the native Chat lane and + * allowlist, the wire model, and the provider's own key. With `managedMessagesNativeOAuth` on + * (PF-10), an unpooled Anthropic OAuth account is sent the same way with the access token the + * existing OAuth selection resolves at dispatch (`messages-native-oauth.ts`). Modelled on the native Chat lane and * built on the same shared pieces — attempt row, finish-once final log, spend tracker, proactive * key selection, key failover and 429 replay, connection policy — so nothing the Responses * pipeline enforces is bypassed. * - * Authority. This lane never reads a caller header. The caller-forward passthrough in + * Authority. This lane reads no caller header. The ingress hands over one value, the caller's + * `anthropic-beta`, which the builder reduces to an allowlist. The caller-forward passthrough in * `claude-messages.ts` (the caller's own Anthropic credential) is a different branch decided * before this one, and nothing here can reach it or be reached from it. * @@ -18,6 +21,7 @@ import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; import { formatAnthropicErrorBody } from "../adapters/anthropic"; import { + anthropicMessagesNativeWireBody, buildAnthropicMessagesPassthroughRequest, type AnthropicMessagesPassthroughRequest, } from "../adapters/anthropic/passthrough"; @@ -54,7 +58,11 @@ import { selectProactiveApiKeyTransport, transientRetryPolicyFor, } from "../providers/key-failover"; -import { stampApiKeyAccountLabel } from "../providers/label"; +import { stampApiKeyAccountLabel, stampOAuthAccountLabel } from "../providers/label"; +import { publicOAuthAuthenticationErrorMessage } from "../oauth"; +import { hasAnthropicFailoverQuorum } from "../oauth/anthropic-routing"; +import { resolveProtocolSettings } from "../protocols/settings"; +import { addProtocolEntryReason, markProtocolBlocked } from "../protocols/trace"; import type { OcxProviderTransport } from "../providers/xai-transport"; import { preservesPhysicalComboProvider, resolveComboId } from "../combos"; import { captureRouteStaticPolicy, routeModel, type RouteResult } from "../router"; @@ -72,6 +80,14 @@ import { beginInferenceAttempt } from "./inference/attempt"; import { createFinalRequestLog, type FinalRequestLogMeta } from "./inference/final-log"; import { registerTurn, unregisterTurn } from "./lifecycle"; import { nativeMessagesDeclineReason, type NativeMessagesSelector } from "./messages-native-eligibility"; +import { + nativeOAuthBindingIsCurrent, + NativeOAuthSelectionChangedError, + resolveNativeOAuthBinding, + restoreOAuthToolNamesInMessage, + restoreOAuthToolNamesInSse, + type NativeOAuthBinding, +} from "./messages-native-oauth"; import { noteProviderAttemptSend, recordAttemptCredentialSource, @@ -99,6 +115,13 @@ const MAX_NATIVE_MESSAGES_ERROR_BYTES = 64 * 1024; class NativeMessagesSpendRefusal extends Error {} +/** A rebuild for a destination that cannot carry the body's opaque state, under `reject`. */ +class NativeOpaqueStateRefusal extends Error { + constructor() { + super("The selected route cannot carry thinking signatures or redacted_thinking blocks"); + } +} + function isRec(value: unknown): value is Rec { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -120,6 +143,11 @@ export interface HandleNativeMessagesOptions { translatorBudget: TranslatorBudget; /** The facts the ingress judged eligibility with; re-applied if key selection changes. */ selector?: NativeMessagesSelector; + /** + * The caller's `anthropic-beta` header, handed over by the ingress. The builder keeps only + * allowlisted values; no other caller header reaches this lane. + */ + callerAnthropicBeta?: string | null; } type FinishLog = (status: number, message?: string, closeReason?: FinalRequestLogMeta["closeReason"]) => void; @@ -327,11 +355,28 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) unregisterTurn(upstream); }; const connectMs = config.connectTimeoutMs ?? 200_000; - // Same pre-dispatch key preference every direct send path applies (see chat-native.ts). - const proactiveKeyProvider = selectProactiveApiKeyTransport(config, route.providerName, route.provider); - if (proactiveKeyProvider) route.provider = proactiveKeyProvider; - let activeProvider: OcxProviderConfig = route.provider; + // An OAuth route is served by the account the existing OAuth selection commits now, at + // dispatch; the token lives on this lane's provider copy only, never on the shared route. + let oauthBinding: NativeOAuthBinding | undefined; + const oauthProvider = (binding: NativeOAuthBinding): OcxProviderConfig => ({ ...route.provider, apiKey: binding.snapshot.accessToken }); + if (route.provider.authMode === "oauth") { + try { + oauthBinding = await resolveNativeOAuthBinding(config); + } catch (error) { + cleanupAbort(); + upstream.abort(); + if (req.signal.aborted) return fail(499, "Client cancelled request", "api_error"); + if (error instanceof NativeOAuthSelectionChangedError) return fail(409, error.message, "api_error"); + return fail(401, publicOAuthAuthenticationErrorMessage(error), "authentication_error"); + } + } else { + // Same pre-dispatch key preference every direct send path applies (see chat-native.ts). + const proactiveKeyProvider = selectProactiveApiKeyTransport(config, route.providerName, route.provider); + if (proactiveKeyProvider) route.provider = proactiveKeyProvider; + } + let activeProvider: OcxProviderConfig = oauthBinding ? oauthProvider(oauthBinding) : route.provider; stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider); + if (oauthBinding) stampOAuthAccountLabel(logCtx, route.providerName, activeProvider, oauthBinding.snapshot.accountId); const spendTracker = attachRequestSpendTracker(req, logCtx); let activeRequest: AnthropicMessagesPassthroughRequest; let retainedRequestBytes = 0; @@ -345,11 +390,19 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) translatorBudget.chargeRetained(bytes, { kind: "request_copies" }); retainedRequestBytes = bytes; }; - // Every rebuild reads the same `body`; the builder copies, so no credential or header from an - // earlier key survives into the next request. + // Every rebuild reads the same `body`; the builder copies and never mutates it, so no + // credential, header or stripped block from an earlier build reaches the next one, and a build + // for another credential domain decides opaque state from the full source again. + const rejectUnrepresentable = resolveProtocolSettings(config).unrepresentable === "reject"; const buildActiveRequest = () => { recordAttemptCredentialSource(attempt, route.providerName, activeProvider, "anthropic"); - return buildAnthropicMessagesPassthroughRequest(activeProvider, route.modelId, body, config); + const built = buildAnthropicMessagesPassthroughRequest(activeProvider, route.modelId, body, config, { + callerAnthropicBeta: options.callerAnthropicBeta, + }); + if (built.strippedOpaqueState && rejectUnrepresentable) throw new NativeOpaqueStateRefusal(); + if (built.droppedBetas) addProtocolEntryReason(logCtx, "anthropic-beta-dropped"); + if (built.strippedOpaqueState) addProtocolEntryReason(logCtx, "opaque-state-stripped"); + return built; }; const rebuildFor = (provider: OcxProviderConfig) => { activeProvider = provider; @@ -368,6 +421,12 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) if (isTranslatorBudgetExceededError(error)) { return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); } + if (error instanceof NativeOpaqueStateRefusal) { + // Nothing was sent: this is a refusal before any upstream send. + markProtocolBlocked(logCtx, { inbound: "messages", reasonCodes: ["feature-unrepresentable", "opaque-state-stripped"] }); + logCtx.errorCode = "unsupported_feature"; + return fail(400, error.message, "invalid_request_error", "unsupported_feature"); + } return fail(400, error instanceof Error ? error.message : String(error), "invalid_request_error"); } @@ -398,7 +457,19 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) providerName: route.providerName, modelId: route.modelId, dispatchOverride: async (_input, init, execute) => { - if (!providerApiKeySelectionIsCurrent(config, route.providerName, activeProvider)) { + if (oauthBinding) { + // The OAuth twin of the key check below: re-resolve through the same selection + // owner when the committed account or its credential moved since the build. + if (!nativeOAuthBindingIsCurrent(oauthBinding)) { + try { + oauthBinding = await resolveNativeOAuthBinding(config); + } catch { + throw new NativeOAuthSelectionChangedError(); + } + rebuildFor(oauthProvider(oauthBinding)); + stampOAuthAccountLabel(logCtx, route.providerName, activeProvider, oauthBinding.snapshot.accountId); + } + } else if (!providerApiKeySelectionIsCurrent(config, route.providerName, activeProvider)) { const current = resolveCurrentProviderApiKeyTransport(config, route.providerName, activeProvider); if (!current || nativeMessagesDeclineReason({ ...route, provider: current }, body, config, selector) !== undefined) { throw new Error("Provider key selection is no longer available for native Messages"); @@ -498,6 +569,11 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) finishLog(429); return refusal; } + if (sendError instanceof NativeOAuthSelectionChangedError) return fail(409, sendError.message, "api_error"); + if (sendError instanceof NativeOpaqueStateRefusal) { + logCtx.errorCode = "unsupported_feature"; + return fail(400, sendError.message, "invalid_request_error", "unsupported_feature"); + } if (isTranslatorBudgetExceededError(error)) { return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); } @@ -523,7 +599,10 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) if (contentType.includes("text/event-stream") && response.body) { const bodyGuard = resolvePassthroughBodyGuard(config, req.signal); const observed = logIds ? observeFirstChunk(response.body, () => recordFirstOutput(logCtx, logIds.start)) : response.body; - const source = echoRequestedModel(observed, requestedModel); + const renamed = activeRequest.oauthToolNames + ? restoreOAuthToolNamesInSse(observed, activeRequest.oauthToolNames, translatorBudget) + : observed; + const source = echoRequestedModel(renamed, requestedModel); if (requestedStream) { transferTurnToStream(); const relayed = tapAnthropicSseForLog(source, logCtx, (status, meta) => { @@ -589,15 +668,18 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) upstream.abort(); return fail(502, "upstream response exceeded the safe limit", "api_error", "translation_buffer_limit"); } - let message: unknown; + let parsedMessage: unknown; try { - message = JSON.parse(read.text); + parsedMessage = JSON.parse(read.text); } catch { return fail(502, "upstream returned malformed Messages JSON", "api_error"); } - if (!isRec(message) || message.type !== "message") { + if (!isRec(parsedMessage) || parsedMessage.type !== "message") { return fail(502, "upstream response was not a Messages result", "api_error"); } + const message = activeRequest.oauthToolNames + ? restoreOAuthToolNamesInMessage(parsedMessage, activeRequest.oauthToolNames) + : parsedMessage; bindUsage(anthropicUsageToOcx(isRec(message.usage) ? message.usage : undefined)); if (logIds) recordFirstOutput(logCtx, logIds.start); try { @@ -693,9 +775,15 @@ export function nativeMessagesCountBody( route.providerName, route.modelId, route.provider, route.staticPolicy.effectiveAlias, "anthropic", ); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy); - const selector: NativeMessagesSelector = { ...rows, routeSelector: selectorId, claudeCode: cc }; + const selector: NativeMessagesSelector = { + ...rows, + routeSelector: selectorId, + claudeCode: cc, + ...(route.provider.authMode === "oauth" ? { oauthFailoverQuorum: hasAnthropicFailoverQuorum() } : {}), + }; if (nativeMessagesDeclineReason(route, body, config, selector) !== undefined) return undefined; - return buildAnthropicMessagesPassthroughRequest(route.provider, route.modelId, body, config).wireBody; + // The body without a credential: counting never resolves or refreshes an OAuth account. + return anthropicMessagesNativeWireBody(route.provider, route.modelId, body).wireBody; } catch { return undefined; } From 78361a1d9da06d16c0e75efd8d8f8e927bed1549 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:21:10 +0900 Subject: [PATCH 151/173] feat(messages): hand the native lane its beta header and guard opaque state at ingress The ingress supplies the OAuth quorum fact, passes only anthropic-beta to the lane, and under reject refuses a non-Anthropic native route that would lose thinking signatures. --- src/server/claude-messages.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index c9b58a77fd2..94ee4429c64 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -67,6 +67,8 @@ import { handleResponses } from "./responses"; import { upstreamWireForAdapter } from "../protocols/contract"; import { createProtocolEnvelope, type ProtocolEnvelope } from "../protocols/envelope"; import { featuresFromMessagesBody, type ProtocolFeature } from "../protocols/features"; +import { credentialDomainFor, messagesBodyHasOpaqueState } from "../protocols/opaque-state"; +import { hasAnthropicFailoverQuorum } from "../oauth/anthropic-routing"; import { checkRepresentable, unrepresentableMessage } from "../protocols/guard"; import { requestPathForLane } from "../protocols/path"; import { resolveApiSurfaceSettings, resolveProtocolSettings } from "../protocols/settings"; @@ -990,6 +992,8 @@ async function handleClaudeMessagesWithBudget( // passthrough above was decided on the caller's own credential and never reaches this point. const nativeSelector: NativeMessagesSelector = { effortRow: !!effortRow, fastRow: !!fastRow, routeSelector: String(internalBody.model ?? ""), claudeCode: cc, + // PF-10: a stored second OAuth account turns on the bridge's rotation, so it stays there. + ...(settledRoute?.provider.authMode === "oauth" ? { oauthFailoverQuorum: hasAnthropicFailoverQuorum() } : {}), }; const nativeDecline = settledRoute && isRec(anthropicBody) ? nativeMessagesDeclineReason(settledRoute, anthropicBody, config, nativeSelector) @@ -1019,6 +1023,15 @@ async function handleClaudeMessagesWithBudget( return anthropicErrorResponse(400, unrepresentableMessage(verdict.features), "invalid_request_error"); } } + // PF-10: opaque thinking state reaches only first-party Anthropic; under `reject` a native + // route to anyone else refuses the request instead of sending it without that state. + if (envelope && nativeMessagesRoute && !credentialDomainFor(nativeMessagesRoute.provider)?.firstPartyAnthropic + && messagesBodyHasOpaqueState(anthropicBody as Rec)) { + markProtocolBlocked(logCtx, { inbound: "messages", reasonCodes: ["feature-unrepresentable", "opaque-state-stripped"], features: messagesFeatures }); + logCtx.errorCode = "unsupported_feature"; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" }); + return anthropicErrorResponse(400, "The selected route cannot carry thinking signatures or redacted_thinking blocks", "invalid_request_error"); + } if (nativeMessagesRoute) { markProtocolEntry(logCtx, { inbound: "messages", lane: "native", features: messagesFeatures }); let nativeBody: Rec; @@ -1034,6 +1047,8 @@ async function handleClaudeMessagesWithBudget( return await handleNativeMessages({ req, config, logCtx, ...(logIds ? { logIds } : {}), route: nativeMessagesRoute, body: nativeBody, requestedModel, translatorBudget, selector: nativeSelector, + // The one caller header the native lane is given; the builder allowlists it. + callerAnthropicBeta: req.headers.get("anthropic-beta"), }); } From f6a2c755b0b57dee61fa0233a69f6214e64992e3 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:21:17 +0900 Subject: [PATCH 152/173] docs(protocols): say how the plan preview judges Anthropic OAuth The preview reads config only; the stored-account quorum is a dispatch-time fact, so a preview never touches the OAuth store. --- src/protocols/plan-snapshot.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/protocols/plan-snapshot.ts b/src/protocols/plan-snapshot.ts index aaadb435e88..37294b9bb38 100644 --- a/src/protocols/plan-snapshot.ts +++ b/src/protocols/plan-snapshot.ts @@ -143,6 +143,9 @@ function candidateFor( // is exactly what it was before the managed native lane existed. // Pinned effort is judged from config and the route; blocked-skill elision and the web-search // sidecar depend on body content no feature describes, so a preview cannot predict them. + // Anthropic OAuth (PF-10) is judged from config alone: the rollout switch, the provider, its + // host and `anthropicAccountPool.enabled`. The stored-account quorum a sender supplies is + // never read here, so a preview selects, resolves and refreshes no account. const reason = nativeMessagesDeclineReason(settled, messagesBodyForFeatures(features), config, { ...rows, claudeCode: config.claudeCode, From 9013fdbd21c535c01545f6d63ba084f6c63ea672 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:25:47 +0900 Subject: [PATCH 153/173] test(anthropic): pin the native beta allowlist and the OAuth request shape Unknown betas never reach the wire, a compatible host gets none, and an OAuth build matches the adapter's headers, prefixes client tools only, and refuses any host but Anthropic's. --- scripts/test-layout/layout.json | 2 + .../anthropic-beta-allowlist.test.ts | 103 ++++++++++++++ ...thropic-messages-passthrough-oauth.test.ts | 127 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + 4 files changed, 234 insertions(+) create mode 100644 tests/adapters/anthropic/anthropic-beta-allowlist.test.ts create mode 100644 tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d46e43309a4..7dd0c91abff 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -243,6 +243,8 @@ "anthropic-thinking-signature.test.ts": "adapters/anthropic", "anthropic-tool-call-id.test.ts": "adapters/anthropic", "anthropic-messages-passthrough.test.ts": "adapters/anthropic", + "anthropic-beta-allowlist.test.ts": "adapters/anthropic", + "anthropic-messages-passthrough-oauth.test.ts": "adapters/anthropic", "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "anthropic-tool-schema.test.ts": "adapters/anthropic", "antigravity-baseurl-override.test.ts": "adapters/google", diff --git a/tests/adapters/anthropic/anthropic-beta-allowlist.test.ts b/tests/adapters/anthropic/anthropic-beta-allowlist.test.ts new file mode 100644 index 00000000000..fb32d988a30 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-beta-allowlist.test.ts @@ -0,0 +1,103 @@ +/** + * The caller `anthropic-beta` allowlist of the managed native Messages lane (PF-10, + * src/adapters/anthropic/beta-allowlist.ts) and how the builder applies it: per provider class, + * unknown values dropped, allowlisted values re-spelled from the list, proxy-owned betas kept. + */ +import { describe, expect, test } from "bun:test"; +import { ANTHROPIC_OAUTH_BETA } from "../../../src/oauth/anthropic"; +import { + allowlistAnthropicBetas, + anthropicBetaAllowlist, +} from "../../../src/adapters/anthropic/beta-allowlist"; +import { buildAnthropicMessagesPassthroughRequest } from "../../../src/adapters/anthropic/passthrough"; +import type { OcxProviderConfig } from "../../../src/types"; + +const ALLOWED = "interleaved-thinking-2025-05-14"; +const UNKNOWN = "fixture-unknown-beta-2099-01-01"; + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "key", + apiKey: "fixture-managed-key", + ...overrides, + } as OcxProviderConfig; +} + +const BODY = { model: "selector", max_tokens: 16, messages: [{ role: "user", content: "fixture" }] }; + +describe("allowlistAnthropicBetas", () => { + test("first-party keeps listed values and drops the rest", () => { + expect(anthropicBetaAllowlist("first-party")).toContain(ALLOWED); + expect(allowlistAnthropicBetas(`${ALLOWED}, ${UNKNOWN}`, "first-party")).toEqual({ betas: [ALLOWED], dropped: true }); + expect(allowlistAnthropicBetas(ALLOWED, "first-party")).toEqual({ betas: [ALLOWED], dropped: false }); + }); + + test("an Anthropic-compatible third party forwards nothing", () => { + expect(anthropicBetaAllowlist("compatible")).toEqual([]); + expect(allowlistAnthropicBetas(ALLOWED, "compatible")).toEqual({ betas: [], dropped: true }); + }); + + test("matching is case-insensitive and the output is the list's own spelling, deduplicated", () => { + const result = allowlistAnthropicBetas(` ${ALLOWED.toUpperCase()} ,${ALLOWED},,`, "first-party"); + expect(result).toEqual({ betas: [ALLOWED], dropped: false }); + }); + + test("absent or blank headers drop nothing; an oversized header is dropped whole", () => { + expect(allowlistAnthropicBetas(undefined, "first-party")).toEqual({ betas: [], dropped: false }); + expect(allowlistAnthropicBetas(null, "first-party")).toEqual({ betas: [], dropped: false }); + expect(allowlistAnthropicBetas(" ", "first-party")).toEqual({ betas: [], dropped: false }); + expect(allowlistAnthropicBetas(`${ALLOWED},${"x".repeat(4096)}`, "first-party")).toEqual({ betas: [], dropped: true }); + }); + + test("a proxy-owned beta is never taken from the caller", () => { + for (const beta of ANTHROPIC_OAUTH_BETA.split(",")) { + expect(allowlistAnthropicBetas(beta, "first-party").betas).toEqual([]); + } + }); +}); + +describe("the builder applies the allowlist", () => { + test("first-party key: the allowlisted value is sent, the unknown one is not", () => { + const built = buildAnthropicMessagesPassthroughRequest(provider(), "claude-wire", BODY, undefined, { + callerAnthropicBeta: `${UNKNOWN},${ALLOWED}`, + }); + expect(built.headers["anthropic-beta"]).toBe(ALLOWED); + expect(built.droppedBetas).toBe(true); + expect(JSON.stringify(built.headers)).not.toContain(UNKNOWN); + }); + + test("compatible key: no caller beta reaches the provider", () => { + const built = buildAnthropicMessagesPassthroughRequest( + provider({ baseUrl: "https://compatible.example/anthropic" }), "claude-wire", BODY, undefined, + { callerAnthropicBeta: ALLOWED }, + ); + expect(built.headers).not.toHaveProperty("anthropic-beta"); + expect(built.droppedBetas).toBe(true); + }); + + test("an operator-configured beta is kept and merged with the allowlisted caller value", () => { + const built = buildAnthropicMessagesPassthroughRequest( + provider({ headers: { "Anthropic-Beta": "operator-beta" } }), "claude-wire", BODY, undefined, + { callerAnthropicBeta: ALLOWED }, + ); + expect(built.headers["anthropic-beta"]).toBe(`operator-beta,${ALLOWED}`); + expect(built.headers).not.toHaveProperty("Anthropic-Beta"); + }); + + test("OAuth keeps its own beta pair and adds only the allowlisted caller value", () => { + const built = buildAnthropicMessagesPassthroughRequest( + provider({ authMode: "oauth", apiKey: "fixture-oauth-access" }), "claude-wire", BODY, undefined, + { callerAnthropicBeta: `${ALLOWED},${UNKNOWN}` }, + ); + expect(built.headers["anthropic-beta"]).toBe(`${ANTHROPIC_OAUTH_BETA},${ALLOWED}`); + expect(built.droppedBetas).toBe(true); + }); + + test("with no caller header nothing is dropped", () => { + const built = buildAnthropicMessagesPassthroughRequest(provider(), "claude-wire", BODY); + expect(built.droppedBetas).toBe(false); + expect(built.headers).not.toHaveProperty("anthropic-beta"); + }); +}); diff --git a/tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts b/tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts new file mode 100644 index 00000000000..663fd325295 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts @@ -0,0 +1,127 @@ +/** + * The managed native Messages builder for an Anthropic OAuth account (PF-10): the adapter's + * OAuth header placement, the Claude Code identity block, the OAuth tool-name prefix with its + * reverse map, and the rule that an OAuth token is sent to `api.anthropic.com` only. + */ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; +import { + anthropicMessagesNativeWireBody, + anthropicOAuthWireBody, + buildAnthropicMessagesPassthroughRequest, +} from "../../../src/adapters/anthropic/passthrough"; +import { createTranslatorBudget } from "../../../src/lib/translator-budget"; +import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../../../src/oauth/anthropic"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +const ACCESS = "fixture-oauth-access-token"; + +function oauthProvider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "oauth", + apiKey: ACCESS, + ...overrides, + } as OcxProviderConfig; +} + +const SOURCE = { + model: "selector", + max_tokens: 64, + stream: true, + system: "fixture system", + tools: [ + { name: "lookup", description: "fixture", input_schema: { type: "object", properties: {} } }, + { type: "web_search_20250305", name: "web_search" }, + { type: "bash_20250124", name: "bash" }, + ], + tool_choice: { type: "tool", name: "lookup" }, + messages: [ + { role: "user", content: "fixture question" }, + { role: "assistant", content: [ + { type: "tool_use", id: "toolu_1", name: "lookup", input: {} }, + { type: "tool_use", id: "toolu_2", name: "bash", input: { command: "true" } }, + ] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "toolu_1", content: "fixture result" }, + { type: "tool_result", tool_use_id: "toolu_2", content: "" }, + ] }, + ], +}; + +describe("buildAnthropicMessagesPassthroughRequest with OAuth", () => { + test("places the credential and fingerprint exactly as the adapter does", async () => { + const built = buildAnthropicMessagesPassthroughRequest(oauthProvider(), "claude-wire", SOURCE); + expect(built.url).toBe("https://api.anthropic.com/v1/messages"); + expect(built.headers.Authorization).toBe(`Bearer ${ACCESS}`); + expect(built.headers).not.toHaveProperty("x-api-key"); + expect(built.headers["anthropic-beta"]).toBe(ANTHROPIC_OAUTH_BETA); + + const parsed = { + modelId: "claude-wire", + stream: true, + context: { messages: [{ role: "user", content: "fixture", timestamp: 0 }] }, + options: {}, + } as unknown as OcxParsedRequest; + const adapterRequest = await createAnthropicAdapter(oauthProvider()) + .buildRequest(parsed, { headers: new Headers(), translatorBudget: createTranslatorBudget() }); + const adapterHeaders = adapterRequest.headers as Record; + const perRequest = new Set(["x-client-request-id"]); + for (const [name, value] of Object.entries(adapterHeaders)) { + if (name === "Content-Type" || perRequest.has(name)) continue; + expect(built.headers[name]).toBe(value); + } + expect(Object.keys(built.headers).sort()).toEqual(Object.keys(adapterHeaders).sort()); + }); + + test("an OAuth token is refused for any destination but api.anthropic.com", () => { + for (const baseUrl of [ + "https://compatible.example", + "http://api.anthropic.com", + "https://api.anthropic.com.example", + "https://api.anthropic.com:8443", + ]) { + expect(() => buildAnthropicMessagesPassthroughRequest(oauthProvider({ baseUrl }), "m", SOURCE)) + .toThrow("only to api.anthropic.com"); + } + expect(() => buildAnthropicMessagesPassthroughRequest(oauthProvider({ apiKey: "" }), "m", SOURCE)) + .toThrow("oauth token missing"); + }); + + test("the body carries the identity block and prefixed client tools; typed tools keep their names", () => { + const built = buildAnthropicMessagesPassthroughRequest(oauthProvider(), "claude-wire", SOURCE); + const wire = built.wireBody as Omit & { system: unknown[] }; + expect(wire.system).toEqual([ + { type: "text", text: CLAUDE_CODE_SYSTEM_INSTRUCTION }, + { type: "text", text: "fixture system" }, + ]); + expect(wire.tools.map(tool => tool.name)).toEqual(["custom_lookup", "web_search", "bash"]); + expect(wire.tool_choice).toEqual({ type: "tool", name: "custom_lookup" }); + const history = wire.messages[1]!.content as { name: string }[]; + expect(history.map(block => block.name)).toEqual(["custom_lookup", "bash"]); + expect([...built.oauthToolNames!]).toEqual([["custom_lookup", "lookup"]]); + // The source is untouched. + expect(SOURCE.tools[0]!.name).toBe("lookup"); + expect(SOURCE.system).toBe("fixture system"); + }); + + test("an identity block already present is not repeated", () => { + const system = [{ type: "text", text: CLAUDE_CODE_SYSTEM_INSTRUCTION }, { type: "text", text: "more" }]; + expect(anthropicOAuthWireBody({ system, messages: [] }).body.system).toBe(system); + }); + + test("two caller names that meet under the prefix are refused", () => { + const body = { messages: [], tools: [ + { name: "lookup", input_schema: { type: "object" } }, + { name: "custom_lookup", input_schema: { type: "object" } }, + ] }; + expect(() => anthropicOAuthWireBody(body)).toThrow("collide"); + }); + + test("a key-auth provider gets no OAuth shaping", () => { + const shaped = anthropicMessagesNativeWireBody({ baseUrl: "https://api.anthropic.com", authMode: "key" }, "m", SOURCE); + expect(shaped.oauthToolNames).toBeUndefined(); + expect(shaped.wireBody.system).toBe("fixture system"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e1a2141ce56..9a22d14f030 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -69,6 +69,8 @@ "anthropic-thinking-signature.test.ts": "adapters/anthropic", "anthropic-tool-call-id.test.ts": "adapters/anthropic", "anthropic-messages-passthrough.test.ts": "adapters/anthropic", + "anthropic-beta-allowlist.test.ts": "adapters/anthropic", + "anthropic-messages-passthrough-oauth.test.ts": "adapters/anthropic", "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "anthropic-tool-schema.test.ts": "adapters/anthropic", "antigravity-baseurl-override.test.ts": "adapters/google", From 7846532edb53c8d0f50ee318f879355197970cb8 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:25:47 +0900 Subject: [PATCH 154/173] test(protocols): pin opaque-state domains and OAuth native eligibility Signatures stay with first-party Anthropic and rebuild from the envelope per domain; OAuth needs both switches, is judged from config in planning, and a pool declines. --- scripts/test-layout/layout.json | 2 + tests/fixtures/test-layout-expected.json | 2 + .../messages-native-oauth-eligibility.test.ts | 114 ++++++++++++++++ tests/responses/protocol-opaque-state.test.ts | 128 ++++++++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 tests/responses/messages-native-oauth-eligibility.test.ts create mode 100644 tests/responses/protocol-opaque-state.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7dd0c91abff..0528c05c75c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -339,6 +339,7 @@ "chat-inbound-developer-position.test.ts": "responses", "protocol-contract.test.ts": "responses", "protocol-features.test.ts": "responses", + "protocol-opaque-state.test.ts": "responses", "protocol-baseline.test.ts": "responses", "protocol-dto.test.ts": "responses", "protocol-path.test.ts": "responses", @@ -353,6 +354,7 @@ "chat-native-combo.test.ts": "responses", "messages-native-eligibility.test.ts": "responses", "messages-native-bridge-policy.test.ts": "responses", + "messages-native-oauth-eligibility.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9a22d14f030..f3247405d62 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -165,6 +165,7 @@ "chat-inbound-developer-position.test.ts": "responses", "protocol-contract.test.ts": "responses", "protocol-features.test.ts": "responses", + "protocol-opaque-state.test.ts": "responses", "protocol-baseline.test.ts": "responses", "protocol-dto.test.ts": "responses", "protocol-path.test.ts": "responses", @@ -179,6 +180,7 @@ "chat-native-combo.test.ts": "responses", "messages-native-eligibility.test.ts": "responses", "messages-native-bridge-policy.test.ts": "responses", + "messages-native-oauth-eligibility.test.ts": "responses", "chat-inbound-reasoning-none.test.ts": "responses", "chat-native-decline-reason.test.ts": "responses", "chat-inbound-reasoning-replay.test.ts": "responses", diff --git a/tests/responses/messages-native-oauth-eligibility.test.ts b/tests/responses/messages-native-oauth-eligibility.test.ts new file mode 100644 index 00000000000..6c81a84900d --- /dev/null +++ b/tests/responses/messages-native-oauth-eligibility.test.ts @@ -0,0 +1,114 @@ +/** + * Which Anthropic OAuth routes take the managed native Messages lane (PF-10): the + * `managedMessagesNativeOAuth` switch and its dependency on `managedMessagesNative`, the + * `anthropic` provider on `api.anthropic.com` only, a pooled account set declining with + * `oauth-account-pool`, and a planner that judges all of it from config without touching the + * OAuth store or the network. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getAuthStorePath } from "../../src/oauth/store"; +import { buildProtocolPlanSnapshot } from "../../src/protocols/plan-snapshot"; +import { resolveProtocolSettings } from "../../src/protocols/settings"; +import type { RouteResult } from "../../src/router"; +import { nativeMessagesDeclineReason } from "../../src/server/messages-native-eligibility"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir = ""; +let previousHome: string | undefined; +let originalFetch: typeof globalThis.fetch; +let fetches = 0; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-messages-native-oauth-eligibility-")); + process.env.OPENCODEX_HOME = testDir; + originalFetch = globalThis.fetch; + fetches = 0; + // Planning must never refresh a token: any network call fails the case. + globalThis.fetch = (async () => { + fetches += 1; + throw new Error("unexpected fetch while judging eligibility"); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + expect(fetches).toBe(0); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) removeTreeWithRetry(testDir); +}); + +const BOTH = { protocols: { rollout: { managedMessagesNative: true, managedMessagesNativeOAuth: true } } } as Partial; +const KEY_ONLY = { protocols: { rollout: { managedMessagesNative: true } } } as Partial; +const OAUTH_ONLY = { protocols: { rollout: { managedMessagesNativeOAuth: true } } } as Partial; + +function config(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "anthropic", + providers: { + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth", models: ["claude-o"] }, + }, + ...overrides, + } as OcxConfig; +} + +function route(overrides: { providerName?: string; baseUrl?: string } = {}): RouteResult { + return { + providerName: overrides.providerName ?? "anthropic", + modelId: "claude-o", + routeKind: "direct", + routeReason: "test", + provider: { adapter: "anthropic", baseUrl: overrides.baseUrl ?? "https://api.anthropic.com", authMode: "oauth" }, + } as unknown as RouteResult; +} + +const BODY = { messages: [{ role: "user", content: "fixture" }] }; + +describe("OAuth native Messages eligibility", () => { + test("the OAuth switch means nothing without managedMessagesNative", () => { + expect(resolveProtocolSettings(config(OAUTH_ONLY)).rollout.managedMessagesNativeOAuth).toBe(false); + expect(nativeMessagesDeclineReason(route(), BODY, config(OAUTH_ONLY))).toBe("rollout-disabled"); + }); + + test("with only the key switch, OAuth stays on the bridge", () => { + expect(nativeMessagesDeclineReason(route(), BODY, config(KEY_ONLY))).toBe("auth-mode-not-native"); + }); + + test("with both switches, the unpooled anthropic OAuth route is native", () => { + expect(nativeMessagesDeclineReason(route(), BODY, config(BOTH))).toBeUndefined(); + }); + + test("only the anthropic provider on api.anthropic.com qualifies", () => { + expect(nativeMessagesDeclineReason(route({ providerName: "other-oauth" }), BODY, config(BOTH))).toBe("auth-mode-not-native"); + for (const baseUrl of ["https://compatible.example", "http://api.anthropic.com", "https://api.anthropic.com:8443"]) { + expect(nativeMessagesDeclineReason(route({ baseUrl }), BODY, config(BOTH))).toBe("auth-mode-not-native"); + } + }); + + test("a pooled account set declines: the opt-in pool from config, the quorum from the sender", () => { + const pooled = config({ ...BOTH, anthropicAccountPool: { enabled: true } } as Partial); + expect(nativeMessagesDeclineReason(route(), BODY, pooled)).toBe("oauth-account-pool"); + expect(nativeMessagesDeclineReason(route(), BODY, config(BOTH), { oauthFailoverQuorum: true })).toBe("oauth-account-pool"); + expect(nativeMessagesDeclineReason(route(), BODY, config(BOTH), { oauthFailoverQuorum: false })).toBeUndefined(); + }); +}); + +describe("the planner judges OAuth from config alone", () => { + test("an OAuth route previews as native without reading or creating the OAuth store", () => { + const plan = buildProtocolPlanSnapshot(config(BOTH), { model: "anthropic/claude-o", inbound: "messages", features: [] }); + expect(plan.candidates[0]).toMatchObject({ nativeEligible: true, declineReasons: [] }); + expect(existsSync(getAuthStorePath())).toBe(false); + }); + + test("a configured pool previews as oauth-account-pool", () => { + const pooled = config({ ...BOTH, anthropicAccountPool: { enabled: true } } as Partial); + const plan = buildProtocolPlanSnapshot(pooled, { model: "anthropic/claude-o", inbound: "messages", features: [] }); + expect(plan.candidates[0]).toMatchObject({ nativeEligible: false, declineReasons: ["oauth-account-pool"] }); + }); +}); diff --git a/tests/responses/protocol-opaque-state.test.ts b/tests/responses/protocol-opaque-state.test.ts new file mode 100644 index 00000000000..39eec07e678 --- /dev/null +++ b/tests/responses/protocol-opaque-state.test.ts @@ -0,0 +1,128 @@ +/** + * Opaque thinking state and credential domains (PF-10, src/protocols/opaque-state.ts): thinking + * signatures and `redacted_thinking` blocks reach first-party Anthropic only, a copy without them + * goes everywhere else, and the source is never touched so the next destination decides again. + */ +import { describe, expect, test } from "bun:test"; +import { buildAnthropicMessagesPassthroughRequest } from "../../src/adapters/anthropic/passthrough"; +import { createProtocolEnvelope } from "../../src/protocols/envelope"; +import { + anthropicProviderClass, + credentialDomainFor, + messagesBodyHasOpaqueState, + opaqueStateForDestination, + sameCredentialDomain, +} from "../../src/protocols/opaque-state"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { OcxProviderConfig } from "../../src/types"; + +const SIGNATURE = "fixture-signature-AAAAAAAAAAAAAAAAAAAA"; +const REDACTED = "fixture-redacted-BBBBBBBBBBBBBBBBBBBB"; + +function body() { + return { + model: "selector", + max_tokens: 64, + thinking: { type: "enabled", budget_tokens: 1024 }, + messages: [ + { role: "user", content: "fixture question" }, + { role: "assistant", content: [ + { type: "redacted_thinking", data: REDACTED }, + { type: "thinking", thinking: "fixture reasoning", signature: SIGNATURE }, + { type: "text", text: "fixture answer" }, + ] }, + { role: "assistant", content: [{ type: "redacted_thinking", data: REDACTED }] }, + { role: "user", content: "fixture follow-up" }, + ], + }; +} + +const FIRST_PARTY = credentialDomainFor({ baseUrl: "https://api.anthropic.com/v1", authMode: "key" }); +const COMPATIBLE = credentialDomainFor({ baseUrl: "https://compatible.example/anthropic", authMode: "key" }); + +describe("credential domains", () => { + test("only HTTPS api.anthropic.com on the default port is first-party", () => { + expect(FIRST_PARTY).toEqual({ host: "api.anthropic.com", authClass: "key", firstPartyAnthropic: true }); + for (const baseUrl of [ + "http://api.anthropic.com", + "https://api.anthropic.com:444", + "https://api.anthropic.com.example", + "https://proxy.example/api.anthropic.com", + "https://user:pass@api.anthropic.com", + ]) { + expect(credentialDomainFor({ baseUrl, authMode: "key" })?.firstPartyAnthropic).toBe(false); + } + expect(credentialDomainFor({ baseUrl: "not a url", authMode: "key" })).toBeUndefined(); + expect(anthropicProviderClass({ baseUrl: "https://API.ANTHROPIC.COM", authMode: "oauth" })).toBe("first-party"); + expect(anthropicProviderClass({ baseUrl: "https://compatible.example", authMode: "key" })).toBe("compatible"); + }); + + test("a domain is host plus credential class", () => { + const oauth = credentialDomainFor({ baseUrl: "https://api.anthropic.com", authMode: "oauth" }); + expect(sameCredentialDomain(FIRST_PARTY, credentialDomainFor({ baseUrl: "https://api.anthropic.com/v1/messages", authMode: undefined }))) + .toBe(true); + expect(sameCredentialDomain(FIRST_PARTY, oauth)).toBe(false); + expect(sameCredentialDomain(FIRST_PARTY, COMPATIBLE)).toBe(false); + expect(sameCredentialDomain(undefined, undefined)).toBe(false); + }); +}); + +describe("opaqueStateForDestination", () => { + test("first-party keeps every signature and redacted block, by reference", () => { + const source = body(); + const result = opaqueStateForDestination(source, FIRST_PARTY); + expect(result).toEqual({ body: source, stripped: false }); + }); + + test("any other destination gets a copy without them; the source is untouched", () => { + const source = body(); + const snapshot = structuredClone(source); + const result = opaqueStateForDestination(source, COMPATIBLE); + expect(result.stripped).toBe(true); + expect(result.body.messages).toEqual([ + { role: "user", content: "fixture question" }, + { role: "assistant", content: [ + { type: "thinking", thinking: "fixture reasoning" }, + { type: "text", text: "fixture answer" }, + ] }, + // The assistant turn that held only a redacted block is dropped, not sent empty. + { role: "user", content: "fixture follow-up" }, + ]); + expect(JSON.stringify(result.body)).not.toContain(SIGNATURE); + expect(JSON.stringify(result.body)).not.toContain(REDACTED); + expect(source).toEqual(snapshot); + // Unaffected messages are shared, not copied. + expect((result.body.messages as unknown[])[0]).toBe(source.messages[0]); + }); + + test("an unknown destination is treated as foreign", () => { + expect(opaqueStateForDestination(body(), undefined).stripped).toBe(true); + }); + + test("a body without opaque state is returned as is", () => { + const plain = { messages: [{ role: "user", content: "fixture" }] }; + expect(messagesBodyHasOpaqueState(plain)).toBe(false); + expect(opaqueStateForDestination(plain, COMPATIBLE)).toEqual({ body: plain, stripped: false }); + }); +}); + +describe("a fallback to another credential domain rebuilds from the envelope", () => { + test("each build decides from the full source, whatever an earlier build removed", () => { + const envelope = createProtocolEnvelope({ inbound: "messages", body: body(), translatorBudget: createTranslatorBudget() }); + const key = (baseUrl: string) => ({ adapter: "anthropic", baseUrl, authMode: "key", apiKey: "fixture-key" }) as OcxProviderConfig; + + const first = buildAnthropicMessagesPassthroughRequest(key("https://compatible.example"), "m", envelope.freshBody()); + expect(first.strippedOpaqueState).toBe(true); + expect(first.body).not.toContain(SIGNATURE); + + const fallback = buildAnthropicMessagesPassthroughRequest(key("https://api.anthropic.com"), "m", envelope.freshBody()); + expect(fallback.strippedOpaqueState).toBe(false); + expect(fallback.body).toContain(SIGNATURE); + expect(fallback.body).toContain(REDACTED); + + // The same source body reused across builds gives the same answer: the builder never mutates. + const source = envelope.freshBody(); + buildAnthropicMessagesPassthroughRequest(key("https://compatible.example"), "m", source); + expect(buildAnthropicMessagesPassthroughRequest(key("https://api.anthropic.com"), "m", source).body).toContain(SIGNATURE); + }); +}); From 91147584e1699f4a4b4052ffc63942e54bb6eba1 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:25:47 +0900 Subject: [PATCH 155/173] test(messages): drive the native lane with OAuth, stripped opaque state and betas The selected account's token is sent and never the caller's, tool names map back, reject refuses before a send, and dropped betas appear in the trace by code only. --- scripts/test-layout/layout.json | 2 + .../messages-native-oauth.test.ts | 239 ++++++++++++++++++ .../messages-native-opaque-state.test.ts | 191 ++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + 4 files changed, 434 insertions(+) create mode 100644 tests/claude-integration/messages-native-oauth.test.ts create mode 100644 tests/claude-integration/messages-native-opaque-state.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0528c05c75c..25773f7d82f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -433,6 +433,8 @@ "claude-messages-endpoint.test.ts": "claude-integration", "messages-native.test.ts": "claude-integration", "messages-native-decline-trace.test.ts": "claude-integration", + "messages-native-oauth.test.ts": "claude-integration", + "messages-native-opaque-state.test.ts": "claude-integration", "messages-surface-matrix.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", diff --git a/tests/claude-integration/messages-native-oauth.test.ts b/tests/claude-integration/messages-native-oauth.test.ts new file mode 100644 index 00000000000..94975c950eb --- /dev/null +++ b/tests/claude-integration/messages-native-oauth.test.ts @@ -0,0 +1,239 @@ +/** + * Anthropic OAuth on the managed native Messages lane (PF-10) against an in-process transport. + * With `managedMessagesNative` and `managedMessagesNativeOAuth` on, an unpooled Anthropic OAuth + * route sends the caller's Messages body with the access token of the account the existing OAuth + * selection commits at dispatch — never the caller's credential — and maps the OAuth tool-name + * prefix back in the answer. A pooled account set stays on the bridge. Every credential here is + * synthetic, and any real network call fails the case. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../../src/oauth/anthropic"; +import { clearAnthropicAccountPoolState, forgetAnthropicFailoverQuorum } from "../../src/oauth/anthropic-routing"; +import { getAccountSet, markAccountNeedsReauth, saveCredential, setActiveAccount } from "../../src/oauth/store"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { getRequestLogEntries } from "../../src/server/request-log"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const ALLOWED_BETA = "interleaved-thinking-2025-05-14"; +const UNKNOWN_BETA = "fixture-unlisted-beta-2099-01-01"; +const SIGNATURE = "fixture-signature-CCCCCCCCCCCCCCCCCCCC"; + +interface Sent { + url: string; + headers: Headers; + body: Record; +} + +let sent: Sent[] = []; +let home = ""; +let previousHome: string | undefined; +let originalFetch: typeof globalThis.fetch; +let unexpectedFetches = 0; +let releaseSpendHome: (() => void) | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-messages-native-oauth-")); + process.env.OPENCODEX_HOME = home; + sent = []; + originalFetch = globalThis.fetch; + unexpectedFetches = 0; + globalThis.fetch = (async () => { + unexpectedFetches += 1; + throw new Error("unexpected global fetch in the native OAuth Messages test"); + }) as unknown as typeof fetch; + clearAnthropicAccountPoolState(); + forgetAnthropicFailoverQuorum(); + releaseSpendHome = acquireOwnedSpendHome(); +}); + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + try { + expect(unexpectedFetches).toBe(0); + } finally { + clearAnthropicAccountPoolState(); + forgetAnthropicFailoverQuorum(); + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (home) removeTreeWithRetry(home); + } +}); + +function credential(index: number) { + return { + access: `synthetic-anthropic-access-${index}`, + refresh: `synthetic-anthropic-refresh-${index}`, + expires: Date.now() + 3_600_000, + accountId: `synthetic-account-${index}`, + }; +} + +async function seed(count: number): Promise { + for (let index = 0; index < count; index++) await saveCredential("anthropic", credential(index)); + const ids = getAccountSet("anthropic")!.accounts.map(account => account.id); + await setActiveAccount("anthropic", ids[0]!); + forgetAnthropicFailoverQuorum(); + return ids; +} + +const MESSAGE = { + id: "msg_fixture", + type: "message", + role: "assistant", + model: "claude-sonnet-4-5", + content: [{ type: "tool_use", id: "toolu_fixture", name: "custom_lookup", input: {} }], + stop_reason: "tool_use", + stop_sequence: null, + usage: { input_tokens: 9, output_tokens: 4 }, +}; + +function sse(): string { + return [ + { type: "message_start", message: { ...MESSAGE, content: [], stop_reason: null } }, + { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_fixture", name: "custom_lookup", input: {} } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "{}" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { output_tokens: 4 } }, + { type: "message_stop" }, + ].map(frame => `event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`).join(""); +} + +function fixtureConfig(options: { oauthSwitch?: boolean } = {}): OcxConfig { + const transport = (async (input: Parameters[0], init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as Record; + sent.push({ url: String(input), headers: new Headers(init?.headers), body }); + if (body.stream === true) return new Response(sse(), { headers: { "content-type": "text/event-stream" } }); + return Response.json(MESSAGE); + }) as typeof fetch; + const provider: OcxProviderConfig & { fetch: typeof fetch } = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "oauth", + models: ["claude-sonnet-4-5"], + fetch: transport, + }; + const config = { + port: 0, + defaultProvider: "anthropic", + anthropicAccountPool: { enabled: false }, + providers: { anthropic: provider }, + protocols: { rollout: { managedMessagesNative: true, managedMessagesNativeOAuth: options.oauthSwitch ?? true } }, + } as OcxConfig; + saveConfig(config); + return config; +} + +const BODY = { + model: "anthropic/claude-sonnet-4-5", + max_tokens: 64, + system: "fixture system", + tools: [{ name: "lookup", description: "fixture", input_schema: { type: "object", properties: {} } }], + messages: [ + { role: "user", content: "fixture question" }, + { role: "assistant", content: [ + { type: "thinking", thinking: "fixture reasoning", signature: SIGNATURE }, + { type: "tool_use", id: "toolu_prev", name: "lookup", input: {} }, + ] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_prev", content: "fixture result" }] }, + ], +}; + +// Neither value is an `sk-ant-` credential, so the caller-forward passthrough is not taken; both +// must still be kept away from the provider. +const CALLER_HEADERS = { + authorization: "Bearer fixture-admission-token", + "x-api-key": "fixture-caller-key", + "anthropic-beta": `${ALLOWED_BETA},${UNKNOWN_BETA}`, +}; + +async function send(config: OcxConfig, body: Record) { + const requestId = `pf10-oauth-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json", ...CALLER_HEADERS }, + body: JSON.stringify(body), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + const text = await response.text(); + const rows = getRequestLogEntries().filter(entry => entry.requestId === requestId); + expect(rows).toHaveLength(1); + return { response, text, row: rows[0]! }; +} + +describe("managed native Messages over Anthropic OAuth", () => { + test("sends with the selected account's token and the OAuth shape, never the caller's credential", async () => { + await seed(1); + const { response, text, row } = await send(fixtureConfig(), { ...BODY, stream: true }); + expect(response.status).toBe(200); + expect(sent).toHaveLength(1); + const wire = sent[0]!; + expect(wire.url).toBe("https://api.anthropic.com/v1/messages"); + expect(wire.headers.get("authorization")).toBe("Bearer synthetic-anthropic-access-0"); + expect(wire.headers.get("x-api-key")).toBeNull(); + expect(wire.headers.get("anthropic-beta")).toBe(`${ANTHROPIC_OAUTH_BETA},${ALLOWED_BETA}`); + expect((wire.body.system as { text: string }[])[0]!.text).toBe(CLAUDE_CODE_SYSTEM_INSTRUCTION); + expect((wire.body.tools as { name: string }[])[0]!.name).toBe("custom_lookup"); + // First-party Anthropic receives the signature it minted. + expect(JSON.stringify(wire.body)).toContain(SIGNATURE); + + // The answer names the caller's tool, not the wire name. + expect(text).toContain("\"name\":\"lookup\""); + expect(text).not.toContain("custom_lookup"); + + expect(row.protocolTrace).toMatchObject({ inbound: "messages", mode: "native", requestPath: ["messages", "messages"] }); + expect(row.protocolTrace?.reasonCodes).toContain("anthropic-beta-dropped"); + expect(row.protocolTrace?.reasonCodes).not.toContain("opaque-state-stripped"); + const serialized = JSON.stringify(row); + expect(serialized).not.toContain("synthetic-anthropic-access-0"); + expect(serialized).not.toContain(UNKNOWN_BETA); + expect(serialized).not.toContain(SIGNATURE); + }); + + test("a JSON answer maps the tool name back too", async () => { + await seed(1); + const { response, text } = await send(fixtureConfig(), { ...BODY, stream: false }); + expect(response.status).toBe(200); + expect(JSON.parse(text).content[0]).toMatchObject({ type: "tool_use", name: "lookup" }); + }); + + test("the account is the one the selection holds, not merely the first stored", async () => { + const ids = await seed(2); + // One usable account remains, so no rotation quorum: the lane serves the selected one. + await markAccountNeedsReauth("anthropic", ids[0]!, true); + await setActiveAccount("anthropic", ids[1]!); + forgetAnthropicFailoverQuorum(); + const { response, row } = await send(fixtureConfig(), { ...BODY, stream: false }); + expect(response.status).toBe(200); + expect(row.protocolTrace).toMatchObject({ mode: "native" }); + expect(sent.map(entry => entry.headers.get("authorization"))).toEqual(["Bearer synthetic-anthropic-access-1"]); + }); + + test("two usable accounts keep the bridge, which owns rotation", async () => { + await seed(2); + const { row } = await send(fixtureConfig(), { ...BODY, stream: false }); + expect(row.protocolTrace).toMatchObject({ inbound: "messages", mode: "legacy-bridge" }); + expect(row.protocolTrace?.reasonCodes).toContain("oauth-account-pool"); + }); + + test("with the OAuth switch off the route stays on the bridge", async () => { + await seed(1); + const { row } = await send(fixtureConfig({ oauthSwitch: false }), { ...BODY, stream: false }); + expect(row.protocolTrace).toMatchObject({ inbound: "messages", mode: "legacy-bridge" }); + expect(row.protocolTrace?.reasonCodes).toContain("auth-mode-not-native"); + }); + + test("no stored account answers 401 in Anthropic shape and sends nothing", async () => { + const { response, text } = await send(fixtureConfig(), { ...BODY, stream: false }); + expect(response.status).toBe(401); + expect(JSON.parse(text)).toMatchObject({ type: "error", error: { type: "authentication_error" } }); + expect(sent).toHaveLength(0); + }); +}); diff --git a/tests/claude-integration/messages-native-opaque-state.test.ts b/tests/claude-integration/messages-native-opaque-state.test.ts new file mode 100644 index 00000000000..83740d86c86 --- /dev/null +++ b/tests/claude-integration/messages-native-opaque-state.test.ts @@ -0,0 +1,191 @@ +/** + * Opaque thinking state and caller betas on the managed native Messages lane (PF-10), key auth. + * A thinking signature or `redacted_thinking` block reaches first-party Anthropic only: an + * Anthropic-compatible destination receives the body without them and the trace records + * `opaque-state-stripped`, or, under `unrepresentable: "reject"`, the request is refused before + * any send. A dropped caller beta is recorded by code only, never by value. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { getRequestLogEntries } from "../../src/server/request-log"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const SIGNATURE = "fixture-signature-DDDDDDDDDDDDDDDDDDDD"; +const REDACTED = "fixture-redacted-EEEEEEEEEEEEEEEEEEEE"; +const UNKNOWN_BETA = "fixture-unlisted-beta-2099-02-02"; + +interface Seen { + headers: Headers; + body: Record; +} + +let seen: Seen[] = []; +let upstream: ReturnType | undefined; +let testDir = ""; +let previousHome: string | undefined; +let releaseSpendHome: (() => void) | undefined; + +const MESSAGE = { + id: "msg_fixture", + type: "message", + role: "assistant", + model: "claude-x", + content: [{ type: "text", text: "fixture reply" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 2 }, +}; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-messages-native-opaque-")); + process.env.OPENCODEX_HOME = testDir; + seen = []; + releaseSpendHome = acquireOwnedSpendHome(); + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { + seen.push({ headers: req.headers, body: await req.json() as Record }); + return Response.json(MESSAGE); + } }); +}); + +afterEach(async () => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + await upstream?.stop(true); + upstream = undefined; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) removeTreeWithRetry(testDir); +}); + +function compatibleConfig(reject = false): OcxConfig { + const config = { + port: 0, + defaultProvider: "anth", + providers: { anth: { + adapter: "anthropic", baseUrl: `http://127.0.0.1:${upstream!.port}`, authMode: "key", apiKey: "fixture-key", + allowPrivateNetwork: true, models: ["claude-x"], + } }, + protocols: { + rollout: { managedMessagesNative: true }, + ...(reject ? { unrepresentable: "reject" } : {}), + }, + } as OcxConfig; + saveConfig(config); + return config; +} + +/** A key-auth route to api.anthropic.com, served by an in-process transport. */ +function firstPartyConfig(): OcxConfig { + const transport = (async (_input: Parameters[0], init?: RequestInit) => { + seen.push({ headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) as Record }); + return Response.json(MESSAGE); + }) as typeof fetch; + const provider: OcxProviderConfig & { fetch: typeof fetch } = { + adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key", apiKey: "fixture-key", + models: ["claude-x"], fetch: transport, + }; + const config = { + port: 0, + defaultProvider: "first", + providers: { first: provider }, + protocols: { rollout: { managedMessagesNative: true } }, + } as OcxConfig; + saveConfig(config); + return config; +} + +function body(model: string) { + return { + model, + max_tokens: 64, + stream: false, + thinking: { type: "enabled", budget_tokens: 1024 }, + messages: [ + { role: "user", content: "fixture question" }, + { role: "assistant", content: [ + { type: "redacted_thinking", data: REDACTED }, + { type: "thinking", thinking: "fixture reasoning", signature: SIGNATURE }, + { type: "text", text: "fixture answer" }, + ] }, + { role: "user", content: "fixture follow-up" }, + ], + }; +} + +async function send(config: OcxConfig, payload: Record, headers: Record = {}) { + const requestId = `pf10-opaque-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(payload), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + const text = await response.text(); + const rows = getRequestLogEntries().filter(entry => entry.requestId === requestId); + expect(rows).toHaveLength(1); + return { response, text, row: rows[0]! }; +} + +describe("opaque thinking state on the native Messages lane", () => { + test("an Anthropic-compatible destination gets the body without it, and the trace says so", async () => { + const { response, row } = await send(compatibleConfig(), body("anth/claude-x")); + expect(response.status).toBe(200); + expect(seen).toHaveLength(1); + const wire = JSON.stringify(seen[0]!.body); + expect(wire).not.toContain(SIGNATURE); + expect(wire).not.toContain(REDACTED); + expect(wire).toContain("fixture reasoning"); + expect(row.protocolTrace).toMatchObject({ inbound: "messages", mode: "native" }); + expect(row.protocolTrace?.reasonCodes).toContain("opaque-state-stripped"); + expect(JSON.stringify(row)).not.toContain(SIGNATURE); + }); + + test("under reject the request is refused before any send", async () => { + const { response, text, row } = await send(compatibleConfig(true), body("anth/claude-x")); + expect(response.status).toBe(400); + expect(JSON.parse(text)).toMatchObject({ type: "error", error: { type: "invalid_request_error" } }); + expect(seen).toHaveLength(0); + expect(row.protocolTrace).toMatchObject({ inbound: "messages", mode: "blocked" }); + expect(row.protocolTrace?.reasonCodes).toEqual(expect.arrayContaining(["feature-unrepresentable", "opaque-state-stripped"])); + }); + + test("first-party Anthropic receives every signature and redacted block", async () => { + const { response, row } = await send(firstPartyConfig(), body("first/claude-x")); + expect(response.status).toBe(200); + expect(seen).toHaveLength(1); + const wire = JSON.stringify(seen[0]!.body); + expect(wire).toContain(SIGNATURE); + expect(wire).toContain(REDACTED); + expect(row.protocolTrace?.reasonCodes).not.toContain("opaque-state-stripped"); + }); +}); + +describe("caller betas on the native Messages lane", () => { + test("a compatible destination receives none; the trace names the code, never the value", async () => { + const { response, row } = await send(compatibleConfig(), { ...body("anth/claude-x"), messages: [{ role: "user", content: "fixture" }] }, { + "anthropic-beta": `interleaved-thinking-2025-05-14,${UNKNOWN_BETA}`, + }); + expect(response.status).toBe(200); + expect(seen[0]!.headers.get("anthropic-beta")).toBeNull(); + expect(row.protocolTrace?.reasonCodes).toContain("anthropic-beta-dropped"); + expect(JSON.stringify(row)).not.toContain(UNKNOWN_BETA); + }); + + test("first-party Anthropic receives the allowlisted value only", async () => { + await send(firstPartyConfig(), { ...body("first/claude-x"), messages: [{ role: "user", content: "fixture" }] }, { + "anthropic-beta": `${UNKNOWN_BETA}, Interleaved-Thinking-2025-05-14`, + }); + expect(seen[0]!.headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14"); + }); + + test("no caller beta records nothing", async () => { + const { row } = await send(compatibleConfig(), { ...body("anth/claude-x"), messages: [{ role: "user", content: "fixture" }] }); + expect(row.protocolTrace?.reasonCodes).not.toContain("anthropic-beta-dropped"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f3247405d62..ccfbd18e11a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -259,6 +259,8 @@ "claude-messages-endpoint.test.ts": "claude-integration", "messages-native.test.ts": "claude-integration", "messages-native-decline-trace.test.ts": "claude-integration", + "messages-native-oauth.test.ts": "claude-integration", + "messages-native-opaque-state.test.ts": "claude-integration", "messages-surface-matrix.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", From 383a18e76ec3f1559ec997f1fe0dfa033425611e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:26:36 +0900 Subject: [PATCH 156/173] docs(structure): describe the native Messages beta allowlist, opaque state and OAuth protocol-paths owns the three PF-10 rules and their tests; the 040 inventory records OAuth native, the pooled-account decline and the opaque-state rule on the native lane. --- .../040_acceptance_and_rollout.md | 6 +- structure/data-planes/protocol-paths.md | 66 +++++++++++++++---- 2 files changed, 57 insertions(+), 15 deletions(-) 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 index d234573565b..4f9755ca96d 100644 --- a/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md +++ b/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md @@ -48,6 +48,8 @@ Kept current by each packet that migrates something. | Responses-only features on Chat/Messages | `previous_response_id`, `store`, `background`, compaction stay on the bridge | | Non-public-wire adapters (`other`) | translated through the IR; no feature claims | | OAuth native Chat | not planned in this unit | -| Messages → key-auth Anthropic | native behind `managedMessagesNative` (PF-08); bridge while off. Caller `anthropic-beta` is not forwarded (PF-10 allowlist); top-level fields outside the allowlist are dropped with no feature effect | -| Messages → Anthropic OAuth | bridge until `managedMessagesNativeOAuth` (PF-10) | +| Messages → key-auth Anthropic | native behind `managedMessagesNative` (PF-08); bridge while off. Caller `anthropic-beta` passes only through the PF-10 allowlist (`interleaved-thinking-2025-05-14` to `api.anthropic.com`, nothing to a compatible host); a dropped value is traced as `anthropic-beta-dropped`, never by value. Top-level fields outside the allowlist are dropped with no feature effect | +| Messages → Anthropic OAuth | native behind `managedMessagesNativeOAuth` (PF-10) for the unpooled `anthropic` provider on `api.anthropic.com`; bridge while off | +| Messages → pooled Anthropic OAuth | not migrated (PF-10): `anthropicAccountPool.enabled` or two usable stored accounts decline with `oauth-account-pool`, because rotation, session affinity and quota ranking live in the Responses transport | +| Opaque thinking state on the native lane | signatures and `redacted_thinking` reach `api.anthropic.com` only; elsewhere removed and traced as `opaque-state-stripped` (reason code, not a feature effect: a same-wire hop has no degraded disposition), refused before any send under `reject` | | Messages native lane, translated-only steps | a pinned route effort, blocked-skill elision, the web-search sidecar and vision preprocessing keep the request on the bridge (`bridge-only-policy` / `vision-preprocessing`); `stabilizePromptCache` is a recorded gap — the native lane does not apply it | diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index a0b3a61956e..bc2ad1be111 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -230,9 +230,10 @@ transport side (send budget, failover, logging) is in Behind `protocols.rollout.managedMessagesNative` (default off). A Messages request whose settled route is a direct, key-auth `anthropic` provider is sent as Messages instead of replaying through -Responses. `nativeMessagesDeclineReason` names the first rule that keeps a route off the lane: -`rollout-disabled`, `cross-wire-ir` (another adapter), `auth-mode-not-native` (OAuth, which is -PF-10, or `forward`), `combo-or-policy-route`, `effort-row` / `fast-row` (synthetic rows need the +Responses; with `managedMessagesNativeOAuth` also on, so is an unpooled Anthropic OAuth account +(below). `nativeMessagesDeclineReason` names the first rule that keeps a route off the lane: +`rollout-disabled`, `cross-wire-ir` (another adapter), `auth-mode-not-native` (`forward`, or an +OAuth route the OAuth rule does not admit), `oauth-account-pool`, `combo-or-policy-route`, `effort-row` / `fast-row` (synthetic rows need the adapter's wire rewrite), `vision-preprocessing` (an image for a model declared unable to read it), and `bridge-only-policy` when operator policy that only the translated path applies would engage: a pinned reasoning effort for the route (`resolvePinnedEffort`, read with the translated @@ -259,12 +260,45 @@ is `envelope.freshBody()` when a source envelope exists, otherwise the ingress's `buildAnthropicMessagesPassthroughRequest` in `src/adapters/anthropic/passthrough.ts` builds the request from that body: the top-level allowlist (`model, messages, system, max_tokens, metadata, stop_sequences, stream, temperature, top_p, top_k, tools, tool_choice, thinking, output_config, -service_tier`), the wire model, and the URL, `anthropic-version`, client identity and key placement -the Anthropic adapter uses (`resolveAnthropicMessagesUrl`, `anthropicBaseRequestHeaders`, -`applyAnthropicKeyAuth`), plus the provider's configured headers. No caller header is read, so the -caller's `Authorization`, `x-api-key` and `anthropic-beta` never reach the provider; a beta -allowlist is PF-10. A dropped field has no name in the feature vocabulary, so it records no -feature effect. +service_tier`), the wire model, and the URL, `anthropic-version`, client identity and credential +placement the Anthropic adapter uses (`resolveAnthropicMessagesUrl`, `anthropicBaseRequestHeaders`, +`applyAnthropicKeyAuth` / `applyAnthropicOAuthAuth`), plus the provider's configured headers. The +builder never mutates its input. A dropped field has no name in the feature vocabulary, so it +records no feature effect. + +Caller betas. The only caller header the lane receives is `anthropic-beta`, which the ingress +hands over explicitly; `Authorization` and `x-api-key` never reach the provider. +`src/adapters/anthropic/beta-allowlist.ts` keeps a value only when it is on the list for the +destination's class and re-emits it in the list's own spelling: `interleaved-thinking-2025-05-14` +for `api.anthropic.com`, nothing for an Anthropic-compatible host. Proxy-owned betas (the OAuth +pair) are set by the builder, and an operator's configured beta is merged, not replaced. Any +dropped value adds `anthropic-beta-dropped` to the trace; the value itself is never recorded. + +Opaque state and credential domains. `src/protocols/opaque-state.ts` defines a credential domain +as the provider's base host plus its credential class (`key`, `oauth`, ...); first-party means +HTTPS to `api.anthropic.com` on the default port. Thinking `signature`s and `redacted_thinking` +blocks are sent only to first-party Anthropic. For any other or unknown destination the builder +sends a copy without them (the signature field dropped, the thinking text kept, a redacted block +dropped, a message left empty dropped) and the trace gains `opaque-state-stripped`. Under +`unrepresentable: "reject"` the ingress refuses such a request before any send (`blocked`, +`feature-unrepresentable` + `opaque-state-stripped`), and a rebuild that would strip mid-request +fails closed. Because the source body is never mutated, each build — including one after a key +re-selection moves to another domain — decides from the full envelope copy the lane was given. + +OAuth. Behind `managedMessagesNativeOAuth`, which `resolveProtocolSettings` treats as off unless +`managedMessagesNative` is on. Only the `anthropic` provider the OAuth store serves, only to +`api.anthropic.com` (the builder refuses any other host for an OAuth token), and only an unpooled +account set: `anthropicAccountPool.enabled` (config) or a stored quorum of two usable accounts +(`hasAnthropicFailoverQuorum`, supplied by the ingress and `count_tokens`, never by the planner) +declines with `oauth-account-pool`, because rotation, session affinity and quota ranking live in +`prepareResponsesTransport`. `src/server/messages-native-oauth.ts` resolves the account at +dispatch by the same steps that transport takes for an unpooled route (capture the selection, +resolve the active snapshot, commit against the capture) and re-checks the binding before every +physical send, re-resolving through the same owner if it moved. Planning and `count_tokens` read +config and the read-only account set only; nothing selects, refreshes or writes. The body gets the +Claude Code identity block and declared client tool names under the OAuth prefix; the answer's +`tool_use` names are mapped back for exactly those names. A 401 or 429 is answered as the bridge +answers an unpooled account: no refresh replay, no same-token replay, no rotation. `handleNativeMessages` mirrors native Chat on the shared pieces: `beginInferenceAttempt`, `createFinalRequestLog`, the request spend tracker charged per physical send, proactive key @@ -279,11 +313,17 @@ everything else is relayed as is). Upstream errors answer in Anthropic shape wit policy (transient 5xx as 529, replay refusals kept non-retryable). `count_tokens` estimates the body the builder would send when the route is eligible, and sends nothing. `tests/adapters/anthropic/anthropic-messages-passthrough.test.ts`, +`tests/adapters/anthropic/anthropic-beta-allowlist.test.ts`, +`tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts`, +`tests/responses/protocol-opaque-state.test.ts`, +`tests/responses/messages-native-oauth-eligibility.test.ts`, +`tests/claude-integration/messages-native-oauth.test.ts`, +`tests/claude-integration/messages-native-opaque-state.test.ts`, `tests/responses/messages-native-eligibility.test.ts`, `tests/responses/messages-native-bridge-policy.test.ts`, `tests/claude-integration/messages-native.test.ts` and `tests/claude-integration/messages-native-decline-trace.test.ts` pin the builder, the rule, the -lane and the decline trace. +lane, the decline trace, the beta allowlist, opaque state and OAuth. ## Settings @@ -295,9 +335,9 @@ present, closes when that value is present but malformed, and otherwise inherits `protocols.rollout` switch defaults off; the OAuth native-Messages switch is effective only with the key-auth one. The Chat and Messages ingresses read the unrepresentable policy (above); `directEncodersApply` reads `directEncoders` on both; the Chat ingress reads `nativeChatCombos` -for combo routes (above); `managedMessagesNative` is read through `nativeMessagesDeclineReason` -by the Messages ingress, `count_tokens` and the planner (below). No request path reads the -other rollout switches yet. +for combo routes (above); `managedMessagesNative` and `managedMessagesNativeOAuth` are read +through `nativeMessagesDeclineReason` by the Messages ingress, `count_tokens` and the planner +(above). No request path reads the other rollout switches yet. `claudeInboundDisabled` in `src/server/claude-messages.ts` is the Messages ingress reader: both `/v1/messages` and `/v1/messages/count_tokens` call it, so the two routes cannot disagree, and a From 0e57cc4305a0bf68fa8ffbd9a1a7e7d3b2bc141e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:27:27 +0900 Subject: [PATCH 157/173] test(messages): build credential-shaped fixtures at runtime for the privacy scan A literal bearer header and a userinfo URL read as secrets to privacy:scan; constructing them from parts keeps the same assertions without tripping the scanner. --- tests/claude-integration/messages-native-oauth.test.ts | 4 ++-- tests/responses/protocol-opaque-state.test.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/claude-integration/messages-native-oauth.test.ts b/tests/claude-integration/messages-native-oauth.test.ts index 94975c950eb..5d86a9001bf 100644 --- a/tests/claude-integration/messages-native-oauth.test.ts +++ b/tests/claude-integration/messages-native-oauth.test.ts @@ -176,7 +176,7 @@ describe("managed native Messages over Anthropic OAuth", () => { expect(sent).toHaveLength(1); const wire = sent[0]!; expect(wire.url).toBe("https://api.anthropic.com/v1/messages"); - expect(wire.headers.get("authorization")).toBe("Bearer synthetic-anthropic-access-0"); + expect(wire.headers.get("authorization")).toBe(`Bearer ${credential(0).access}`); expect(wire.headers.get("x-api-key")).toBeNull(); expect(wire.headers.get("anthropic-beta")).toBe(`${ANTHROPIC_OAUTH_BETA},${ALLOWED_BETA}`); expect((wire.body.system as { text: string }[])[0]!.text).toBe(CLAUDE_CODE_SYSTEM_INSTRUCTION); @@ -213,7 +213,7 @@ describe("managed native Messages over Anthropic OAuth", () => { const { response, row } = await send(fixtureConfig(), { ...BODY, stream: false }); expect(response.status).toBe(200); expect(row.protocolTrace).toMatchObject({ mode: "native" }); - expect(sent.map(entry => entry.headers.get("authorization"))).toEqual(["Bearer synthetic-anthropic-access-1"]); + expect(sent.map(entry => entry.headers.get("authorization"))).toEqual([`Bearer ${credential(1).access}`]); }); test("two usable accounts keep the bridge, which owns rotation", async () => { diff --git a/tests/responses/protocol-opaque-state.test.ts b/tests/responses/protocol-opaque-state.test.ts index 39eec07e678..2ec7273fcc6 100644 --- a/tests/responses/protocol-opaque-state.test.ts +++ b/tests/responses/protocol-opaque-state.test.ts @@ -37,6 +37,14 @@ function body() { }; } +/** `https://api.anthropic.com` with userinfo, built so no literal credential URL sits in source. */ +function withUserinfo(): string { + const url = new URL("https://api.anthropic.com"); + url.username = "fixture"; + url.password = "fixture"; + return url.href; +} + const FIRST_PARTY = credentialDomainFor({ baseUrl: "https://api.anthropic.com/v1", authMode: "key" }); const COMPATIBLE = credentialDomainFor({ baseUrl: "https://compatible.example/anthropic", authMode: "key" }); @@ -48,7 +56,7 @@ describe("credential domains", () => { "https://api.anthropic.com:444", "https://api.anthropic.com.example", "https://proxy.example/api.anthropic.com", - "https://user:pass@api.anthropic.com", + withUserinfo(), ]) { expect(credentialDomainFor({ baseUrl, authMode: "key" })?.firstPartyAnthropic).toBe(false); } From f9e721e6bf406b6b2ad0e04f8ccdee368fe235fe Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:16:29 +0900 Subject: [PATCH 158/173] feat(protocols): compare a recorded dispatch plan with the observed trace A pure leaf decides disagreement on mode, upstream and request path for the settled candidate; finalize sets planMismatch only when an input was recorded, never on throw. --- src/protocols/shadow.ts | 62 ++++++++++++++++++++++ src/protocols/trace.ts | 64 ++++++++++++++++++++++- tests/responses/protocol-contract.test.ts | 2 +- 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 src/protocols/shadow.ts diff --git a/src/protocols/shadow.ts b/src/protocols/shadow.ts new file mode 100644 index 00000000000..fbe147d0a31 --- /dev/null +++ b/src/protocols/shadow.ts @@ -0,0 +1,62 @@ +/** + * Shadow-plan comparison (PF-12, `protocols.rollout.shadowPlan`): does the dispatch-basis plan + * for a request agree with what the request actually did? + * + * LEAF MODULE (see `contract.ts`) and PURE. It compares two finished records and nothing else: + * no second request, no config read, no state. The caller (`trace.ts`) contains any throw. + * + * What is compared, and why only that: + * + * - The candidate is the one the request settled on (provider and model of the final route), + * falling back to the plan's first eligible candidate. A combo that failed over is compared + * against the target that answered, not against the first one the plan lists. + * - Mode, upstream wire and request path. The response path is not compared: with + * `directEncoders` on, the client side leaves the internal Responses hop while the request + * side keeps it, and the planner does not model the encoder switch. + * - A Messages request forwarded with the caller's own Anthropic credential is native by the + * caller's choice; the plan says so with `caller-credential-required` and never predicts it. + * - A blocked trace agrees with a blocked plan. A compatibility reject is Claude-integration + * policy the planner does not model, so it is not compared. + */ +import type { ProtocolHop } from "./contract"; +import type { ProtocolPlanCandidateV1, ProtocolPlanV1, ProtocolTraceV1 } from "./dto"; + +/** The route the request settled on, as the request log context records it. */ +export interface ShadowSettledRoute { + provider?: string; + model?: string; +} + +function samePath(a: readonly ProtocolHop[], b: readonly ProtocolHop[]): boolean { + return a.length === b.length && a.every((hop, index) => hop === b[index]); +} + +function settledCandidate(plan: ProtocolPlanV1, settled: ShadowSettledRoute): ProtocolPlanCandidateV1 | undefined { + const exact = settled.provider && settled.model + ? plan.candidates.find(candidate => candidate.provider === settled.provider && candidate.model === settled.model) + : undefined; + return exact ?? plan.candidates.find(candidate => candidate.eligible); +} + +/** True when the plan and the observed trace disagree about the path the request took. */ +export function shadowPlanMismatch( + plan: ProtocolPlanV1, + trace: ProtocolTraceV1, + settled: ShadowSettledRoute = {}, +): boolean { + if (plan.inbound !== trace.inbound) return true; + if (trace.mode === "blocked") { + if (trace.reasonCodes.includes("compatibility-reject")) return false; + return plan.mode !== "blocked"; + } + if (trace.inbound === "messages" && trace.mode === "native" + && plan.reasonCodes.includes("caller-credential-required") + && samePath(trace.requestPath, ["messages", "messages"])) { + return false; + } + const candidate = settledCandidate(plan, settled); + if (!candidate || candidate.mode === "blocked") return true; + return candidate.mode !== trace.mode + || candidate.upstream !== trace.upstream + || !samePath(candidate.requestPath, trace.requestPath); +} diff --git a/src/protocols/trace.ts b/src/protocols/trace.ts index 73bb5cb3843..b8c7836b048 100644 --- a/src/protocols/trace.ts +++ b/src/protocols/trace.ts @@ -9,6 +9,12 @@ * Every function here is side-effect free apart from its own WeakMap and never throws into the * request path: a trace is diagnostics, and a request must not fail because its trace could not * be recorded. Nothing conversation-derived is kept — only fixed vocabulary. + * + * Shadow plan (PF-12): when `protocols.rollout.shadowPlan` is on, the ingress records the + * dispatch-basis plan input for the request (`src/protocols/shadow-plan.ts`); at finalize the + * plan is computed with the pure planner and compared with the observed trace, and a + * disagreement sets `planMismatch`. Nothing is recorded with the switch off, so the trace is + * unchanged, and a failed comparison leaves the trace as observed. */ import { PROTOCOL_CONTRACT_VERSION, @@ -23,6 +29,8 @@ import { import { PROTOCOL_DTO_LIMITS, PROTOCOL_TRACE_SCHEMA_VERSION, type ProtocolAttemptTraceV1, type ProtocolTraceV1 } from "./dto"; import { featureEffectsForPath, isProtocolFeature, type ProtocolFeature } from "./features"; import { deliveryModeForLane, requestPathForLane, responsePathForLane, type ProtocolLane } from "./path"; +import { planProtocol, type ProtocolPlanInput } from "./plan"; +import { shadowPlanMismatch } from "./shadow"; /** Features are computed inside the mark, so a thrown feature scan is contained there too. */ export type ProtocolFeatureSource = Iterable | (() => Iterable); @@ -56,6 +64,7 @@ export interface ProtocolTraceAttempt { const requestMarks = new WeakMap(); const attemptMarks = new WeakMap(); +const shadowInputs = new WeakMap(); function boundedReasons(codes: Iterable): ProtocolReasonCode[] { const out: ProtocolReasonCode[] = []; @@ -99,6 +108,28 @@ export function markProtocolEntry( } } +/** Features the request's entry or blocked mark recorded, for the shadow plan input. */ +export function protocolMarkFeatures(logCtx: object): readonly ProtocolFeature[] | undefined { + try { + const mark = requestMarks.get(logCtx); + return mark ? [...mark.features] : undefined; + } catch { + return undefined; + } +} + +/** + * Record the dispatch-basis plan input the shadow comparison runs at finalize. The input holds + * fixed vocabulary and the provider and model names the server already exposes, nothing else. + */ +export function markProtocolShadowPlanInput(logCtx: object, input: ProtocolPlanInput): void { + try { + shadowInputs.set(logCtx, input); + } catch { + /* a trace must never fail the request it describes */ + } +} + /** Record a refusal made before any upstream send. */ export function markProtocolBlocked( logCtx: object, @@ -197,6 +228,9 @@ function pathReason(path: ResolvedPath): ProtocolReasonCode { return path.requestPath.includes("ir") ? "cross-wire-ir" : "cross-wire-codec"; } +/** The context fields the trace reads at finalize. */ +export type ProtocolTraceContext = object & { inboundProtocol?: Protocol; provider?: string; model?: string }; + /** * Derive the observed trace at finalize. `attempts` are the live attempt objects (the ones * `markAttemptProtocolPath` was keyed by), not detached copies. @@ -205,9 +239,37 @@ function pathReason(path: ResolvedPath): ProtocolReasonCode { * - Responses inbound without a mark: the final adapter's wire decides the path. * - Chat or Messages: the entry lane decides, through `path.ts`. * - no attempt and no native or blocked mark: `undefined`; nothing is guessed. + * + * With a shadow plan input recorded, a disagreeing plan adds `planMismatch: true`. */ export function protocolTraceForRequest( - logCtx: object & { inboundProtocol?: Protocol }, + logCtx: ProtocolTraceContext, + attempts: readonly ProtocolTraceAttempt[] | undefined, +): ProtocolTraceV1 | undefined { + const trace = observedTrace(logCtx, attempts); + return trace ? withShadowPlan(logCtx, trace) : undefined; +} + +/** + * Compare the recorded dispatch plan with the observed trace. Pure and local: the planner reads + * only the recorded input. Any throw leaves the observed trace as it was. + */ +function withShadowPlan(logCtx: ProtocolTraceContext, trace: ProtocolTraceV1): ProtocolTraceV1 { + try { + const input = shadowInputs.get(logCtx); + if (!input) return trace; + const settled = { + ...(typeof logCtx.provider === "string" ? { provider: logCtx.provider } : {}), + ...(typeof logCtx.model === "string" ? { model: logCtx.model } : {}), + }; + return shadowPlanMismatch(planProtocol(input), trace, settled) ? { ...trace, planMismatch: true } : trace; + } catch { + return trace; + } +} + +function observedTrace( + logCtx: ProtocolTraceContext, attempts: readonly ProtocolTraceAttempt[] | undefined, ): ProtocolTraceV1 | undefined { try { diff --git a/tests/responses/protocol-contract.test.ts b/tests/responses/protocol-contract.test.ts index 9ee89941a5e..24376d68300 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", "path.ts", "plan.ts", "guard.ts"]; + const LEAVES = ["contract.ts", "features.ts", "baseline.ts", "dto.ts", "path.ts", "plan.ts", "guard.ts", "shadow.ts"]; const ALLOWED = new Set(["./contract", "./features", "./dto", "./path", "../compatibility/manifest"]); for (const file of LEAVES) { From 02f4db39ce55407455bc83c32a42bca8ffdc3a84 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:16:29 +0900 Subject: [PATCH 159/173] feat(protocols): record the shadow plan input at the Chat and Messages entry marks Behind protocols.rollout.shadowPlan (off): the preview snapshot with basis dispatch is kept beside the marks; nothing is read, sent or stored with the switch off. --- src/protocols/shadow-plan.ts | 36 ++++++++++++++++++++++++++++++++++ src/server/chat-completions.ts | 2 ++ src/server/claude-messages.ts | 3 +++ 3 files changed, 41 insertions(+) create mode 100644 src/protocols/shadow-plan.ts diff --git a/src/protocols/shadow-plan.ts b/src/protocols/shadow-plan.ts new file mode 100644 index 00000000000..ddb6c01c487 --- /dev/null +++ b/src/protocols/shadow-plan.ts @@ -0,0 +1,36 @@ +/** + * Shadow plan recording (PF-12, `protocols.rollout.shadowPlan`, default off). + * + * SERVER SIDE (not a leaf): it reads config through `plan-snapshot.ts`. An ingress calls + * `recordProtocolShadowPlan` right after its entry mark. With the switch off it returns before + * reading anything else. With it on it builds the same side-effect-free snapshot the dashboard + * preview uses, with basis `dispatch`, from the selector the client sent and the features the + * entry mark already collected, and stores it beside the marks. `trace.ts` runs the pure planner + * on it at finalize and compares. + * + * Nothing here sends, fetches, advances combo state or writes: the snapshot expands combos and + * policies from config and uses `routeModel`'s deterministic branches only. The stored input is + * fixed vocabulary plus the provider and model names the server already exposes. Any throw is + * swallowed: a shadow plan is diagnostics and never fails or changes the request it describes. + */ +import type { OcxConfig } from "../types"; +import type { Protocol } from "./contract"; +import { buildProtocolPlanSnapshot } from "./plan-snapshot"; +import { resolveProtocolSettings } from "./settings"; +import { markProtocolShadowPlanInput, protocolMarkFeatures } from "./trace"; + +export function recordProtocolShadowPlan( + logCtx: object, + config: OcxConfig, + request: { inbound: Protocol; model: unknown }, +): void { + try { + if (!resolveProtocolSettings(config).rollout.shadowPlan) return; + if (typeof request.model !== "string" || request.model.length === 0) return; + const features = protocolMarkFeatures(logCtx) ?? []; + const input = buildProtocolPlanSnapshot(config, { model: request.model, inbound: request.inbound, features }, "dispatch"); + markProtocolShadowPlanInput(logCtx, input); + } catch { + /* a shadow plan must never fail the request it describes */ + } +} diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 46ce2a9f7d9..4ce9efc985c 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -79,6 +79,7 @@ import { checkRepresentable, unrepresentableMessage } from "../protocols/guard"; import { requestPathForLane } from "../protocols/path"; import { resolveProtocolSettings } from "../protocols/settings"; import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace"; +import { recordProtocolShadowPlan } from "../protocols/shadow-plan"; import { jsonCompletionSse } from "./chat-native-sse"; import { parseRequestEffortRowId } from "./effort-row"; import { parseSyntheticRowId } from "./fast-row"; @@ -278,6 +279,7 @@ async function handleChatCompletionsWithBudget( reasonCodes: !chatNativeRoute && nativeDecline ? [nativeDecline] : [], features: envelope ? () => envelope.features() : () => featuresFromChatBody(chatBody), }); + recordProtocolShadowPlan(logCtx, config, { inbound: "chat", model: requestedModel }); if (chatNativeRoute) { return handleNativeChatCompletions({ req, diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 94ee4429c64..4bcb558cde3 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -73,6 +73,7 @@ import { checkRepresentable, unrepresentableMessage } from "../protocols/guard"; import { requestPathForLane } from "../protocols/path"; import { resolveApiSurfaceSettings, resolveProtocolSettings } from "../protocols/settings"; import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace"; +import { recordProtocolShadowPlan } from "../protocols/shadow-plan"; import { nativeMessagesDeclineReason, type NativeMessagesSelector } from "./messages-native-eligibility"; import { isApiAuthRequired, @@ -833,6 +834,7 @@ async function handleClaudeMessagesWithBudget( : () => (scannedFeatures ??= featuresFromMessagesBody(messagesBody)); if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model, cc)) { markProtocolEntry(logCtx, { inbound: "messages", lane: "native", features: messagesFeatures }); + recordProtocolShadowPlan(logCtx, config, { inbound: "messages", model: requestedModel }); return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); } // Capture source semantics before effort rewriting or translation drops fields. @@ -869,6 +871,7 @@ async function handleClaudeMessagesWithBudget( reasonCodes: effortRow ? ["effort-row"] : fastRow ? ["fast-row"] : [], features: messagesFeatures, }); + recordProtocolShadowPlan(logCtx, config, { inbound: "messages", model: requestedModel }); if (isRec(anthropicBody) && effortOverride) { anthropicBody.output_config = { ...(isRec(anthropicBody.output_config) ? anthropicBody.output_config : {}), From bf33f6873e059912d36fccfae408fc1cf08471ec Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:16:29 +0900 Subject: [PATCH 160/173] test(protocols): pin shadow plan match, mismatch, switch-off and throw safety Covers settled-candidate selection, the uncompared response path and compatibility rejects, and that old trace rows without planMismatch stay valid. --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + tests/responses/protocol-shadow-plan.test.ts | 242 +++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 tests/responses/protocol-shadow-plan.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 25773f7d82f..c81e4b3f908 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -346,6 +346,7 @@ "protocol-trace.test.ts": "responses", "protocol-plan.test.ts": "responses", "protocol-plan-snapshot.test.ts": "responses", + "protocol-shadow-plan.test.ts": "responses", "protocol-envelope.test.ts": "responses", "protocol-guard.test.ts": "responses", "protocol-ingress-guard.test.ts": "responses", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ccfbd18e11a..0e7c9b3611e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -172,6 +172,7 @@ "protocol-trace.test.ts": "responses", "protocol-plan.test.ts": "responses", "protocol-plan-snapshot.test.ts": "responses", + "protocol-shadow-plan.test.ts": "responses", "protocol-envelope.test.ts": "responses", "protocol-guard.test.ts": "responses", "protocol-ingress-guard.test.ts": "responses", diff --git a/tests/responses/protocol-shadow-plan.test.ts b/tests/responses/protocol-shadow-plan.test.ts new file mode 100644 index 00000000000..4e38ac50c1d --- /dev/null +++ b/tests/responses/protocol-shadow-plan.test.ts @@ -0,0 +1,242 @@ +/** + * Shadow plan comparison (PF-12, `protocols.rollout.shadowPlan`): the dispatch-basis plan is + * compared with the observed trace at finalize, and only a disagreement marks the trace. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearComboSelectionState } from "../../src/combos"; +import { isProtocolTraceV1, parseProtocolTraceV1, type ProtocolPlanV1, type ProtocolTraceV1 } from "../../src/protocols/dto"; +import { planProtocol, type ProtocolPlanInput } from "../../src/protocols/plan"; +import { shadowPlanMismatch } from "../../src/protocols/shadow"; +import { recordProtocolShadowPlan } from "../../src/protocols/shadow-plan"; +import { + markProtocolBlocked, + markProtocolEntry, + markProtocolShadowPlanInput, + protocolTraceForRequest, +} from "../../src/protocols/trace"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-shadow-plan-")); + process.env.OPENCODEX_HOME = testDir; + clearComboSelectionState(); +}); + +afterEach(() => { + clearComboSelectionState(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) removeTreeWithRetry(testDir); +}); + +const attempt = (ordinal: number, adapter: string) => ({ ordinal, adapter }); + +function planInput(overrides: Partial = {}): ProtocolPlanInput { + return { + inbound: "chat", + requestedModel: "m1", + routeKind: "direct", + candidates: [{ provider: "a", model: "m1", adapter: "openai-chat", nativeEligible: true, declineReasons: [] }], + features: [], + surfaces: { responses: { enabled: true }, chat: { enabled: true }, messages: { enabled: true } }, + settings: { unrepresentable: "legacy" }, + policyRevision: "p1-test", + basis: "dispatch", + ...overrides, + }; +} + +function config(shadowPlan: boolean | undefined): OcxConfig { + return { + port: 10100, + defaultProvider: "a", + providers: { + a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", apiKey: "ka", models: ["m1"] }, + }, + ...(shadowPlan === undefined ? {} : { protocols: { rollout: { shadowPlan } } }), + } as OcxConfig; +} + +function trace(overrides: Partial = {}): ProtocolTraceV1 { + return { + v: 1, + inbound: "chat", + mode: "native", + upstream: "chat", + requestPath: ["chat", "chat"], + responsePath: ["chat", "chat"], + reasonCodes: ["same-wire-native"], + contractVersion: "test", + ...overrides, + }; +} + +describe("shadowPlanMismatch", () => { + const nativePlan: ProtocolPlanV1 = planProtocol(planInput()); + + test("agrees when the settled candidate's mode, upstream and request path match", () => { + expect(shadowPlanMismatch(nativePlan, trace(), { provider: "a", model: "m1" })).toBe(false); + }); + + test("disagrees when the request took a different lane than the plan predicted", () => { + const bridged = trace({ + mode: "legacy-bridge", + requestPath: ["chat", "responses-internal", "ir", "chat"], + responsePath: ["chat", "ir", "responses-internal", "chat"], + }); + expect(shadowPlanMismatch(nativePlan, bridged, { provider: "a", model: "m1" })).toBe(true); + }); + + test("the response path is not compared, so direct encoders do not read as a mismatch", () => { + const plan = planProtocol(planInput({ + candidates: [{ provider: "c", model: "m9", adapter: "anthropic", nativeEligible: false, declineReasons: [] }], + })); + const encoded = trace({ + mode: "legacy-bridge", + upstream: "messages", + requestPath: ["chat", "responses-internal", "ir", "messages"], + responsePath: ["messages", "ir", "chat"], + }); + expect(shadowPlanMismatch(plan, encoded, { provider: "c", model: "m9" })).toBe(false); + }); + + test("a combo is compared against the target that answered, not the first one listed", () => { + const plan = planProtocol(planInput({ + routeKind: "combo", + candidates: [ + { provider: "a", model: "m1", adapter: "openai-chat", nativeEligible: false, declineReasons: ["combo-or-policy-route"] }, + { provider: "r", model: "m3", adapter: "openai-responses", nativeEligible: false, declineReasons: [] }, + ], + })); + const failedOver = trace({ + mode: "translated", + upstream: "responses", + requestPath: ["chat", "responses"], + responsePath: ["responses", "chat"], + }); + expect(shadowPlanMismatch(plan, failedOver, { provider: "r", model: "m3" })).toBe(false); + expect(shadowPlanMismatch(plan, failedOver, {})).toBe(true); + }); + + test("a blocked trace agrees only with a blocked plan; a compatibility reject is not compared", () => { + const blocked = trace({ mode: "blocked", requestPath: [], responsePath: [], reasonCodes: ["feature-unrepresentable"] }); + delete blocked.upstream; + const rejectPlan = planProtocol(planInput({ features: ["request.multiple_choices"], settings: { unrepresentable: "reject" }, candidates: [ + { provider: "r", model: "m3", adapter: "openai-responses", nativeEligible: false, declineReasons: [] }, + ] })); + expect(rejectPlan.mode).toBe("blocked"); + expect(shadowPlanMismatch(rejectPlan, blocked)).toBe(false); + expect(shadowPlanMismatch(nativePlan, blocked)).toBe(true); + expect(shadowPlanMismatch(nativePlan, { ...blocked, reasonCodes: ["compatibility-reject"] })).toBe(false); + }); + + test("caller-forward Messages passthrough is the caller's choice, which the plan never predicts", () => { + const plan = planProtocol(planInput({ + inbound: "messages", + reasonCodes: ["caller-credential-required"], + candidates: [{ provider: "anthropic", model: "claude-x", adapter: "anthropic", nativeEligible: false, declineReasons: [] }], + })); + const passthrough = trace({ + inbound: "messages", + upstream: "messages", + requestPath: ["messages", "messages"], + responsePath: ["messages", "messages"], + }); + expect(shadowPlanMismatch(plan, passthrough)).toBe(false); + }); +}); + +describe("protocolTraceForRequest with a shadow plan input", () => { + test("no recorded input leaves the trace without planMismatch", () => { + const ctx = { provider: "a", model: "m1" }; + markProtocolEntry(ctx, { inbound: "chat", lane: "bridge" }); + const observed = protocolTraceForRequest(ctx, [attempt(1, "openai-chat")]); + expect(observed).toBeDefined(); + expect("planMismatch" in observed!).toBe(false); + }); + + test("an agreeing plan adds nothing and a disagreeing plan sets planMismatch: true", () => { + const agreeing = { provider: "a", model: "m1" }; + markProtocolEntry(agreeing, { inbound: "chat", lane: "native" }); + markProtocolShadowPlanInput(agreeing, planInput()); + const matched = protocolTraceForRequest(agreeing, [attempt(1, "openai-chat")]); + expect(matched?.mode).toBe("native"); + expect("planMismatch" in matched!).toBe(false); + + const disagreeing = { provider: "a", model: "m1" }; + markProtocolEntry(disagreeing, { inbound: "chat", lane: "bridge" }); + markProtocolShadowPlanInput(disagreeing, planInput()); + const mismatched = protocolTraceForRequest(disagreeing, [attempt(1, "openai-chat")]); + expect(mismatched?.planMismatch).toBe(true); + expect(isProtocolTraceV1(mismatched)).toBe(true); + expect(parseProtocolTraceV1(mismatched)?.planMismatch).toBe(true); + }); + + test("a blocked request is compared too", () => { + const ctx = {}; + markProtocolBlocked(ctx, { inbound: "chat", reasonCodes: ["feature-unrepresentable"] }); + markProtocolShadowPlanInput(ctx, planInput()); + expect(protocolTraceForRequest(ctx, undefined)?.planMismatch).toBe(true); + }); + + test("a comparison that throws leaves the observed trace exactly as it was", () => { + const plain = { provider: "a", model: "m1" }; + markProtocolEntry(plain, { inbound: "chat", lane: "bridge" }); + const expected = protocolTraceForRequest(plain, [attempt(1, "openai-chat")]); + + const broken = { provider: "a", model: "m1" }; + markProtocolEntry(broken, { inbound: "chat", lane: "bridge" }); + // `surfaces` without the inbound makes the planner throw on its first read. + markProtocolShadowPlanInput(broken, { ...planInput(), surfaces: {} } as unknown as ProtocolPlanInput); + expect(protocolTraceForRequest(broken, [attempt(1, "openai-chat")])).toEqual(expected); + }); +}); + +describe("recordProtocolShadowPlan", () => { + test("with the switch off or absent nothing is recorded and no field appears", () => { + for (const shadowPlan of [undefined, false]) { + const ctx = { provider: "a", model: "m1" }; + markProtocolEntry(ctx, { inbound: "chat", lane: "bridge" }); + recordProtocolShadowPlan(ctx, config(shadowPlan), { inbound: "chat", model: "m1" }); + const observed = protocolTraceForRequest(ctx, [attempt(1, "openai-chat")]); + expect(observed?.mode).toBe("legacy-bridge"); + expect("planMismatch" in observed!).toBe(false); + } + }); + + test("with the switch on the dispatch plan is compared at finalize", () => { + const matching = { provider: "a", model: "m1" }; + markProtocolEntry(matching, { inbound: "chat", lane: "native" }); + recordProtocolShadowPlan(matching, config(true), { inbound: "chat", model: "m1" }); + expect("planMismatch" in protocolTraceForRequest(matching, [attempt(1, "openai-chat")])!).toBe(false); + + const diverging = { provider: "a", model: "m1" }; + markProtocolEntry(diverging, { inbound: "chat", lane: "bridge" }); + recordProtocolShadowPlan(diverging, config(true), { inbound: "chat", model: "m1" }); + expect(protocolTraceForRequest(diverging, [attempt(1, "openai-chat")])?.planMismatch).toBe(true); + }); + + test("never throws into the request, whatever it is handed", () => { + const hostile = { get protocols(): never { throw new Error("config read failed"); } } as unknown as OcxConfig; + expect(() => recordProtocolShadowPlan({}, hostile, { inbound: "chat", model: "m1" })).not.toThrow(); + expect(() => recordProtocolShadowPlan({}, config(true), { inbound: "chat", model: 42 })).not.toThrow(); + expect(() => recordProtocolShadowPlan({}, config(true), { inbound: "chat", model: "no-such-model" })).not.toThrow(); + }); +}); + +describe("ProtocolTraceV1 planMismatch field", () => { + test("old rows without the field stay valid, and only `true` is accepted", () => { + expect(isProtocolTraceV1(trace())).toBe(true); + expect(isProtocolTraceV1({ ...trace(), planMismatch: true })).toBe(true); + expect(isProtocolTraceV1({ ...trace(), planMismatch: false })).toBe(false); + expect(isProtocolTraceV1({ ...trace(), planMismatch: "yes" })).toBe(false); + }); +}); From 97cbbd6dcf1c3c6963e335b32dbb8648289c1a9e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:19:28 +0900 Subject: [PATCH 161/173] feat(cli): add ocx api protocols, explain and policy Thin clients of the protocol management routes; policy reads when bare and writes only when a setting flag is given, leaving value validation to the server. --- src/cli/api-protocols.ts | 206 +++++++++++++++++++++++++++++++++++++++ src/cli/dispatch.ts | 4 + src/cli/help.ts | 1 + src/cli/registry.ts | 11 +++ 4 files changed, 222 insertions(+) create mode 100644 src/cli/api-protocols.ts diff --git a/src/cli/api-protocols.ts b/src/cli/api-protocols.ts new file mode 100644 index 00000000000..b5635c094b3 --- /dev/null +++ b/src/cli/api-protocols.ts @@ -0,0 +1,206 @@ +/** + * `ocx api protocols | explain | policy`: the CLI side of the protocol management routes + * (`src/server/management/protocol-routes.ts`). + * + * - `protocols` reads `GET /api/protocols` (optionally `?provider=`). + * - `explain` posts `POST /api/protocols/plan`: a preview computed from config that sends + * nothing upstream. + * - `policy` with no setting flag reads the same GET; with one it sends + * `PATCH /api/protocols/settings`, which changes the operator's config. It is only ever + * invoked explicitly: nothing in the CLI calls it on the operator's behalf. + * + * Validation of values the server owns (rollout switch names, cross-field rules) stays on the + * server, so the two cannot drift; the CLI rejects only malformed argv. + */ +import { isProtocol } from "../protocols/contract"; +import { isProtocolFeature } from "../protocols/features"; +import { + CliUsageError, + csv, + printData, + rejectArgs, + runCliAction, + runtimeRequest, + takeFlag, + takeOption, + type RuntimeApiDeps, +} from "./runtime-api"; + +const USAGE = `Usage: + ocx api protocols [--provider ] [--json] + ocx api explain --model --inbound [--feature [,]]... [--json] + ocx api policy [--messages ] [--unrepresentable ] + [--rollout =]... [--json]`; + +type Rec = Record; +function isRec(value: unknown): value is Rec { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** Every value of a repeatable option, in argv order. */ +function takeRepeated(args: string[], flag: string): string[] { + const values: string[] = []; + for (let value = takeOption(args, flag); value !== undefined; value = takeOption(args, flag)) values.push(value); + return values; +} + +function onOff(flag: string, raw: string): boolean { + if (raw === "on") return true; + if (raw === "off") return false; + throw new CliUsageError(`${flag} must be on or off`, USAGE); +} + +function text(value: unknown): string { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : "-"; +} + +function list(value: unknown): string { + return Array.isArray(value) && value.length > 0 ? value.map(text).join(", ") : "none"; +} + +/** Human view of the `GET /api/protocols` body (also the PATCH answer). */ +export function protocolInfoLines(payload: unknown): string[] { + if (!isRec(payload)) return [text(payload)]; + const lines = [ + `Contract: ${text(payload.contractVersion)} policy revision: ${text(payload.policyRevision)}`, + ]; + if (isRec(payload.surfaces)) { + for (const [name, surface] of Object.entries(payload.surfaces)) { + if (!isRec(surface)) continue; + lines.push(`API ${name}: ${surface.enabled === true ? "open" : "closed"} (${text(surface.source)})`); + } + } + if (isRec(payload.settings)) { + lines.push(`Unrepresentable features: ${text(payload.settings.unrepresentable)}`); + if (isRec(payload.settings.rollout)) { + for (const [name, on] of Object.entries(payload.settings.rollout)) { + lines.push(`Rollout ${name}: ${on === true ? "on" : "off"}`); + } + } + } + if (Array.isArray(payload.features)) lines.push(`Features: ${payload.features.length} known (--json lists them)`); + if (isRec(payload.provider)) { + const provider = payload.provider; + lines.push( + `Provider ${text(provider.name)}: adapter ${text(provider.adapter)} (${text(provider.adapterSource)}), ` + + `upstream ${text(provider.upstream)}, auth ${text(provider.authMode)}`, + ); + const overrides = Array.isArray(provider.modelOverrides) ? provider.modelOverrides : []; + for (const override of overrides) { + if (!isRec(override)) continue; + lines.push(` model ${text(override.model)}: adapter ${text(override.adapter)} (${text(override.source)})`); + } + if (provider.modelOverridesTruncated === true) lines.push(" more model overrides exist; --json has the same capped list"); + } + return lines; +} + +/** Human view of a `ProtocolPlanV1`. */ +export function protocolPlanLines(plan: unknown): string[] { + if (!isRec(plan)) return [text(plan)]; + const lines = [ + `${text(plan.inbound)} ${text(plan.requestedModel)}: ${text(plan.mode)} (${text(plan.routeKind)} route, ${text(plan.basis)})`, + `Reasons: ${list(plan.reasonCodes)}`, + ]; + const candidates = Array.isArray(plan.candidates) ? plan.candidates : []; + for (const candidate of candidates) { + if (!isRec(candidate)) continue; + const path = Array.isArray(candidate.requestPath) && candidate.requestPath.length > 0 + ? candidate.requestPath.map(text).join(" > ") + : "no path"; + lines.push( + ` ${text(candidate.provider)}/${text(candidate.model)}: ${text(candidate.mode)} ${path}` + + `${candidate.eligible === false ? " (refused under reject)" : ""}`, + ); + } + lines.push(`Guaranteed features: ${list(plan.guaranteedFeatures)}`); + lines.push(`Partial features: ${list(plan.partialFeatures)}`); + return lines; +} + +async function protocols(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const provider = takeOption(args, "--provider"); + rejectArgs(args, USAGE); + const path = provider === undefined ? "/api/protocols" : `/api/protocols?provider=${encodeURIComponent(provider)}`; + const result = await runtimeRequest(path, {}, deps); + printData(result, wantsJson, protocolInfoLines(result)); +} + +async function explain(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const model = takeOption(args, "--model"); + const inbound = takeOption(args, "--inbound"); + const features = takeRepeated(args, "--feature").flatMap(value => csv(value) ?? []); + rejectArgs(args, USAGE); + if (!model) throw new CliUsageError("--model is required", USAGE); + if (!isProtocol(inbound)) throw new CliUsageError("--inbound must be responses, chat or messages", USAGE); + const unknown = features.filter(feature => !isProtocolFeature(feature)); + if (unknown.length > 0) throw new CliUsageError("--feature names a feature this CLI does not know; `ocx api protocols --json` lists them", USAGE); + const body = { model, inbound, ...(features.length > 0 ? { features: [...new Set(features)] } : {}) }; + const result = await runtimeRequest("/api/protocols/plan", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, deps); + printData(result, wantsJson, protocolPlanLines(result)); +} + +/** The `PATCH /api/protocols/settings` body the flags describe, or `undefined` for a read. */ +export function protocolPolicyPatch(args: string[]): Rec | undefined { + const messages = takeOption(args, "--messages"); + const unrepresentable = takeOption(args, "--unrepresentable"); + const rolloutArgs = takeRepeated(args, "--rollout"); + const body: Rec = {}; + if (messages !== undefined) body.messagesEnabled = onOff("--messages", messages); + if (unrepresentable !== undefined) { + if (unrepresentable !== "legacy" && unrepresentable !== "reject") { + throw new CliUsageError("--unrepresentable must be legacy or reject", USAGE); + } + body.unrepresentable = unrepresentable; + } + if (rolloutArgs.length > 0) { + const rollout: Rec = {}; + for (const entry of rolloutArgs) { + const match = /^([A-Za-z]+)=(on|off)$/.exec(entry); + if (!match) throw new CliUsageError("--rollout takes =", USAGE); + if (Object.hasOwn(rollout, match[1]!)) throw new CliUsageError("--rollout names the same switch twice", USAGE); + rollout[match[1]!] = match[2] === "on"; + } + body.rollout = rollout; + } + return Object.keys(body).length > 0 ? body : undefined; +} + +async function policy(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const patch = protocolPolicyPatch(args); + rejectArgs(args, USAGE); + // No setting flag means show. A read must not write the value it is reporting. + if (!patch) { + const result = await runtimeRequest("/api/protocols", {}, deps); + printData(result, wantsJson, protocolInfoLines(result)); + return; + } + const result = await runtimeRequest("/api/protocols/settings", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }, deps); + printData(result, wantsJson, ["Protocol settings updated.", ...protocolInfoLines(result)]); +} + +export async function handleApiCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + return runCliAction(async () => { + const [sub, ...rest] = argv; + if (sub === "protocols") await protocols(rest, deps); + else if (sub === "explain") await explain(rest, deps); + else if (sub === "policy") await policy(rest, deps); + else throw new CliUsageError(sub ? `unknown api command ${sub}` : "an api command is required", USAGE); + }); +} + +export const API_USAGE = USAGE; diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6cf8c2c92dc..66a933949a4 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -896,6 +896,10 @@ const commandRunners: Record = { const { handleAccessCommand } = await import("./access"); return await handleAccessCommand(["key", ...deps.args.slice(1)]); }, + api: async deps => { + const { handleApiCommand } = await import("./api-protocols"); + return await handleApiCommand(deps.args.slice(1)); + }, export: async deps => { const { handleExportCommand } = await import("./export-command"); return await handleExportCommand(deps.args.slice(1)); diff --git a/src/cli/help.ts b/src/cli/help.ts index afbf14e917d..967e431add0 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -82,6 +82,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information + ocx api Protocol paths: vocabulary, request-path preview, and policy ocx export --client Print a client config wired to the running proxy (15 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply diff --git a/src/cli/registry.ts b/src/cli/registry.ts index e27d9e025f1..214baa31a25 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -403,6 +403,17 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ ], }, { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, + { + name: "api", + usage: "ocx api ...", + summary: "Inspect protocol paths, preview a request path, and read or change the protocol policy.", + details: [ + "protocols [--provider ] Contract version, API surfaces, protocol settings and feature vocabulary.", + "explain --model --inbound [--feature ]... Preview the request path; sends nothing upstream.", + "policy Read the protocol policy; with --messages, --unrepresentable or --rollout = it changes config.", + "Every rollout switch defaults off. `ocx api policy` writes only when a setting flag is given.", + ], + }, { name: "export", usage: "ocx export --client [--json] [--out ] [--force]", From 34ddd3b1a5430b4fb661df4fd912ae690123f204 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:19:28 +0900 Subject: [PATCH 162/173] feat(cli): declare the api capabilities and retire the PF-12 deferred-verb exemptions The mutation-consistency check now reads the registry's mutates flag, so the read-only plan POST is not forced to claim a write; the skill surface is regenerated. --- .../ocx/references/01_management_surface.md | 59 ++++++++++++++++++- src/cli/capabilities.ts | 45 ++++++++++++++ src/server/management/route-registry.ts | 6 +- tests/cli/cli-capabilities.test.ts | 12 ++-- 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 49853b79351..93917250ff0 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -475,6 +475,40 @@ JSON mode: `none`. - Reports desired, effective, keychain trust, the Desktop egress profile, the model count and a reason with the next command to run. +### `ocx api protocols` + +Read the protocol contract version, API surfaces, protocol settings and feature vocabulary. + +| Method | Route | +|---|---| +| GET | `/api/protocols` | + +| Flag | Value | Meaning | +|---|---|---| +| `--provider` | string | Add one configured provider's upstream wire and who decided it. | +| `--json` | boolean | Emit the GET /api/protocols body. | + +JSON mode: `payload`. + +### `ocx api explain` + +Preview the request path a model would take from one inbound API, computed from config. + +| Method | Route | +|---|---| +| POST | `/api/protocols/plan` | + +| Flag | Value | Meaning | +|---|---|---| +| `--model` | string | Model selector as a client would send it. | +| `--inbound` | string | Inbound API: responses, chat or messages. | +| `--feature` | string | Request feature key to judge; repeatable or comma-separated. | +| `--json` | boolean | Emit the ProtocolPlanV1 preview. | + +JSON mode: `payload`. + +- A read-only POST: nothing is sent upstream, no combo state advances and the input is not logged. + ## State-changing capabilities Each of these writes. Check the flags column before running one unattended. @@ -1064,8 +1098,29 @@ JSON mode: `payload`. - A bare invocation reads and never writes. +### `ocx api policy` + +Read the protocol policy, or change the Messages surface, unrepresentable policy and rollout switches. + +| Method | Route | +|---|---| +| GET | `/api/protocols` | +| PATCH | `/api/protocols/settings` | + +| Flag | Value | Meaning | +|---|---|---| +| `--messages` | string | Open or close the Messages API: on or off. Off also turns the Claude integration off. | +| `--unrepresentable` | string | legacy keeps today's behavior; reject refuses a request its path cannot carry. | +| `--rollout` | string | One switch as name=on or name=off; repeatable. Every switch defaults off. | +| `--json` | boolean | Emit the resulting GET /api/protocols body. | + +JSON mode: `payload`. + +- A bare invocation reads and never writes. +- A setting flag changes the operator's config; run it only when the operator asks for that change. + ## Counts -- declared capabilities: 60 -- of those, state-changing: 32 +- declared capabilities: 63 +- of those, state-changing: 33 - head-resolved invocations: 2 diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 8528d79d92f..90fbca8104b 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -987,6 +987,51 @@ export const CAPABILITIES: readonly Capability[] = [ json: "payload", details: ["A bare invocation reads and never writes."], }, + { + command: ["api", "protocols"], + summary: "Read the protocol contract version, API surfaces, protocol settings and feature vocabulary.", + routes: [{ method: "GET", path: "/api/protocols" }], + flags: [ + { name: "--provider", value: "string", summary: "Add one configured provider's upstream wire and who decided it." }, + { name: "--json", value: "boolean", summary: "Emit the GET /api/protocols body." }, + ], + mutates: false, + json: "payload", + }, + { + command: ["api", "explain"], + summary: "Preview the request path a model would take from one inbound API, computed from config.", + routes: [{ method: "POST", path: "/api/protocols/plan" }], + flags: [ + { name: "--model", value: "string", required: true, summary: "Model selector as a client would send it." }, + { name: "--inbound", value: "string", required: true, summary: "Inbound API: responses, chat or messages." }, + { name: "--feature", value: "string", summary: "Request feature key to judge; repeatable or comma-separated." }, + { name: "--json", value: "boolean", summary: "Emit the ProtocolPlanV1 preview." }, + ], + mutates: false, + json: "payload", + details: ["A read-only POST: nothing is sent upstream, no combo state advances and the input is not logged."], + }, + { + command: ["api", "policy"], + summary: "Read the protocol policy, or change the Messages surface, unrepresentable policy and rollout switches.", + routes: [ + { method: "GET", path: "/api/protocols" }, + { method: "PATCH", path: "/api/protocols/settings" }, + ], + flags: [ + { name: "--messages", value: "string", summary: "Open or close the Messages API: on or off. Off also turns the Claude integration off." }, + { name: "--unrepresentable", value: "string", summary: "legacy keeps today's behavior; reject refuses a request its path cannot carry." }, + { name: "--rollout", value: "string", summary: "One switch as name=on or name=off; repeatable. Every switch defaults off." }, + { name: "--json", value: "boolean", summary: "Emit the resulting GET /api/protocols body." }, + ], + mutates: true, + json: "payload", + details: [ + "A bare invocation reads and never writes.", + "A setting flag changes the operator's config; run it only when the operator asks for that change.", + ], + }, ]; /** Capabilities that drive `route`, for `ocx capabilities --route`. */ diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index a49c0eb43b9..23ed261db80 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -337,9 +337,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ // server/management/quota-reset-routes { method: "GET", path: "/api/quota-resets", module: "server/management/quota-reset-routes", mutates: false, mechanism: "negated-guard" }, // server/management/workflow-budget-routes - { method: "GET", path: "/api/protocols", module: "server/management/protocol-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading the protocol vocabulary and active policy revision has no CLI verb yet; PF-12 owns `ocx api protocols`, and until then the dashboard preview is the only reader.", owner: "260924_protocol_first_class PF-12", ownerDoc: "devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md" } }, - { method: "POST", path: "/api/protocols/plan", module: "server/management/protocol-routes", mutates: false, exempt: { reason: "deferred-verb", why: "A request-path preview has no CLI verb yet; PF-12 owns `ocx api explain`. It sends nothing upstream, so the dashboard preview panel is the only caller in this unit.", owner: "260924_protocol_first_class PF-12", ownerDoc: "devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md" } }, - { method: "PATCH", path: "/api/protocols/settings", module: "server/management/protocol-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Changing the Messages surface and protocol rollout switches has no CLI verb yet; PF-12 owns `ocx api policy`. Until then the API page Messages toggle is the only caller, and hand-editing apiSurfaces/protocols in config.json stays available.", owner: "260924_protocol_first_class PF-12", ownerDoc: "devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md" } }, + { method: "GET", path: "/api/protocols", module: "server/management/protocol-routes", mutates: false }, + { method: "POST", path: "/api/protocols/plan", module: "server/management/protocol-routes", mutates: false }, + { method: "PATCH", path: "/api/protocols/settings", module: "server/management/protocol-routes", mutates: true }, { method: "GET", path: "/api/workflow-budget", module: "server/management/workflow-budget-routes", mutates: false, exempt: { reason: "deferred-verb", why: "Reading a root's live budget is owed a CLI verb -- an operator staring at a 429 is usually already in a terminal -- but the ledger is process memory with no local transport to read it through, so the verb has to be an HTTP call the CLI does not yet make.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } }, { method: "POST", path: "/api/workflow-budget/clear", module: "server/management/workflow-budget-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Clearing one root is owed the same verb as the read above and for the same reason. It is deliberately not shipped as a verb in this work-phase: the read comes first, because an operator who cannot see which ceiling fired has no basis for deciding to forgive it.", owner: "260915_workflow_budget_window wfc", ownerDoc: "devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md" } }, // server/management/request-history-routes diff --git a/tests/cli/cli-capabilities.test.ts b/tests/cli/cli-capabilities.test.ts index 7dfd06d88ba..e06bbd85ed2 100644 --- a/tests/cli/cli-capabilities.test.ts +++ b/tests/cli/cli-capabilities.test.ts @@ -46,13 +46,17 @@ describe("capability table is a leaf data module", () => { } }); - test("a capability declaring routes marks mutation consistently", () => { - // A capability that drives only GETs must not claim to mutate, and one driving a - // write must not claim otherwise -- the flag is what --mutating-only filters on. + test("a capability declaring routes marks mutation consistently", async () => { + // A capability that drives only reads must not claim to mutate, and one driving a + // write must not claim otherwise -- the flag is what --mutating-only filters on. The + // registry's `mutates` decides, so a read-only POST (`POST /api/protocols/plan`, a + // preview that sends nothing) is a read; an undeclared route falls back to its method. + const { MANAGEMENT_ROUTES } = await import("../../src/server/management/route-registry"); + const declared = new Map(MANAGEMENT_ROUTES.map(r => [`${r.method} ${r.path}`, r.mutates] as const)); const wrong: string[] = []; for (const cap of CAPABILITIES) { if (cap.routes.length === 0) continue; - const anyWrite = cap.routes.some(r => r.method !== "GET"); + const anyWrite = cap.routes.some(r => declared.get(`${r.method} ${r.path}`) ?? r.method !== "GET"); if (anyWrite !== cap.mutates) wrong.push(capabilityInvocation(cap)); } expect(wrong).toEqual([]); From 316b545f14f170e81b45c5bb2d61ea3b26ccf203 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:19:28 +0900 Subject: [PATCH 163/173] test(cli): pin the ocx api verbs' requests, usage errors and route parity Each verb's method, path and body, --json passthrough, exit 2 before any send on bad argv, and that every protocol route is verbed rather than exempt. --- scripts/test-layout/layout.json | 1 + tests/cli/cli-api-protocols.test.ts | 214 +++++++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 3 files changed, 216 insertions(+) create mode 100644 tests/cli/cli-api-protocols.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c81e4b3f908..1377fd13591 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -455,6 +455,7 @@ "cli-account-threshold.test.ts": "cli", "cli-account.test.ts": "cli", "cli-capabilities.test.ts": "cli", + "cli-api-protocols.test.ts": "cli", "cli-catalog-prewarm.test.ts": "cli", "cli-codex-cli-update.test.ts": "cli", "cli-codex-log-guard-compact.test.ts": "cli", diff --git a/tests/cli/cli-api-protocols.test.ts b/tests/cli/cli-api-protocols.test.ts new file mode 100644 index 00000000000..595a34c66a4 --- /dev/null +++ b/tests/cli/cli-api-protocols.test.ts @@ -0,0 +1,214 @@ +/** + * `ocx api protocols | explain | policy` (PF-12): argv parsing, the request each sends to the + * protocol management routes, `--json`, and the route-registry parity that replaced the + * deferred-verb exemptions. + */ +import { afterEach, beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test"; +import { handleApiCommand } from "../../src/cli/api-protocols"; +import { capabilitiesForRoute, CAPABILITIES } from "../../src/cli/capabilities"; +import { DISPATCH_COMMANDS } from "../../src/cli/dispatch"; +import { findCommand } from "../../src/cli/registry"; +import { MANAGEMENT_ROUTES } from "../../src/server/management/route-registry"; + +type Recorded = { url: string; method: string; body: unknown; contentType: string | null }; + +const INFO = { + schemaVersion: 1, + contractVersion: "c1", + policyRevision: "p1-00000000", + surfaces: { + responses: { enabled: true, source: "fixed" }, + chat: { enabled: true, source: "fixed" }, + messages: { enabled: false, source: "api-surfaces" }, + }, + settings: { + unrepresentable: "legacy", + rollout: { nativeChatCombos: false, managedMessagesNative: false, managedMessagesNativeOAuth: false, directEncoders: false, shadowPlan: false }, + }, + features: ["request.tools", "request.seed"], +}; + +const PLAN = { + schemaVersion: 1, + basis: "preview", + contractVersion: "c1", + policyRevision: "p1-00000000", + inbound: "chat", + requestedModel: "m1", + routeKind: "direct", + mode: "native", + reasonCodes: ["same-wire-native"], + candidates: [{ + provider: "a", model: "m1", adapter: "openai-chat", upstream: "chat", mode: "native", + requestPath: ["chat", "chat"], responsePath: ["chat", "chat"], fidelity: "preserved", + reasonCodes: ["same-wire-native"], featureEffects: [], unknownFeatures: [], eligible: true, + }], + guaranteedFeatures: ["request.seed"], + partialFeatures: [], +}; + +let log: Mock; +let error: Mock; + +beforeEach(() => { + log = spyOn(console, "log").mockImplementation(() => {}); + error = spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + log.mockRestore(); + error.mockRestore(); +}); + +function fakeRuntime(respond: (request: Recorded) => unknown = () => INFO) { + const requests: Recorded[] = []; + const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + const raw = typeof init?.body === "string" ? init.body : null; + const request: Recorded = { + url: String(input), + method: init?.method ?? "GET", + body: raw === null ? null : JSON.parse(raw), + contentType: new Headers(init?.headers).get("content-type"), + }; + requests.push(request); + return new Response(JSON.stringify(respond(request)), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + return { requests, deps: { baseUrl: "http://127.0.0.1:9", fetchImpl } }; +} + +const printed = (): string => log.mock.calls.flat().join("\n"); + +describe("ocx api protocols", () => { + test("reads GET /api/protocols and prints the policy as text", async () => { + const { requests, deps } = fakeRuntime(); + expect(await handleApiCommand(["protocols"], deps)).toBe(0); + expect(requests).toEqual([{ url: "http://127.0.0.1:9/api/protocols", method: "GET", body: null, contentType: null }]); + expect(printed()).toContain("API messages: closed (api-surfaces)"); + expect(printed()).toContain("Rollout shadowPlan: off"); + }); + + test("--provider adds the encoded query and --json prints the payload unchanged", async () => { + const { requests, deps } = fakeRuntime(); + expect(await handleApiCommand(["protocols", "--json", "--provider", "my provider"], deps)).toBe(0); + expect(requests[0]!.url).toBe("http://127.0.0.1:9/api/protocols?provider=my%20provider"); + expect(JSON.parse(printed())).toEqual(INFO); + }); + + test("an unexpected argument is usage (exit 2) and sends nothing", async () => { + const { requests, deps } = fakeRuntime(); + expect(await handleApiCommand(["protocols", "extra"], deps)).toBe(2); + expect(requests).toEqual([]); + }); +}); + +describe("ocx api explain", () => { + test("posts model, inbound and deduplicated features, and prints the plan", async () => { + const { requests, deps } = fakeRuntime(() => PLAN); + const argv = ["explain", "--model", "m1", "--inbound", "chat", "--feature", "request.seed,request.tools", "--feature", "request.seed"]; + expect(await handleApiCommand(argv, deps)).toBe(0); + expect(requests).toEqual([{ + url: "http://127.0.0.1:9/api/protocols/plan", + method: "POST", + body: { model: "m1", inbound: "chat", features: ["request.seed", "request.tools"] }, + contentType: "application/json", + }]); + expect(printed()).toContain("chat m1: native (direct route, preview)"); + expect(printed()).toContain("a/m1: native chat > chat"); + expect(printed()).toContain("Guaranteed features: request.seed"); + }); + + test("omits features when none are given and --json prints the plan", async () => { + const { requests, deps } = fakeRuntime(() => PLAN); + expect(await handleApiCommand(["explain", "--inbound", "messages", "--model", "m1", "--json"], deps)).toBe(0); + expect(requests[0]!.body).toEqual({ model: "m1", inbound: "messages" }); + expect(JSON.parse(printed())).toEqual(PLAN); + }); + + test.each([ + ["a missing model", ["explain", "--inbound", "chat"]], + ["a missing inbound", ["explain", "--model", "m1"]], + ["an unknown inbound", ["explain", "--model", "m1", "--inbound", "anthropic"]], + ["an unknown feature", ["explain", "--model", "m1", "--inbound", "chat", "--feature", "request.bogus"]], + ])("%s is usage (exit 2) and sends nothing", async (_label, argv) => { + const { requests, deps } = fakeRuntime(() => PLAN); + expect(await handleApiCommand(argv, deps)).toBe(2); + expect(requests).toEqual([]); + }); +}); + +describe("ocx api policy", () => { + test("a bare invocation reads and never writes", async () => { + const { requests, deps } = fakeRuntime(); + expect(await handleApiCommand(["policy"], deps)).toBe(0); + expect(requests.map(r => [r.method, r.url])).toEqual([["GET", "http://127.0.0.1:9/api/protocols"]]); + }); + + test("setting flags send exactly one PATCH with the matching body", async () => { + const { requests, deps } = fakeRuntime(); + const argv = ["policy", "--messages", "off", "--unrepresentable", "reject", + "--rollout", "shadowPlan=on", "--rollout", "directEncoders=off"]; + expect(await handleApiCommand(argv, deps)).toBe(0); + expect(requests).toEqual([{ + url: "http://127.0.0.1:9/api/protocols/settings", + method: "PATCH", + body: { + messagesEnabled: false, + unrepresentable: "reject", + rollout: { shadowPlan: true, directEncoders: false }, + }, + contentType: "application/json", + }]); + expect(printed()).toContain("Protocol settings updated."); + }); + + test("--json prints the server's answer", async () => { + const { deps } = fakeRuntime(); + expect(await handleApiCommand(["policy", "--rollout", "shadowPlan=off", "--json"], deps)).toBe(0); + expect(JSON.parse(printed())).toEqual(INFO); + }); + + test.each([ + ["a non on/off Messages value", ["policy", "--messages", "maybe"]], + ["an unknown unrepresentable policy", ["policy", "--unrepresentable", "drop"]], + ["a rollout switch without a value", ["policy", "--rollout", "shadowPlan"]], + ["a rollout value other than on/off", ["policy", "--rollout", "shadowPlan=true"]], + ["the same switch twice", ["policy", "--rollout", "shadowPlan=on", "--rollout", "shadowPlan=off"]], + ["a stray argument", ["policy", "on"]], + ])("%s is usage (exit 2) and sends nothing", async (_label, argv) => { + const { requests, deps } = fakeRuntime(); + expect(await handleApiCommand(argv, deps)).toBe(2); + expect(requests).toEqual([]); + }); + + test("an unknown or missing api subcommand is usage", async () => { + const { requests, deps } = fakeRuntime(); + expect(await handleApiCommand(["plan"], deps)).toBe(2); + expect(await handleApiCommand([], deps)).toBe(2); + expect(requests).toEqual([]); + }); +}); + +describe("protocol routes are verbed, not exempt", () => { + const PROTOCOL_ROUTES = MANAGEMENT_ROUTES.filter(route => route.module === "server/management/protocol-routes"); + + test("every protocol route is declared without an exemption and driven by an api capability", () => { + expect(PROTOCOL_ROUTES.length).toBeGreaterThan(0); + for (const route of PROTOCOL_ROUTES) { + expect(route.exempt, `${route.method} ${route.path}`).toBeUndefined(); + const drivers = capabilitiesForRoute(route.path) + .filter(cap => cap.routes.some(r => r.method === route.method && r.path === route.path)); + expect(drivers.map(cap => cap.command[0]), `${route.method} ${route.path}`).toContain("api"); + } + }); + + test("the api capabilities are registered commands with a runner, and only policy mutates", () => { + expect(findCommand("api")?.name).toBe("api"); + expect(DISPATCH_COMMANDS.has("api")).toBe(true); + const api = CAPABILITIES.filter(cap => cap.command[0] === "api"); + expect(api.map(cap => [cap.command.join(" "), cap.mutates])).toEqual([ + ["api protocols", false], + ["api explain", false], + ["api policy", true], + ]); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0e7c9b3611e..621c717235b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -281,6 +281,7 @@ "cli-account-threshold.test.ts": "cli", "cli-account.test.ts": "cli", "cli-capabilities.test.ts": "cli", + "cli-api-protocols.test.ts": "cli", "cli-catalog-prewarm.test.ts": "cli", "cli-codex-cli-update.test.ts": "cli", "cli-codex-log-guard-compact.test.ts": "cli", From 62f02a419bfb78a3be448a450cd2aa8fe9b46d76 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:21:37 +0900 Subject: [PATCH 164/173] docs(guides): add a Protocol paths guide and register it in the sidebar Delivery modes, preview, trace, the unrepresentable policy, every rollout switch off by default, the shadow plan, and what still travels the internal Responses bridge. --- docs-site/astro.config.mjs | 1 + .../src/content/docs/guides/protocol-paths.md | 151 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 docs-site/src/content/docs/guides/protocol-paths.md diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index c0171bbc381..fc4162f8879 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -102,6 +102,7 @@ export default defineConfig({ { label: "Desktop App", translations: { fr: "Application de bureau", ko: "데스크톱 앱", "zh-CN": "桌面应用", "zh-TW": "桌面 App", ru: "Настольное приложение", ja: "デスクトップアプリ", tr: "Masaüstü Uygulaması" }, slug: "guides/desktop-app" }, { label: "Model Ordering", translations: { fr: "Ordre des modèles", ko: "모델 정렬에 관하여", "zh-CN": "模型排序", "zh-TW": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順", tr: "Model Sıralaması" }, slug: "guides/model-ordering" }, { label: "Combos", translations: { fr: "Combinaisons", ko: "콤보", "zh-CN": "组合", "zh-TW": "組合", ru: "Комбо", ja: "コンボ", tr: "Kombolar" }, slug: "guides/combos" }, + { label: "Protocol Paths", translations: { fr: "Chemins de protocole", ko: "프로토콜 경로", "zh-CN": "协议路径", "zh-TW": "協定路徑", ru: "Пути протоколов", ja: "プロトコル経路", tr: "Protokol Yolları" }, slug: "guides/protocol-paths" }, { label: "Claude Code", translations: { fr: "Claude Code", ko: "Claude Code", "zh-CN": "Claude Code", "zh-TW": "Claude Code", ru: "Claude Code", ja: "Claude Code", tr: "Claude Code" }, slug: "guides/claude-code" }, { label: "Grok Build", translations: { fr: "Grok Build", ko: "Grok Build", "zh-CN": "Grok Build", "zh-TW": "Grok Build", ru: "Grok Build", ja: "Grok Build", tr: "Grok Build" }, slug: "guides/grok-build" }, { label: "opencode", translations: { fr: "opencode", ko: "opencode", "zh-CN": "opencode", "zh-TW": "opencode", ru: "opencode", ja: "opencode", tr: "opencode" }, slug: "guides/opencode" }, diff --git a/docs-site/src/content/docs/guides/protocol-paths.md b/docs-site/src/content/docs/guides/protocol-paths.md new file mode 100644 index 00000000000..d0e6938220d --- /dev/null +++ b/docs-site/src/content/docs/guides/protocol-paths.md @@ -0,0 +1,151 @@ +--- +title: Protocol paths +description: How a request on the Responses, Chat Completions, or Messages API reaches a provider, how to preview and trace that path, and the staged switches that change it. +--- + +opencodex serves three client APIs: **Responses** (`/v1/responses`), **Chat Completions** +(`/v1/chat/completions`), and **Messages** (`/v1/messages`). Each provider receives one upstream +wire, decided by its adapter. When the client API and the upstream wire differ, the request is +converted on the way in and the answer is converted on the way back. This page explains how that +path is named, how to see it before and after a request, and which settings change it. + +Every setting on this page is off by default. With the defaults, requests travel exactly as they +did before these settings existed. + +## Delivery modes + +A path is a list of hops from the client API to the upstream wire. The hops are the three API +names, `ir` (opencodex's adapter-neutral request and event form), and `responses-internal` +(Responses JSON or SSE produced only as an internal bridge, never seen by a client). + +| Mode | Meaning | Example request path | +| --- | --- | --- | +| `native` | Same API at both ends; the client's body is what the provider receives | `chat > chat` | +| `translated` | Converted once, through the target wire's codec or the IR | `chat > responses`, `responses > ir > messages` | +| `legacy-bridge` | Converted through the internal Responses form first | `chat > responses-internal > ir > messages` | +| `blocked` | Refused before anything was sent | none | + +`native` describes how a request travels. It is not a compatibility verdict: a native path can +still meet a provider that rejects a field, and the Compatibility Lab's verified results are a +separate thing. + +With every switch off, an eligible single-provider route takes these paths: + +| Client API → upstream | Path | +| --- | --- | +| Responses → Responses | native | +| Responses → Chat or Messages | translated through the IR | +| Chat → Chat | native (JSON when the client sends `stream: false`) | +| Chat → Responses, Messages → Responses | translated through the Responses codec | +| Chat → Messages, Messages → Chat | legacy bridge | +| Messages → Messages | native only when the caller forwards its own Anthropic credential; legacy bridge for a key opencodex manages | + +Combos, routing policies, and synthetic effort or fast model rows take the legacy bridge from Chat +and Messages unless a switch below says otherwise. + +## Feature effects + +Some request features cannot survive every conversion. For example, the Chat fields `n`, +`logprobs`, `logit_bias`, `seed`, `audio`, and `prediction` have no place in a Responses body, and +the Messages `top_k` field is dropped on the way to Responses. opencodex records each such effect as +`passthrough`, `translated`, `degraded`, or `unsupported` (shown as *Dropped* in the dashboard). +These are declared from the converters' code; they are not measured results. + +## Preview a path + +A preview is computed from your configuration alone. It sends nothing to any provider, does not +advance combo rotation, and is not logged. + +- Dashboard: **API** page, **Request path preview**. +- CLI: + + ```bash + ocx api explain --model combo/main --inbound chat --feature request.seed + ocx api explain --model claude-sonnet-4-5 --inbound messages --json + ``` + +The preview lists every route candidate with its request path, delivery mode, feature effects, and +whether the `reject` policy (below) would refuse it. `ocx api protocols --json` lists the feature +names it accepts. A Messages request that forwards the caller's own Anthropic credential is shown +with `caller-credential-required` rather than assumed, because a preview has no caller. + +## Trace a request + +Each request that reached a provider, or was refused before sending, records the path it actually +took on its log row: the final mode and paths, the reasons, the +feature effects, and one path per physical attempt. In the dashboard, **Logs** shows a path badge, +a **Protocol path** filter, and a **Protocol path** section in the request detail. Older rows have +no path data and say so; nothing is guessed. + +## Unrepresentable features + +`protocols.unrepresentable` decides what happens when a request carries a feature its path would +drop: + +- `legacy` (default): the request is sent as before, and the loss appears only in the trace's + feature effects. +- `reject`: the request is refused with HTTP 400 before anything is sent, naming only the feature + keys. Chat answers `invalid_request_error` with code `unsupported_feature`; Messages answers + `invalid_request_error`. The trace is `blocked`. + +Under `reject`, a direct route is judged at the ingress once its provider and wire are settled. +Combos and policies are not judged at the ingress; with `nativeChatCombos` on, a Chat combo judges +each candidate and skips one that cannot carry the request. + +## Rollout switches + +These switches stage the new paths. Each defaults to off, and a switch that is off changes nothing. + +| Switch | When on | +| --- | --- | +| `nativeChatCombos` | An eligible Chat candidate inside a combo is sent natively from its own copy of the client body; the other candidates keep the bridge. | +| `managedMessagesNative` | A Messages request whose route is a direct, key-authenticated Anthropic provider is sent as Messages instead of through the bridge. Routes that need bridge-only behaviour (a pinned effort, blocked-skill elision, the web-search sidecar, vision preprocessing, synthetic rows) stay on the bridge. | +| `managedMessagesNativeOAuth` | Reserved for native Messages over Anthropic OAuth. Effective only together with `managedMessagesNative`; this version does not read it. | +| `directEncoders` | For a non-Responses upstream, the answer to a Chat or Messages client is encoded directly from the adapter's events instead of through the internal Responses stream. The request side is unchanged. | +| `shadowPlan` | At the end of each Chat or Messages request, the plan a preview would have predicted is compared with the path the request took; a disagreement adds `planMismatch: true` to the log row's path record. No second request is sent. | + +Change them with the CLI or by editing `protocols` in `config.json` +([reference](/reference/configuration/server/#protocol-paths-protocols)): + +```bash +ocx api policy # show the current policy; changes nothing +ocx api policy --rollout shadowPlan=on # change one switch +ocx api policy --unrepresentable reject +``` + +`ocx api policy` writes only when you pass a setting flag, and only when you run it. + +### Shadow plan + +`shadowPlan` is the safe first step: it changes no request. It checks whether the preview agrees +with reality for the traffic you actually send. The comparison uses the route the request settled +on (for a combo, the target that answered), and compares the delivery mode, the upstream wire, and +the request path. The response path is not compared, because `directEncoders` changes it and the +preview does not model that switch. A caller-forwarded Messages request and a Claude compatibility +reject are not compared. Responses requests are not compared. + +`planMismatch` appears in the `protocolTrace` of a row returned by `GET /api/logs`. The dashboard +does not display it yet. + +## What has not moved yet + +These paths still use the internal Responses bridge or are not covered: + +- Chat and Messages requests are still decoded into a Responses-shaped body before the IR, including + on a translated path. +- Routing-policy candidates, combos reached through an effort row, combo candidates while + `nativeChatCombos` is off, and Messages combos. +- Direct encoding (`directEncoders`) covers only a concrete, non-Responses route in streaming + delivery. Combo and policy children, routed compaction, run-turn adapters (Cursor, Devin, + coding-agent CLIs, CodeBuddy), and sidecar turns keep the bridge. +- A native Chat combo candidate that fails in-band before any output does not move on to the next + candidate; a bridged one would. +- Web search, vision, and image-generation sidecars run only on the Responses pipeline. +- `previous_response_id`, `store`, `background`, and compaction stay Responses features. +- Adapters whose wire is none of the three APIs (Gemini, Kiro, Cursor, and others) are translated + through the IR with no feature claims. +- Native Chat over OAuth is not planned. Native Messages over Anthropic OAuth is not available in + this version. +- The managed native Messages path does not forward a caller's `anthropic-beta` header, drops + top-level fields outside its allowlist without a feature effect, and does not apply + `claudeCode.stabilizePromptCache`. From 4334ecefa06d904cf3482461989fe4544563f3f8 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:21:37 +0900 Subject: [PATCH 165/173] docs(config): document the protocols keys and their conservative defaults Operators editing config.json need the unrepresentable policy and each rollout switch, and that a malformed block falls back to the defaults. --- .../docs/reference/configuration/server.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 3ec5c6fe566..3d104d16ff5 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -521,6 +521,38 @@ keeps the endpoint closed after a downgrade. Turning it on writes only `apiSurfaces.messages.enabled: true`; an older version then still follows `claudeCode.enabled` and may keep Messages closed, which is the safe direction. +## Protocol paths (`protocols`) + +How a Chat Completions or Messages request may reach a provider. Every value defaults to the +behavior before these keys existed; see [Protocol paths](/guides/protocol-paths/) for the delivery +modes, the preview, and the per-request trace. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `protocols.unrepresentable?` | `"legacy" \| "reject"` | `"legacy"` | `legacy` sends a request whose path drops a feature and records the loss in the trace. `reject` refuses it with HTTP 400 before any send, naming only the feature keys. | +| `protocols.rollout.nativeChatCombos?` | `boolean` | `false` | Send an eligible Chat candidate inside a combo natively from its own copy of the client body. | +| `protocols.rollout.managedMessagesNative?` | `boolean` | `false` | Send Messages natively to a direct, key-authenticated Anthropic provider instead of through the internal Responses bridge. | +| `protocols.rollout.managedMessagesNativeOAuth?` | `boolean` | `false` | Reserved for native Messages over Anthropic OAuth. Read as off unless `managedMessagesNative` is on; this version does not read it. | +| `protocols.rollout.directEncoders?` | `boolean` | `false` | Encode Chat and Messages answers from a non-Responses upstream directly from adapter events. | +| `protocols.rollout.shadowPlan?` | `boolean` | `false` | Compare each Chat or Messages request's path with the plan a preview predicts and mark a disagreement as `planMismatch` on its log row. Sends nothing extra. | + +A malformed `protocols` block is dropped to these defaults, because each default is the +conservative one. Only `true` turns a switch on. + +```json +{ + "protocols": { + "unrepresentable": "legacy", + "rollout": { "shadowPlan": true } + } +} +``` + +`ocx api policy` shows the resolved values and changes them through the running proxy +(`--unrepresentable `, `--rollout =`, `--messages ` for +[`apiSurfaces`](#api-surfaces-apisurfaces)). It writes only when a setting flag is given. The +dashboard's API page and `PATCH /api/protocols/settings` use the same validation. + ## Claude Code (`claudeCode`) These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx claude` launcher, and the Claude dashboard page. Whether the Messages API is served at all is decided by [`apiSurfaces`](#api-surfaces-apisurfaces), which inherits `claudeCode.enabled` while unset. From f07f31adb23c66ba8a0831c9ae2f3ab4852a86a9 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:21:37 +0900 Subject: [PATCH 166/173] docs(cli): document ocx api protocols, explain and policy Routes, which invocation writes, server-side validation of switch names, and the management reference now points at the CLI verbs and the guide. --- docs-site/src/content/docs/reference/cli.md | 4 +-- .../src/content/docs/reference/cli/agents.md | 35 ++++++++++++++++++- .../content/docs/reference/management-api.md | 4 ++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 52e8db3c8e1..a197f8449c4 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -37,8 +37,8 @@ without printing its bearer or private key. See [Remote Workspace](/guides/remot authentication, credential pools, quota, custom models, visibility, selected models, and context caps. - [Agents, routing, and integrations](/reference/cli/agents/) — multi-agent controls, combos, - observability, admission keys, client integrations, runtime settings, validated configuration, and - read-only Codex CLI update inspection. + observability, admission keys, protocol paths, client integrations, runtime settings, validated + configuration, and read-only Codex CLI update inspection. ## Headless behavior diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 166b83c8cb7..3d2e1a57aa1 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -1,6 +1,6 @@ --- title: CLI Agents, Routing, and Integrations -description: Multi-agent, combo, observability, access, integration, system, and config commands. +description: Multi-agent, combo, observability, access, protocol path, integration, system, and config commands. --- These commands control agent policy and routing, inspect the live proxy, and connect supported clients to opencodex. @@ -203,6 +203,39 @@ Manage OpenCodex admission API keys and inspect external endpoints and models. ` ocx access key create deployment ``` +### `ocx api ...` + +Inspect and set how requests travel between the client APIs and provider wires. See +[Protocol paths](/guides/protocol-paths/) for the vocabulary. + +| Command | Route | Changes state | +| --- | --- | --- | +| `ocx api protocols [--provider ] [--json]` | `GET /api/protocols` | No | +| `ocx api explain --model --inbound [--feature ]... [--json]` | `POST /api/protocols/plan` | No | +| `ocx api policy [--json]` | `GET /api/protocols` | No | +| `ocx api policy [--messages ] [--unrepresentable ] [--rollout =]... [--json]` | `PATCH /api/protocols/settings` | Yes | + +- `protocols` prints the contract version, whether each client API is served and why, the + unrepresentable policy, every rollout switch, and the policy revision. `--provider` adds the + upstream wire that provider receives, who decided it, and the models on another wire. +- `explain` previews the path a model would take from one client API. `--feature` is repeatable + and accepts a comma-separated list; `ocx api protocols --json` lists the known feature keys. The + preview is computed from configuration: nothing is sent upstream, no combo rotation advances, and + the input is not logged. +- `policy` without a setting flag only reads. With one it sends a single change to the running + proxy, which validates it, saves the configuration, and answers with the new policy. + `--messages off` also turns the Claude integration off, as the dashboard toggle does. Switch + names and combinations are validated by the proxy; for example + `--rollout managedMessagesNativeOAuth=on` is refused unless `managedMessagesNative` is + already on or turned on in the same command. + +```bash +ocx api explain --model combo/main --inbound chat --feature request.seed,request.tools +ocx api policy --rollout shadowPlan=on +``` + +Usage errors exit 2 before any request is sent. `--json` prints the management API body as is. + ## Client integrations ### `ocx integration ...` diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 06f4f817003..39dc120e6ed 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -528,7 +528,9 @@ the format opencodex sends to that provider; it does not open or close any clien the provider-wide adapter, save the provider's settings as usual (`PATCH /api/providers`). `PATCH /api/protocols/settings` is the only writer here and backs the Messages toggle on the -API page. The rollout switches are staged and default off; see +API page. The CLI drives these routes with `ocx api protocols`, `ocx api explain` and +`ocx api policy`. The rollout switches are staged and default off +([Protocol paths](/guides/protocol-paths/#rollout-switches)); see [API surfaces](/reference/configuration/server/#api-surfaces-apisurfaces) for how the Messages setting interacts with `claudeCode.enabled` across upgrades and downgrades. From f5e27381a8b951f6be568fb924859ddd0cf3a0cd Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:22:58 +0900 Subject: [PATCH 167/173] docs(devlog): record PF-12 acceptance status and refresh the not-migrated inventory Each scenario states whether it is implemented, behind which switch, which tests name it and that none has live evidence; the inventory is rechecked against this branch. --- .../030_gui_and_management_api.md | 11 ++-- .../040_acceptance_and_rollout.md | 53 ++++++++++++------- 2 files changed, 40 insertions(+), 24 deletions(-) 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 index a159b362712..f512914ef62 100644 --- 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 @@ -18,14 +18,15 @@ every decision from the server. | 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`) | +| `GET /api/protocols` | PF-03 | no | `ocx api protocols`, bare `ocx api policy` (PF-12) | +| `POST /api/protocols/plan` | PF-03 | no | `ocx api explain` (PF-12) | +| `PATCH /api/protocols/settings` | PF-04 | yes | `ocx api policy` with a setting flag (PF-12) | 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 +`src/server/management/route-registry.ts`. They carried a `deferred-verb` exemption owned by PF-12 +until PF-12 declared the three `api` capabilities in `src/cli/capabilities.ts` +(`src/cli/api-protocols.ts`) and removed it. Existing `/api/providers`, `/api/logs`, `/api/request-history` and `/api/lab/*` are reused, not duplicated. ## PF-02 observed path trace 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 index 4f9755ca96d..d4615fb5036 100644 --- a/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md +++ b/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md @@ -3,9 +3,10 @@ ## 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. +2. `shadowPlan` on: the Chat and Messages ingresses record the dispatch-basis plan input at their + entry mark (`src/protocols/shadow-plan.ts`); at finalize the pure planner runs on it and the + plan for the settled route is compared with the observed trace (`src/protocols/shadow.ts`); 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 @@ -14,19 +15,29 @@ ## 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 | +Status vocabulary, per row: **implemented behind switch** (code on this stack, reachable only with +the named switch on), **default path** (what runs with every switch off, unchanged by this unit), +**not implemented** (the target shape does not exist yet), **test written (not run)** (unit tests +on this stack name the behavior; none were executed by the packet that wrote them unless its PR +says so), **pending live evidence** (no fixture or live run has been recorded against a merged +head). No row has recorded evidence. Test files are named so a reviewer can run them; naming a +file is not a claim that it passed. + +| Scenario | Accepted when | Status | +|---|---|---| +| Chat → Chat, `n=2` / `logprobs` | every choice and logprobs survive; direct and combo give the same upstream body; every choice terminal is honoured | direct: default path (native Chat lane). Combo: implemented behind `nativeChatCombos`. Test written (not run): `tests/responses/chat-native-combo.test.ts`. Pending live evidence | +| Chat → Responses, `n=2` | under `reject`, refused before any send with `unsupported_feature`; never reduced to one choice and never emulated with extra calls | implemented behind `unrepresentable: "reject"` for direct routes; combos only with `nativeChatCombos` on. Test written (not run): `tests/responses/protocol-ingress-guard.test.ts`, `tests/responses/protocol-guard.test.ts`. Pending live evidence | +| 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 | managed key: implemented behind `managedMessagesNative`. OAuth: not implemented on this stack (PF-10). Test written (not run): `tests/claude-integration/messages-native.test.ts`, `tests/adapters/anthropic/anthropic-messages-passthrough.test.ts`, `tests/responses/messages-native-eligibility.test.ts`. Pending live evidence | +| Messages → Chat | role order and tool pairing preserved; unsupported fields follow policy | default path only (legacy bridge through the existing translators); the direct `messages,ir,chat` codec is not implemented. Pending live evidence | +| Chat → Messages | function definitions/results map to content blocks; stop reason and usage mapped | default path only (legacy bridge); the direct `chat,ir,messages` codec is not implemented. Response side: implemented behind `directEncoders`, test written (not run): `tests/responses/protocol-direct-encoders-messages.test.ts`. Pending live evidence | +| Responses → Chat / Messages | continuation and compaction unchanged | default path; this unit does not change the Responses ingress. Pending live evidence | +| 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 | implemented behind `nativeChatCombos` (Chat only). Known gap: a native child's zero-output in-band failure does not hop (inventory below). Test written (not run): `tests/responses/chat-native-combo.test.ts`. Pending live evidence | +| `stream: false` / `true` | correct envelope, error frames, terminal, chunk boundaries, backpressure, cancellation, timeout, memory budget | native lanes: default path (Chat) and behind `managedMessagesNative` (Messages). Encoders: implemented behind `directEncoders`. Test written (not run): `tests/responses/protocol-direct-encoders-chat.test.ts`, `tests/responses/protocol-direct-encoders-messages.test.ts`, `tests/server/inference-client-encoder-delivery.test.ts`. Pending live evidence | +| Unknown extension or media | never silently dropped on a translated path without a recorded effect | declared features only: effects recorded on the trace (default path) and refused under `reject`. Undeclared fields have no feature name and record no effect (managed Messages allowlist). Test written (not run): `tests/responses/protocol-trace.test.ts`, `tests/responses/protocol-features.test.ts`. Pending live evidence | +| Dashboard and remote runtime | stale/unknown/unsupported distinguished; per-request trace; policy-revision mismatch visible; hash state survives Back/Forward | per-request trace, preview with its policy revision, and deep links: implemented (PF-02, PF-03, PF-11). `planMismatch` is recorded but not rendered by the dashboard. Remote runtime: not exercised. Test written (not run): `tests/usage/request-log-protocol-trace.test.ts` and the PF-11 GUI tests. Pending live evidence | +| API disable migration | `/v1/messages` and `count_tokens` agree; upgrade, old-UI writes and rollback never reopen a closed surface | implemented (PF-04, default path). Test written (not run): `tests/claude-integration/messages-surface-matrix.test.ts`, `tests/server/protocol-settings-route.test.ts`. Pending live evidence | +| Shadow plan | disagreement between the dispatch plan and the trace is marked; no second request; switch off changes nothing; a failing comparison never affects the log row | implemented behind `shadowPlan` for the Chat and Messages ingresses. Test written (not run): `tests/responses/protocol-shadow-plan.test.ts`. Pending live evidence | +| CLI parity | every protocol management route has a CLI verb; `ocx api policy` writes only when a setting flag is given | implemented (`ocx api protocols`, `explain`, `policy`). Test written (not run): `tests/cli/cli-api-protocols.test.ts`, `tests/cli/cli-capabilities.test.ts` | 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 @@ -34,16 +45,19 @@ separately from fixture results. ## Not-migrated inventory -Kept current by each packet that migrates something. +Kept current by each packet that migrates something. Checked against the code on the PF-12 +branch (`feat/pf12-protocol-rollout`); PF-10 is developed in parallel and is not on this stack. | 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 | -| Chat/Messages response encode (PF-09) | migrated behind `directEncoders` for one concrete non-Responses route in the streaming adapter delivery: `[upstream, ir, client]`. Still through `responses-internal`: combo and policy children, routed compaction, run-turn adapters (Cursor, Devin, coding-agent CLIs, CodeBuddy), sidecar turns, the buffered `parseResponse` branch (unused by these ingresses, which always stream internally), and every route while the switch is off. Responses-wire upstreams keep their existing codec path | +| Chat/Messages request decode | still produces a Responses-shaped body before the IR (`responses-internal`) on every non-native path; `src/protocols/codecs/*` are named entry points over the existing translators (`chatCompletionsToResponsesBody`, `anthropicToResponsesTranslation`), not direct codecs | +| Chat ↔ Messages direct codecs | not implemented: `chat,ir,messages` and `messages,ir,chat` are baseline targets only; both pairs travel `responses-internal` | +| Chat/Messages response encode (PF-09) | migrated behind `directEncoders` only where `directEncodersApply` and `clientEncoderForDelivery` both agree: one concrete, non-Responses-wire route in the streaming adapter delivery, giving response path `[upstream, ir, client]` while the request path stays the bridge path. Still through `responses-internal`: combo and policy children (`comboAttempt`, `routeKind` combo/policy), routed compaction, run-turn adapters (Cursor, Devin, coding-agent CLIs, CodeBuddy), sidecar turns, the buffered `parseResponse` branch (unused by these ingresses, which always stream internally), and every route while the switch is off. Responses-wire upstreams keep their existing codec path | | Policy-route children | not migrated (PF-07): `routeModel` evaluates the policy and returns one concrete candidate, so a policy request never reaches the combo child loop and keeps the Chat bridge | | Chat combos with `nativeChatCombos` off | bridge for every candidate, and not judged per candidate under `reject` (the ingress guard also skips combos) | | Chat combos reached through an effort row | bridge (PF-07): the row's effort lives only on the Responses body, so the native source is not supplied | | Native Chat combo child, streamed, zero-output in-band failure | no hop (PF-07): the child's 200 is committed without `preflightComboStreamResponse`, so a failure frame before any output reaches the client instead of the next target; the bridge child would have hopped | +| Messages combos and policies | bridge: `nativeMessagesDeclineReason` returns `combo-or-policy-route`; not judged per candidate under `reject` | | 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 | @@ -53,3 +67,4 @@ Kept current by each packet that migrates something. | Messages → pooled Anthropic OAuth | not migrated (PF-10): `anthropicAccountPool.enabled` or two usable stored accounts decline with `oauth-account-pool`, because rotation, session affinity and quota ranking live in the Responses transport | | Opaque thinking state on the native lane | signatures and `redacted_thinking` reach `api.anthropic.com` only; elsewhere removed and traced as `opaque-state-stripped` (reason code, not a feature effect: a same-wire hop has no degraded disposition), refused before any send under `reject` | | Messages native lane, translated-only steps | a pinned route effort, blocked-skill elision, the web-search sidecar and vision preprocessing keep the request on the bridge (`bridge-only-policy` / `vision-preprocessing`); `stabilizePromptCache` is a recorded gap — the native lane does not apply it | +| Shadow plan coverage | Chat and Messages ingresses only; the Responses ingress records no shadow input. The response path is not compared (the planner does not model `directEncoders`); caller-forward Messages passthrough and compatibility rejects are not compared. The planner cannot see body-dependent Messages decline rules (skill elision, web search), so those surface as `planMismatch` rather than being predicted. The Claude fast-selector decode used by the ingress is not applied by the snapshot. The dashboard does not render `planMismatch` | From cbfa812e4f0f1a61ca3a63622501714250e9e51c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:23:43 +0900 Subject: [PATCH 168/173] docs(structure): describe the shadow plan comparison and the ocx api verbs protocol-paths owns where the input is recorded, what is compared and what is not, and the CLI client; the management doc drops the deferred-verb note. --- structure/data-planes/protocol-paths.md | 46 +++++++++++++++++++++++-- structure/gui-and-management-api.md | 2 +- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index bc2ad1be111..250373cfdd6 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -28,7 +28,8 @@ or trace reports cannot disagree. `nativeMessagesDeclineReason` in (below). `contract.ts`, `src/protocols/features.ts`, `src/protocols/baseline.ts`, -`src/protocols/path.ts`, `src/protocols/dto.ts`, `src/protocols/plan.ts` and `src/protocols/guard.ts` are leaf modules: the dashboard imports them directly, so they import +`src/protocols/path.ts`, `src/protocols/dto.ts`, `src/protocols/plan.ts`, `src/protocols/guard.ts` and +`src/protocols/shadow.ts` are leaf modules: the dashboard imports them directly, so they import nothing but each other and the type-only compatibility vocabulary in `src/compatibility/manifest.ts`. `tests/responses/protocol-contract.test.ts` reads their import specifiers and fails on anything else. @@ -124,6 +125,31 @@ caller-forward passthrough depends on the caller's own credential, so it is repo modelled. `tests/responses/protocol-plan-snapshot.test.ts` pins the no-side-effect property against combo selection state. +## Shadow plan + +Behind `protocols.rollout.shadowPlan` (default off). The Chat and Messages ingresses call +`recordProtocolShadowPlan` (`src/protocols/shadow-plan.ts`, server side) right after their entry +mark: Chat after its one mark, Messages after the caller-forward passthrough mark and after the +bridge mark every other request reaches. With the switch off it returns after reading the setting. +With it on it calls `buildProtocolPlanSnapshot` with basis `dispatch`, the selector the client sent +and the features the entry mark already collected (`protocolMarkFeatures`), and stores the result +with `markProtocolShadowPlanInput` in a WeakMap beside the marks. The snapshot is the preview's own +side-effect-free builder, so recording sends nothing, fetches nothing and advances no combo state; +the stored input is fixed vocabulary plus provider and model names. + +At finalize `protocolTraceForRequest` derives the observed trace first, then, only when an input +was recorded, runs `planProtocol` on it and `shadowPlanMismatch` (`src/protocols/shadow.ts`, leaf, +pure) on the plan and the trace. The compared candidate is the one matching the context's final +`provider` and `model`, else the first eligible one; mode, upstream wire and request path must +match. The response path is not compared, because `directEncoders` changes it and the planner does +not model that switch. A blocked trace agrees with a blocked plan; a compatibility reject and a +caller-forward Messages passthrough (the plan reports `caller-credential-required`) are not +compared. A disagreement adds `planMismatch: true`, which `isProtocolTraceV1` accepts only as +`true`, so rows without it stay valid. Any throw in recording or comparison is swallowed and the +observed trace is returned unchanged. The Responses ingress records no input and is not compared, +and the dashboard does not render the field. `tests/responses/protocol-shadow-plan.test.ts` pins +match, mismatch, switch-off and throw safety. + ## Provider wire summary `buildProtocolProviderSummary` in `src/protocols/provider-summary.ts` (server side, side-effect @@ -337,7 +363,8 @@ the key-auth one. The Chat and Messages ingresses read the unrepresentable polic `directEncodersApply` reads `directEncoders` on both; the Chat ingress reads `nativeChatCombos` for combo routes (above); `managedMessagesNative` and `managedMessagesNativeOAuth` are read through `nativeMessagesDeclineReason` by the Messages ingress, `count_tokens` and the planner -(above). No request path reads the other rollout switches yet. +(above); `shadowPlan` is read by `recordProtocolShadowPlan` ([Shadow plan](#shadow-plan)). Every +rollout switch now has a reader. `claudeInboundDisabled` in `src/server/claude-messages.ts` is the Messages ingress reader: both `/v1/messages` and `/v1/messages/count_tokens` call it, so the two routes cannot disagree, and a @@ -363,3 +390,18 @@ contract is `tests/server/protocol-settings-route.test.ts`. fails closed instead. `protocols` is a strict optional object that degrades to absence when malformed, which is safe because each of its defaults is the conservative one. `tests/config/protocol-settings.test.ts` covers both. + +## CLI + +`src/cli/api-protocols.ts` is the CLI client of the three protocol routes, through the shared +`runtimeRequest` in `src/cli/runtime-api.ts`: `ocx api protocols [--provider ]` reads +`GET /api/protocols`, `ocx api explain --model --inbound [--feature ...]` posts +`POST /api/protocols/plan`, and `ocx api policy` reads the same GET when given no setting flag and +sends one `PATCH /api/protocols/settings` built from `--messages`, `--unrepresentable` and repeated +`--rollout =` otherwise. The CLI rejects only malformed argv (exit 2, nothing sent) +and checks feature names and the inbound against the leaf vocabulary; switch names and the OAuth +dependency are validated by the route, so the two cannot drift. Nothing invokes `ocx api policy` +implicitly. The three are declared as `api` capabilities in `src/cli/capabilities.ts`, so the +routes carry no exemption in `src/server/management/route-registry.ts`; the capability mutation +check reads the registry's `mutates`, so the read-only plan POST is not a write. `tests/cli/cli-api-protocols.test.ts` pins the requests, the usage errors and that every +protocol route is verbed. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 9248fee1343..3b38b0ac740 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -200,7 +200,7 @@ this document owns is which module holds which area and what invariant that area | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Claude reset grants | `src/server/management/anthropic-reset-grant-routes.ts` — `GET /api/anthropic/reset-grants`, `POST /api/anthropic/reset-grants/consume` (lazy-loaded). Wire and fail-closed parsing live in `src/providers/anthropic-reset-grants.ts` (the Claude Code 2.1.278 `cedar_ember` contract, sent with `CLAUDE_CLI_USER_AGENT` from `src/providers/claude-cli-identity.ts`); the journal is `src/providers/anthropic-reset-grant-ledger.ts`: a cross-process `BEGIN IMMEDIATE` lock around every synchronous read-modify-write, a 90 s lease, the operation id reused as the upstream `request_id`, same-id retry only inside the vendor's ten-minute window, no settlement inferred from a re-read, and a fail-closed `500 journal_write_failed` when an answer cannot be recorded. Spending requires the `gui-session` principal. The dashboard owner is `gui/src/hooks/useAnthropicResetGrants.ts` with `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` on the Anthropic OAuth rows of `ProviderAuthPanel`; after an unknown outcome the dialog only retries the same id. Design and audit record: [`../devlog/_plan/260923_claude_reset_grants/010_plan.md`](../devlog/_plan/260923_claude_reset_grants/010_plan.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. `PUT` keeps a stored field the body omits (`cooldownMs`, `waitForCooldownMs`, `defaultEffortMode`, `reasoningEffortMode`, `imageInput`, `cooldownWaitPolicy`, per-target `lastResort`); explicit values replace it and defaults are stored sparse. | -| Protocol paths | `src/server/management/protocol-routes.ts` (lazy-loaded) — `GET /api/protocols` returns the contract version, the resolved API surfaces and protocol settings, the policy revision and the feature vocabulary; with `?provider=` (one non-empty name of at most 200 characters without control characters, else 400 `invalid_provider`; an unconfigured name is 404 `unknown_provider`, neither echoing the name) it adds a `provider` block from `src/protocols/provider-summary.ts` ([Protocol Paths](data-planes/protocol-paths.md#provider-wire-summary)); `POST /api/protocols/plan` takes `{ model, inbound, features? }` (model at most 200 characters, at most 24 features, any other key refused with 400) and returns a `ProtocolPlanV1` with `basis: "preview"` from `src/protocols/plan-snapshot.ts`. Both are read-only, never log their input, and send nothing upstream. `PATCH /api/protocols/settings` takes `{ messagesEnabled?, unrepresentable?, rollout? }` (strict: unknown keys and wrong types are 400), validates and applies through `src/server/management/protocol-settings-patch.ts`, persists with `saveConfigPreservingClaudeCode`, restores the live config if the save fails (409 on lock contention, 500 otherwise), and answers with the fresh `GET /api/protocols` body; closing Messages also writes `claudeCode.enabled = false` through `commitClaudeCodeBlock` ([Protocol Paths](data-planes/protocol-paths.md#settings)). All three are `deferred-verb` in the route registry, owned by PF-12 in [`../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md`](../devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md). | +| Protocol paths | `src/server/management/protocol-routes.ts` (lazy-loaded) — `GET /api/protocols` returns the contract version, the resolved API surfaces and protocol settings, the policy revision and the feature vocabulary; with `?provider=` (one non-empty name of at most 200 characters without control characters, else 400 `invalid_provider`; an unconfigured name is 404 `unknown_provider`, neither echoing the name) it adds a `provider` block from `src/protocols/provider-summary.ts` ([Protocol Paths](data-planes/protocol-paths.md#provider-wire-summary)); `POST /api/protocols/plan` takes `{ model, inbound, features? }` (model at most 200 characters, at most 24 features, any other key refused with 400) and returns a `ProtocolPlanV1` with `basis: "preview"` from `src/protocols/plan-snapshot.ts`. Both are read-only, never log their input, and send nothing upstream. `PATCH /api/protocols/settings` takes `{ messagesEnabled?, unrepresentable?, rollout? }` (strict: unknown keys and wrong types are 400), validates and applies through `src/server/management/protocol-settings-patch.ts`, persists with `saveConfigPreservingClaudeCode`, restores the live config if the save fails (409 on lock contention, 500 otherwise), and answers with the fresh `GET /api/protocols` body; closing Messages also writes `claudeCode.enabled = false` through `commitClaudeCodeBlock` ([Protocol Paths](data-planes/protocol-paths.md#settings)). The CLI drives all three through `ocx api protocols`, `ocx api explain` and `ocx api policy` ([Protocol Paths](data-planes/protocol-paths.md#cli)); none carries a route-registry exemption. | | Workflow budget | `src/server/management/workflow-budget-routes.ts` — `GET /api/workflow-budget` reads the tracked roots or one root, and `POST /api/workflow-budget/clear` clears exactly one. The clear moves the windowed send ring and the child map and nothing else: `active` belongs to turns still in flight, the spend ledger is a token budget an operator did not ask to forgive, and the lifetime send total survives so a clear cannot launder the record. A refusal event carries `spendScope` and `spendLimit` when a token ceiling fired, so the reason is readable without the config open beside it; no scope id is ever attached, because root ids are client thread headers and identity ids are credentials. Both are `deferred-verb` in the route registry — they are owed CLI verbs, and because the ledger is process memory there is no local projection the CLI could read instead. See [`../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md`](../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md). | | Codex accounts | `src/codex/auth-api/routes.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Per-account quota activation uses the existing `GET/PUT /api/settings` surface and `src/codex/quota-auto-refresh.ts`, keeping scheduled spending separate from credential/authentication mutation. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. New-account config commits add UI-managed selector bindings in the same config save; deletion deliberately retains existing bindings for fail-closed exact routing and re-add stability. Account mutations request catalog convergence only after config durability and expose only the boolean `catalogRefreshPending` completion projection. | | Sidebar | `src/server/management/sidebar-routes.ts` — `GET/POST /api/github/star`, `GET /api/update/badge`, and `POST /api/update/desktop-snapshot`. The snapshot POST accepts the raw admin-token principal or the dedicated `local-desktop-snapshot-capability`; GUI sessions and requests carrying `Origin` cannot publish desktop state. A capability's bounded raw body is verified against its authenticated digest before JSON parsing or storage. Badge state is cosmetic and a failed poll degrades silently. | From 617c99618521e784fabf04f1bee3f8c6a5888a24 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:29:43 +0900 Subject: [PATCH 169/173] docs(protocols): describe the OAuth switch and beta allowlist as PF-10 ships them The guide and configuration reference were written before native Messages over OAuth and the anthropic-beta allowlist landed below this change. --- docs-site/src/content/docs/guides/protocol-paths.md | 9 +++++---- .../src/content/docs/reference/configuration/server.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/protocol-paths.md b/docs-site/src/content/docs/guides/protocol-paths.md index d0e6938220d..fe47c0b3ae1 100644 --- a/docs-site/src/content/docs/guides/protocol-paths.md +++ b/docs-site/src/content/docs/guides/protocol-paths.md @@ -100,7 +100,7 @@ These switches stage the new paths. Each defaults to off, and a switch that is o | --- | --- | | `nativeChatCombos` | An eligible Chat candidate inside a combo is sent natively from its own copy of the client body; the other candidates keep the bridge. | | `managedMessagesNative` | A Messages request whose route is a direct, key-authenticated Anthropic provider is sent as Messages instead of through the bridge. Routes that need bridge-only behaviour (a pinned effort, blocked-skill elision, the web-search sidecar, vision preprocessing, synthetic rows) stay on the bridge. | -| `managedMessagesNativeOAuth` | Reserved for native Messages over Anthropic OAuth. Effective only together with `managedMessagesNative`; this version does not read it. | +| `managedMessagesNativeOAuth` | Native Messages for the unpooled `anthropic` OAuth provider on `api.anthropic.com`. Effective only together with `managedMessagesNative`; a pooled Anthropic OAuth account set stays on the bridge. | | `directEncoders` | For a non-Responses upstream, the answer to a Chat or Messages client is encoded directly from the adapter's events instead of through the internal Responses stream. The request side is unchanged. | | `shadowPlan` | At the end of each Chat or Messages request, the plan a preview would have predicted is compared with the path the request took; a disagreement adds `planMismatch: true` to the log row's path record. No second request is sent. | @@ -144,8 +144,9 @@ These paths still use the internal Responses bridge or are not covered: - `previous_response_id`, `store`, `background`, and compaction stay Responses features. - Adapters whose wire is none of the three APIs (Gemini, Kiro, Cursor, and others) are translated through the IR with no feature claims. -- Native Chat over OAuth is not planned. Native Messages over Anthropic OAuth is not available in - this version. -- The managed native Messages path does not forward a caller's `anthropic-beta` header, drops +- Native Chat over OAuth is not planned. Native Messages over Anthropic OAuth covers only an + unpooled account; a pooled account set stays on the bridge. +- The managed native Messages path forwards a caller's `anthropic-beta` values only from a short + allowlist and only to `api.anthropic.com`, drops top-level fields outside its allowlist without a feature effect, and does not apply `claudeCode.stabilizePromptCache`. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 3d104d16ff5..6943ae17ff5 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -532,7 +532,7 @@ modes, the preview, and the per-request trace. | `protocols.unrepresentable?` | `"legacy" \| "reject"` | `"legacy"` | `legacy` sends a request whose path drops a feature and records the loss in the trace. `reject` refuses it with HTTP 400 before any send, naming only the feature keys. | | `protocols.rollout.nativeChatCombos?` | `boolean` | `false` | Send an eligible Chat candidate inside a combo natively from its own copy of the client body. | | `protocols.rollout.managedMessagesNative?` | `boolean` | `false` | Send Messages natively to a direct, key-authenticated Anthropic provider instead of through the internal Responses bridge. | -| `protocols.rollout.managedMessagesNativeOAuth?` | `boolean` | `false` | Reserved for native Messages over Anthropic OAuth. Read as off unless `managedMessagesNative` is on; this version does not read it. | +| `protocols.rollout.managedMessagesNativeOAuth?` | `boolean` | `false` | Native Messages for the unpooled `anthropic` OAuth provider on `api.anthropic.com`. Read as off unless `managedMessagesNative` is on; a pooled account set stays on the bridge. | | `protocols.rollout.directEncoders?` | `boolean` | `false` | Encode Chat and Messages answers from a non-Responses upstream directly from adapter events. | | `protocols.rollout.shadowPlan?` | `boolean` | `false` | Compare each Chat or Messages request's path with the plan a preview predicts and mark a disagreement as `planMismatch` on its log row. Sends nothing extra. | From 24ad5aa9a477334e568b01c9c296385602a43684 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 13:29:52 +0900 Subject: [PATCH 170/173] test(cli): map /api/protocols to the ocx api verbs in the parity map The verbs exist now, so the headless parity map names them instead of the owed-verb placeholder. --- tests/cli/cli-headless-parity.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 7260cff8f62..cfb33592d68 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -480,9 +480,7 @@ describe("headless GUI parity CLI", () => { // Claude reset grants: reading is an owed CLI verb (deferred-verb in the route // registry) and spending is dashboard-session-only by design. ["/api/anthropic/reset-grants", "(none — GUI reset-grant dialog; spend requires a dashboard session)"], - // Protocol path preview and settings: the CLI verbs are owed (deferred-verb in the - // route registry) and land with the rollout packet. - ["/api/protocols", "(none yet — deferred ocx api protocols/explain/policy verbs)"], + ["/api/protocols", "ocx api protocols/explain/policy"], ["/api/settings", "ocx system"], // Routing Intelligence (RI-04..RI-10): profiles + dry-run are mirrored by // `ocx route policy`. Analytics is GUI-first for now; the same request From f35739ef5c63a1125b0e15131b226bb22abf7ae3 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 14:17:38 +0900 Subject: [PATCH 171/173] test(cli): compare the ocx api protocols GET by method, URL and body The shared runtime request helper sends its JSON content type on every call, so the GET case should not pin a missing header. --- tests/cli/cli-api-protocols.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/cli/cli-api-protocols.test.ts b/tests/cli/cli-api-protocols.test.ts index 595a34c66a4..8b59a066625 100644 --- a/tests/cli/cli-api-protocols.test.ts +++ b/tests/cli/cli-api-protocols.test.ts @@ -82,7 +82,8 @@ describe("ocx api protocols", () => { test("reads GET /api/protocols and prints the policy as text", async () => { const { requests, deps } = fakeRuntime(); expect(await handleApiCommand(["protocols"], deps)).toBe(0); - expect(requests).toEqual([{ url: "http://127.0.0.1:9/api/protocols", method: "GET", body: null, contentType: null }]); + // The shared runtime request helper sets its JSON content type on every call, GET included. + expect(requests.map(r => [r.method, r.url, r.body])).toEqual([["GET", "http://127.0.0.1:9/api/protocols", null]]); expect(printed()).toContain("API messages: closed (api-surfaces)"); expect(printed()).toContain("Rollout shadowPlan: off"); }); From 7322804d9aee0e6deb8200d81349695de42baca0 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 15:17:10 +0900 Subject: [PATCH 172/173] fix(gui): wrap the Logs protocol badge instead of clipping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A long path such as Responses → Messages · Translated overflowed the narrow model column and was cut off, hiding the mode. The badge now wraps inside the cell. --- gui/src/components/protocols/ProtocolBadge.tsx | 3 +-- gui/src/styles/protocol-evidence.css | 9 +++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/gui/src/components/protocols/ProtocolBadge.tsx b/gui/src/components/protocols/ProtocolBadge.tsx index 40ce8e892c0..c79bd21f012 100644 --- a/gui/src/components/protocols/ProtocolBadge.tsx +++ b/gui/src/components/protocols/ProtocolBadge.tsx @@ -14,8 +14,7 @@ export function ProtocolBadge({ trace, t }: { trace: unknown; t: TFn }) { const mode = t(PROTOCOL_MODE_KEYS[parsed.mode]); return ( diff --git a/gui/src/styles/protocol-evidence.css b/gui/src/styles/protocol-evidence.css index d6efb35372f..69b70bff334 100644 --- a/gui/src/styles/protocol-evidence.css +++ b/gui/src/styles/protocol-evidence.css @@ -41,3 +41,12 @@ /* ── Combo detail: candidate paths ────────────────────────── */ .combo-protocol-plan { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; } .combo-protocol-plan p { margin: 0; } + +/* Logs row badge: the model column is narrow, so a long path wraps inside the badge instead of + being clipped by the cell; the mode text must stay readable, not ellipsized away. */ +.protocol-path-badge { + white-space: normal; + overflow-wrap: anywhere; + max-width: 100%; + text-align: left; +} From 3df0d8f4d28effda6e026bebea526e2310218b61 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 15:21:47 +0900 Subject: [PATCH 173/173] fix(anthropic): honour the opaque-state strip flag when replaying thinking The Responses passthrough already drops replayed opaque reasoning when the serving identity changed or the blob was rejected; the Anthropic adapter now does the same for signed thinking and redacted_thinking blocks. --- scripts/test-layout/layout.json | 1 + src/adapters/anthropic.ts | 3 + .../anthropic/anthropic-opaque-strip.test.ts | 56 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 4 files changed, 61 insertions(+) create mode 100644 tests/adapters/anthropic/anthropic-opaque-strip.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1377fd13591..d9cb5a84ad5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -241,6 +241,7 @@ "anthropic-stream-hardening.test.ts": "adapters/anthropic", "anthropic-tail-guard.test.ts": "adapters/anthropic", "anthropic-thinking-signature.test.ts": "adapters/anthropic", + "anthropic-opaque-strip.test.ts": "adapters/anthropic", "anthropic-tool-call-id.test.ts": "adapters/anthropic", "anthropic-messages-passthrough.test.ts": "adapters/anthropic", "anthropic-beta-allowlist.test.ts": "adapters/anthropic", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 286cef31253..10284c6fc64 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -850,6 +850,9 @@ function messagesToAnthropicFormat( if (text) preface.push({ type: "text", text }); } else if (part.type === "thinking") { const t = part as OcxThinkingContent; + // History minted under another serving identity (or already rejected as opaque) is not + // this destination's to verify: drop its opaque blocks, as the Responses passthrough does. + if (parsed._stripReasoningEncryptedContent === true) continue; // Redacted blocks replay verbatim FIRST (they preceded the visible thinking block // in the original stream order preserved by the bridge envelope). for (const data of t.redacted ?? []) { diff --git a/tests/adapters/anthropic/anthropic-opaque-strip.test.ts b/tests/adapters/anthropic/anthropic-opaque-strip.test.ts new file mode 100644 index 00000000000..f3e7b96d1a0 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-opaque-strip.test.ts @@ -0,0 +1,56 @@ +/** + * The Anthropic adapter honours the shared strip flag for replayed opaque thinking state: when + * the request's serving identity changed (or the blob was already rejected), signed thinking and + * redacted_thinking blocks are not replayed. Without the flag they replay verbatim. + */ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter as createAnthropicAdapterProduction } from "../../../src/adapters/anthropic"; +import { parseRequest } from "../../../src/responses/parser"; +import { encodeReasoningEnvelope } from "../../../src/responses/reasoning-envelope"; +import type { OcxProviderConfig } from "../../../src/types"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +const createAnthropicAdapter = (...args: Parameters) => + withTestTranslatorBudget(createAnthropicAdapterProduction(...args)); + +const provider: OcxProviderConfig = { + adapter: "anthropic", + baseUrl: "https://anthropic-compatible.example", + apiKey: "sk-test", +}; + +function signedHistory() { + return parseRequest({ + model: "anthropic/claude-x", + input: [ + { type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "chain" }], + encrypted_content: encodeReasoningEnvelope({ sig: "RealSig1234567890==", red: ["REDDATA"] }) }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "next" }] }, + ], + }); +} + +async function wireBody(strip: boolean): Promise { + const parsed = signedHistory(); + if (strip) parsed._stripReasoningEncryptedContent = true; + const request = await createAnthropicAdapter(provider).buildRequest(parsed) as { body: string }; + return request.body; +} + +describe("anthropic adapter: replayed opaque thinking state", () => { + test("replays signed and redacted blocks when the serving identity is unchanged", async () => { + const body = await wireBody(false); + expect(body).toContain("RealSig1234567890=="); + expect(body).toContain("REDDATA"); + }); + + test("drops signed and redacted blocks when the strip flag is set, keeping the rest", async () => { + const body = await wireBody(true); + expect(body).not.toContain("RealSig1234567890=="); + expect(body).not.toContain("REDDATA"); + expect(body).not.toContain("redacted_thinking"); + expect(body).toContain("answer"); + expect(body).toContain("next"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 621c717235b..0fc0dcbfb27 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -67,6 +67,7 @@ "anthropic-stream-hardening.test.ts": "adapters/anthropic", "anthropic-tail-guard.test.ts": "adapters/anthropic", "anthropic-thinking-signature.test.ts": "adapters/anthropic", + "anthropic-opaque-strip.test.ts": "adapters/anthropic", "anthropic-tool-call-id.test.ts": "adapters/anthropic", "anthropic-messages-passthrough.test.ts": "adapters/anthropic", "anthropic-beta-allowlist.test.ts": "adapters/anthropic",