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..f512914ef62 --- /dev/null +++ b/devlog/_plan/260924_protocol_first_class/030_gui_and_management_api.md @@ -0,0 +1,143 @@ +# 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 | `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`. 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 + +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..d4615fb5036 --- /dev/null +++ b/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md @@ -0,0 +1,70 @@ +# 040 — acceptance and rollout (PF-12) + +## Rollout order + +1. Existing execution is the default. Every `protocols.rollout.*` switch is off. +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 + head, in a separate reviewed change. +5. `unrepresentable: "reject"` is an operator policy, not a rollout step; it stays opt-in. + +## Acceptance scenarios + +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 +separately from fixture results. + +## Not-migrated inventory + +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`) 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 | +| OAuth native Chat | not planned in this unit | +| 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 | +| 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` | 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..fe47c0b3ae1 --- /dev/null +++ b/docs-site/src/content/docs/guides/protocol-paths.md @@ -0,0 +1,152 @@ +--- +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` | 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. | + +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 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/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/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index d3aee62c2c1..6943ae17ff5 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -500,9 +500,62 @@ 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. + +## 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` | 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. | + +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. +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 d62086f9567..39dc120e6ed 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -509,6 +509,31 @@ 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 | — | +| `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) | + +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. + +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 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. + ### Providers | Method and path | Purpose | Notable errors | 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/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 ? ( void; localeTag?: string; newName: string; creating: boolean; @@ -51,6 +57,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. */ + planModels?: ExternalModelRow[]; modelsLoading: boolean; /** Quiet revalidation / retry over rows already on screen — not a skeleton. */ modelsRefreshing?: boolean; @@ -92,6 +100,8 @@ export default function ApiKeysWorkspace({ keysLoadFailed, endpoints, claudeCodeEnabled, + surfaces, + onSurfacesChanged, localeTag, newName, creating, @@ -100,6 +110,7 @@ export default function ApiKeysWorkspace({ rotationSecret = null, rotationCopied = false, filteredModels, + planModels, modelsLoading, modelsRefreshing = false, modelsLoadFailed, @@ -203,6 +214,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) }, @@ -534,7 +546,19 @@ 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. */} +
+
{active && } diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx index b33d937917f..4fb2fda9f7b 100644 --- a/gui/src/components/combo-workspace-detail-panel.tsx +++ b/gui/src/components/combo-workspace-detail-panel.tsx @@ -17,6 +17,7 @@ import type { ModelOption, ProviderOption } from "./combo-workspace-types"; import { ComboCapabilities, EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls"; import { COMBO_STRATEGY_HINT_KEYS, COMBO_TARGETS_HINT_KEYS } from "../combo-workspace-data"; import { clampedNumberInput } from "./combo-workspace-utils"; +import { ComboProtocolPlan } from "./protocols/ComboProtocolPlan"; type DetailTab = "config" | "about"; @@ -31,6 +32,7 @@ const detailTabDomId = (tab: DetailTab) => `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 !== undefined && } {/* 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/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/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/ProtocolBadge.tsx b/gui/src/components/protocols/ProtocolBadge.tsx new file mode 100644 index 00000000000..c79bd21f012 --- /dev/null +++ b/gui/src/components/protocols/ProtocolBadge.tsx @@ -0,0 +1,24 @@ +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/ProtocolPlanPanel.tsx b/gui/src/components/protocols/ProtocolPlanPanel.tsx new file mode 100644 index 00000000000..99804a5b747 --- /dev/null +++ b/gui/src/components/protocols/ProtocolPlanPanel.tsx @@ -0,0 +1,274 @@ +/** + * "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 { openCompatibilityPair, openProviderSettings, protocolPairUpstream } from "../../protocol-deep-links"; +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})} + + ); +} + +/** + * 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 ( +
+
+
+
{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 => ( +
  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/components/protocols/ProtocolTracePanel.tsx b/gui/src/components/protocols/ProtocolTracePanel.tsx new file mode 100644 index 00000000000..80c336c8ab5 --- /dev/null +++ b/gui/src/components/protocols/ProtocolTracePanel.tsx @@ -0,0 +1,80 @@ +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. 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); + 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; +} 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/components/provider-workspace/ProviderProtocolPanel.tsx b/gui/src/components/provider-workspace/ProviderProtocolPanel.tsx new file mode 100644 index 00000000000..c5da3ff7998 --- /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 === undefined) 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 === undefined || !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/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 ? ( <>