From 7fd85d3b67235f33a1772b59e7778d9f9d99e82f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 11:55:44 +0900 Subject: [PATCH 001/231] docs(devlog): roadmap for the Codex WS commit-boundary realignment --- .../260911_ws_commit_boundary/000_plan.md | 52 +++++++++++ .../010_journey_evaluation.md | 50 +++++++++++ .../020_design_record.md | 90 +++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 devlog/_plan/260911_ws_commit_boundary/000_plan.md create mode 100644 devlog/_plan/260911_ws_commit_boundary/010_journey_evaluation.md create mode 100644 devlog/_plan/260911_ws_commit_boundary/020_design_record.md diff --git a/devlog/_plan/260911_ws_commit_boundary/000_plan.md b/devlog/_plan/260911_ws_commit_boundary/000_plan.md new file mode 100644 index 0000000000..95f7274034 --- /dev/null +++ b/devlog/_plan/260911_ws_commit_boundary/000_plan.md @@ -0,0 +1,52 @@ +# WS commit boundary — 260911 + +Base: `origin/dev` `babb76449f` (fetched 2026-09-11 KST). Branch `codex/260911-ws-commit-boundary`, +worktree `/Users/jun/.codex/worktrees/260911-wsc/opencodex`. + +## Why this unit exists + +#4191 reports a long Codex thread that fails only while routed through OpenCodex, as either +`codex websocket closed before a Responses terminal event (close 1006 Connection ended)` or +`codex websocket response prelude timed out`, and works immediately when the proxy is bypassed. +#4083 raised the fixed prelude deadline from 30 s to 90 s for slow multi-image starts; #3976 asked +for the number to be configurable; #2471 fixed the 16 MiB create-frame ceiling. + +The lane dispatch round (`260911_lane_dispatch_round`) added the #4191 failure-stage counters so +a user can tell an unanswered socket from one that carried only quota frames. That was +diagnosis. This unit is the fix to the boundary the diagnosis exposed, after an external +semantic review (`010_journey_evaluation.md`) overturned the first framing. + +## Scope + +- `src/server/responses/codex-ws-exchange.ts` — settle post-send, pre-response failures as an + honest HTTP status; replace the fixed prelude timer with silence-based liveness; cancel the + upstream turn on a pre-commit client abort. +- `src/server/responses/codex-ws-wire.ts` — liveness constants and the non-replayable body shape. +- `src/lib/upstream-retry.ts` — a non-replayable marker that `fetchWithTransientRetry` honours. +- `tests/responses/ws-upstream.test.ts`, `tests/lib/upstream-retry.test.ts` — oracle updates and + new cases. + +Out of scope, recorded in `020_design_record.md`: resume-by-id after 1006 (Codex does not request +background responses, so the vendor resume surface does not apply), the opt-in provider path +without a metadata channel (it commits at send today and keeps doing so), the create-frame size +predicate, and `src/server/responses/core.ts`. + +## Rules for this unit + +- No local product suite: no `bun test`, `bun run test`, `test:changed`, `typecheck`, + `build:gui`, or `bun install` in this worktree. Every verification line reads NOT RUN until + remote CI on the final head says otherwise. +- Push with `--no-verify` and `core.hooksPath=/dev/null`. +- xai/grok-4.6 subagents are read-only verifiers of the diff; aside/web research is free. +- One work-phase is one PABCD cycle: wp1 this roadmap, wp2 honest status + marker, wp3 liveness + and abort propagation, wp4 PR, review, CI. + +## Work phases + +| wp | unit | doc | exit | +|---|---|---|---| +| wp1 | roadmap | 000, 010, 020 | docs committed on the branch | +| wp2 | honest post-send status | 030 | code + tests committed, NOT RUN | +| wp3 | liveness + abort | 040 | code + tests committed, NOT RUN | +| wp4 | PR + review + CI | 050 | final-head CI green, review dispositioned | + diff --git a/devlog/_plan/260911_ws_commit_boundary/010_journey_evaluation.md b/devlog/_plan/260911_ws_commit_boundary/010_journey_evaluation.md new file mode 100644 index 0000000000..a4dd9c48f8 --- /dev/null +++ b/devlog/_plan/260911_ws_commit_boundary/010_journey_evaluation.md @@ -0,0 +1,50 @@ +# Journey evaluation — how the framing changed + +## What was done before this unit + +1. Lane dispatch round: seven file-disjoint lanes from `6d3ad12e3`, each a worktree and a + Codex thread, merged serially on final-head green CI (#4217 … #4248). One of those lanes landed + the #4191 failure-stage counters in `codex-ws-wire.ts`: request bytes, sent, frames, control + frames, relayed events, first-frame and elapsed durations. The counters are content-free by + construction and only classify; they were never a fallback signal. +2. Structure question from the owner: `codex -> http -> opencodex -> ws -> openai` — is the + asymmetry itself the bug? Source reading said no: WS is chosen only for streaming POSTs on a + bounded-relay Bun, the create frame is measured before dialling, and the one reversible point is + the send. First framing: the reversible window is too narrow and judged by size alone; widen the + HTTP path below the ceiling and scale the prelude budget by frame size. +3. Semantic review by anthropic/claude-fable-5-1. Three corrections were accepted after source + confirmation: + - The no-resend-after-send rule is not a defect. RFC 9110 §9.2.2 forbids an intermediary from + automatically repeating a non-idempotent request; the user agent owns that decision. Offering + "allow fallback after send" as an option was the wrong question. + - The broken contract is the status code. `commitResponse` builds `new Response(stream, + { status: 200 })` before any upstream frame, and `failStream` commits that 200 on the failure + path (`if (sent) commitResponse()`) precisely so the pre-stream wrapper cannot resend. The proxy + therefore converts "no response" into "a response that failed", removes the status the client + would use for its own retry policy, and neuters the client's first-byte timeout with chunked + headers. Direct-to-vendor Codex survives the same at-most-once lane through its own retry; the + proxy is stricter than the party whose money is at stake and pays for it with a hard failure. + - The 90 s prelude is the wrong kind of quantity: it folds "dead" and "slow" into one number. + Dead is a liveness question with a native answer (ping/pong); slow already has an owner (the + client deadline). A fixed proxy deadline in series always inherits the tighter bound. + +## What the evaluation keeps and drops + +Kept: every existing oracle (no HTTP fallback after send, one `response.create` per exchange, +refused-create 4xx projection, correlation before conversion, bounded queue). Kept: the 90 s +number, but demoted from "time to first response event" to "unanswered silence with no pong", +which is unreachable on a socket whose peer answers pings. + +Dropped: post-send HTTP fallback (never acceptable), size-scaled prelude budgets (treats the +symptom), resume-by-id after 1006 (Codex sends `stream: true` without `background: true`; the +vendor resume endpoint requires a background response, so there is nothing to resume for this +client; recorded as a follow-up for callers that do opt in). + +## What this unit does not claim + +It does not claim the Codex backend answers WebSocket pings; the exchange feature-detects +`ws.ping` and degrades to the previous 90 s behaviour when no pong ever arrives. It does not run +any local suite. Whether the honest 504 improves the #4191 user's experience is a live question +that only a field report can answer; what this unit guarantees is that the proxy stops hiding the +signal that user's client needs. + diff --git a/devlog/_plan/260911_ws_commit_boundary/020_design_record.md b/devlog/_plan/260911_ws_commit_boundary/020_design_record.md new file mode 100644 index 0000000000..2f7b963c69 --- /dev/null +++ b/devlog/_plan/260911_ws_commit_boundary/020_design_record.md @@ -0,0 +1,90 @@ +# Design record — commit boundary, liveness, abort + +## Invariants that stay + +- I1 No HTTP SSE fallback once `ws.send()` has returned (`sent === true`). +- I2 One `response.create` frame per exchange; no proxy-internal resend after send. +- I3 A refused create (`type: error`, no `stream_id`, 4xx status) before any response event is + projected as that 4xx with the metadata snapshot (#3740); correlation runs first. +- I4 After the first `response.*` or `error` event has been relayed, every later failure is a + body error on the already-committed 200 (the relay synthesizes `response.failed`). + +## New invariant + +- I5 The client commit never precedes the upstream acknowledgment. Before the first + `response.*`/`error` event the exchange holds no client Response. A failure in that window + settles as a JSON error with an honest gateway status, marked non-replayable. + +## Diff-level plan + +### `src/lib/upstream-retry.ts` + +Add a `WeakSet` with `markResponseNonReplayable(res)` and +`isNonReplayableResponse(res)`. In `fetchWithTransientRetry` the loop guard becomes +`if (res.ok || !isTransientUpstreamStatus(res.status) || isNonReplayableResponse(res)) return res;`. +Rationale in the doc comment: the origin may already be executing the request (RFC 9110 §9.2.2), +so a gateway status from a post-send transport is returned to the caller for its own policy. + +### `src/server/responses/codex-ws-wire.ts` + +- `CODEX_WS_LIVENESS_PING_INTERVAL_MS = 15_000`. +- `CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS` keeps its value (90 000) and gains a new meaning in its + comment: the longest inbound silence (no message frame, no pong) tolerated before the first + response event. +- `codexWsPreResponseFailure(status, message, prelude: Headers): Response` — builds + `{ error: { type: "upstream_error", code, message } }` with `content-type: application/json`, + `cache-control: no-store`, the metadata snapshot headers, and calls + `markResponseNonReplayable`. `code` is `upstream_timeout` for 504 and + `upstream_closed_before_response` for 502. +- `CodexWsFailureStage` gains `pings` and `pongs`; `codexWsFailureDetail` appends + ` pings=N pongs=N` inside the bracket, after `elapsed`. `tests/responses/ws-failure-stage.test.ts` + is updated in the same commit. + +### `src/server/responses/codex-ws-exchange.ts` + +- `failStream(error, status: 502 | 504 = 502)`: when `sent && !responseCommitted`, resolve + `codexWsPreResponseFailure(status, message, metadata.snapshot())` instead of committing a 200, + close the controller, dispose the session. When committed, unchanged. +- `cancelExchange(reason)` when `sent && !responseCommitted`: mark terminal, cleanup, dispose the + session (this closes the socket, which is the upstream cancel), `reject(reason)`. The caller's + own abort is never retried by the wrappers (`isConnectionResetError` excludes AbortError and the + retry loops check `abortSignal.aborted`). +- Liveness replaces the single `preludeTimer`: + - `armSilence()` (re)starts a `CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS` timer whose expiry calls + `failStream("codex websocket response prelude timed out" + detail, 504)`. + - `onMessage` and `onPong` call `armSilence()` while `!responseCommitted`. + - After send, when `typeof ws.ping === "function"`, a repeating + `CODEX_WS_LIVENESS_PING_INTERVAL_MS` timer calls `ws.ping()` until commit or terminal; a + throwing `ping()` stops the pinger only. + - `cleanup()` clears both timers and removes the `pong` listener; `commitResponse()` clears + them too. +- The non-metadata path (`if (!metadata) commitResponse()`) is unchanged. + +### Tests (`tests/responses/ws-upstream.test.ts`) + +Updated oracles: prelude overflow → 502 JSON, not a WS-marked stream; first-response deadline +through `fetchWithTransientRetry` → 504, one send, zero HTTP; foreign-stream identity mismatch → +502; close 1006 / 1009 before any response event → 502 carrying the same messages; abort after send +before commit → the pending fetch rejects with the caller reason and the socket is closed. + +New cases: a pong resets the silence clock past 90 s and the response still completes with one +send; a socket exposing `ping` is pinged every 15 s of prelude and stops after commit; a socket +without `ping` is never pinged and keeps the 90 s bound; `fetchWithTransientRetry` returns a +non-replayable 504 without a second call (`tests/lib/upstream-retry.test.ts`). + +## Risks and their answers + +- Client behaviour on 504: Codex retries stream requests on 5xx with backoff, which is the same + policy it applies on the direct path; the proxy no longer substitutes its own. +- Pool recovery on 5xx: `shouldRetryCodexPoolAccountQuota` rotates only on body-confirmed quota + evidence; the new body carries none. Opaque-blob recovery excludes 5xx other than 502 with an + encrypted-output body, which this is not. +- Backend pong support unknown: feature-detected and degrades to the current bound. +- Request log: the failure is now a 504/502 row instead of a 200 with `streamAborted`; this is + the intended diagnostic change. + +## Verification + +NOT RUN locally by rule. Remote CI on the final head is the only executable proof; the read-only +grok-4.6 review of the diff is the second pair of eyes. + From c3c1ea673161a5e8676f7417cb1ecdd4989a15e6 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 12:16:56 +0900 Subject: [PATCH 002/231] docs(devlog): audit round 1 dispositions for the WS commit boundary --- .../260911_ws_commit_boundary/000_plan.md | 8 +++-- .../020_design_record.md | 7 ++++- .../025_audit_round1.md | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/260911_ws_commit_boundary/025_audit_round1.md diff --git a/devlog/_plan/260911_ws_commit_boundary/000_plan.md b/devlog/_plan/260911_ws_commit_boundary/000_plan.md index 95f7274034..2194f3c901 100644 --- a/devlog/_plan/260911_ws_commit_boundary/000_plan.md +++ b/devlog/_plan/260911_ws_commit_boundary/000_plan.md @@ -22,14 +22,18 @@ semantic review (`010_journey_evaluation.md`) overturned the first framing. honest HTTP status; replace the fixed prelude timer with silence-based liveness; cancel the upstream turn on a pre-commit client abort. - `src/server/responses/codex-ws-wire.ts` — liveness constants and the non-replayable body shape. -- `src/lib/upstream-retry.ts` — a non-replayable marker that `fetchWithTransientRetry` honours. +- `src/lib/upstream-retry.ts` — a non-replayable marker that `fetchWithTransientRetry` honours, and the + structured error codes the other resend paths stop on. +- `src/server/responses/core.ts` — two early returns on the marker (pool quota rotation, opaque-blob + recovery); `src/combos/failover.ts` — structured-code stop. See 025. +- `docs-site/src/content/docs/reference/configuration/server.md` — the prelude paragraph. - `tests/responses/ws-upstream.test.ts`, `tests/lib/upstream-retry.test.ts` — oracle updates and new cases. Out of scope, recorded in `020_design_record.md`: resume-by-id after 1006 (Codex does not request background responses, so the vendor resume surface does not apply), the opt-in provider path without a metadata channel (it commits at send today and keeps doing so), the create-frame size -predicate, and `src/server/responses/core.ts`. +predicate, and any core.ts change beyond the two marker guards named in 025. ## Rules for this unit diff --git a/devlog/_plan/260911_ws_commit_boundary/020_design_record.md b/devlog/_plan/260911_ws_commit_boundary/020_design_record.md index 2f7b963c69..36dbd8b533 100644 --- a/devlog/_plan/260911_ws_commit_boundary/020_design_record.md +++ b/devlog/_plan/260911_ws_commit_boundary/020_design_record.md @@ -11,7 +11,8 @@ ## New invariant -- I5 The client commit never precedes the upstream acknowledgment. Before the first +- I5 (exchanges with a metadata channel, i.e. the canonical Codex backend) The client commit never + precedes the upstream acknowledgment. Before the first `response.*`/`error` event the exchange holds no client Response. A failure in that window settles as a JSON error with an honest gateway status, marked non-replayable. @@ -72,6 +73,10 @@ send; a socket exposing `ping` is pinged every 15 s of prelude and stops after c without `ping` is never pinged and keeps the 90 s bound; `fetchWithTransientRetry` returns a non-replayable 504 without a second call (`tests/lib/upstream-retry.test.ts`). +## Audit amendments + +See `025_audit_round1.md`; its deltas override this file where they differ. + ## Risks and their answers - Client behaviour on 504: Codex retries stream requests on 5xx with backoff, which is the same diff --git a/devlog/_plan/260911_ws_commit_boundary/025_audit_round1.md b/devlog/_plan/260911_ws_commit_boundary/025_audit_round1.md new file mode 100644 index 0000000000..69ff1642b9 --- /dev/null +++ b/devlog/_plan/260911_ws_commit_boundary/025_audit_round1.md @@ -0,0 +1,29 @@ +# Audit round 1 — xai/grok-4.6 (read-only), dispositions + +Verdict received: FAIL as written. Every finding below is dispositioned; the design record is +amended in place and the scope in 000 is widened to match. + +| # | severity | finding | disposition | +|---|---|---|---| +| 1 | blocker | A marker honoured only by `fetchWithTransientRetry` leaves the Codex pool quota rotation (`shouldRetryCodexPoolAccountQuota`, core.ts:1120) and the combo 5xx hop (core.ts:3004 → `comboFailureDecision`) free to send again after `ws.send()`. | ACCEPTED. core.ts and src/combos/failover.ts enter scope minimally: (a) `shouldRetryCodexPoolAccountQuota` and `opaqueBlobRejectionBodyForRecovery` return early on `isNonReplayableResponse`; (b) the JSON body carries a structured `error.code` (`upstream_no_response`, `upstream_closed_before_response`) and `comboFailureDecision` returns `stop` for those codes, the same mechanism `origin_rejected` already uses. The code set lives in `src/lib/upstream-retry.ts` so combos need no server import. | +| 2 | major | Resetting the 90 s clock on quota/control frames removes the cap for a quota-only socket; it then runs to `connectTimeoutMs` (default 200 s) and settles as a `TimeoutError` 502 from `transportFailureResponse`, not the 504 the record promises. | ACCEPTED as a named behaviour change, with the status fixed. A socket that keeps sending frames or pongs is alive; the record now says so and names the quota-only case explicitly: it waits up to the operator's `connectTimeoutMs`, then the composite signal aborts with `TimeoutError`, and `cancelExchange` maps a pre-commit `TimeoutError` to the same non-replayable 504 instead of rejecting. Only a caller abort (AbortError) rejects. | +| 3 | major | Oracle list is short: metadata budget overflow rows (794), cumulative prelude bound (807), pre-response oversized frame (1064), and the `failureMessage()` helper cases in ws-failure-stage (171, 182, 208) all leave the 200 body-error shape. Foreign-stream 502 conflicts with the in-source note that a reused socket's foreign error must not become an HTTP refusal. | ACCEPTED. All listed tests are updated in wp2. The foreign-stream note was about a 4xx conversion that could authorize account replay; a non-replayable 502 authorizes nothing, and the test now asserts status 502, one send, zero fallback. The source comment is reworded to say that. | +| 4 | major | `failStream` rewrite could skip `cleanup()` and leak the pinger, silence timer, pong listener, or double-settle via `onClose`. | ACCEPTED. Order fixed in the record: `terminal = true; cleanup();` then settle, then `session.dispose()`. `cleanup()` and `commitResponse()` both clear the liveness timers and detach `pong`. | +| 5 | minor | I5 is stated globally while the no-metadata path commits at send. | ACCEPTED. I5 is scoped to exchanges with a metadata channel (the canonical Codex backend). | +| 6 | minor | `connectTimeoutMs` < 90 s makes a post-send abort a 502 connect timeout, not a 504. | ACCEPTED via finding 2: any pre-commit `TimeoutError` becomes the non-replayable 504. | +| 7 | minor | `docs-site/` paragraph on the fixed 90-second prelude deadline (reference/configuration/server.md:38-47) becomes wrong. | ACCEPTED. The paragraph is rewritten in wp3 to describe silence-based liveness and the honest status. | +| 8 | nit | Exact `codexWsFailureDetail` pin, `stage()` fixture defaults, fake-timer stepping for pong tests, feature-detect `ping` not pong. | ACCEPTED. `stage()` defaults `pings: 0, pongs: 0`; pinned strings updated; pong tests step the clock. | + +Not accepted: none. + +## Amended plan deltas (authoritative over 020 where they differ) + +- Scope adds `src/server/responses/core.ts` (two early returns), `src/combos/failover.ts` (one + structured-code stop), `docs-site/src/content/docs/reference/configuration/server.md` (one + paragraph), `tests/responses/ws-failure-stage.test.ts`, `tests/combos/*` only if an existing + decision table needs the new row. +- `cancelExchange(reason)` pre-commit: `reason?.name === "TimeoutError"` → non-replayable 504 + with `upstream_no_response`; anything else → `reject(reason)`. +- Liveness semantics: silence = no inbound message frame and no pong. Any inbound frame resets. + Quota-only sockets are alive and wait for the client or `connectTimeoutMs`. + From 0309c809b033d658fc1ddc5053d8841d5065fdad Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 12:20:15 +0900 Subject: [PATCH 003/231] docs(devlog): wp2 diff plan for the honest post-send status --- .../260911_ws_commit_boundary/030_wp2_plan.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 devlog/_plan/260911_ws_commit_boundary/030_wp2_plan.md diff --git a/devlog/_plan/260911_ws_commit_boundary/030_wp2_plan.md b/devlog/_plan/260911_ws_commit_boundary/030_wp2_plan.md new file mode 100644 index 0000000000..29176614f0 --- /dev/null +++ b/devlog/_plan/260911_ws_commit_boundary/030_wp2_plan.md @@ -0,0 +1,36 @@ +# wp2 — honest post-send status and the non-replayable marker + +Previous D (wp1): roadmap locked at c3c1ea6731; direction unchanged — the fix is the commit +boundary, not the transport choice. 025 deltas are authoritative over 020. + +## Files and exact changes + +### src/lib/upstream-retry.ts +- Add a WeakSet with markResponseNonReplayable(res) and isNonReplayableResponse(res). +- Add NON_REPLAYABLE_UPSTREAM_CODES = {"upstream_no_response", "upstream_closed_before_response"} and isNonReplayableUpstreamCode(code). +- fetchWithTransientRetry loop guard: return res when isNonReplayableResponse(res). + +### src/combos/failover.ts +- comboFailureDecision: after the 499/origin_rejected checks, return "stop" when isNonReplayableUpstreamCode(options?.code). + +### src/server/responses/core.ts +- shouldRetryCodexPoolAccountQuota: first line returns false on isNonReplayableResponse(response). +- opaqueBlobRejectionBodyForRecovery: same early return undefined. + +### src/server/responses/codex-ws-wire.ts +- codexWsPreResponseFailure(status: 502 | 504, message, prelude: Headers): Response — JSON body { error: { type: "upstream_error", code, message } }, code by status (504 upstream_no_response, 502 upstream_closed_before_response), headers = prelude snapshot + content-type application/json + cache-control no-store, marked non-replayable. + +### src/server/responses/codex-ws-exchange.ts +- failStream(error, status = 502): when sent && !responseCommitted && metadata: terminal = true; cleanup(); resolve(codexWsPreResponseFailure(status, message, metadata.snapshot())); close the unused controller; session.dispose(). Otherwise the existing body-error path. (The non-metadata path commits at send.) +- cancelExchange(reason) when sent && !responseCommitted && metadata: TimeoutError -> failStream(reason, 504); otherwise terminal = true; cleanup(); session.dispose(); reject(reason). +- The prelude timer expiry calls failStream(..., 504); liveness itself is wp3. +- Reword the foreign-stream comment: a pre-response failure settles as a non-replayable 502; the 4xx projection stays reserved for a genuine refused create. + +### Tests +- ws-upstream.test.ts: update 794/807 (metadata overflow -> 502 JSON, isCodexWsUpstreamResponse false), 873 foreign -> 502 + one send, 1064 oversized pre-response -> 502, 1180 abort after open -> the fetch rejects with the caller reason and the socket is closed, 1218 -> 502, 1262 -> 504 with sends === 1, 1533/1547 -> 502 with the same messages in error.message. +- ws-failure-stage.test.ts: failureMessage() returns error.message from a 5xx JSON body, else the thrown body error. +- New: upstream-transient-retry.test.ts — a marked 504 returns after one send; an unmarked 504 still retries. combos test — comboFailureDecision(504, "Provider error 504", { code: "upstream_no_response" }) is stop. + +## Verification +NOT RUN locally (owner rule). Remote CI on the final head in wp4. + From 42988a169383a0ffebe53005811aa513135dec03 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 12:33:28 +0900 Subject: [PATCH 004/231] fix(codex-ws): settle a post-send, pre-response failure as an honest gateway status The Codex WebSocket relay committed a 200 SSE Response the moment a failure landed after ws.send(), on the reasoning that a 5xx could make the pre-stream retry wrapper resend the frame. That was right about the resend and wrong about the status: it turned "no response" into "a response that failed", removed the code a user agent needs for its own retry policy, and neutered the client's first-byte timeout with chunked headers (#4191, #4083). Before the first response.*/error event the exchange now resolves a JSON 504 (prelude silence, or the proxy's own connect deadline) or 502 (close, transport error, prelude overflow, foreign stream) carrying the stage detail and the metadata snapshot. The response is marked non-replayable: fetchWithTransientRetry returns it without a second send, the Codex pool quota rotation and opaque-blob recovery ignore it, and comboFailureDecision stops on its structured error code. A caller abort in that window rejects with the caller's reason and disposes the socket, cancelling the turn. Behaviour after the response has started is unchanged. Local suite: NOT RUN by owner rule; remote CI on the final head is the gate. --- src/combos/failover.ts | 5 ++ src/lib/upstream-retry.ts | 37 ++++++++++- src/server/responses/codex-ws-exchange.ts | 47 +++++++++++--- src/server/responses/codex-ws-wire.ts | 34 ++++++++++ src/server/responses/core.ts | 7 +- .../upstream-transient-retry.test.ts | 42 +++++++++++- tests/responses/ws-failure-stage.test.ts | 18 +++++- tests/responses/ws-upstream.test.ts | 64 +++++++++++++------ ...uter-combo-failover-classification.test.ts | 10 +++ 9 files changed, 230 insertions(+), 34 deletions(-) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 3868bc4b0c..e592298d88 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -1,5 +1,6 @@ import { parseResetCooldownMs } from "../codex/routing"; import { classifyError, isCyberPolicyCode } from "../lib/errors"; +import { isNonReplayableUpstreamCode } from "../lib/upstream-retry"; import type { OcxComboTarget } from "../types"; import { targetKey } from "./types"; import { @@ -413,6 +414,10 @@ export function comboFailureDecision( ): ComboFailureDecision { if (status === 499) return "stop"; if (message.toLowerCase().includes("origin_rejected")) return "stop"; + // The origin may already be executing this turn (the Codex WebSocket relay sent the create + // frame and never saw a response event). Hopping would send the same request to a second + // target while the first may still be generating; the honest status goes to the client. + if (isNonReplayableUpstreamCode(options?.code)) return "stop"; // Cyber policy is a hard non-retryable refusal — honor structured code even when // classificationText was truncated before the JSON code field. if (isCyberPolicyCode(options?.code)) return "stop"; diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index 74302af51f..afdbbcf3ae 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -16,6 +16,39 @@ */ import { clearableDeadline } from "./abort"; +/** + * Responses the origin may already be executing. RFC 9110 §9.2.2 forbids an intermediary + * from automatically repeating a non-idempotent request; when a post-send transport (the + * Codex WebSocket relay) settles a gateway status because the origin never acknowledged the + * turn, the request body must not be sent again by this process — not by the transient-5xx + * layer below, not by a pool account rotation, not by a combo hop. The status is returned + * to the caller so the user agent can apply its own retry policy, exactly as it does on the + * direct path. The WeakSet is the in-process marker; the structured error codes are the + * marker that survives body re-wrapping (combo failure consumption re-parses the JSON). + */ +const nonReplayableResponses = new WeakSet(); + +export function markResponseNonReplayable(response: Response): void { + nonReplayableResponses.add(response); +} + +export function isNonReplayableResponse(response: Response): boolean { + return nonReplayableResponses.has(response); +} + +/** Origin never produced a response event; the turn may still be executing. */ +export const UPSTREAM_NO_RESPONSE_CODE = "upstream_no_response"; +/** Transport closed after the send, before any response event. */ +export const UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE = "upstream_closed_before_response"; +const NON_REPLAYABLE_UPSTREAM_CODES: ReadonlySet = new Set([ + UPSTREAM_NO_RESPONSE_CODE, + UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE, +]); + +export function isNonReplayableUpstreamCode(code: unknown): boolean { + return typeof code === "string" && NON_REPLAYABLE_UPSTREAM_CODES.has(code); +} + // 1 initial + 2 retries: the pool may hold more than one stale socket. const RESET_RETRY_MAX_ATTEMPTS = 3; const RESET_RETRY_BASE_DELAY_MS = 150; @@ -395,7 +428,9 @@ export async function fetchWithTransientRetry( let attemptStart = Date.now(); let res = await fetchWithResetRetry(countedFetch, { ...opts, attempts: remaining() }); for (let attempt = 0; sent < budget; attempt++) { - if (res.ok || !isTransientUpstreamStatus(res.status)) return res; + // A non-replayable gateway status was settled after the request body had already left + // for the origin; retrying it here is the automatic resend the marker exists to forbid. + if (res.ok || !isTransientUpstreamStatus(res.status) || isNonReplayableResponse(res)) return res; // Checked before cancelResponseBodyBestEffort so an already-aborted caller never receives // a response whose body we just cancelled. if (opts.abortSignal?.aborted) return res; diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index 64a8e5aba4..7eee36b420 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -6,7 +6,7 @@ import { CodexWsCorrelation } from "./codex-ws-correlation"; import type { CodexWsSession } from "./codex-ws-session"; import { UPGRADE_DEADLINE_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage, - codexWsFailureDetail, type CodexWsFailureStage } from "./codex-ws-wire"; + codexWsFailureDetail, codexWsPreResponseFailure, type CodexWsFailureStage } from "./codex-ws-wire"; interface ExchangeOptions { session: CodexWsSession; @@ -148,7 +148,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise { }); const commitResponse = () => { - if (responseCommitted) return; + // A pre-response settlement (gateway status) has already resolved this exchange. + if (responseCommitted || terminal) return; responseCommitted = true; clearTimeout(preludeTimer); const responseHeaders = metadata?.snapshot() ?? new Headers(); @@ -159,11 +160,26 @@ export function codexWsExchange(options: ExchangeOptions): Promise { resolve(response); }; - const failStream = (error: unknown) => { + const failStream = (error: unknown, status: 502 | 504 = 502) => { if (terminal) return; terminal = true; - // A frame may already be executing upstream. Settle as a body failure, - // never a fetch rejection/5xx that the pre-stream wrapper could resend. + if (sent && !responseCommitted && metadata) { + // Nothing has been promised to the client yet, so the honest answer is a gateway + // status, not a 200 whose body then fails. The frame may already be executing + // upstream: the response is marked non-replayable so no layer of this process sends + // it again, and the client applies its own retry policy as it would on the direct + // path. Same settle order as a refused create: snapshot, detach, close, dispose. + const prelude = metadata.snapshot(); + cleanup(); + try { controller?.close(); } catch { /* unused stream already closed */ } + session.dispose(); + const message = error instanceof Error ? error.message : String(error); + resolve(codexWsPreResponseFailure(status, message, prelude)); + return; + } + // A response is already flowing (or this transport has no metadata channel and + // committed at send). Settle as a body failure, never a fetch rejection/5xx that the + // pre-stream wrapper could resend. if (sent) commitResponse(); cleanup(); try { controller?.error(typeof error === "string" ? new Error(error) : error); } catch { /* stream already done */ } @@ -188,6 +204,20 @@ export function codexWsExchange(options: ExchangeOptions): Promise { reject(reason); return; } + if (!responseCommitted && metadata) { + // Sent, unacknowledged. The proxy's own connect deadline is an origin-silence + // verdict and settles like one; a caller abort is the caller's decision, so the + // exchange rejects with that reason and disposing the socket cancels the turn. + if ((reason as { name?: unknown } | null)?.name === "TimeoutError") { + failStream(`codex websocket response did not start before the connect deadline${codexWsFailureDetail(failureStage())}`, 504); + return; + } + terminal = true; + cleanup(); + session.dispose(); + reject(reason); + return; + } failStream(reason); }; const onAbort = () => cancelExchange(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError")); @@ -237,7 +267,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { if (!metadata) commitResponse(); else if (!responseCommitted && !terminal) { preludeTimer = setTimeout( - () => failStream(`codex websocket response prelude timed out${codexWsFailureDetail(failureStage())}`), + () => failStream(`codex websocket response prelude timed out${codexWsFailureDetail(failureStage())}`, 504), CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, ); } @@ -288,8 +318,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise { if (!controlFrame && !type.startsWith("response.") && type !== "error") return; if (!controlFrame) { try { correlation?.accept(normalized.payload); } catch (error) { failStream(error); return; } - // Correlation must run first: a reused socket's foreign-stream error - // must not become an HTTP refusal that could authorize account replay. + // Correlation must run first: a reused socket's foreign-stream error settles as a + // non-replayable 502 above, never as the refused-create 4xx projection below, which + // is the one status family that could authorize an account replay. if (metadata && sent && !responseCommitted && type === "error") { let rejection: Response | null; try { rejection = wrappedRejectionResponse(normalized.payload, metadata.snapshot()); } diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index 770ce21b54..64b732b22e 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -1,4 +1,9 @@ import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer"; +import { + markResponseNonReplayable, + UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE, + UPSTREAM_NO_RESPONSE_CODE, +} from "../../lib/upstream-retry"; // If the 101 never arrives (network black hole), give SSE a chance well before // the caller's connect timeout (default 200s) would fire. export const UPGRADE_DEADLINE_MS = 10_000; @@ -48,6 +53,35 @@ export function markCodexWsResponse(response: Response, observed: boolean): void if (observed) quotaObservedResponses.add(response); } +/** + * The honest settlement for an exchange that sent its create frame and never saw a + * response event. + * + * Before this existed the relay committed a 200 SSE Response and errored its body, on the + * reasoning that a 5xx could make the pre-stream retry wrapper resend the frame. That + * reasoning was right about the resend and wrong about the status: it turned "no response" + * into "a response that failed", removed the code a user agent uses for its own retry + * policy, and neutered the client's first-byte timeout with chunked headers. The client + * on the direct path receives a gateway status in this situation and retries under its own + * policy; this response restores that equivalence. The resend is forbidden by the + * non-replayable marker instead (see upstream-retry.ts), and the structured code lets the + * combo failover reach the same verdict after it re-parses the body. + * + * 504 is the origin's silence (nothing at all, or nothing but liveness, for the tolerated + * window); 502 is a transport that closed or misbehaved after the send. Both carry the + * content-free stage detail in the message and the metadata snapshot in the headers, the + * same way a refused create does, so quota captured during the prelude is not lost. + */ +export function codexWsPreResponseFailure(status: 502 | 504, message: string, prelude: Headers): Response { + const headers = new Headers(prelude); + headers.set("content-type", "application/json"); + headers.set("cache-control", "no-store"); + const code = status === 504 ? UPSTREAM_NO_RESPONSE_CODE : UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE; + const response = new Response(JSON.stringify({ error: { type: "upstream_error", code, message } }), { status, headers }); + markResponseNonReplayable(response); + return response; +} + const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event"; /** diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 425621814c..f24b1b79a5 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -210,6 +210,7 @@ import { applyUpstreamRecoveryInit, fetchWithResetRetry, fetchWithTransientRetry, + isNonReplayableResponse, prepareSameTarget429Wait, } from "../../lib/upstream-retry"; import { @@ -827,7 +828,8 @@ async function opaqueBlobRejectionBodyForRecovery( signal: AbortSignal, ): Promise { if ( - response.status < 400 + isNonReplayableResponse(response) + || response.status < 400 || (response.status >= 500 && response.status !== 502) || adapterName !== "openai-responses" || alreadyAttempted @@ -1121,6 +1123,9 @@ export async function shouldRetryCodexPoolAccountQuota( response: Response, signal?: AbortSignal, ): Promise { + // A post-send WebSocket gateway status must not become a second account's send; the + // body carries no quota evidence either, but the marker is the contract, not the prose. + if (isNonReplayableResponse(response)) return false; if (response.status === 402 || response.status === 429) return true; if (response.status < 500 || response.status >= 600) return false; try { diff --git a/tests/providers/upstream-transient-retry.test.ts b/tests/providers/upstream-transient-retry.test.ts index 58199ac55a..77a224eb36 100644 --- a/tests/providers/upstream-transient-retry.test.ts +++ b/tests/providers/upstream-transient-retry.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { fetchWithTransientRetry, isTransientUpstreamStatus } from "../../src/lib/upstream-retry"; +import { + fetchWithTransientRetry, + isNonReplayableResponse, + isNonReplayableUpstreamCode, + isTransientUpstreamStatus, + markResponseNonReplayable, +} from "../../src/lib/upstream-retry"; import { transientRetryPolicyFor } from "../../src/providers/key-failover"; import type { OcxProviderConfig } from "../../src/types"; @@ -50,6 +56,40 @@ describe("transientRetryPolicyFor", () => { }); describe("fetchWithTransientRetry", () => { + test("a non-replayable gateway status is returned after one send, body intact", async () => { + // The Codex WebSocket relay settles a 504 when the origin never acknowledged a frame it + // already sent. 504 is transient by status, but the origin may be executing that turn: + // the marker, not the status, decides that this layer must not send the body again. + let sends = 0; + const res = await fetchWithTransientRetry(async () => { + sends += 1; + const response = bodyResponse(504); + markResponseNonReplayable(response); + return response; + }, { attempts: 3, slowAttemptMs: 60_000 }); + expect(sends).toBe(1); + expect(res.status).toBe(504); + expect(isNonReplayableResponse(res)).toBe(true); + expect((res as Response & { __wasCancelled: () => boolean }).__wasCancelled()).toBe(false); + }); + + test("an unmarked 504 keeps the transient retry", async () => { + let sends = 0; + const res = await fetchWithTransientRetry(async () => { + sends += 1; + return bodyResponse(504); + }, { attempts: 2, slowAttemptMs: 60_000 }); + expect(sends).toBe(2); + expect(res.status).toBe(504); + }); + + test("the structured codes name exactly the two post-send verdicts", () => { + expect(isNonReplayableUpstreamCode("upstream_no_response")).toBe(true); + expect(isNonReplayableUpstreamCode("upstream_closed_before_response")).toBe(true); + expect(isNonReplayableUpstreamCode("upstream_error")).toBe(false); + expect(isNonReplayableUpstreamCode(undefined)).toBe(false); + }); + test("attempts is one total-send budget, not a per-layer multiplier", async () => { // The two layers used to multiply: attempts:3 meant 3 transient rounds each independently // retrying 3 connection resets, so a single call could emit up to 9 upstream sends. All diff --git a/tests/responses/ws-failure-stage.test.ts b/tests/responses/ws-failure-stage.test.ts index af4db2136a..d578d801d4 100644 --- a/tests/responses/ws-failure-stage.test.ts +++ b/tests/responses/ws-failure-stage.test.ts @@ -98,6 +98,20 @@ async function failureMessage(script: (ws: FakeWebSocket) => void): Promise { + if (response.status >= 500) { + const body = await response.json() as { error?: { message?: unknown } }; + if (typeof body.error?.message !== "string") throw new Error("expected a gateway failure body"); + return body.error.message; + } try { await response.text(); } catch (error) { @@ -219,7 +233,8 @@ describe("codexWsUpstreamFetch failure reporting", () => { await opened.promise; jest.advanceTimersByTime(CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS); const response = await pending; - await expect(response.text()).rejects.toThrow( + expect(response.status).toBe(504); + expect(await failureMessageOf(response)).toMatch( /prelude timed out \[cause=no-upstream-frame request=\d+B sent=yes frames=0 control=0 relayed=0/, ); } finally { @@ -227,4 +242,3 @@ describe("codexWsUpstreamFetch failure reporting", () => { } }); }); - diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index 9e4f689208..6afb9e312c 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -3,7 +3,7 @@ import { providerFetch } from "../../src/server/responses/fetch-helpers"; import { handleResponses } from "../../src/server/responses"; import { isEagerRelaySseResponse } from "../../src/server/relay"; import { isWin32EagerRewrite } from "../../src/lib/bun-stream-caps"; -import { fetchWithTransientRetry } from "../../src/lib/upstream-retry"; +import { fetchWithTransientRetry, isNonReplayableResponse } from "../../src/lib/upstream-retry"; import { codexWsExchange } from "../../src/server/responses/codex-ws-exchange"; import { CodexWsSession } from "../../src/server/responses/codex-ws-session"; import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-request"; @@ -798,9 +798,14 @@ describe("codexWsUpstreamFetch", () => { for (const [name, value] of Object.entries(headers)) expect(response.headers.get(name)).toBe(value); expect(await response.json()).toEqual({ error: refusal.error }); } else { - expect(response.status).toBe(200); - expect(isCodexWsUpstreamResponse(response)).toBe(true); - await expect(response.text()).rejects.toThrow("metadata"); + // The overflow lands before any response event: an honest 502, never a 200 whose + // body then fails, and never the HTTP fallback (the frame was sent). + expect(response.status).toBe(502); + expect(isCodexWsUpstreamResponse(response)).toBe(false); + expect(response.headers.get("content-type")).toBe("application/json"); + const failure = ((await response.json()) as { error: { code: string; message: string } }).error; + expect(failure.code).toBe("upstream_closed_before_response"); + expect(failure.message).toContain("metadata"); } }); @@ -808,8 +813,8 @@ describe("codexWsUpstreamFetch", () => { const response = await receive({ ...refusal, headers: boundedHeaders(5, "x".repeat(4096)) }, [ { type: "codex.response.metadata", headers: boundedHeaders(4, "y".repeat(4096)) }, ]); - expect(response.status).toBe(200); - await expect(response.text()).rejects.toThrow("metadata"); + expect(response.status).toBe(502); + expect(((await response.json()) as { error: { message: string } }).error.message).toContain("metadata"); }); test.each([ @@ -896,8 +901,11 @@ describe("codexWsUpstreamFetch", () => { expect(session.reserve()).toBe(true); const response = await codexWsExchange(options); if (foreign) { - expect(response.status).toBe(200); - await expect(response.text()).rejects.toThrow("identity mismatch"); + // A foreign stream before any response event is a transport that misbehaved after + // the send: non-replayable 502, and never the 4xx refusal projection. + expect(response.status).toBe(502); + expect(isCodexWsUpstreamResponse(response)).toBe(false); + expect(((await response.json()) as { error: { message: string } }).error.message).toContain("identity mismatch"); } else { expect(response.status).toBe(429); expect(isCodexWsUpstreamResponse(response)).toBe(false); @@ -1070,7 +1078,10 @@ describe("codexWsUpstreamFetch", () => { throw new Error("fallback must not run after open"); }) as unknown as typeof fetch); - await expect(response.text()).rejects.toThrow("frame exceeds the response size limit"); + // No response event preceded the oversized frame, so the exchange never owed a stream. + expect(response.status).toBe(502); + expect(((await response.json()) as { error: { message: string } }).error.message) + .toContain("frame exceeds the response size limit"); expect(FakeWebSocket.instances[0].closed).toBe(true); }); @@ -1189,9 +1200,9 @@ describe("codexWsUpstreamFetch", () => { await opened.promise; controller.abort(new Error("turn cancelled")); - const response = await pending; - - await expect(response.text()).rejects.toThrow("turn cancelled"); + // Sent and unacknowledged: the caller's abort is the caller's decision, so the fetch + // itself rejects with that reason and closing the socket cancels the upstream turn. + await expect(pending).rejects.toThrow("turn cancelled"); expect(FakeWebSocket.instances[0].closed).toBe(true); }); @@ -1215,7 +1226,7 @@ describe("codexWsUpstreamFetch", () => { await response.text(); }); - test("post-send prelude overflow settles as an errored body without HTTP fallback", async () => { + test("post-send prelude overflow settles as a non-replayable 502 without HTTP fallback", async () => { installFake(ws => { ws.emit("open", {}); ws.emit("message", { data: JSON.stringify({ type: "codex.response.metadata", headers: { "x-models-etag": "x".repeat(CODEX_WS_METADATA_MAX_BYTES) } }) }); @@ -1225,9 +1236,10 @@ describe("codexWsUpstreamFetch", () => { resends++; return new Response("unexpected resend"); }) as typeof fetch); - expect(response.status).toBe(200); - expect(isCodexWsUpstreamResponse(response)).toBe(true); - await expect(response.text()).rejects.toThrow("metadata"); + expect(response.status).toBe(502); + expect(isCodexWsUpstreamResponse(response)).toBe(false); + expect(isNonReplayableResponse(response)).toBe(true); + expect(((await response.json()) as { error: { message: string } }).error.message).toContain("metadata"); expect(resends).toBe(0); expect(FakeWebSocket.instances[0].sent).toHaveLength(1); }); @@ -1259,7 +1271,7 @@ describe("codexWsUpstreamFetch", () => { } }); - test("the first-response deadline settles a sent request through the outer retry wrapper without resending", async () => { + test("the first-response deadline settles a sent request as a 504 the outer retry wrapper does not resend", async () => { const { fetchWithTransientRetry } = await import("../../src/lib/upstream-retry"); jest.useFakeTimers(); const opened = Promise.withResolvers(); @@ -1277,8 +1289,14 @@ describe("codexWsUpstreamFetch", () => { await opened.promise; jest.advanceTimersByTime(CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS); const response = await pending; - expect(response.status).toBe(200); - await expect(response.text()).rejects.toThrow("prelude timed out"); + // 504 is a transient status for the wrapper; the non-replayable marker is what stops + // the second send, and the status is what lets the client apply its own policy. + expect(response.status).toBe(504); + expect(isNonReplayableResponse(response)).toBe(true); + const failure = ((await response.json()) as { error: { code: string; message: string } }).error; + expect(failure.code).toBe("upstream_no_response"); + expect(failure.message).toContain("prelude timed out"); + expect(failure.message).toContain("cause=no-upstream-frame"); expect(sends).toBe(1); expect(http).toBe(0); } finally { @@ -1539,7 +1557,8 @@ describe("oversized Codex create frames", () => { throw new Error("fallback must not run after open"); }) as unknown as typeof fetch); - await expect(response.text()).rejects.toThrow( + expect(response.status).toBe(502); + expect(((await response.json()) as { error: { message: string } }).error.message).toMatch( /rejected the request frame as too large \(close 1009 Message Too Big\)/, ); }); @@ -1553,7 +1572,10 @@ describe("oversized Codex create frames", () => { throw new Error("fallback must not run after open"); }) as unknown as typeof fetch); - await expect(response.text()).rejects.toThrow("closed before a Responses terminal event (close 1006)"); + expect(response.status).toBe(502); + const failure = ((await response.json()) as { error: { code: string; message: string } }).error; + expect(failure.code).toBe("upstream_closed_before_response"); + expect(failure.message).toContain("closed before a Responses terminal event (close 1006)"); }); test("dials the configured provider's own wss URL for an opt-in upstream", async () => { diff --git a/tests/routing/router-combo-failover-classification.test.ts b/tests/routing/router-combo-failover-classification.test.ts index 742f411bfb..93b58170a6 100644 --- a/tests/routing/router-combo-failover-classification.test.ts +++ b/tests/routing/router-combo-failover-classification.test.ts @@ -129,6 +129,16 @@ describe("combo failure hop/stop verdicts", () => { test("INVARIANT: a structured model lifecycle 410 still hops", () => { expect(comboFailureDecision(410, "model retired", { code: "model_end_of_life" })).toBe("hop"); }); + + test("a post-send gateway status from the Codex WebSocket relay never hops", () => { + // The relay sent the create frame and the origin never acknowledged it (504) or the + // transport closed first (502). The turn may still be executing at the first target, so + // a second target must not receive the same request; the client decides the retry. + expect(comboFailureDecision(504, "Provider error 504", { code: "upstream_no_response" })).toBe("stop"); + expect(comboFailureDecision(502, "Provider error 502", { code: "upstream_closed_before_response" })).toBe("stop"); + // The same statuses without the structured code keep the ordinary transient hop. + expect(comboFailureDecision(504, "Provider error 504")).toBe("hop"); + }); }); describe("cooled targets are not selectable", () => { From d79eac5c179ae96ab2aea4f13e8cf2837eac5ff3 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 12:35:16 +0900 Subject: [PATCH 005/231] docs(devlog): wp3 diff plan for prelude liveness --- .../260911_ws_commit_boundary/040_wp3_plan.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 devlog/_plan/260911_ws_commit_boundary/040_wp3_plan.md diff --git a/devlog/_plan/260911_ws_commit_boundary/040_wp3_plan.md b/devlog/_plan/260911_ws_commit_boundary/040_wp3_plan.md new file mode 100644 index 0000000000..8034ba2721 --- /dev/null +++ b/devlog/_plan/260911_ws_commit_boundary/040_wp3_plan.md @@ -0,0 +1,30 @@ +# wp3 — liveness replaces the fixed prelude deadline + +Previous D (wp2): honest 502/504 with the non-replayable marker landed at 42988a1693; draft PR #4256 opened so remote CI runs on that head. Direction unchanged. + +## Files and exact changes + +### src/server/responses/codex-ws-wire.ts +- CODEX_WS_LIVENESS_PING_INTERVAL_MS = 15_000. +- CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS keeps 90_000; its comment now defines it as the longest inbound silence (no message frame, no pong) tolerated before the first response event. +- CodexWsFailureStage gains pings and pongs (numbers); codexWsFailureDetail appends " pings=N pongs=N" after elapsed, inside the bracket. + +### src/server/responses/codex-ws-exchange.ts +- Counters pings, pongs. Timers silenceTimer (replaces preludeTimer) and pingTimer. +- armSilence(): clearTimeout(silenceTimer); if (responseCommitted || terminal) return; silenceTimer = setTimeout(() => failStream("codex websocket response prelude timed out" + detail, 504), CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS). +- schedulePing(): only when typeof ws.ping === "function"; pingTimer = setTimeout(() => { if (responseCommitted || terminal) return; try { ws.ping(); pings += 1; } catch { return; } schedulePing(); }, CODEX_WS_LIVENESS_PING_INTERVAL_MS). +- onPong(): pongs += 1; if (!responseCommitted) armSilence(). Listener added with the others, removed in cleanup(). +- After a successful send on the metadata path: armSilence(); schedulePing(). onMessage calls armSilence() while !responseCommitted (after the terminal guard). +- commitResponse() and cleanup() clear both timers; cleanup() removes the pong listener. +- The non-metadata path is untouched (commits at send; no liveness). + +### docs-site/src/content/docs/reference/configuration/server.md +- Replace the "fixed 90-second response-prelude deadline" paragraph: silence-based liveness, ping every 15 s, any inbound frame or pong resets, 90 s of nothing settles a non-replayable 504, closes/transport errors before the first response event settle 502, connectTimeoutMs remains the outer bound and a pre-response connect timeout is the same 504; no HTTP resend either way. + +### Tests +- tests/responses/ws-upstream.test.ts, new describe "prelude liveness": (a) a socket whose ping() emits pong stays alive across 7 x 15 s steps (105 s > 90 s) and the response still completes with one send and zero HTTP; (b) a socket whose ping() never pongs settles 504 at 90 s with pongs=0 in the message; (c) after response.created the pinger stops (no further ping calls across 60 s). The existing first-response-deadline test already covers a socket without ping(). +- tests/responses/ws-failure-stage.test.ts: stage() defaults pings: 0, pongs: 0; the two exact toBe strings gain " pings=0 pongs=0". + +## Verification +NOT RUN locally (owner rule). Remote CI on the final head in wp4. + From 075b9f39f5db19cd4f70853501d7aecf04d95a6c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 12:49:38 +0900 Subject: [PATCH 006/231] feat(codex-ws): replace the fixed prelude deadline with silence-based liveness The 90 s response-prelude timer folded "the origin is dead" and "the origin is slow" into one number the proxy owned. Dead is a liveness question and WebSocket answers it natively; slow already has owners: the client's own deadline and the operator's connectTimeoutMs. While waiting for the first response event the exchange now pings every 15 s on sockets that expose ping(), any inbound frame or pong resets the 90 s silence clock, and only silence settles the 504. A peer that never pongs keeps exactly the previous bound; a peer that does can never trip it while alive. The failure stage names the pings sent and pongs received so a field report can tell an unanswered peer from a slow one (#4191, #4083). Local suite: NOT RUN by owner rule; remote CI on the final head is the gate. --- .../docs/reference/configuration/server.md | 22 ++-- src/server/responses/codex-ws-exchange.ts | 55 ++++++++-- src/server/responses/codex-ws-wire.ts | 18 +++- src/server/responses/ws-upstream.ts | 2 +- tests/responses/ws-failure-stage.test.ts | 8 +- tests/responses/ws-upstream.test.ts | 101 ++++++++++++++++++ 6 files changed, 184 insertions(+), 22 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 6a3533f10c..b5c6158090 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -35,16 +35,18 @@ runs helper features around provider requests. | `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | Image-description sidecar options. | | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Standalone Images relay options for Codex `image_gen`. | -The canonical ChatGPT upstream WebSocket has a fixed 90-second response-prelude deadline, -measured after sending the create frame. Quota and response-metadata control frames do not -reset it; the first non-control Responses event ends it. This is not a total generation -deadline, and neither `connectTimeoutMs` nor `stallTimeoutSec` retunes the 90 seconds -themselves. That constant is only the WebSocket-specific upper bound: the exchange runs under -the signal `connectTimeoutMs` (default 200s) aborts, and that abort cancels an already-sent -create before the prelude timer can fire. A `connectTimeoutMs` below 90 seconds therefore -ends the wait earlier, so the deadline a request actually gets is the shorter of the two. If -either expires after sending, the stream fails without an HTTP resend, avoiding duplicate -inference. +While the canonical ChatGPT upstream WebSocket waits for the first Responses event after +sending the create frame, it watches for liveness rather than a fixed deadline. The proxy pings +the socket every 15 seconds; any inbound frame — quota, response metadata, or a pong — resets a +90-second silence clock, and only 90 seconds with nothing at all settles the request as an +HTTP 504 with an `upstream_no_response` error. A slow but alive origin therefore waits for the +client's own deadline or for `connectTimeoutMs` (default 200s), whichever comes first; a +connect timeout that fires after the create frame was sent settles as the same 504. A socket +that closes or errors before the first Responses event settles as an HTTP 502 with +`upstream_closed_before_response`. These statuses are never retried inside the proxy — the +frame may already be executing upstream, so the client applies its own retry policy exactly as +it would when connected to the backend directly. Once the response has started, a later drop +surfaces inside the stream as before. `stallTimeoutSec` is unrelated to this window. `noProxy` accepts either a comma-separated string or an array. Both forms add entries without replacing an inherited `NO_PROXY`: diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index 7eee36b420..f682e3d73d 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -4,7 +4,7 @@ import { CodexWsMetadata, type CodexWsQuotaObserver } from "./codex-ws-metadata" import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-ws-request"; import { CodexWsCorrelation } from "./codex-ws-correlation"; import type { CodexWsSession } from "./codex-ws-session"; -import { UPGRADE_DEADLINE_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, +import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage, codexWsFailureDetail, codexWsPreResponseFailure, type CodexWsFailureStage } from "./codex-ws-wire"; @@ -101,6 +101,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise { let upstreamFrames = 0; let controlFrames = 0; let relayedEvents = 0; + let pings = 0; + let pongs = 0; let sentAt: number | null = null; let firstFrameAt: number | null = null; let controller: ReadableStreamDefaultController | null = null; @@ -108,7 +110,11 @@ export function codexWsExchange(options: ExchangeOptions): Promise { const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null; const correlation = session.retainable ? new CodexWsCorrelation(session.reused, id => session.hasCompleted(id)) : null; let detachOwner = () => {}; - let preludeTimer: ReturnType | undefined; + // Liveness while waiting for the first response event (metadata path only): the + // silence timer is re-armed by every inbound frame or pong; the pinger runs on a fixed + // interval so a peer that answers pings can never trip the silence bound while alive. + let silenceTimer: ReturnType | undefined; + let pingTimer: ReturnType | undefined; const stream = new ReadableStream({ start(c) { controller = c; }, cancel() { @@ -121,7 +127,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise { const cleanup = () => { clearTimeout(upgradeTimer); - clearTimeout(preludeTimer); + clearTimeout(silenceTimer); + clearTimeout(pingTimer); signal?.removeEventListener("abort", onAbort); metadata?.finish(); correlation?.finish(); @@ -130,6 +137,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { ws.removeEventListener("message", onMessage); ws.removeEventListener("close", onClose); ws.removeEventListener("error", onError); + ws.removeEventListener("pong", onPong); }; /** @@ -145,13 +153,16 @@ export function codexWsExchange(options: ExchangeOptions): Promise { relayedEvents, firstFrameMs: sentAt !== null && firstFrameAt !== null ? Math.max(0, firstFrameAt - sentAt) : null, elapsedMs: sentAt !== null ? Math.max(0, Date.now() - sentAt) : null, + pings, + pongs, }); const commitResponse = () => { // A pre-response settlement (gateway status) has already resolved this exchange. if (responseCommitted || terminal) return; responseCommitted = true; - clearTimeout(preludeTimer); + clearTimeout(silenceTimer); + clearTimeout(pingTimer); const responseHeaders = metadata?.snapshot() ?? new Headers(); responseHeaders.set("content-type", "text/event-stream; charset=utf-8"); const response = new Response(stream, { status: 200, headers: responseHeaders }); @@ -186,6 +197,33 @@ export function codexWsExchange(options: ExchangeOptions): Promise { session.dispose(); }; + /** (Re)start the silence bound; every inbound frame or pong is proof of life. */ + const armSilence = () => { + clearTimeout(silenceTimer); + if (responseCommitted || terminal) return; + silenceTimer = setTimeout( + () => failStream(`codex websocket response prelude timed out${codexWsFailureDetail(failureStage())}`, 504), + CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, + ); + }; + /** Ping on a fixed interval until the response starts; a socket without ping() is never pinged. */ + const schedulePing = () => { + const socket = ws as WebSocket & { ping?: (data?: string) => void }; + if (typeof socket.ping !== "function" || responseCommitted || terminal) return; + pingTimer = setTimeout(() => { + if (responseCommitted || terminal) return; + try { socket.ping(); } catch { return; } + pings += 1; + // ping() may close the socket synchronously and settle the exchange; re-check. + if (!terminal) schedulePing(); + }, CODEX_WS_LIVENESS_PING_INTERVAL_MS); + }; + const onPong = () => { + if (terminal) return; + pongs += 1; + if (!responseCommitted) armSilence(); + }; + const upgradeTimer = setTimeout(() => { if (opened || settledPreOpen) return; settledPreOpen = true; @@ -238,6 +276,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { ws.removeEventListener("message", onMessage); ws.removeEventListener("close", onClose); ws.removeEventListener("error", onError); + ws.removeEventListener("pong", onPong); session.dispose(); reject(error); return; @@ -266,16 +305,15 @@ export function codexWsExchange(options: ExchangeOptions): Promise { } if (!metadata) commitResponse(); else if (!responseCommitted && !terminal) { - preludeTimer = setTimeout( - () => failStream(`codex websocket response prelude timed out${codexWsFailureDetail(failureStage())}`, 504), - CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, - ); + armSilence(); + schedulePing(); } }; const onMessage = (event: MessageEvent) => { if (!controller || terminal) return; received = true; + if (!responseCommitted) armSilence(); upstreamFrames += 1; if (firstFrameAt === null) firstFrameAt = Date.now(); const text = typeof event.data === "string" ? event.data : ""; @@ -397,6 +435,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { ws.addEventListener("message", onMessage); ws.addEventListener("close", onClose); ws.addEventListener("error", onError); + ws.addEventListener("pong", onPong); if (signal?.aborted) onAbort(); else if (session.opened) onOpen(); }); diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index 64b732b22e..6dc3a45c3b 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -7,6 +7,17 @@ import { // If the 101 never arrives (network black hole), give SSE a chance well before // the caller's connect timeout (default 200s) would fire. export const UPGRADE_DEADLINE_MS = 10_000; +// Liveness while the exchange waits for its first response event. The proxy cannot know +// how long the origin legitimately needs before `response.created` (a multi-image replay +// spends that time in prefill; #4083 measured 30 s), and it is not the party that owns +// the slowness policy — the client holds its own deadline and the operator holds +// `connectTimeoutMs`. What the proxy can decide is whether the peer is still there, and +// WebSocket has a native answer: ping. While waiting, the exchange pings every +// CODEX_WS_LIVENESS_PING_INTERVAL_MS on sockets that expose `ping()`; any inbound frame +// or pong resets the silence clock, and only CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS of +// nothing at all settles the exchange as an origin-silence 504. A peer that never pongs +// keeps exactly the previous 90 s bound; a peer that does can never trip it while alive. +export const CODEX_WS_LIVENESS_PING_INTERVAL_MS = 15_000; export const CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS = 90_000; // Keep the push-based WS transport inside the same memory envelope as the // bounded SSE relays that consume this response. Unlike fetch response bodies, @@ -112,6 +123,10 @@ export type CodexWsFailureStage = { firstFrameMs: number | null; /** Milliseconds from send to this failure; null when the failure predates the send. */ elapsedMs: number | null; + /** Liveness pings the exchange sent while waiting for the first response event. */ + pings: number; + /** Pongs the peer answered with; zero on a peer that never answers pings. */ + pongs: number; }; /** @@ -147,7 +162,8 @@ export function codexWsFailureDetail(stage: CodexWsFailureStage): string { return ` [cause=${classifyCodexWsFailure(stage)} request=${stage.requestBytes}B` + ` sent=${stage.sent ? "yes" : "no"} frames=${stage.upstreamFrames}` + ` control=${stage.controlFrames} relayed=${stage.relayedEvents}` - + ` first-frame=${duration(stage.firstFrameMs)} elapsed=${duration(stage.elapsedMs)}]`; + + ` first-frame=${duration(stage.firstFrameMs)} elapsed=${duration(stage.elapsedMs)}` + + ` pings=${stage.pings} pongs=${stage.pongs}]`; } export type ResponsesWsRelayEvent = { diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts index 87b3767d2b..b6799267d6 100644 --- a/src/server/responses/ws-upstream.ts +++ b/src/server/responses/ws-upstream.ts @@ -20,7 +20,7 @@ import { codexWsExchange } from "./codex-ws-exchange"; import { CodexWsSession } from "./codex-ws-session"; import { codexWsPool, codexWsReuseIdentity } from "./codex-ws-pool"; import { codexWsCreateFrameExceedsLimit } from "./codex-ws-wire"; -export { CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, +export { CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, MAX_CODEX_WS_CREATE_FRAME_BYTES, CODEX_WS_CREATE_FRAME_LIMIT_BYTES, codexWsCreateFrameExceedsLimit, isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse } from "./codex-ws-wire"; export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0"; diff --git a/tests/responses/ws-failure-stage.test.ts b/tests/responses/ws-failure-stage.test.ts index d578d801d4..04c12c6357 100644 --- a/tests/responses/ws-failure-stage.test.ts +++ b/tests/responses/ws-failure-stage.test.ts @@ -86,6 +86,8 @@ function stage(overrides: Partial = {}): CodexWsFailureStag relayedEvents: 0, firstFrameMs: null, elapsedMs: 90_003, + pings: 0, + pongs: 0, ...overrides, }; } @@ -150,12 +152,14 @@ describe("codex WS failure classification", () => { test("renders every field, with n/a for the durations that do not exist yet", () => { expect(codexWsFailureDetail(stage({ upstreamFrames: 2, controlFrames: 2, firstFrameMs: 41 }))).toBe( " [cause=no-response-event request=812B sent=yes frames=2 control=2 relayed=0" - + " first-frame=41ms elapsed=90003ms]", + + " first-frame=41ms elapsed=90003ms pings=0 pongs=0]", ); expect(codexWsFailureDetail(stage({ sent: false, elapsedMs: null }))).toBe( " [cause=before-send request=812B sent=no frames=0 control=0 relayed=0" - + " first-frame=n/a elapsed=n/a]", + + " first-frame=n/a elapsed=n/a pings=0 pongs=0]", ); + // A peer that answered pings but never started a response is named as such. + expect(codexWsFailureDetail(stage({ upstreamFrames: 0, pings: 6, pongs: 6 }))).toContain(" pings=6 pongs=6]"); }); }); diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index 6afb9e312c..9151fba103 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -20,6 +20,7 @@ import { MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, + CODEX_WS_LIVENESS_PING_INTERVAL_MS, shouldUseCodexWsUpstream as rawShouldUseCodexWsUpstream, } from "../../src/server/responses/ws-upstream"; import type { OcxProviderConfig } from "../../src/types"; @@ -1304,6 +1305,106 @@ describe("codexWsUpstreamFetch", () => { } }); + describe("prelude liveness", () => { + // The 90 s bound is a silence bound, not a deadline: a peer that answers pings is alive, + // and how long an alive origin may take before response.created belongs to the client's + // own deadline and the operator's connectTimeoutMs, not to a fixed number in the proxy. + const noResend = (counter: { http: number }) => (async () => { + counter.http++; + return new Response("must not resend"); + }) as typeof fetch; + + test("a peer that answers pings stays alive past the silence bound and still completes with one send", async () => { + jest.useFakeTimers(); + const opened = Promise.withResolvers(); + let pingsSent = 0; + installFake(ws => { + Object.assign(ws, { ping: () => { pingsSent++; ws.emit("pong", {}); } }); + ws.emit("open", {}); + opened.resolve(); + }); + const counter = { http: 0 }; + try { + const pending = codexWsUpstreamFetch(CODEX_URL, streamingInit(), noResend(counter)); + await opened.promise; + const ws = FakeWebSocket.instances[0]; + // Seven steps: 105 s of no message frames, well past the 90 s bound, every step ponged. + for (let step = 0; step < 7; step++) jest.advanceTimersByTime(CODEX_WS_LIVENESS_PING_INTERVAL_MS); + expect(pingsSent).toBe(7); + ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r1" } }) }); + const response = await pending; + expect(response.status).toBe(200); + expect(await response.text()).toContain("event: response.completed"); + expect(ws.sent).toHaveLength(1); + expect(counter.http).toBe(0); + // The pinger stops once the response has started. + jest.advanceTimersByTime(CODEX_WS_LIVENESS_PING_INTERVAL_MS * 4); + expect(pingsSent).toBe(7); + expect([...ws.listeners.values()].every(listeners => listeners.length === 0)).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + test("a peer that never pongs keeps the previous 90 s bound and names the unanswered pings", async () => { + jest.useFakeTimers(); + const opened = Promise.withResolvers(); + let pingsSent = 0; + installFake(ws => { + Object.assign(ws, { ping: () => { pingsSent++; } }); + ws.emit("open", {}); + opened.resolve(); + }); + const counter = { http: 0 }; + try { + const pending = codexWsUpstreamFetch(CODEX_URL, streamingInit(), noResend(counter)); + await opened.promise; + jest.advanceTimersByTime(CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS); + const response = await pending; + expect(response.status).toBe(504); + const failure = ((await response.json()) as { error: { code: string; message: string } }).error; + expect(failure.code).toBe("upstream_no_response"); + expect(failure.message).toContain("prelude timed out"); + expect(failure.message).toMatch(/pings=[56] pongs=0\]/); + expect(pingsSent).toBeGreaterThanOrEqual(5); + expect(FakeWebSocket.instances[0].sent).toHaveLength(1); + expect(counter.http).toBe(0); + expect(FakeWebSocket.instances[0].closed).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + test("a control frame is proof of life too: quota keeps the exchange waiting", async () => { + jest.useFakeTimers(); + const opened = Promise.withResolvers(); + installFake(ws => { ws.emit("open", {}); opened.resolve(); }); + const counter = { http: 0 }; + try { + const pending = codexWsUpstreamFetch(CODEX_URL, streamingInit(), noResend(counter)); + await opened.promise; + const ws = FakeWebSocket.instances[0]; + // No ping() on this socket. Quota at 60 s and 120 s resets the silence clock each time. + jest.advanceTimersByTime(60_000); + ws.emit("message", { data: JSON.stringify({ type: "codex.rate_limits", rate_limits: { primary: { used_percent: 10, window_minutes: 10080 } } }) }); + jest.advanceTimersByTime(60_000); + ws.emit("message", { data: JSON.stringify({ type: "codex.rate_limits", rate_limits: { primary: { used_percent: 11, window_minutes: 10080 } } }) }); + jest.advanceTimersByTime(60_000); + ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r1" } }) }); + const response = await pending; + expect(response.status).toBe(200); + expect(response.headers.get("x-codex-primary-used-percent")).toBe("11"); + expect(await response.text()).toContain("event: response.completed"); + expect(ws.sent).toHaveLength(1); + expect(counter.http).toBe(0); + } finally { + jest.useRealTimers(); + } + }); + }); + test("malformed native WS metadata still normalizes the real HTTP fallback routing hint", async () => { let fallbackInit: RequestInit | undefined; const body = JSON.stringify({ model: "gpt-6-astra", service_tier: "priority", stream: true, client_metadata: [] }); From 9e9dd5315f501723ac9f858b52f73b1e100cb568 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 12:50:16 +0900 Subject: [PATCH 007/231] docs(devlog): wp4 plan for review and final-head CI --- .../_plan/260911_ws_commit_boundary/045_wp4_plan.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 devlog/_plan/260911_ws_commit_boundary/045_wp4_plan.md diff --git a/devlog/_plan/260911_ws_commit_boundary/045_wp4_plan.md b/devlog/_plan/260911_ws_commit_boundary/045_wp4_plan.md new file mode 100644 index 0000000000..02ec4a0393 --- /dev/null +++ b/devlog/_plan/260911_ws_commit_boundary/045_wp4_plan.md @@ -0,0 +1,13 @@ +# wp4 — PR, read-only diff review, final-head CI + +Previous D (wp3): liveness landed at 075b9f39f5 and is pushed to draft PR #4256. Direction unchanged. + +## Steps +- xai/grok-4.6 read-only review of the full diff origin/dev...HEAD (t2); findings fixed or dispositioned in 050_review.md. +- Mark PR #4256 ready for review with the final description (t1). +- Every newest-per-workflow pull_request run on the final head SHA success (t3); record run ids in 060_ci_evidence.md. +- Merge is not part of this goal (owner said "PR까지"); report readiness. + +## Verification +NOT RUN locally. Remote CI only. + From a00ef49af70ef4f8bd5b4f6cc58c6b42d1f110bd Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 13:03:53 +0900 Subject: [PATCH 008/231] fix(codex-ws): capture ping() before the liveness timer closure (TS2722) --- src/server/responses/codex-ws-exchange.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index f682e3d73d..2119063ce4 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -208,11 +208,11 @@ export function codexWsExchange(options: ExchangeOptions): Promise { }; /** Ping on a fixed interval until the response starts; a socket without ping() is never pinged. */ const schedulePing = () => { - const socket = ws as WebSocket & { ping?: (data?: string) => void }; - if (typeof socket.ping !== "function" || responseCommitted || terminal) return; + const ping = (ws as WebSocket & { ping?: (data?: string) => void }).ping; + if (typeof ping !== "function" || responseCommitted || terminal) return; pingTimer = setTimeout(() => { if (responseCommitted || terminal) return; - try { socket.ping(); } catch { return; } + try { ping.call(ws); } catch { return; } pings += 1; // ping() may close the socket synchronously and settle the exchange; re-check. if (!terminal) schedulePing(); From 76527004445393267b9ad718dc09dd93d6a31f87 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 13:12:33 +0900 Subject: [PATCH 009/231] fix(codex-ws): keep commitResponse for the body-error path; step the never-pongs oracle Review finding: guarding commitResponse on terminal left an exchange without a metadata channel unsettled when failStream ran after send, because that path relies on commitResponse to hand the client its 200 before erroring the body. The pre-response JSON settle now claims the commit slot itself instead. The never-pongs test steps the fake clock per ping interval. --- .../260911_ws_commit_boundary/050_review.md | 27 +++++++++++++++++++ src/server/responses/codex-ws-exchange.ts | 5 ++-- tests/responses/ws-upstream.test.ts | 5 +++- 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/260911_ws_commit_boundary/050_review.md diff --git a/devlog/_plan/260911_ws_commit_boundary/050_review.md b/devlog/_plan/260911_ws_commit_boundary/050_review.md new file mode 100644 index 0000000000..6ac5ddea26 --- /dev/null +++ b/devlog/_plan/260911_ws_commit_boundary/050_review.md @@ -0,0 +1,27 @@ +# wp4 review — xai/grok-4.6 read-only diff review (agent 01a08e96) + +Verdict received: FAIL with two majors and one minor. + +| # | severity | finding | disposition | +|---|---|---|---| +| 1 | major | commitResponse bailing on terminal leaves the non-metadata body-error path unsettled (failStream sets terminal first, then calls commitResponse, then controller.error with no resolve). | ACCEPTED, real bug. commitResponse guards only responseCommitted again; the JSON settle path sets responseCommitted = true before resolving so a second 200 can never be committed. | +| 2 | major | bun-types WebSocketEventMap lists only close/error/message/open, so a client pong event may never reach onPong; ping-alive would be harness-only. | REBUTTED with a runtime probe. Bun 1.4.0 (the minimum version the bounded relay gate accepts) was probed on 2026-09-11 with a local Bun.serve websocket and a client new WebSocket: ws.ping is a function, ws.pong is a function, and addEventListener("pong") fired with the ping payload (seen: open, pong:x, message:ack). The type map is incomplete; the runtime dispatches the event. The exchange still feature-detects ping() and degrades to the message-only 90 s bound where no pong arrives, which is exactly the never-pongs oracle. Probe script kept below. | +| 3 | minor | The never-pongs oracle used one 90 s jump and relied on recursive fake-timer scheduling. | ACCEPTED. The test now steps 15 s at a time like its sibling. | + +Findings 4-6 were confirmations (harness oracles, TypeScript after a00ef49af7, privacy of the JSON body). + +## Probe (not product code, run once in /tmp) + +```js +const srv = Bun.serve({ port: 0, fetch(req, s){ if (s.upgrade(req)) return; return new Response("no"); }, + websocket: { open(ws){}, message(ws,m){ if (m==="hi") ws.send("ack"); }, ping(ws,data){ }, pong(ws,data){ } } }); +const ws = new WebSocket("ws://127.0.0.1:"+srv.port); +const seen = []; +for (const ev of ["open","message","close","error","ping","pong"]) ws.addEventListener(ev, e => seen.push(ev + (e.data!==undefined? ":"+String(e.data):""))); +await new Promise(r => ws.addEventListener("open", r, {once:true})); +ws.ping("x"); ws.send("hi"); await new Promise(r => setTimeout(r, 400)); +console.log(JSON.stringify({ bun: Bun.version, ping: typeof ws.ping, pong: typeof ws.pong, seen })); +``` + +Output: {"bun":"1.4.0","ping":"function","pong":"function","seen":["open","pong:x","message:ack"]} + diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index 2119063ce4..f65a7ea538 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -158,8 +158,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise { }); const commitResponse = () => { - // A pre-response settlement (gateway status) has already resolved this exchange. - if (responseCommitted || terminal) return; + if (responseCommitted) return; responseCommitted = true; clearTimeout(silenceTimer); clearTimeout(pingTimer); @@ -181,6 +180,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise { // it again, and the client applies its own retry policy as it would on the direct // path. Same settle order as a refused create: snapshot, detach, close, dispose. const prelude = metadata.snapshot(); + // Claim the commit slot so no later path can resolve a second, 200 Response. + responseCommitted = true; cleanup(); try { controller?.close(); } catch { /* unused stream already closed */ } session.dispose(); diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index 9151fba103..80c6291780 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -1360,7 +1360,10 @@ describe("codexWsUpstreamFetch", () => { try { const pending = codexWsUpstreamFetch(CODEX_URL, streamingInit(), noResend(counter)); await opened.promise; - jest.advanceTimersByTime(CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS); + // Step the clock so each chained ping timer is scheduled and fired in turn. + for (let elapsed = 0; elapsed < CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS; elapsed += CODEX_WS_LIVENESS_PING_INTERVAL_MS) { + jest.advanceTimersByTime(CODEX_WS_LIVENESS_PING_INTERVAL_MS); + } const response = await pending; expect(response.status).toBe(504); const failure = ((await response.json()) as { error: { code: string; message: string } }).error; From 361200e79b9d48ab7d6e685cfbe10b963f91e0aa Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 13:27:34 +0900 Subject: [PATCH 010/231] docs(codex-ws): qualify ping by socket capability; explain the TimeoutError discriminator --- .../src/content/docs/reference/configuration/server.md | 7 ++++--- src/server/responses/codex-ws-exchange.ts | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index b5c6158090..3b61d51739 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -36,9 +36,10 @@ runs helper features around provider requests. | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Standalone Images relay options for Codex `image_gen`. | While the canonical ChatGPT upstream WebSocket waits for the first Responses event after -sending the create frame, it watches for liveness rather than a fixed deadline. The proxy pings -the socket every 15 seconds; any inbound frame — quota, response metadata, or a pong — resets a -90-second silence clock, and only 90 seconds with nothing at all settles the request as an +sending the create frame, it watches for liveness rather than a fixed deadline. When the socket +supports protocol pings, the proxy pings it every 15 seconds. Any inbound frame — quota, +response metadata, or a pong — resets a 90-second silence clock, so a socket without ping +support still stays alive on its own frames, and only 90 seconds with nothing at all settles the request as an HTTP 504 with an `upstream_no_response` error. A slow but alive origin therefore waits for the client's own deadline or for `connectTimeoutMs` (default 200s), whichever comes first; a connect timeout that fires after the create frame was sent settles as the same 504. A socket diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index f65a7ea538..698eb93340 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -247,6 +247,11 @@ export function codexWsExchange(options: ExchangeOptions): Promise { // Sent, unacknowledged. The proxy's own connect deadline is an origin-silence // verdict and settles like one; a caller abort is the caller's decision, so the // exchange rejects with that reason and disposing the socket cancels the turn. + // Both arrive on the same composite signal (fetchWithHeaderTimeout joins the + // caller's controller with its own), so the reason is the only discriminator: the + // deadline aborts with a TimeoutError DOMException, and every caller abort in this + // process (upstream.abort() in core.ts) carries the default AbortError. A future + // proxy-side deadline that aborts with TimeoutError would still be honestly a 504. if ((reason as { name?: unknown } | null)?.name === "TimeoutError") { failStream(`codex websocket response did not start before the connect deadline${codexWsFailureDetail(failureStage())}`, 504); return; From cfe88ac8aae3f83c99d41e8181b9f74297aa9fcf Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Thu, 10 Sep 2026 02:04:12 +0000 Subject: [PATCH 011/231] fix(zai): route coding quota probes by canonical base --- .../docs/reference/configuration/providers.md | 12 +++++ src/providers/quota.ts | 33 +++++++----- structure/05_gui-and-management-api.md | 16 ++++++ tests/providers/provider-quota.test.ts | 50 +++++++++++++++++++ 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e2769ab807..618c016de3 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -21,6 +21,18 @@ ocx models provider openrouter on After GUI registration or OAuth login, the confirmation dialog lets you open the Models page. CLI registration and login print model-management commands; JSON includes structured next steps. `--no-wait` reports pending login, not completion. Start the proxy with `ocx start` before using live model commands. +## Z.ai Coding Plan quota endpoints + +The Z.ai quota probe recognizes the international coding Chat base +`https://api.z.ai/api/coding/paas/v4`, the documented +[Claude Code Anthropic base](https://docs.z.ai/devpack/tool/claude) +`https://api.z.ai/api/anthropic`, and the documented +[Codex Responses base](https://docs.z.ai/devpack/tool/codex) +`https://api.z.ai/api/v1`. All three read quota from the international monitor with +Bearer authentication; this does not change the inference URL or imply different +quota consumption between adapters. Existing BigModel CN monitor selection remains +separate. Full request URLs such as `/api/v1/responses` are not provider base URLs. + ## Provider-related top-level fields | Field | Type | Default | Meaning | diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 79e6a6bf91..69ee60626c 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -345,14 +345,26 @@ function isCanonicalOllamaCloudBaseUrl(baseUrl?: string): boolean { } } +function zaiQuotaMonitorHost(baseUrl: string): string | null { + // Admission and destination selection must share one mapping: admitting a new + // international wire must never fall through to the CN host/authentication scheme. + switch (normalizedBaseUrl(baseUrl)) { + case ZAI_BASE_URL: + case `${ZAI_BASE_URL}/api/coding/paas/v4`: + case `${ZAI_BASE_URL}/api/anthropic`: + case `${ZAI_BASE_URL}/api/v1`: + return ZAI_BASE_URL; + case ZAI_CN_BASE_URL: + case `${ZAI_CN_BASE_URL}/api/coding/paas/v4`: + case `${ZAI_CN_BASE_URL}/api/v1`: + return ZAI_CN_BASE_URL; + default: + return null; + } +} + function isCanonicalZaiBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - return normalized === ZAI_BASE_URL - || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4` - || normalized === ZAI_CN_BASE_URL - || normalized === `${ZAI_CN_BASE_URL}/api/coding/paas/v4` - // BigModel serves the same GLM Coding Plan on the OpenAI Responses wire at /api/v1. - || normalized === `${ZAI_CN_BASE_URL}/api/v1`; + return zaiQuotaMonitorHost(baseUrl) !== null; } function isCanonicalMinimaxBaseUrl(baseUrl: string): boolean { @@ -851,13 +863,10 @@ function parseZaiQuotaLegacyFields(data: Record | null): Provid * host or follow a redirect off-origin. */ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null; + const monitorHost = zaiQuotaMonitorHost(config.baseUrl); + if (!monitorHost) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; - const normalized = normalizedBaseUrl(config.baseUrl); - const monitorHost = normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4` - ? ZAI_BASE_URL - : ZAI_CN_BASE_URL; const authorization = monitorHost === ZAI_CN_BASE_URL ? apiKey : `Bearer ${apiKey}`; const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, { headers: { Accept: "application/json", Authorization: authorization }, diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 2c21897855..f9d4fb79ec 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -506,6 +506,22 @@ once it exceeds 200) with the upstream content-type, body kind (`sse / json / ot body sample, and the extracted usage. Off by default; the hot path is guarded so production stays untouched. +## Z.ai quota destination ownership + +`src/providers/quota.ts` uses one exact normalized-base mapping for both Z.ai quota +eligibility and monitor selection. International root, coding Chat, Anthropic and +Responses bases use `api.z.ai` with Bearer authentication. Existing BigModel CN root, +coding Chat and Responses bases use `open.bigmodel.cn` with the raw key. Unsupported +bases produce no probe; redirect refusal and quota parsing/cache semantics are unchanged. + +[Decision Log] +- 목적과 의도: Restore quota reads for documented international Anthropic and Responses bases without changing their inference configuration. +- 기존 구현 및 제약 조건: Admission omitted both bases; a separate monitor ternary treated all other admitted bases as CN. +- 검토한 주요 대안: Add the same paths to two lists, accept any path on either host, or share one exact mapping. +- 선택한 방식: Share one base-to-monitor mapping and preserve the existing CN allowlist. +- 다른 대안 대신 이 방식을 선택한 이유: A single mapping prevents new international admission from silently selecting the CN authentication scheme, without admitting unrelated pay-as-you-go paths. +- 장점, 단점 및 영향: No config migration or inference change; new documented endpoints still require an explicit reviewed mapping entry. Quota-consumption differences are not inferred from adapter choice. + ## Provider debug logging Provider transport diagnostics (dropped SSE frames, adapter dial/stream events, etc.) are opt-in: diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index a8d4bef728..09212b9916 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -1014,6 +1014,56 @@ describe("fetchProviderQuotaReports", () => { expect(seen[0]?.redirect).toBe("error"); }); + test.each([ + ["https://api.z.ai/api/anthropic", "anthropic"], + ["https://api.z.ai/api/v1", "openai-responses"], + ["https://API.Z.AI/api/anthropic/", "anthropic"], + ["https://api.z.ai/api/v1/", "openai-responses"], + ] as const)("Z.AI quota uses the international monitor for %s (%s)", async (baseUrl, adapter) => { + const seen: Array<{ url: string; authorization: string | null; redirect?: RequestRedirect }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push({ url: String(input), authorization: new Headers(init?.headers).get("authorization"), redirect: init?.redirect }); + return Response.json({ success: true, data: { limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 25 }, + ] } }); + }) as typeof fetch; + const config = keyQuotaConfig("zai", baseUrl); + config.providers.zai!.adapter = adapter; + + const result = await fetchProviderQuotaReports(config, true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("zai:quota-limit"); + expect(result.reports[0]?.quota.fiveHourPercent).toBe(25); + expect(seen).toEqual([{ + url: "https://api.z.ai/api/monitor/usage/quota/limit", + authorization: "Bearer zai-secret", + redirect: "error", + }]); + }); + + test.each([ + "https://api.z.ai.example/api/anthropic", + "http://api.z.ai/api/anthropic", + "https://api.z.ai:8443/api/v1", + "https://user@api.z.ai/api/anthropic", + "https://api.z.ai/api/anthropic?region=cn", + "https://api.z.ai/api/v1#fragment", + "https://api.z.ai/api/anthropic/v1/messages", + "https://api.z.ai/api/v1/responses", + "https://api.z.ai/api/paas/v4", + "https://open.bigmodel.cn/api/anthropic", + ])("Z.AI quota does not probe unsupported base %s", async baseUrl => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + expect((await fetchProviderQuotaReports(keyQuotaConfig("zai", baseUrl), true)).reports).toEqual([]); + expect(calls).toBe(0); + }); + test("Z.AI quota probes the BigModel region from the provider's own host", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { From e37d3670243bce648862e676b4e7157380179fe0 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Thu, 10 Sep 2026 05:29:46 +0000 Subject: [PATCH 012/231] test(zai): preserve userinfo rejection without email-like fixtures --- tests/providers/provider-quota.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 09212b9916..56d69951d5 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -1046,7 +1046,6 @@ describe("fetchProviderQuotaReports", () => { "https://api.z.ai.example/api/anthropic", "http://api.z.ai/api/anthropic", "https://api.z.ai:8443/api/v1", - "https://user@api.z.ai/api/anthropic", "https://api.z.ai/api/anthropic?region=cn", "https://api.z.ai/api/v1#fragment", "https://api.z.ai/api/anthropic/v1/messages", @@ -1064,6 +1063,21 @@ describe("fetchProviderQuotaReports", () => { expect(calls).toBe(0); }); + test.each(["username", "password"] as const)("Z.AI quota rejects URL %s before probing", async field => { + // Construct dummy userinfo instead of embedding an email-shaped fixture in source. + // Keep this distinct from host/path rejection: the monitor host would otherwise match. + const url = new URL("https://api.z.ai/api/anthropic"); + url[field] = "fixture"; + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + expect((await fetchProviderQuotaReports(keyQuotaConfig("zai", url.href), true)).reports).toEqual([]); + expect(calls).toBe(0); + }); + test("Z.AI quota probes the BigModel region from the provider's own host", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { From 7cdcb0c2ba924dcc1041120111e3777ba6831a01 Mon Sep 17 00:00:00 2001 From: chilung Date: Thu, 10 Sep 2026 07:44:11 +0000 Subject: [PATCH 013/231] fix(claude): allow deleting unavailable routes in desktop profile --- .../management/agent-settings-routes.ts | 16 +++++-- .../claude-management-api.test.ts | 47 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d7617c5884..278baf42aa 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -945,13 +945,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise let body: { profile?: unknown }; try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } try { - const { parseDesktopProfile, reconcileDesktopProfile } = await import("../../claude/desktop-profile"); - const parsed = parseDesktopProfile(body.profile); - const current = await buildClaudeDesktopState(config); - for (const model of current.models.filter(item => !item.available)) { + const { parseDesktopProfile, reconcileDesktopProfile } = await import("../../claude/desktop-profile"); + const parsed = parseDesktopProfile(body.profile); + const current = await buildClaudeDesktopState(config); + const availableRoutes = new Set(current.models.filter(item => item.available).map(item => item.route)); + for (const route of Object.keys(parsed.assignments)) { + if (!current.profile.assignments[route] && !availableRoutes.has(route)) { + throw new Error(`현재 사용할 수 없는 모델은 추가할 수 없습니다: ${route}`); + } + } + for (const model of current.models.filter(item => !item.available)) { const before = current.profile.assignments[model.route]; const after = parsed.assignments[model.route]; - if (JSON.stringify(before) !== JSON.stringify(after)) { + if (after !== undefined && JSON.stringify(before) !== JSON.stringify(after)) { throw new Error(`현재 사용할 수 없는 모델은 옮길 수 없습니다: ${model.route}`); } } diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index fa99f3c14f..dc0371a470 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -895,3 +895,50 @@ test("Claude Desktop PUT retains but cannot move an unavailable route", async () await server.stop(true); } }); + +test("Claude Desktop PUT allows deleting an unavailable route, but rejects adding one", async () => { + const seeded = loadConfig(); + seeded.claudeCode = { + desktopProfile: { + version: 1, + assignments: { + "missing/old-model": { family: "opus", alias: "claude-opus-4-8-20260101" }, + }, + defaults: { opus: "missing/old-model", fable: null, sonnet: null, haiku: null }, + }, + }; + saveConfig(seeded); + const server = startServer(0); + try { + const state = await fetch(new URL("/api/claude-desktop", server.url)).then(r => r.json()) as Record; + expect(state.models.find((model: { route: string }) => model.route === "missing/old-model")?.available).toBe(false); + + const deleteEdit = structuredClone(state.profile); + delete deleteEdit.assignments["missing/old-model"]; + deleteEdit.defaults.opus = Object.keys(deleteEdit.assignments).filter(route => deleteEdit.assignments[route].family === "opus").sort()[0] ?? null; + + const putDelete = await fetch(new URL("/api/claude-desktop", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: deleteEdit }), + }); + expect(putDelete.status).toBe(200); + const deleteResult = await putDelete.json() as Record; + expect(deleteResult.models.some((model: { route: string }) => model.route === "missing/old-model")).toBe(false); + expect(deleteResult.profile.assignments["missing/old-model"]).toBeUndefined(); + expect(loadConfig().claudeCode?.desktopProfile?.assignments["missing/old-model"]).toBeUndefined(); + + const addEdit = structuredClone(deleteResult.profile); + addEdit.assignments["missing/new-model"] = { family: "fable", alias: "claude-opus-4-8-20260102" }; + addEdit.defaults.fable = "missing/new-model"; + const putAdd = await fetch(new URL("/api/claude-desktop", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: addEdit }), + }); + expect(putAdd.status).toBe(400); + expect((await putAdd.json() as { error: string }).error).toContain("현재 사용할 수 없는 모델은 추가할 수 없습니다: missing/new-model"); +} finally { + await server.stop(true); + } +}); From ef2a95cbb930c77b24d9850d0cbf0e46c113eba5 Mon Sep 17 00:00:00 2001 From: chilung Date: Fri, 11 Sep 2026 02:12:30 +0000 Subject: [PATCH 014/231] test(claude): cover rejecting modifications to unavailable routes --- .../management/agent-settings-routes.ts | 8 +-- .../claude-management-api.test.ts | 58 ++++++++++++------- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 278baf42aa..d9757d5b11 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -945,16 +945,16 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise let body: { profile?: unknown }; try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } try { - const { parseDesktopProfile, reconcileDesktopProfile } = await import("../../claude/desktop-profile"); - const parsed = parseDesktopProfile(body.profile); - const current = await buildClaudeDesktopState(config); + const { parseDesktopProfile, reconcileDesktopProfile } = await import("../../claude/desktop-profile"); + const parsed = parseDesktopProfile(body.profile); + const current = await buildClaudeDesktopState(config); const availableRoutes = new Set(current.models.filter(item => item.available).map(item => item.route)); for (const route of Object.keys(parsed.assignments)) { if (!current.profile.assignments[route] && !availableRoutes.has(route)) { throw new Error(`현재 사용할 수 없는 모델은 추가할 수 없습니다: ${route}`); } } - for (const model of current.models.filter(item => !item.available)) { + for (const model of current.models.filter(item => !item.available)) { const before = current.profile.assignments[model.route]; const after = parsed.assignments[model.route]; if (after !== undefined && JSON.stringify(before) !== JSON.stringify(after)) { diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index dc0371a470..60bc012fa2 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -896,7 +896,7 @@ test("Claude Desktop PUT retains but cannot move an unavailable route", async () } }); -test("Claude Desktop PUT allows deleting an unavailable route, but rejects adding one", async () => { +test("Claude Desktop PUT allows deleting an unavailable route, but rejects modifying or adding one", async () => { const seeded = loadConfig(); seeded.claudeCode = { desktopProfile: { @@ -908,37 +908,51 @@ test("Claude Desktop PUT allows deleting an unavailable route, but rejects addin }, }; saveConfig(seeded); - const server = startServer(0); - try { - const state = await fetch(new URL("/api/claude-desktop", server.url)).then(r => r.json()) as Record; + const server = startServer(0); + try { + const state = await fetch(new URL("/api/claude-desktop", server.url)).then(r => r.json()) as Record; expect(state.models.find((model: { route: string }) => model.route === "missing/old-model")?.available).toBe(false); - const deleteEdit = structuredClone(state.profile); - delete deleteEdit.assignments["missing/old-model"]; - deleteEdit.defaults.opus = Object.keys(deleteEdit.assignments).filter(route => deleteEdit.assignments[route].family === "opus").sort()[0] ?? null; - - const putDelete = await fetch(new URL("/api/claude-desktop", server.url), { + // Modifying an existing unavailable assignment (e.g. changing alias) is rejected with 400. + const modifyEdit = structuredClone(state.profile); + modifyEdit.assignments["missing/old-model"].alias = "claude-opus-4-8-20260202"; + const putModify = await fetch(new URL("/api/claude-desktop", server.url), { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ profile: deleteEdit }), - }); - expect(putDelete.status).toBe(200); + body: JSON.stringify({ profile: modifyEdit }), + }); + expect(putModify.status).toBe(400); + expect((await putModify.json() as { error: string }).error).toContain("현재 사용할 수 없는 모델은 옮길 수 없습니다: missing/old-model"); + expect(loadConfig().claudeCode?.desktopProfile?.assignments["missing/old-model"]?.alias).toBe("claude-opus-4-8-20260101"); + + // Deleting an existing unavailable assignment succeeds with 200. + const deleteEdit = structuredClone(state.profile); + delete deleteEdit.assignments["missing/old-model"]; + deleteEdit.defaults.opus = Object.keys(deleteEdit.assignments).filter(route => deleteEdit.assignments[route].family === "opus").sort()[0] ?? null; + + const putDelete = await fetch(new URL("/api/claude-desktop", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: deleteEdit }), + }); + expect(putDelete.status).toBe(200); const deleteResult = await putDelete.json() as Record; expect(deleteResult.models.some((model: { route: string }) => model.route === "missing/old-model")).toBe(false); expect(deleteResult.profile.assignments["missing/old-model"]).toBeUndefined(); expect(loadConfig().claudeCode?.desktopProfile?.assignments["missing/old-model"]).toBeUndefined(); - + + // Adding a newly unavailable assignment is rejected with 400. const addEdit = structuredClone(deleteResult.profile); addEdit.assignments["missing/new-model"] = { family: "fable", alias: "claude-opus-4-8-20260102" }; addEdit.defaults.fable = "missing/new-model"; - const putAdd = await fetch(new URL("/api/claude-desktop", server.url), { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ profile: addEdit }), - }); - expect(putAdd.status).toBe(400); + const putAdd = await fetch(new URL("/api/claude-desktop", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: addEdit }), + }); + expect(putAdd.status).toBe(400); expect((await putAdd.json() as { error: string }).error).toContain("현재 사용할 수 없는 모델은 추가할 수 없습니다: missing/new-model"); -} finally { - await server.stop(true); - } + } finally { + await server.stop(true); + } }); From 2349403ec640c545ba6b01306c0e376aa957acfb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:55:15 +0000 Subject: [PATCH 015/231] chore(release): open dev at 2.52.0 before releasing 2.51.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 936324ccec..221e03b24b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.51.0", + "version": "2.52.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 391e40d230fcf28bb4737f1f51d5d4a32eb3cf85 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:43:31 +0900 Subject: [PATCH 016/231] fix(responses): normalize invented default. namespace back to the declared bare tool (#4264) * fix(tools): normalize invented default. namespace back to declared bare tool * fix(responses): normalize default namespace to declared bare tool and track bare provenance - Downstream identity normalization in Responses relay: rewrite provider-invented default. prefix or namespace: "default" back to declared bare tool for SSE streams (added, done, terminal completed/incomplete snapshots) and non-streaming JSON responses, preserving all item fields (id, call_id, arguments). - Bare tool provenance tracking: collectDeclaredBareWireToolNames collects top-level and builtin functions namespace declarations that do not carry . or __, preventing declarations like foo__view_image from authorizing default.view_image or { namespace: "default", name: "view_image" }. - Shared normalization helper: update normalizeDeclaredToolName and guard helper docstrings to clarify default namespace normalization boundary beyond code-mode exec helpers. - Test coverage in tests/responses/responses-undeclared-tool-guard.test.ts covering bare wire collection, SSE stream item rewriting, terminal snapshot rewriting, JSON rewriting, negative controls for declared namespaced tools, and preservation of explicitly declared default. and default__ tools. * test(responses): cover 2-turn continuation caller relay and normalize function_call_arguments.done --------- Co-authored-by: chilung --- src/server/responses-undeclared-tool-guard.ts | 273 ++++++++++- src/server/responses/core.ts | 30 +- src/types/tools.ts | 40 +- .../bridge-legacy-shell-normalization.test.ts | 10 + .../responses-undeclared-tool-guard.test.ts | 459 ++++++++++++++++++ 5 files changed, 793 insertions(+), 19 deletions(-) diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index cad1b39011..69b445a713 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -5,7 +5,7 @@ import { namespacedToolName, normalizeDeclaredToolName, } from "../types"; -import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; +import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; /** Item types the client executes through a request-declared wire name. */ const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]); @@ -171,6 +171,49 @@ export function collectDeclaredWireToolNames(body: unknown): Set { return names; } +/** + * Collects explicitly declared bare wire tool names from a Responses request body. + * + * Bare wire tools are top-level declarations (or grouped under the builtin `functions` + * namespace) that are not namespaced and do not carry a flattened namespace delimiter (`__`) + * or dotted namespace alias (`.`). + * + * @param body - The outbound or inbound request body. + * @returns A set of declared bare tool names. + */ +export function collectDeclaredBareWireToolNames(body: unknown): Set { + const names = new Set(); + if (!isPlainObject(body)) return names; + const specGroups: unknown[] = [body.tools]; + if (Array.isArray(body.input)) { + for (const item of body.input) { + if ( + isPlainObject(item) + && (item.type === "additional_tools" || item.type === "tool_search_output") + ) specGroups.push(item.tools); + } + } + for (const specs of specGroups) { + if (!Array.isArray(specs)) continue; + for (const spec of specs) { + if (!isPlainObject(spec)) continue; + if (spec.type === "namespace" && Array.isArray(spec.tools)) { + if (spec.name === BUILTIN_FUNCTIONS_NAMESPACE) { + for (const inner of spec.tools) { + if (!isPlainObject(inner)) continue; + const name = wireToolInnerName(inner); + if (name && !name.includes("__") && !name.includes(".")) names.add(name); + } + } + continue; + } + const name = wireToolInnerName(spec); + if (name && !name.includes("__") && !name.includes(".")) names.add(name); + } + } + return names; +} + function addNamelessClientCallTypes(callTypes: Set, specs: unknown): void { if (!Array.isArray(specs)) return; for (const spec of specs) { @@ -286,11 +329,22 @@ export function hasExplicitWireToolCatalog(body: unknown): boolean { ); } +/** + * Evaluates whether an individual output item represents an undeclared tool call. + * + * @param item - The item to check. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The undeclared tool call name if unauthorized, or undefined if permitted. + */ function undeclaredNameInItem( item: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): string | undefined { if (!isPlainObject(item)) return undefined; if (typeof item.type !== "string") return undefined; @@ -319,51 +373,232 @@ function undeclaredNameInItem( dottedAliasIsUnambiguous(item.namespace, name) && declared.has(dottedToolName(item.namespace, name)) ) return undefined; + const bareDeclared = declaredBare ?? declared; + const bare = name.startsWith("default.") ? name.slice("default.".length) : name; + if ( + item.namespace === "default" + && bare.length > 0 + && bareDeclared.has(bare) + && !declared.has(namespacedToolName(item.namespace, bare)) + && !declared.has(dottedToolName(item.namespace, bare)) + ) return undefined; return name; } - const effectiveName = normalizeDeclaredToolName(name, declared); + const effectiveName = normalizeDeclaredToolName(name, declared, declaredBare); if (declared.has(effectiveName)) return undefined; return name; } -/** First undeclared client tool named by a Responses SSE payload, or undefined. */ +/** + * First undeclared client tool named by a Responses SSE payload, or undefined. + * + * @param payload - The parsed SSE event payload. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The name of the first undeclared tool call, or undefined. + */ export function undeclaredToolCallName( payload: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): string | undefined { if (!isPlainObject(payload)) return undefined; if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") { - return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); + return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); + } + if (payload.type === "response.function_call_arguments.done" && typeof payload.name === "string") { + const fakeItem = { type: "function_call", name: payload.name, namespace: payload.namespace }; + return undeclaredNameInItem(fakeItem, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); } // Sparse gateways skip incremental items and only ever ship the terminal snapshot. if (payload.type === "response.completed" || payload.type === "response.incomplete") { - return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); + return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); } return undefined; } -/** First undeclared client tool in a Responses object's `output` array, or undefined. */ +/** + * First undeclared client tool in a Responses object's `output` array, or undefined. + * + * @param response - The Responses result object containing `output`. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The name of the first undeclared tool call, or undefined. + */ export function undeclaredToolCallNameInResponse( response: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): string | undefined { if (!isPlainObject(response) || !Array.isArray(response.output)) return undefined; for (const item of response.output) { - const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); + const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); if (name !== undefined) return name; } return undefined; } +/** + * Formats an error message indicating that a routed provider emitted an undeclared tool call. + * + * @param name - The undeclared tool name emitted by the provider. + * @returns A formatted error message string. + */ export function undeclaredToolCallMessage(name: string): string { const reported = name.slice(0, MAX_REPORTED_NAME_CHARS); return `routed provider emitted undeclared client tool "${reported}"; only request-declared tools may be called`; } +/** + * Normalizes a single output item's default-namespaced tool call back to declared bare tool. + * + * Strips invented `default.` prefixes or `namespace: "default"` from tool calls when the bare + * tool name was declared and neither dotted nor flattened namespaced forms were declared (#4176). + * + * @param item - The output item to normalize. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An object with the normalized value and a boolean indicating if changes occurred. + */ +export function normalizeDefaultNamespaceInItem( + item: unknown, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): { value: unknown; changed: boolean } { + if (!isPlainObject(item)) return { value: item, changed: false }; + if (!CLIENT_EXECUTED_CALL_TYPES.has(item.type as string)) { + return { value: item, changed: false }; + } + const name = item.name; + if (typeof name !== "string" || name.length === 0) { + return { value: item, changed: false }; + } + const bareDeclared = declaredBare ?? declared; + if (item.namespace === "default") { + const bare = name.startsWith("default.") ? name.slice("default.".length) : name; + if ( + bare.length > 0 + && bareDeclared.has(bare) + && !declared.has(namespacedToolName("default", bare)) + && !declared.has(dottedToolName("default", bare)) + ) { + const next: Record = { ...(item as Record), name: bare }; + delete next.namespace; + return { value: next, changed: true }; + } + return { value: item, changed: false }; + } + if (item.namespace === undefined || item.namespace === BUILTIN_FUNCTIONS_NAMESPACE) { + if (name.startsWith("default.")) { + const bare = name.slice("default.".length); + if ( + bare.length > 0 + && bareDeclared.has(bare) + && !declared.has("default." + bare) + && !declared.has("default__" + bare) + ) { + return { value: { ...item, name: bare }, changed: true }; + } + } + } + return { value: item, changed: false }; +} + +/** + * Normalizes default-namespaced tool calls in a Responses object's `output` array. + * + * @param response - The Responses result object containing `output`. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An object with the normalized response and a boolean indicating if changes occurred. + */ +export function normalizeDefaultNamespaceInResponse( + response: unknown, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): { value: unknown; changed: boolean } { + if (!isPlainObject(response) || !Array.isArray(response.output)) { + return { value: response, changed: false }; + } + let changed = false; + const newOutput = response.output.map(item => { + const res = normalizeDefaultNamespaceInItem(item, declared, declaredBare); + if (res.changed) changed = true; + return res.value; + }); + if (!changed) return { value: response, changed: false }; + return { value: { ...response, output: newOutput }, changed: true }; +} + +/** + * Normalizes default-namespaced tool calls in a Responses SSE payload object. + * + * @param payload - The parsed SSE event payload. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An object with the normalized payload and a boolean indicating if changes occurred. + */ +export function normalizeDefaultNamespaceInPayload( + payload: unknown, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): { value: unknown; changed: boolean } { + if (!isPlainObject(payload)) return { value: payload, changed: false }; + if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") { + const res = normalizeDefaultNamespaceInItem(payload.item, declared, declaredBare); + if (!res.changed) return { value: payload, changed: false }; + return { value: { ...payload, item: res.value }, changed: true }; + } + if (payload.type === "response.function_call_arguments.done" && typeof payload.name === "string") { + const fakeItem = { type: "function_call", name: payload.name, namespace: payload.namespace }; + const res = normalizeDefaultNamespaceInItem(fakeItem, declared, declaredBare); + if (res.changed) { + const normalizedItem = res.value as Record; + const next: Record = { ...payload, name: normalizedItem.name }; + if ("namespace" in next && !("namespace" in normalizedItem)) { + delete next.namespace; + } + return { value: next, changed: true }; + } + } + if (payload.type === "response.completed" || payload.type === "response.incomplete") { + const res = normalizeDefaultNamespaceInResponse(payload.response, declared, declaredBare); + if (!res.changed) return { value: payload, changed: false }; + return { value: { ...payload, response: res.value }, changed: true }; + } + return { value: payload, changed: false }; +} + +/** + * Normalizes default-namespaced tool calls in a raw Responses JSON string. + * + * @param jsonText - Raw JSON string representing a Responses object. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The normalized JSON string, or original text if unchanged or invalid JSON. + */ +export function normalizeDefaultNamespaceInJson( + jsonText: string, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): string { + try { + const parsed = JSON.parse(jsonText); + const normalized = normalizeDefaultNamespaceInResponse(parsed, declared, declaredBare); + return normalized.changed ? JSON.stringify(normalized.value) : jsonText; + } catch { + return jsonText; + } +} + function failedBlocks(name: string, newline: string): readonly string[] { const failure = { type: "upstream_error", @@ -378,7 +613,8 @@ function failedBlocks(name: string, newline: string): readonly string[] { } /** - * Fail closed when a routed provider calls a tool the request never declared (#1700). + * Fail closed when a routed provider calls a tool the request never declared (#1700), + * and normalize provider-invented default namespaces back to declared bare tools (#4176). * * The bridged paths already refuse such a call (`declaredToolNames` in src/bridge.ts), but the * native Responses passthrough relayed it verbatim: Codex received a `function_call` for a tool @@ -389,11 +625,18 @@ function failedBlocks(name: string, newline: string): readonly string[] { * * Everything after the trip is dropped so a later `response.completed` cannot contradict the * terminal already sent. Non-JSON and non-item blocks pass through untouched. + * + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An SSE block rewrite function. */ export function createUndeclaredToolCallGuardBlockRewrite( declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): SseBlockRewrite { let tripped = false; return (block: string) => { @@ -406,9 +649,15 @@ export function createUndeclaredToolCallGuardBlockRewrite( } catch { return [block]; } - const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); - if (name === undefined) return [block]; - tripped = true; - return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); + const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); + if (name !== undefined) { + tripped = true; + return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); + } + const normalized = normalizeDefaultNamespaceInPayload(parsed, declared, declaredBare); + if (normalized.changed) { + return [replaceSseDataPayload(block, JSON.stringify(normalized.value))]; + } + return [block]; }; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3f4cfe4345..1925ad7d91 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -424,10 +424,13 @@ import { type RoutedNamespaceToolAliases, } from "../../responses/namespace-tool-compat"; import { + collectDeclaredBareWireToolNames, collectDeclaredNamelessClientCallTypes, collectDeclaredWireToolNames, collectProviderExecutedCallTypes, createUndeclaredToolCallGuardBlockRewrite, + normalizeDefaultNamespaceInJson, + normalizeDefaultNamespaceInResponse, currentTurnWireToolCatalogBody, hasExplicitWireToolCatalog, undeclaredToolCallMessage, @@ -4714,6 +4717,7 @@ async function handleResponsesInner( ); const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody); const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody); + const clientDeclaredBareWireToolNames = collectDeclaredBareWireToolNames(clientToolAuthorizationBody); const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes( clientToolAuthorizationBody, ); @@ -4789,6 +4793,7 @@ async function handleResponsesInner( }; let outboundRequestBody: Record | undefined; const declaredWireToolNames = new Set(); + const declaredBareWireToolNames = new Set(); const declaredNamelessClientCallTypes = new Set(); // `buildToolBridgeMaps` creates a bare alias only when the caller selected exactly one // namespaced tool through a bare tool_choice. Restore that request-bounded identity before @@ -4838,12 +4843,17 @@ async function handleResponsesInner( // aliases are authoritative. A continuation's outbound body still contains historical // catalogs (and may promote historical tool-search definitions), so it can never widen the // current caller snapshot captured above. + declaredBareWireToolNames.clear(); if (replayedInputPrefixLength === 0) { for (const name of collectDeclaredWireToolNames(outboundRequestBody)) { declaredWireToolNames.add(name); } + for (const name of collectDeclaredBareWireToolNames(outboundRequestBody)) { + declaredBareWireToolNames.add(name); + } } for (const name of clientDeclaredWireToolNames) declaredWireToolNames.add(name); + for (const name of clientDeclaredBareWireToolNames) declaredBareWireToolNames.add(name); declaredNamelessClientCallTypes.clear(); if (replayedInputPrefixLength === 0) { for (const callType of collectDeclaredNamelessClientCallTypes(outboundRequestBody)) { @@ -4936,6 +4946,7 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ) !== undefined) { inspectionSawUndeclaredTool = true; } @@ -4973,11 +4984,19 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ) !== undefined ) { return; } - rememberPassthroughResponse?.(replayResponse); + const normalizedReplayResponse = (undeclaredToolGuardActive + ? normalizeDefaultNamespaceInResponse( + replayResponse, + declaredWireToolNames, + declaredBareWireToolNames, + ).value + : replayResponse) as typeof replayResponse; + rememberPassthroughResponse?.(normalizedReplayResponse); const firstCompletion = !inspectedCompletionSeen; inspectedCompletionSeen = true; if (firstCompletion && (inspectedTerminal === null || firstTerminalAllowsRecall)) { @@ -5998,6 +6017,7 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ) : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); @@ -6198,7 +6218,7 @@ async function handleResponsesInner( } const text = bounded.text; inspectResponseLogJson(logCtx, text); - const clientJson = (() => { + let clientJson = (() => { const restoredNamespace = restoreRoutedNamespaceCallsInJson( scrubSelfNamedToolCallNamespaceInJson( restoreImageGenCallsInJson(text, imageGenCallAliases), @@ -6244,6 +6264,7 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ); } catch { return undefined; @@ -6252,6 +6273,11 @@ async function handleResponsesInner( if (undeclared !== undefined) { return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared)); } + clientJson = normalizeDefaultNamespaceInJson( + clientJson, + declaredWireToolNames, + declaredBareWireToolNames, + ); } commitReasoningReplayServingRoute(); try { diff --git a/src/types/tools.ts b/src/types/tools.ts index bb91ebe63f..fcc0689819 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -66,22 +66,52 @@ const CODE_MODE_HELPER_TOOL_NAMES = [ */ export const CODE_MODE_EXEC_TOOL_NAME = "exec"; +/** + * Normalizes provider-emitted tool names against declared tool catalogs. + * + * Rewrites invented `default.` prefixes back to a declared bare tool when that bare tool + * is declared and neither `default.` nor `default__` was explicitly declared (#4176). + * Also normalizes legacy helper names (`exec_command`, `shell_command`, `apply_patch`) to + * `exec` when code-mode `exec` is declared in the request catalog. + * + * @param name - The tool name emitted on the wire by the provider. + * @param declared - All wire tool names declared in the request catalog, including aliases. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * When omitted, falls back to `declared`. + * @returns The normalized tool name to expose downstream. + */ export function normalizeDeclaredToolName( name: string, declared: ReadonlySet | undefined, + declaredBare?: ReadonlySet, ): string { - if (!declared || !declared.has(CODE_MODE_EXEC_TOOL_NAME)) return name; + if (!declared) return name; if (declared.has(name)) return name; - if (name === "apply_patch") return CODE_MODE_EXEC_TOOL_NAME; + let candidate = name; + if (name.startsWith("default.")) { + const bare = name.slice("default.".length); + const bareDeclared = declaredBare ?? declared; + if ( + bare.length > 0 + && bareDeclared.has(bare) + && !declared.has("default." + bare) + && !declared.has("default__" + bare) + ) { + candidate = bare; + } + } + if (!declared.has(CODE_MODE_EXEC_TOOL_NAME)) return candidate; + if (declared.has(candidate)) return candidate; + if (candidate === "apply_patch") return CODE_MODE_EXEC_TOOL_NAME; // When the catalog explicitly declares any legacy shell bridge name, the environment // genuinely exposes that tool — turn normalization off so a call is never mis-routed // to `exec`. if ((LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).some(legacy => declared.has(legacy))) { - return name; + return candidate; } - return (CODE_MODE_HELPER_TOOL_NAMES as readonly string[]).includes(name) + return (CODE_MODE_HELPER_TOOL_NAMES as readonly string[]).includes(candidate) ? CODE_MODE_EXEC_TOOL_NAME - : name; + : candidate; } /** diff --git a/tests/adapters/bridge-legacy-shell-normalization.test.ts b/tests/adapters/bridge-legacy-shell-normalization.test.ts index c6d5cdbe2d..0b4a94283c 100644 --- a/tests/adapters/bridge-legacy-shell-normalization.test.ts +++ b/tests/adapters/bridge-legacy-shell-normalization.test.ts @@ -87,6 +87,16 @@ describe("bridge normalizes code-mode helper names against the declared catalog" expect(sse).toContain("await tools.apply_patch"); }); + test("default.view_image echoes are normalized back to declared bare view_image (#4176)", async () => { + const sse = await drain(bridgeToResponsesSSE( + toolTurn("default.view_image", "{\"path\":\"image.png\"}"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, + { declaredToolNames: new Set(["view_image"]) }, + )); + expect(sse).not.toContain("undeclared client tool"); + expect(sse).toContain("\"name\":\"view_image\""); + expect(sse).toContain("image.png"); + }); + test("a catalog that declares exec_command itself is never rewritten", async () => { const sse = await drain(bridgeToResponsesSSE( toolTurn("exec_command"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, diff --git a/tests/responses/responses-undeclared-tool-guard.test.ts b/tests/responses/responses-undeclared-tool-guard.test.ts index 41d8383a6c..6dd680c9d2 100644 --- a/tests/responses/responses-undeclared-tool-guard.test.ts +++ b/tests/responses/responses-undeclared-tool-guard.test.ts @@ -7,7 +7,11 @@ import { describe, expect, test } from "bun:test"; import { collectDeclaredNamelessClientCallTypes, + collectDeclaredBareWireToolNames, collectDeclaredWireToolNames, + normalizeDefaultNamespaceInJson, + normalizeDefaultNamespaceInPayload, + normalizeDefaultNamespaceInResponse, collectProviderExecutedCallTypes, createUndeclaredToolCallGuardBlockRewrite, currentTurnWireToolCatalogBody, @@ -63,6 +67,7 @@ async function relay( upstream: string, declared: Iterable, declaredNamelessClientCallTypes: Iterable = [], + declaredBare?: Iterable, ): Promise { const budget = createTestTranslatorBudget(); try { @@ -71,6 +76,8 @@ async function relay( createUndeclaredToolCallGuardBlockRewrite( new Set(declared), new Set(declaredNamelessClientCallTypes), + undefined, + declaredBare ? new Set(declaredBare) : undefined, ), budget, )); @@ -79,6 +86,33 @@ async function relay( } } +describe("collectDeclaredBareWireToolNames", () => { + test("collects top-level bare tools and functions namespace, ignoring other namespaces and flattened/dotted names", () => { + const names = collectDeclaredBareWireToolNames({ + tools: [ + { type: "function", name: "view_image" }, + { type: "custom", name: "exec" }, + { type: "function", name: "foo__tool" }, + { type: "function", name: "foo.tool" }, + { type: "namespace", name: "functions", tools: [{ type: "function", name: "shell" }, { type: "function", name: "bar.baz" }] }, + { type: "namespace", name: "linear", tools: [{ type: "function", name: "create_issue" }] }, + ], + input: [ + { + type: "additional_tools", + tools: [{ type: "function", name: "extra_tool" }, { type: "function", name: "pkg__sub" }], + }, + ], + }); + expect([...names].sort()).toEqual(["exec", "extra_tool", "shell", "view_image"]); + }); + + test("returns empty set for invalid or missing body", () => { + expect(collectDeclaredBareWireToolNames(null).size).toBe(0); + expect(collectDeclaredBareWireToolNames({}).size).toBe(0); + }); +}); + describe("collectDeclaredWireToolNames", () => { test("reads function, custom, and namespaced tools off the outbound body", () => { const names = collectDeclaredWireToolNames({ @@ -436,6 +470,235 @@ describe("undeclared tool call guard", () => { expect(await relay(upstream, ["linear.create_issue"])).toBe(upstream); }); + test("accepts and rewrites dotted default.view_image back to bare view_image in SSE added and done items (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + // output_item.added + const upstreamAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + const expectedAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: "{}" }, + }); + expect(await relay(upstreamAdded, declared, [], declaredBare)).toBe(expectedAdded); + + // output_item.done with custom args + const upstreamDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: JSON.stringify({ path: "/tmp/img.png" }) }, + }); + const expectedDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: JSON.stringify({ path: "/tmp/img.png" }) }, + }); + expect(await relay(upstreamDone, declared, [], declaredBare)).toBe(expectedDone); + }); + + test("accepts and rewrites default. prefix in response.function_call_arguments.done SSE event (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamDone = sse("response.function_call_arguments.done", { + item_id: "item_1", + output_index: 0, + call_id: "call_1", + name: "default.view_image", + arguments: JSON.stringify({ path: "/tmp/img.png" }), + }); + const expectedDone = sse("response.function_call_arguments.done", { + item_id: "item_1", + output_index: 0, + call_id: "call_1", + name: "view_image", + arguments: JSON.stringify({ path: "/tmp/img.png" }), + }); + expect(await relay(upstreamDone, declared, [], declaredBare)).toBe(expectedDone); + }); + + test("accepts and rewrites namespace: 'default' with bare name back to bare tool (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", namespace: "default", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + const expectedAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + expect(await relay(upstreamAdded, declared, [], declaredBare)).toBe(expectedAdded); + + const upstreamDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", namespace: "default", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + const expectedDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + expect(await relay(upstreamDone, declared, [], declaredBare)).toBe(expectedDone); + }); + + test("rewrites terminal snapshots (completed/incomplete) in SSE streams (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamCompleted = sse("response.completed", { + response: { + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + { type: "function_call", id: "fc_2", call_id: "call_2", namespace: "default", name: "view_image", arguments: "{}" }, + ], + }, + }); + const expectedCompleted = sse("response.completed", { + response: { + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: "{}" }, + { type: "function_call", id: "fc_2", call_id: "call_2", name: "view_image", arguments: "{}" }, + ], + }, + }); + expect(await relay(upstreamCompleted, declared, [], declaredBare)).toBe(expectedCompleted); + }); + + test("does not rewrite default.view_image to bare when default.view_image is explicitly declared (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }, { type: "function", name: "default.view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const upstream = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + expect(await relay(upstream, declared, [], declaredBare)).toBe(upstream); + }); + + test("preserves namespaced default__view_image over bare normalization (#4176)", async () => { + const outbound = { + tools: [ + { type: "function", name: "view_image" }, + { type: "namespace", name: "default", tools: [{ type: "function", name: "view_image" }] }, + ], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const upstream = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + expect(await relay(upstream, declared, [], declaredBare)).toBe(upstream); + }); + + test("rejects default. prefix when the bare tool was not declared (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "list_dir" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const upstream = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + const out = await relay(upstream, declared, [], declaredBare); + expect(out).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + }); + + test("rejects default.view_image and namespace=default when only a different namespaced tool was declared (#4176)", async () => { + const outbound = { + tools: [ + { type: "namespace", name: "foo", tools: [{ type: "function", name: "view_image" }] }, + ], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamDotted = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + const outDotted = await relay(upstreamDotted, declared, [], declaredBare); + expect(outDotted).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + + const upstreamNs = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", namespace: "default", name: "view_image", arguments: "{}" }, + }); + const outNs = await relay(upstreamNs, declared, [], declaredBare); + expect(outNs).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + }); + + test("normalizes default namespace in non-streaming JSON responses (#4176)", () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const jsonInput = JSON.stringify({ + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: JSON.stringify({ path: "img.png" }) }, + { type: "function_call", id: "fc_2", call_id: "call_2", namespace: "default", name: "view_image", arguments: "{}" }, + ], + }); + const normalized = normalizeDefaultNamespaceInJson(jsonInput, declared, declaredBare); + const parsed = JSON.parse(normalized); + expect(parsed.output[0]).toEqual({ + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "view_image", + arguments: JSON.stringify({ path: "img.png" }), + }); + expect(parsed.output[1]).toEqual({ + type: "function_call", + id: "fc_2", + call_id: "call_2", + name: "view_image", + arguments: "{}", + }); + }); + + test("does not normalize non-streaming JSON when bare tool was not declared (#4176)", () => { + const outbound = { + tools: [{ type: "namespace", name: "foo", tools: [{ type: "function", name: "view_image" }] }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const jsonInput = JSON.stringify({ + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + ], + }); + const normalized = normalizeDefaultNamespaceInJson(jsonInput, declared, declaredBare); + expect(normalized).toBe(jsonInput); + expect(undeclaredToolCallNameInResponse(JSON.parse(normalized), declared, [], undefined, declaredBare)).toBe("default.view_image"); + }); + test("never blocks apply_patch when the request really declared it", async () => { // `apply_patch` is exempt from the routed custom-tool rewrite, so it reaches upstream as // `{type:"custom"}` and comes back as a `custom_tool_call`. A request that declares it must @@ -829,6 +1092,202 @@ describe("a refused turn does not become continuation state", () => { }); }); +describe("real relay and continuation caller normalization (#4176 / #4181)", () => { + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + test("Turn 1 stream normalizes default.view_image, and Turn 2 continuation expands normalized replay", async () => { + const originalFetch = globalThis.fetch; + const capturedOutbound: Array> = []; + let turn = 1; + + globalThis.fetch = (async (_input, init) => { + const body = init?.body ? JSON.parse(String(init.body)) as Record : {}; + capturedOutbound.push(body); + + if (turn === 2) { + return Response.json({ + id: "resp_turn2", + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "text", text: "image processed" }] }], + }); + } + turn++; + + const toolCall = { + type: "function_call", + id: "fc_img_1", + call_id: "call_img_1", + name: "default.view_image", + arguments: JSON.stringify({ path: "/tmp/sample.png" }), + status: "completed", + }; + const sse = [ + `data: ${JSON.stringify({ type: "response.created", response: { id: "resp_turn1", status: "in_progress" } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: { ...toolCall, arguments: "", status: "in_progress" } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.done", output_index: 0, item: toolCall })}\n\n`, + `data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_turn1", status: "completed", output: [toolCall] } })}\n\n`, + "data: [DONE]\n\n", + ].join(""); + return new Response(sse, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + try { + // 1. Turn 1 (stream): Client declares bare tool 'view_image'. + // Upstream sends SSE stream containing 'default.view_image' with call_id 'call_img_1'. + // Client receives normalized 'view_image' with 'call_img_1' and no 'default.view_image'. + const turn1Req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: true, + input: [{ role: "user", content: [{ type: "input_text", text: "inspect this image" }] }], + tools: [{ type: "function", name: "view_image", parameters: { type: "object" } }], + }), + }); + + const turn1Res = await handleResponses(turn1Req, config, { model: "", provider: "" }); + expect(turn1Res.status).toBe(200); + const clientStreamText = await turn1Res.text(); + + expect(clientStreamText).toContain('"name":"view_image"'); + expect(clientStreamText).toContain('"call_id":"call_img_1"'); + expect(clientStreamText).not.toContain("default.view_image"); + expect(clientStreamText).not.toContain("response.failed"); + + // Wait briefly for background stream inspector tee to commit normalized response state + await Bun.sleep(50); + + // 2. Turn 2: Client continuation with previous_response_id and function_call_output for 'call_img_1'. + // Outbound request to upstream expands the replayed tool call with normalized name 'view_image' and matching 'call_img_1'. + const turn2Req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + previous_response_id: "resp_turn1", + input: [ + { + type: "function_call_output", + call_id: "call_img_1", + output: JSON.stringify({ width: 800, height: 600 }), + }, + ], + tools: [{ type: "function", name: "view_image", parameters: { type: "object" } }], + }), + }); + + const turn2Res = await handleResponses(turn2Req, config, { model: "", provider: "" }); + expect(turn2Res.status).toBe(200); + await turn2Res.json(); + + expect(capturedOutbound.length).toBe(2); + const turn2Outbound = capturedOutbound[1]; + const replayedToolCall = (turn2Outbound.input as Array>)?.find( + item => item.call_id === "call_img_1", + ); + expect(replayedToolCall).toBeDefined(); + expect(replayedToolCall).toMatchObject({ + type: "function_call", + id: "fc_img_1", + call_id: "call_img_1", + name: "view_image", + }); + expect(JSON.stringify(turn2Outbound)).not.toContain("default.view_image"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("negative control: request declaring only 'foo__view_image', upstream returning 'default.view_image' is rejected with 502", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ + id: "resp_foo", + status: "completed", + output: [{ + type: "function_call", + id: "fc_img_bad", + call_id: "call_img_bad", + name: "default.view_image", + arguments: "{}", + status: "completed", + }], + })) as typeof fetch; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "inspect" }] }], + tools: [{ type: "function", name: "foo__view_image", parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + + expect(response.status).toBe(502); + const body = await response.json() as { error: { message: string } }; + expect(body.error.message).toContain('undeclared client tool "default.view_image"'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("negative control: request explicitly declaring 'default.view_image' preserves 'default.view_image'", async () => { + const originalFetch = globalThis.fetch; + const toolCall = { + type: "function_call", + id: "fc_img_explicit", + call_id: "call_img_explicit", + name: "default.view_image", + arguments: JSON.stringify({ path: "/tmp/explicit.png" }), + status: "completed", + }; + + globalThis.fetch = (async () => Response.json({ + id: "resp_explicit", + status: "completed", + output: [toolCall], + })) as typeof fetch; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "inspect" }] }], + tools: [{ type: "function", name: "default.view_image", parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + + expect(response.status).toBe(200); + const body = await response.json() as { output: Array> }; + expect(body.output[0]).toMatchObject({ + type: "function_call", + name: "default.view_image", + call_id: "call_img_explicit", + }); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + describe("empty and absent tool catalogs", () => { const config = { port: 0, From 2b7aff866207ba7ba798183de7369377fe5b8a09 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:54:14 +0900 Subject: [PATCH 017/231] fix(catalog): register the canonical Antigravity discovery RPC (#4267) * fix(claude): allow deleting unavailable routes in desktop profile * test(claude): cover rejecting modifications to unavailable routes * fix(tools): normalize invented default. namespace back to declared bare tool * fix(responses): normalize default namespace to declared bare tool and track bare provenance - Downstream identity normalization in Responses relay: rewrite provider-invented default. prefix or namespace: "default" back to declared bare tool for SSE streams (added, done, terminal completed/incomplete snapshots) and non-streaming JSON responses, preserving all item fields (id, call_id, arguments). - Bare tool provenance tracking: collectDeclaredBareWireToolNames collects top-level and builtin functions namespace declarations that do not carry . or __, preventing declarations like foo__view_image from authorizing default.view_image or { namespace: "default", name: "view_image" }. - Shared normalization helper: update normalizeDeclaredToolName and guard helper docstrings to clarify default namespace normalization boundary beyond code-mode exec helpers. - Test coverage in tests/responses/responses-undeclared-tool-guard.test.ts covering bare wire collection, SSE stream item rewriting, terminal snapshot rewriting, JSON rewriting, negative controls for declared namespaced tools, and preservation of explicitly declared default. and default__ tools. * test(responses): cover 2-turn continuation caller relay and normalize function_call_arguments.done * fix(catalog): register the canonical Antigravity discovery RPC google-antigravity declared liveModels but no modelDiscovery spec, so isRegistryModelDiscoveryUrl rejected its own canonical CCA URL and the TUN Fake-IP exception never applied. Relative path keeps allowBaseUrlOverride bases custom; the resolved URL is byte-identical to what buildModelsRequest already sent. --------- Co-authored-by: chilung --- src/providers/registry.ts | 9 +++- .../provider-model-discovery-contract.test.ts | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index c4ea553d2d..7f08485bd3 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1994,7 +1994,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API // evidence from ai.google.dev does not establish Vertex publisher availability. { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, - { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, + // Antigravity discovers models with a POST to the CCA `:fetchAvailableModels` RPC, which + // `buildModelsRequest` already built by hand. Declaring it here changes no request URL — the + // relative path resolves to the same destination — but it lets `isRegistryModelDiscoveryUrl` + // prove that URL, which is what admits a Clash/Surge/Mihomo TUN fake-IP answer (#4261). The + // path must stay RELATIVE: this row sets `allowBaseUrlOverride`, and an absolute `url` would + // retarget a user's custom base back to Google. A leading `./` is required because a bare + // `v1internal:` reads as a URL scheme and `providerModelDiscoverySpecError` rejects it. + { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, diff --git a/tests/providers/provider-model-discovery-contract.test.ts b/tests/providers/provider-model-discovery-contract.test.ts index 4687594379..89cfef37b7 100644 --- a/tests/providers/provider-model-discovery-contract.test.ts +++ b/tests/providers/provider-model-discovery-contract.test.ts @@ -641,6 +641,47 @@ describe("registry-owned provider model discovery", () => { } }); + // #4261: Antigravity is the one live-discovery row that never declared its own + // discovery spec, so the loop above did not cover it and the proof returned + // false for Antigravity's OWN canonical URL. Under a Clash/Surge/Mihomo TUN the + // benchmark fake-IP answer was then rejected and the model list came back empty. + // Pin all three halves: the declared spec is valid, the URL the adapter already + // sends is unchanged, and a custom base still fails the proof. + test("google-antigravity proves its own canonical CCA discovery RPC (#4261)", () => { + const entry = PROVIDER_REGISTRY.find(row => row.id === "google-antigravity"); + if (!entry?.modelDiscovery) throw new Error("google-antigravity must declare modelDiscovery"); + expect(providerModelDiscoverySpecError(entry.modelDiscovery)).toBeNull(); + + const seed = providerConfigSeed(entry); + const canonical = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; + expect(resolveProviderModelDiscoveryUrl(entry.id, seed, entry.baseUrl, canonical)).toBe(canonical); + expect(isRegistryModelDiscoveryUrl(entry.id, canonical)).toBe(true); + // Declaring the spec must not move the request the adapter already made. + expect(buildModelsRequest(seed, "agy-access-token", entry.id)).toMatchObject({ + method: "POST", + url: canonical, + }); + + // allowBaseUrlOverride is set on this row, so a custom base must stay custom + // and must NOT inherit the fake-IP exception. + const custom = resolveProviderModelDiscoveryUrl( + entry.id, + { ...seed, baseUrl: "https://custom.example/proxy" }, + "https://custom.example/proxy", + "https://custom.example/proxy/v1internal:fetchAvailableModels", + ); + expect(custom).toBe("https://custom.example/proxy/v1internal:fetchAvailableModels"); + expect(isRegistryModelDiscoveryUrl(entry.id, custom)).toBe(false); + + for (const url of [ + "https://evil.example/v1internal:fetchAvailableModels", + `${canonical}?token=1`, + `${canonical}#frag`, + canonical.replace("https:", "http:"), + "https://daily-cloudcode-pa.googleapis.com/v1internal:other", + ]) expect(isRegistryModelDiscoveryUrl(entry.id, url)).toBe(false); + }); + // The resolver accepts an effective (possibly custom) baseUrl while the proof // must stay registry-owned: a custom destination that merely resembles the // registry shape must NOT gain the benchmark-address exception. Nebius opts From 2c15fcb45eb205a9a5e3391502d85d75f20fc743 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:54:25 +0900 Subject: [PATCH 018/231] fix(integrations): apply DSH into an empty providers container and name flow-style refusals (#4268) * fix(tools): normalize invented default. namespace back to declared bare tool * fix(responses): normalize default namespace to declared bare tool and track bare provenance - Downstream identity normalization in Responses relay: rewrite provider-invented default. prefix or namespace: "default" back to declared bare tool for SSE streams (added, done, terminal completed/incomplete snapshots) and non-streaming JSON responses, preserving all item fields (id, call_id, arguments). - Bare tool provenance tracking: collectDeclaredBareWireToolNames collects top-level and builtin functions namespace declarations that do not carry . or __, preventing declarations like foo__view_image from authorizing default.view_image or { namespace: "default", name: "view_image" }. - Shared normalization helper: update normalizeDeclaredToolName and guard helper docstrings to clarify default namespace normalization boundary beyond code-mode exec helpers. - Test coverage in tests/responses/responses-undeclared-tool-guard.test.ts covering bare wire collection, SSE stream item rewriting, terminal snapshot rewriting, JSON rewriting, negative controls for declared namespaced tools, and preservation of explicitly declared default. and default__ tools. * test(responses): cover 2-turn continuation caller relay and normalize function_call_arguments.done * fix(catalog): register the canonical Antigravity discovery RPC google-antigravity declared liveModels but no modelDiscovery spec, so isRegistryModelDiscoveryUrl rejected its own canonical CCA URL and the TUN Fake-IP exception never applied. Relative path keeps allowBaseUrlOverride bases custom; the resolved URL is byte-identical to what buildModelsRequest already sent. * fix(integrations): apply DSH into an empty providers container and name flow-style refusals locatePath refused both shapes a DSH-managed file has before any provider exists: a valueless providers: key (parses as null, so isPlainRecord rejected it) and providers: {} (no plain block key, so the missing-key branch never ran). Adopt both, and report a populated flow container as its own cause instead of blaming comments that are not in the file. --------- Co-authored-by: chilung --- src/integrations/omp-yaml-source.ts | 127 +++++++++++++++++++++- src/integrations/writer.ts | 30 ++++- tests/config/yaml-fragment-source.test.ts | 51 ++++++++- 3 files changed, 199 insertions(+), 9 deletions(-) diff --git a/src/integrations/omp-yaml-source.ts b/src/integrations/omp-yaml-source.ts index dc2c19275b..bfe6d05834 100644 --- a/src/integrations/omp-yaml-source.ts +++ b/src/integrations/omp-yaml-source.ts @@ -29,7 +29,20 @@ interface MissingEntry { insertAt: number; } -type LocatedPath = { kind: "existing"; entry: LocatedEntry } | { kind: "missing"; entry: MissingEntry }; +interface ReplaceLineEntry { + lines: readonly SourceLine[]; + index: number; + indent: number; + missingDepth: number; +} + +type LocatedPath = + | { kind: "existing"; entry: LocatedEntry } + | { kind: "missing"; entry: MissingEntry } + // `key: {}` — an empty inline map the block-key scanner cannot see (#4260). + | { kind: "replace-line"; entry: ReplaceLineEntry } + // A populated flow container. Still refused, but nameable as its own cause. + | { kind: "unsupported-style" }; export type YamlFragmentMutation = | { kind: "upsert"; value: unknown } @@ -82,6 +95,61 @@ function isPlainBlockKey(line: string, indent: number, key: string): boolean { return new RegExp(`^${regexpEscape(key)}:[ ]*(?:#.*)?$`, "u").test(rest); } +/** The inline value written after `key:` on this line, or null if the key is not here. */ +function inlineValueAfterKey(line: string, indent: number, key: string): string | null { + const spaces = leadingSpaces(line); + if (spaces !== indent) return null; + const rest = line.slice(indent); + const head = `${key}:`; + // Compared as text, not as a pattern: a path segment is arbitrary user data, + // and brace escaping inside a `u`-flag regex is its own hazard. + if (!rest.startsWith(head)) return null; + return rest.slice(head.length).trim(); +} + +/** Exactly `key: {}` (any inner spacing) — an empty inline map, no inline comment. */ +function isEmptyInlineMapKey(line: string, indent: number, key: string): boolean { + const value = inlineValueAfterKey(line, indent, key); + if (value === null) return false; + return value.startsWith("{") && value.endsWith("}") && value.slice(1, -1).trim().length === 0; +} + +/** `key: { ... }` or `key: [ ... ]` on one line: content we would have to re-render. */ +function isPopulatedInlineFlowKey(line: string, indent: number, key: string): boolean { + const value = inlineValueAfterKey(line, indent, key); + if (value === null) return false; + if (!value.startsWith("{") && !value.startsWith("[")) return false; + return !isEmptyInlineMapKey(line, indent, key); +} + +/** + * A plain block key whose first child opens a flow collection: + * + * providers: + * { native: { ... } } + * + * DSH writes this shape itself. The walk passes straight through it — the key + * line is a plain block key and `containerEnd` does not stop at `}` — so the + * refusal used to surface only as a failed re-parse at the very end and got + * reported as a comment or formatting problem that was not there (#4260). + */ +function firstChildOpensFlow( + lines: readonly SourceLine[], + start: number, + end: number, + parentIndent: number, +): boolean { + for (let index = start + 1; index < end; index += 1) { + const body = lines[index]!.body; + if (isBlank(body) || isComment(body)) continue; + const spaces = leadingSpaces(body); + if (spaces === null || spaces <= parentIndent) continue; + const trimmed = body.trimStart(); + return trimmed.startsWith("{") || trimmed.startsWith("["); + } + return false; +} + function containerEnd(lines: readonly SourceLine[], start: number, indent: number): number | null { for (let index = start + 1; index < lines.length; index += 1) { const body = lines[index]!.body; @@ -193,9 +261,24 @@ function locatePath(text: string, parsed: unknown, path: readonly string[]): Loc if (matches.length > 1) return null; prefix.push(path[depth]!); if (matches.length === 0) { + const seen = readPath(parsed, prefix); + // An empty inline map is the one flow shape we can adopt: rewriting that + // single line into block form adds our subtree and re-renders nothing the + // user wrote, because there is nothing in it (#4260). + const inline: number[] = []; + const populatedFlow: number[] = []; + for (let index = rangeStart; index < rangeEnd; index += 1) { + const body = lines[index]!.body; + if (isEmptyInlineMapKey(body, indent, path[depth]!)) inline.push(index); + else if (isPopulatedInlineFlowKey(body, indent, path[depth]!)) populatedFlow.push(index); + } + if (inline.length === 1 && isPlainRecord(seen) && Object.keys(seen).length === 0) { + return { kind: "replace-line", entry: { lines, index: inline[0]!, indent, missingDepth: depth } }; + } + if (populatedFlow.length === 1 && seen !== undefined) return { kind: "unsupported-style" }; // The parser saw this key through syntax we do not patch (quoted/flow, // merge aliases, or an ambiguous indentation shape). - if (readPath(parsed, prefix) !== undefined) return null; + if (seen !== undefined) return null; const insertAt = rangeEnd < lines.length ? lines[rangeEnd]!.start : text.length; return { kind: "missing", entry: { lines, missingDepth: depth, indent, insertAt } }; } @@ -209,7 +292,20 @@ function locatePath(text: string, parsed: unknown, path: readonly string[]): Loc if (leafEnd === null) return null; return { kind: "existing", entry: { lines, index, indent, endIndex: leafEnd } }; } - if (!isPlainRecord(readPath(parsed, prefix))) return null; + const container = readPath(parsed, prefix); + // `key:` with no children parses as null. The key line matched, so the + // missing-key branch above never runs, and `isPlainRecord(null)` is false — + // so an empty container used to refuse the whole document (#4260). Insert + // our subtree as its first child instead. + if (container === null) { + const insertAt = end < lines.length ? lines[end]!.start : text.length; + return { + kind: "missing", + entry: { lines, missingDepth: depth + 1, indent: indent + 2, insertAt }, + }; + } + if (!isPlainRecord(container)) return null; + if (firstChildOpensFlow(lines, index, end, indent)) return { kind: "unsupported-style" }; rangeStart = index + 1; rangeEnd = end; parentIndent = indent; @@ -241,7 +337,7 @@ function upsertSource( value: unknown, ): string | null { const located = locatePath(text, parsed, path); - if (located === null) return null; + if (located === null || located.kind === "unsupported-style") return null; const eol = lineEnding(text); if (located.kind === "existing") { const { lines, index, indent, endIndex } = located.entry; @@ -250,6 +346,13 @@ function upsertSource( const candidate = `${text.slice(0, startOffset)}${rendered({ [path[path.length - 1]!]: value }, indent, eol)}${text.slice(endOffset)}`; return preserveFinalNewline(candidate, text, eol); } + if (located.kind === "replace-line") { + const { lines, index, indent, missingDepth } = located.entry; + const startOffset = lines[index]!.start; + const endOffset = index + 1 < lines.length ? lines[index + 1]!.start : text.length; + const insertion = rendered(nestedValue(path.slice(missingDepth), value), indent, eol); + return preserveFinalNewline(`${text.slice(0, startOffset)}${insertion}${text.slice(endOffset)}`, text, eol); + } const { missingDepth, indent, insertAt } = located.entry; const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : ""; @@ -338,6 +441,22 @@ export function patchYamlFragmentSource( return patched !== null && semanticallyMatches(patched, expected) ? patched : null; } +/** + * True when a refusal on this path is caused by a flow-style container rather + * than by comments or formatting we would have to re-render. DSH writes that + * shape itself, so naming it is the difference between an actionable message + * and one that sends the user hunting for a comment that is not there (#4260). + */ +export function yamlFragmentUnsupportedStyle(text: string, path: readonly string[]): boolean { + let parsed: unknown; + try { + parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text); + } catch { + return false; + } + return locatePath(text, parsed, path)?.kind === "unsupported-style"; +} + /** Backward-compatible OMP wrapper around the generic path patcher. */ export function patchOmpYamlSource( text: string, diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 514fbc3220..2dcc60bda5 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -35,7 +35,29 @@ import { serializeDocument, UnserializableValueError } from "./serialize"; import { ClientPathError } from "../clients/config-export"; import { matchesOperationResult, newOpId, type JournalEntry } from "./journal"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; -import { patchYamlFragmentSource, sourcePrunableYamlContainers } from "./omp-yaml-source"; +import { + patchYamlFragmentSource, + sourcePrunableYamlContainers, + yamlFragmentUnsupportedStyle, +} from "./omp-yaml-source"; + +/** + * "comments or formatting" used to be the only refusal this path could report. + * For a flow-style container that names a cause which is not in the file, and + * DSH writes that shape itself, so the misdirection was routine rather than + * exotic: users went looking for a comment that was never there (#4260). + */ +function yamlRefusalReason( + source: string, + path: readonly string[], + configPath: string, + outcome: string, +): string { + if (yamlFragmentUnsupportedStyle(source, path)) { + return `${configPath} writes ${path.join(".")} as a flow mapping or sequence, a YAML style opencodex will not re-render, so ${outcome}`; + } + return `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so ${outcome}`; +} import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./writer-lock"; export type RefusalReason = @@ -399,7 +421,7 @@ function applyOrRefreshIntegration( ); if (patched === null) { return refuse(clientId, "unsafe", "unsafe", - `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so it was left alone`); + yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "it was left alone")); } text = patched; } else { @@ -536,7 +558,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { : recordedCreated; if (prunableCreated === null) { return refuse(clientId, "unsafe", "unsafe", - `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`); + yamlRefusalReason(before ?? "", spec.sourcePreservingYaml!.path, configPath, "nothing was removed")); } let doc: unknown; let removed: boolean; @@ -559,7 +581,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { }, doc); if (patched === null) { return refuse(clientId, "unsafe", "unsafe", - `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`); + yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "nothing was removed")); } text = patched; } else { diff --git a/tests/config/yaml-fragment-source.test.ts b/tests/config/yaml-fragment-source.test.ts index 68450fb7f9..49112f49f0 100644 --- a/tests/config/yaml-fragment-source.test.ts +++ b/tests/config/yaml-fragment-source.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { patchYamlFragmentSource } from "../../src/integrations/omp-yaml-source"; +import { patchYamlFragmentSource, yamlFragmentUnsupportedStyle } from "../../src/integrations/omp-yaml-source"; const DSH_PATH = ["llm-pi-ai", "providers", "opencodex"] as const; const VALUE = { api: "openai-responses", baseURL: "http://127.0.0.1:10100/v1" }; @@ -31,6 +31,55 @@ describe("generic source-preserving YAML fragment mutation", () => { expect(patched).toEndWith("# tail\n"); }); + // #4260: the DSH toggle wrote nothing and blamed comments or formatting. Both + // shapes below are what a DSH-managed file looks like before any provider + // exists, and neither contains a comment. + test("adopts an empty providers container, whether block or inline", () => { + const expected = { + "llm-pi-ai": { providers: { opencodex: VALUE } }, + "ui-theme": { preference: "system" }, + }; + const sources = { + "valueless block key": "llm-pi-ai:\n providers:\nui-theme:\n preference: system\n", + "empty inline map": "llm-pi-ai:\n providers: {}\nui-theme:\n preference: system\n", + "empty inline map, inner space": "llm-pi-ai:\n providers: { }\nui-theme:\n preference: system\n", + }; + for (const [label, source] of Object.entries(sources)) { + const patched = upsert(source, expected); + expect(patched, label).not.toBeNull(); + expect(Bun.YAML.parse(patched!), label).toEqual(expected); + // The untouched sibling keeps its own bytes. + expect(patched, label).toContain("ui-theme:\n preference: system\n"); + expect(yamlFragmentUnsupportedStyle(source, DSH_PATH), label).toBe(false); + } + }); + + // Still refused — re-rendering a user's populated flow collection is exactly + // what this module exists not to do — but the cause is now nameable, so the + // caller stops pointing at a comment that is not there. + test("names a populated flow container as its own refusal cause", () => { + const multiline = [ + "llm-pi-ai:", + " providers:", + " {", + " native:", + " {", + " api: openai-completions", + " }", + " }", + "ui-theme:", + " preference: system", + "", + ].join("\n"); + const inline = "llm-pi-ai:\n providers: { native: { api: openai-completions } }\n"; + for (const source of [multiline, inline]) { + const expected = Bun.YAML.parse(source) as Record; + ((expected["llm-pi-ai"] as { providers: Record }).providers).opencodex = VALUE; + expect(upsert(source, expected)).toBeNull(); + expect(yamlFragmentUnsupportedStyle(source, DSH_PATH)).toBe(true); + } + }); + test("creates every missing container and preserves CRLF plus missing final newline", () => { const source = "agent-default-model: native\r\nother: keep"; const expected = { From 6b0851921ea094fa13f6100e7463e95c67863aa1 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 20:54:35 +0900 Subject: [PATCH 019/231] feat(cli): route ocx login codex to the Codex account-pool login (#4266) * feat(cli): route ocx login codex to the Codex account-pool login ocx login codex|chatgpt|openai answered with the ~90-provider usage wall, which never contains the word the user typed, because the Codex pool lives behind ocx account login. Route those three spellings to that flow and name it in the usage wall, ocx help and the registry entry. * docs(cli): document the ocx login codex route and fold the review findings Audit round 1 ran four independent grok-4.6 agents over the plan and the diff. Folded: redact bare leftovers in the account login parser now that ocx login reaches it, replace a flags-survive test that could not fail with one that reads the POST body, name openai-apikey in the wall and registry details since ocx login openai no longer shows the provider list, guard a future key-provider id collision, assert the production wall and the non-Codex path. Docs: the stale ocx login chatgpt form is replaced across the English guide, seven locales and both CLI reference pages. --- .../000_plan.md | 136 +++++++++++++++++ .../010_audit_round1.md | 77 ++++++++++ .../src/content/docs/fr/guides/providers.md | 12 +- .../fr/reference/cli/providers-accounts.md | 8 +- .../src/content/docs/guides/providers.md | 13 +- .../src/content/docs/ja/guides/providers.md | 8 +- .../ja/reference/cli/providers-accounts.md | 2 +- .../src/content/docs/ko/guides/providers.md | 7 +- .../ko/reference/cli/providers-accounts.md | 2 +- .../docs/reference/cli/providers-accounts.md | 6 +- .../src/content/docs/ru/guides/providers.md | 8 +- .../ru/reference/cli/providers-accounts.md | 8 +- .../src/content/docs/tr/guides/providers.md | 8 +- .../tr/reference/cli/providers-accounts.md | 11 +- .../content/docs/zh-cn/guides/providers.md | 7 +- .../zh-cn/reference/cli/providers-accounts.md | 7 +- .../content/docs/zh-tw/guides/providers.md | 10 +- .../zh-tw/reference/cli/providers-accounts.md | 2 +- src/cli/account-auth.ts | 19 ++- src/cli/dispatch.ts | 14 +- src/cli/help.ts | 2 +- src/cli/registry.ts | 11 +- src/oauth/login-cli.ts | 24 ++- tests/cli/cli-dispatch.test.ts | 137 ++++++++++++++++++ 24 files changed, 483 insertions(+), 56 deletions(-) create mode 100644 devlog/_plan/260911_ocx_login_codex_routing/000_plan.md create mode 100644 devlog/_plan/260911_ocx_login_codex_routing/010_audit_round1.md diff --git a/devlog/_plan/260911_ocx_login_codex_routing/000_plan.md b/devlog/_plan/260911_ocx_login_codex_routing/000_plan.md new file mode 100644 index 0000000000..f693dea604 --- /dev/null +++ b/devlog/_plan/260911_ocx_login_codex_routing/000_plan.md @@ -0,0 +1,136 @@ +# ocx login codex — route the Codex account names out of the provider wall + +## Summary for a reader + +`ocx login codex` is the first thing a person types when they want the proxy to +talk to their ChatGPT/Codex account, and until now it answered with a usage list +of roughly ninety provider ids that never contains the word `codex`. The +capability was never missing — the Codex account pool has its own login at +`ocx account login codex` — so the dead end was vocabulary, not function. This +unit routes the three Codex spellings (`codex`, `chatgpt`, `openai`) from +`ocx login` into that existing account-pool flow, and makes the usage wall, +`ocx help` and the CLI registry entry name the route. Nothing about credential +handling, the pool ledger, or the `/api/codex-auth` surface changes. + +## Loop spec + +- **Loop archetype**: satisfy-spec. One work-phase (wp1), one PABCD cycle. +- **Trigger**: user asked why `ocx login` has no `codex`, then asked to add it + because people get confused, under `cxc-loop` with reviewer dispatch and a PR. +- **Goal**: `ocx login codex|chatgpt|openai` performs the Codex account-pool + login; the provider wall and help text name that route; the docs stop + advertising the stale `ocx login chatgpt` form. +- **Non-goals**: `isPublicOAuthProvider`/`listOAuthProviders` semantics and the + deliberate `chatgpt` exclusion from the generic `/api/oauth` surface; any + credential, token, refresh or `/api/codex-auth` behavior; `ocx logout`; + the GUI; every other CLI command. +- **Verifier**: see the verifier reality table below. +- **Stop condition**: the PR is open against `dev` with the template filled and + every criterion in the bound goalplan carries fresh captured evidence. +- **Memory artifact**: this unit, plus the goalplan at + `.codexclaw/goalplans/opencodex-ship-ocx-login-codex-codex-chatgpt-acc/` and + the session ledger. +- **Expected terminal outcomes**: DONE with the PR URL; NEEDS_HUMAN if the + requested `xai/grok-4.6` reviewer cannot be routed and the user must pick + another reviewer model; BLOCKED if the push or PR is refused. +- **Escalation condition**: anything that would touch credential material, log + into a provider on the user's behalf, or merge/promote the PR. Main reclaims a + slice after two distinct agents fail its packet; moving a slice to a worker + requires a P-phase amendment. +- **Resource bounds**: local repository writes only, plus one authorized push and + one PR creation against `lidge-jun/opencodex`. Reviewer dispatch is read-only. + No token or wall-clock budget was set by the user, so none is invented. + +## Why routing, not a pointer message + +`cxc-dev-uiux-design` UX-LAZY-01 orders the options: do nothing, delete, absorb, +demote. "Print a nicer error naming `ocx account login codex`" is the *demote* +answer — it still makes the user learn a second noun before they can log in. +Absorbing is available here because the account flow already accepts the same +argument shape, so the system can take the complexity instead of the user. +UX-STATE-01 covers the failure mode that absorption introduces: the pool login +runs inside the proxy, so it can fail when the proxy is down. That path already +ends in `Proxy is not running. Start it with: ocx start` +(`src/cli/runtime-api.ts:48`), which names its own recovery, so the routed +command never dead-ends either. + +Destructive symmetry is deliberately NOT absorbed: `ocx logout codex` keeps its +current behavior, because UX-LAZY-01 exempts destructive actions from magic +defaults and removing a pool account is `ocx account remove openai --yes`. + +## File change map + +| File | Change | +|------|--------| +| `src/cli/account-auth.ts` | Export `isCodexAccountLoginName()` over the existing private `CODEX_NAMES` set, so the three spellings keep one source of truth. | +| `src/cli/dispatch.ts` | `login` runner: lazily import the predicate, and on a match call `handleAccountAuthCommand("login", argv, { findLiveProxy })` instead of `handleLogin`. Full argv is forwarded, so `--reauth`, `--id`, `--device`, `--code`, `--no-wait` and `--json` keep working. | +| `src/oauth/login-cli.ts` | Extract `loginUsageMessage()` and add a first line naming the Codex route. `handleLogin` prints it. | +| `src/cli/registry.ts` | `login` entry gains `details` naming the Codex route and its running-proxy precondition. | +| `src/cli/help.ts` | Banner line for `ocx login` mentions `ocx login codex`. | +| `tests/cli/cli-dispatch.test.ts` | New describe block: every spelling routes (incl. case/whitespace), flags survive, an unknown flag is still a usage error, the usage text names the route, and `listOAuthProviders()` still excludes `chatgpt`/`codex`. | +| `docs-site/src/content/docs/guides/providers.md` + 7 locale mirrors | Replace the stale `ocx login chatgpt` line and its prose claim with the routed `ocx login codex` form. | + +Dependency order: predicate -> routing -> usage/help text -> tests -> docs. Each +step is independently verifiable by `bun test tests/cli/cli-dispatch.test.ts`. + +## Field chain (PLAN-FIELD-CHAIN-01) + +No new type field or enum value is introduced. The only new value class is the +set of routed names, and its chain is: creation = argv (`deps.args`), matching = +`isCodexAccountLoginName` (`src/cli/account-auth.ts`), consumption = +`handleAccountAuthCommand("login", ...)` -> `login()` -> `CODEX_NAMES.has` +branch -> `/api/codex-auth/login`. Serialization/deserialization: N/A, the value +never leaves the process as data. The pre-existing consumer +`src/cli/model-selection-guidance.ts:3` maps `codex`/`chatgpt` to `openai` +independently and is unaffected. + +## Verifier reality (PLAN-VERIFIER-REAL-01) + +| Command | Exit | Observes this change? | +|---------|------|-----------------------| +| `bun x tsc --noEmit` | 0 (run on the rebased branch head) | Yes — `tsconfig.json` includes `src` and `tests`, so both edited trees typecheck. | +| `bun test tests/cli/cli-dispatch.test.ts` | 0, 43 pass (rebased head) | Yes — the file is the direct argument and imports `dispatchCommand`, `loginUsageMessage`, `isCodexAccountLoginName`. | +| `bun test tests/cli/cli-registry.test.ts tests/cli/cli-help.test.ts` | 0, 29 pass | Yes — these cross-check `src/cli/help.ts` against `src/cli/registry.ts`, the two text surfaces edited here. | +| `bun test tests/oauth/oauth-public-surface.test.ts` | to run in C | Yes — it owns the `chatgpt` public-surface exclusion this change must not reopen. | +| `bun run test:changed` | to run in C | Partially — it follows Bun's module graph from the changed files; it does not observe the docs-site markdown. | +| docs-site markdown | no gate | No. Nothing in build/typecheck/test reads `docs-site/` content for this claim, so the docs rows are **human review**, verified by `rg` for the stale string. | + +## Enforcement bypass (PLAN-BYPASS-NAMED-01) + +This unit adds no enforcement layer; it adds routing plus regression tests. +Tier E1 (test suite), executing surface = `bun test` in CI and locally. Known +bypass path: the routing lives in a dispatch runner, so any future caller that +invokes `handleLogin()` directly bypasses it — `src/cli/dispatch.ts` is the only +caller today (verified by grep) and the test asserts through `dispatchCommand`. +Residual risk: a second entry point could reintroduce the wall without failing a +test. Final layer: none. No wording was downgraded. + +## Accept criteria + +1. `ocx login codex`, `ocx login chatgpt`, `ocx login openai` reach the account + login. Activation scenario for the conditional path: with no live proxy + (`findLiveProxy` returning null), the command exits 1 and prints + `Proxy is not running. Start it with: ocx start`, which `handleLogin` would + never print. Observable effect proving the branch ran = that exact message. +2. `ocx login codex --reauth --id ` reaches the same path (flags forwarded, + not dropped); `ocx login codex --nope` is still a usage error (exit 2). +3. `loginUsageMessage()` names `ocx login codex`, and `listOAuthProviders()` + still excludes `chatgpt` and `codex`. +4. `rg "ocx login chatgpt" docs-site` returns nothing. +5. tsc and the focused suites above are green on the branch head. + +## Source-of-truth sync (SOT-SYNC-01) + +The user-facing source of truth for this surface is +`docs-site/src/content/docs/guides/providers.md` (+ locales) and the CLI's own +help/registry text; both are patched in this unit. `skills/ocx/` is generated +from `src/cli/capabilities.ts`, which declares no `login` capability, so no +surface-map regeneration is required — confirmed by grep before planning. + +## Architect consultation + +Recorded honestly: this is a C2 slice whose design question (route vs. pointer) +is decided above from an owned skill rule, and the exposed `architect` role is +dispatched for a reflection check on this written plan rather than a fresh design +proposal. Any MISALIGNED finding is folded before A. + diff --git a/devlog/_plan/260911_ocx_login_codex_routing/010_audit_round1.md b/devlog/_plan/260911_ocx_login_codex_routing/010_audit_round1.md new file mode 100644 index 0000000000..ef1e5d1f46 --- /dev/null +++ b/devlog/_plan/260911_ocx_login_codex_routing/010_audit_round1.md @@ -0,0 +1,77 @@ +# Audit round 1 — four independent agents on grok-4.6 + +Dispatched from P with read-only packets (DISPATCH-TASK-01), each required to +anchor every finding with `path:line` and a verbatim quote. + +| Agent | Lens | Verdict | +|-------|------|---------| +| `01a08fc7-0e6f` | execution correctness (argv, exit codes, ordering, bypass) | PASS, no findings | +| `01a08fc7-0fa9` | security and boundary | PASS, 1 minor | +| `01a08fc7-10fd` | repository conventions and test quality | PASS-WITH-FIXES, 2 major + 3 minor | +| `01a08fc6-352e` | architect reflection on the written plan | MISALIGNED (read a pre-docs snapshot), 5 gaps | + +Synthesis verdict: **near-pass / GO-WITH-FIXES**. No blocker. Eight findings +folded, one rebutted. + +## Folded + +1. **Secret echo on the newly reachable parser** (security, minor). + `src/cli/account-auth.ts` called `rejectArgs(args, USAGE)` with no + redaction, so an authorization code pasted as a bare positional was echoed + in `Unexpected argument(s): …`. That parser is now one word away from + `ocx login`, so it takes `{ redactValues: true }` — flag-shaped leftovers + still print, because a mistyped flag is what the message has to name. +2. **The "flags survive" test could not fail** (test quality, major). + Dropping the flags at the dispatch seam leaves an empty leftover list, so + `rejectArgs` stays quiet and the liveness probe prints the same message the + test asserted. Replaced with a case that answers the probe with a live proxy, + stubs `fetch`, and reads the `/api/codex-auth/login` POST body. Proven red + by passing only `loginArgs[0]`. +3. **Docs not in the commit** (conventions, major). They existed in the working + tree when the architect read the committed snapshot; they are in this unit's + commit now, across English, seven locales, and both CLI reference pages. +4. **The wall was asserted in isolation** (minor). A case now spies + `process.exit` and asserts what `handleLogin` actually prints. +5. **Nothing proved a non-Codex name stays off the account path** (minor). The + same case asserts the wall appears and `Proxy is not running` does not, so a + regression routing every name through the account command fails here. +6. **No content assertion on the discoverability text** (minor). The registry + `details` for `login` are asserted directly. +7. **No guard against a future key-provider id collision** (architect, minor). + `isKeyLoginProvider` is now asserted false for all three spellings and true + for `openai-apikey`. +8. **`ocx login openai` lost its only pointer to `openai-apikey`** (architect, + minor). Routing `openai` means that user no longer sees the list that named + the platform-key provider, so the wall and the registry details name it. + The `?? 1` coalesce is also explained in place rather than left looking dead. + +## Rebutted + +**The `account-auth` import is unconditional on the `login` path.** Kept. +Both the architect and the execution reviewer independently judged the cost +acceptable: one CLI module load on a user-typed browser-login command, acyclic, +no import-time IO, and none of the three files `AGENTS.md` protects +(`src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts`) is +on the path. Splitting the predicate into its own module to save it would +contradict the single-source-of-truth decision for a cost that cannot be +measured at a login prompt. + +## Explicitly cleared by review + +- `/api/codex-auth` behavior, token storage and refresh: unchanged. +- The `chatgpt` exclusion from the generic public OAuth surface: still closed. + The routed call never reaches `/api/oauth/login`; `isPublicOAuthProvider` and + `listOAuthProviders` are untouched. +- Name collisions: `openai` is `authKind: "forward"` in the provider registry + and was never a key login; the key id is `openai-apikey`. There is no + registry id `codex` or `chatgpt`. +- `handleLogin` has no second caller, and the `login` registry entry declares + no alias that could reach the runner by another name. +- Test placement needs no `layout.json` change: the cases were added to an + existing mapped file. + +The security reviewer also recorded that this diff sits on the `ocx login` +authentication entrypoint and therefore falls under the `AGENTS.md` security +review requirement, and that its review is that review — token storage, OAuth +internals and the Codex auth routes are not modified by it. + diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 0b36b95a92..67289f68dd 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -96,8 +96,11 @@ Le catalogue du transfert ChatGPT ajoute également les identifiants non qualifi Huit préréglages de fournisseurs utilisent une connexion OAuth. GitHub Copilot s'y ajoute au moyen d'un pont expérimental et non officiel reposant sur un flux d'autorisation d'appareil. opencodex enregistre leurs identifiants dans -`~/.opencodex/auth.json` et les actualise automatiquement. La CLI de connexion accepte également `chatgpt` ; -elle obtient un identifiant ChatGPT tout en créant une entrée de fournisseur en mode `forward`. +`~/.opencodex/auth.json` et les actualise automatiquement. La CLI de connexion accepte également +`ocx login codex`, qui n'est pas l'un des fournisseurs ci-dessus : la commande est routée vers la +connexion au pool de comptes Codex (le même flux que `ocx account login codex`). Ce pool tient son +propre registre de comptes, donc cette route nécessite un proxy en cours d'exécution. `chatgpt` et +`openai` sont des alias de la même route. ```bash ocx login xai # xAI Grok @@ -109,7 +112,7 @@ ocx login google-antigravity ocx login cursor # standalone Cursor PKCE login ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json) ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business) -ocx login chatgpt # standalone ChatGPT OAuth login +ocx login codex # pool de comptes Codex (alias : chatgpt, openai ; nécessite un proxy en cours d'exécution) ocx logout ``` @@ -213,7 +216,8 @@ identifiants de compte expurgés, aucun jeton. `ocx doctor` ajoute une section s contrôles du magasin accessible en écriture et de l'appel unique, ainsi que des lignes WARN qui indiquent une action de récupération. Lorsqu'un compte de fournisseur OAuth doit être réauthentifié, exécutez `ocx login ` ou utilisez **Réauthentifier** dans le tableau de bord. Les comptes du pool Codex ne -constituent pas un fournisseur `ocx login` : réauthentifiez-les dans le groupe de comptes Codex du tableau de bord. Consultez +font pas partie de ces fournisseurs, mais `ocx login codex --reauth` est routé vers leur réauthentification +dans le pool de comptes, ce que fait aussi le pool de comptes Codex du tableau de bord. Consultez [`ocx status` / `ocx doctor`](/fr/reference/cli/) dans la référence CLI. ### Importation des identifiants Kiro diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index ac4e42bbc2..c41c2ee7cd 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -70,9 +70,11 @@ de fournisseurs OAuth et à clé API actuellement acceptés. Utilisez la même commande pour **réauthentifier** après `ocx status` / `ocx doctor` rapports réauthentification requise ou échec de l'actualisation du terminal (ou utilisez Réauthentifier dans le tableau de bord). -Les comptes du groupe Codex ne constituent pas un fournisseur public pour `ocx login` : réauthentifiez-vous -plutôt depuis le groupe de comptes Codex du tableau de bord (**Réauthentifier**) ou avec le flux non -interactif `ocx account reauth`. +Les comptes du pool Codex ne font pas partie des fournisseurs OAuth ou API-key ci-dessus, mais +`ocx login codex` les atteint : la commande est routée vers la connexion au pool de comptes, si bien que +`ocx login codex --reauth` équivaut à `ocx account reauth codex`. Le pool de comptes Codex du tableau de +bord (**Réauthentifier**) fait de même. Cette route s'exécute dans le proxy, elle en exige donc un en +cours d'exécution. ```bash ocx login xai diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index d93dbe0af5..6b359a8ad5 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -161,8 +161,10 @@ The ChatGPT passthrough catalog also layers in the bare GPT-5.6 Sol/Terra/Luna s Provider presets can use account login — including GitHub Copilot via an experimental unofficial device-flow bridge. opencodex stores their credentials in `~/.opencodex/auth.json`; refreshable tokens are refreshed automatically, while durable keys are -reused until the provider revokes them. `chatgpt` is also accepted by the login -CLI; it acquires a ChatGPT credential while creating a `forward`-mode provider entry. +reused until the provider revokes them. `ocx login codex` is accepted as well, but it is not one of +these providers: it routes to the Codex account pool — the same flow as `ocx account login codex`, +which keeps its own account ledger and needs a running proxy. `chatgpt` and `openai` are aliases of +that route. ```bash ocx login xai # xAI Grok @@ -175,7 +177,7 @@ ocx login cursor # standalone Cursor PKCE login ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json) ocx login orcarouter-oauth # OrcaRouter browser consent + PKCE ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business) -ocx login chatgpt # standalone ChatGPT OAuth login +ocx login codex # Codex account pool (aliases: chatgpt, openai; needs a running proxy) ocx logout ``` @@ -336,8 +338,9 @@ does not strip metadata, retry a request, switch accounts, reset a thread, or ot **Diagnostics and reauth.** Human `ocx status` prints an OAuth health block (redacted account ids, no tokens). `ocx doctor` adds an OAuth reliability section with writable-store / single-flight checks and WARN rows that include a recovery Action. When an OAuth provider account needs reauthentication, run -`ocx login ` (or use Reauthenticate in the dashboard). Codex pool accounts are not an -`ocx login` provider — reauthenticate via the dashboard Codex account pool. See +`ocx login ` (or use Reauthenticate in the dashboard). Codex pool accounts are not one of +those providers, but `ocx login codex --reauth` routes to their account-pool reauthentication, which +the dashboard Codex account pool also performs. See [`ocx status` / `ocx doctor`](/reference/cli/) in the CLI reference. ### Kiro credential import diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 346c3552fa..5056d63594 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -86,8 +86,10 @@ ChatGPT パススルーカタログには GPT-5.6 Sol/Terra/Luna の名前空間 OAuth ログインを使うプロバイダープリセットは 8 つで、これに実験的な非公式デバイスフロー ブリッジ経由の GitHub Copilot が加わります。認証情報は `~/.opencodex/auth.json` に保存され、 -自動更新されます。ログイン CLI は `chatgpt` も受け付けます。このコマンドは ChatGPT 認証情報を -発行し `forward` モードのプロバイダーエントリを作成します。 +自動更新されます。`ocx login codex` も受け付けますが、これは上記のプロバイダーではありません。 +Codex アカウントプールのログイン (`ocx account login codex` と同じフロー) に転送されます。 +プールは独自の台帳を持ち、この経路はプロキシの起動を必要とします。`chatgpt` と `openai` は +同じ経路の別名です。 ```bash ocx login xai # xAI Grok @@ -99,7 +101,7 @@ ocx login google-antigravity ocx login cursor # Cursor 専用 PKCE ログイン ocx login command-code # Command Code のブラウザ OAuth (または ~/.commandcode/auth.json を取り込み) ocx login github-copilot # GitHub デバイスフロー → Copilot トークン (Copilot Pro/Business) -ocx login chatgpt # 別途 ChatGPT OAuth ログイン +ocx login codex # Codex アカウントプール (別名: chatgpt, openai / プロキシの起動が必要) ocx logout ``` diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index 594d1fc30e..3557cd5ea7 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -61,7 +61,7 @@ ocx models live --provider ark --json プロバイダーの登録済みログイン フローを開始します。 OAuth プロバイダーはブラウザを開き、自動更新された認証情報を `~/.opencodex/` に保存します。 API キー ログイン プロバイダーは、キー ダッシュボードを開き、キーの入力を求め、可能な場合は検証し、結果のプロバイダー設定を保存します。名前が欠落しているか不明な場合、このコマンドは現在受け入れられている OAuth および API キーのプロバイダー ID を出力します。 -`ocx status` / `ocx doctor` が再認証が必要であるか、端末の更新失敗を報告した後、同じコマンドを使用して **再認証**します (またはダッシュボードで再認証を使用します)。 Codex プール アカウントはパブリック `ocx login` プロバイダーではありません。代わりに、ダッシュボード Codex アカウント プール (再認証) またはヘッドレス `ocx account reauth` フローを介して再認証します。 +`ocx status` / `ocx doctor` が再認証が必要であるか、端末の更新失敗を報告した後、同じコマンドを使用して **再認証**します (またはダッシュボードで再認証を使用します)。 Codex プール アカウントは上記の OAuth / API キーのプロバイダーではありませんが、`ocx login codex` から到達できます。このコマンドはアカウントプールのログインに転送されるため、`ocx login codex --reauth` は `ocx account reauth codex` と同じです。ダッシュボードの Codex アカウントプール (再認証) でも行えます。この経路はプロキシ内部で動くため、プロキシの起動が必要です。 ```bash ocx login xai diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 20b5b4fe77..951dff100e 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -85,8 +85,9 @@ ChatGPT 패스스루 카탈로그에는 GPT-5.6 Sol/Terra/Luna의 네임스페 OAuth 로그인을 사용하는 프로바이더 프리셋은 여덟 개이며, 여기에 실험적 비공식 디바이스 플로우 브리지를 쓰는 GitHub Copilot이 추가됩니다. 자격 증명은 `~/.opencodex/auth.json`에 저장되고 -자동으로 갱신됩니다. 로그인 CLI는 `chatgpt`도 받습니다. 이 명령은 ChatGPT 자격 증명을 -발급받고 `forward` 모드 프로바이더 항목을 만듭니다. +자동으로 갱신됩니다. `ocx login codex`도 받지만 이건 위 프로바이더가 아닙니다. Codex 계정 풀 +로그인(`ocx account login codex`와 같은 흐름)으로 연결되고, 이 풀은 자체 계정 원장을 쓰기 때문에 +프록시가 실행 중이어야 합니다. `chatgpt`와 `openai`는 같은 경로의 별칭입니다. ```bash ocx login xai # xAI Grok @@ -98,7 +99,7 @@ ocx login google-antigravity ocx login cursor # Cursor 전용 PKCE 로그인 ocx login command-code # Command Code 브라우저 OAuth (또는 ~/.commandcode/auth.json 가져오기) ocx login github-copilot # GitHub 디바이스 플로우 → Copilot 토큰 (Copilot Pro/Business) -ocx login chatgpt # 별도 ChatGPT OAuth 로그인 +ocx login codex # Codex 계정 풀 (별칭: chatgpt, openai / 프록시가 실행 중이어야 함) ocx logout ``` diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index fd99e29147..c6eaeec910 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -61,7 +61,7 @@ ocx models live --provider ark --json 제공자에 등록된 로그인 흐름을 시작합니다. OAuth 제공자는 브라우저를 열고 자동 갱신되는 자격 증명을 `~/.opencodex/` 아래에 저장합니다. API 키 로그인 제공자는 키 대시보드를 열고, 키 입력을 요청한 뒤, 가능한 경우 검증하고, 그 결과 나온 제공자 설정을 저장합니다. 이름이 없거나 알 수 없으면 현재 허용되는 OAuth 및 API 키 제공자 id를 출력합니다. -`ocx status` / `ocx doctor`가 재인증 필요 또는 터미널 새로고침 실패를 보고한 뒤에는 같은 명령으로 **재인증**하면 됩니다(대시보드의 Reauthenticate를 써도 됩니다). Codex 풀 계정은 공개 `ocx login` 제공자가 아닙니다. 대신 대시보드의 Codex 계정 풀(Reauthenticate)이나 헤드리스 `ocx account reauth` 흐름으로 재인증해야 합니다. +`ocx status` / `ocx doctor`가 재인증 필요 또는 터미널 새로고침 실패를 보고한 뒤에는 같은 명령으로 **재인증**하면 됩니다(대시보드의 Reauthenticate를 써도 됩니다). Codex 풀 계정은 위 OAuth·API 키 제공자 중 하나가 아니지만 `ocx login codex`로 닿습니다. 이 명령은 계정 풀 로그인으로 연결되므로 `ocx login codex --reauth`는 `ocx account reauth codex`와 같습니다. 대시보드의 Codex 계정 풀(Reauthenticate)로도 됩니다. 이 경로는 프록시 안에서 돌기 때문에 프록시가 실행 중이어야 합니다. ```bash ocx login xai diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index bc5f56e02d..7ec246a96f 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -96,8 +96,10 @@ currently accepted OAuth and API-key provider ids when the name is missing or un Use the same command to **reauthenticate** after `ocx status` / `ocx doctor` reports reauthentication required or a terminal refresh failure (or use Reauthenticate in the dashboard). -Codex pool accounts are not a public `ocx login` provider — reauthenticate via the dashboard Codex -account pool (Reauthenticate) or the headless `ocx account reauth` flow instead. +Codex pool accounts are not one of those OAuth or API-key providers, but `ocx login codex` reaches +them anyway: it routes to the account-pool login, so `ocx login codex --reauth` is the same thing as +`ocx account reauth codex`. The dashboard Codex account pool (Reauthenticate) does it too. That route +runs inside the proxy, so it needs a running one. ```bash ocx login xai diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 39e0cb4dd3..8b1415f6b8 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -95,8 +95,10 @@ account id, OpenAI beta/originator/session — см. [Адаптеры](/ru/refe Восемь пресетов провайдеров используют вход через OAuth — плюс GitHub Copilot через экспериментальный неофициальный мост device flow. opencodex хранит их учётные данные в -`~/.opencodex/auth.json` и обновляет их автоматически. CLI входа также принимает `chatgpt`: эта -команда получает учётные данные ChatGPT и одновременно создаёт запись провайдера в режиме `forward`. +`~/.opencodex/auth.json` и обновляет их автоматически. CLI входа принимает и `ocx login codex`, но это +не один из провайдеров выше: команда направляется во вход пула аккаунтов Codex (тот же поток, что и +`ocx account login codex`). У пула отдельный реестр аккаунтов, поэтому такому входу нужен запущенный +прокси. `chatgpt` и `openai` — псевдонимы того же маршрута. ```bash ocx login xai # xAI Grok @@ -108,7 +110,7 @@ ocx login google-antigravity ocx login cursor # отдельный PKCE-вход Cursor ocx login command-code # браузерный OAuth Command Code (или импорт ~/.commandcode/auth.json) ocx login github-copilot # device flow GitHub → токен Copilot (Copilot Pro/Business) -ocx login chatgpt # отдельный OAuth-вход ChatGPT +ocx login codex # пул аккаунтов Codex (псевдонимы: chatgpt, openai; нужен запущенный прокси) ocx logout ``` diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 4ae2bc7b5f..9ee81cb626 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -71,9 +71,11 @@ API-key-провайдеров. Ту же команду используйте и для **reauthentication**, когда `ocx status` / `ocx doctor` сообщают, что нужна переавторизация или refresh завершился терминальной ошибкой (либо используйте -Reauthenticate в дашборде). Аккаунты пула Codex не являются публичным провайдером для `ocx login` -— переавторизовать их нужно либо через пул аккаунтов Codex в дашборде, либо через headless-flow -`ocx account reauth`. +Reauthenticate в дашборде). Аккаунты пула Codex не входят в список OAuth- и API-key-провайдеров выше, +но `ocx login codex` до них доходит: команда направляется во вход пула аккаунтов, поэтому +`ocx login codex --reauth` — это то же самое, что `ocx account reauth codex`. Пул аккаунтов Codex в +дашборде (Reauthenticate) делает то же. Этот маршрут работает внутри прокси, поэтому ему нужен +запущенный прокси. ```bash ocx login xai diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index fde08cd29e..1c166afd9b 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -110,8 +110,10 @@ GPT-5.6 Sol/Terra/Luna slug'larını (`gpt-5.6-sol`, `gpt-5.6-terra`, Sekiz sağlayıcı önayarı OAuth girişini kullanır — artı deneysel resmi olmayan bir cihaz akışı köprüsü aracılığıyla GitHub Copilot. opencodex bunların kimlik bilgilerini `~/.opencodex/auth.json` içinde saklar ve otomatik olarak yeniler. -`chatgpt` ayrıca oturum açma CLI'sı tarafından kabul edilir; bir `forward` modu -sağlayıcı girdisi oluştururken bir ChatGPT kimlik bilgisi alır. +Oturum açma CLI'sı `ocx login codex` komutunu da kabul eder; bu yukarıdaki sağlayıcılardan biri +değildir: komut Codex hesap havuzu girişine yönlendirilir (`ocx account login codex` ile aynı akış). +Havuzun kendi hesap defteri vardır, bu nedenle bu yol çalışan bir proxy gerektirir. `chatgpt` ve +`openai` aynı yolun takma adlarıdır. ```bash ocx login xai # xAI Grok @@ -123,7 +125,7 @@ ocx login google-antigravity ocx login cursor # bağımsız Cursor PKCE girişi ocx login command-code # Command Code tarayıcı OAuth (veya ~/.commandcode/auth.json içe aktarma) ocx login github-copilot # GitHub cihaz akışı → Copilot belirteci (Copilot Pro/Business) -ocx login chatgpt # bağımsız ChatGPT OAuth girişi +ocx login codex # Codex hesap havuzu (takma adlar: chatgpt, openai; çalışan bir proxy gerekir) ocx logout ``` diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index 2d58adae3b..729596215e 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -75,10 +75,12 @@ kabul edilen OAuth ve API anahtarı sağlayıcı kimliklerini yazdırır. `ocx status` / `ocx doctor` yeniden kimlik doğrulama gerektiğini veya bir terminal yenileme hatasını bildirdikten sonra **yeniden kimlik doğrulaması yapmak** için aynı komutu kullanın (veya kontrol panelinde Yeniden Kimlik -Doğrula'yı kullanın). Codex havuz hesapları genel bir `ocx login` sağlayıcısı -değildir — bunun yerine kontrol paneli Codex hesap havuzu (Yeniden Kimlik -Doğrula) veya başsız `ocx account reauth` akışı aracılığıyla yeniden kimlik -doğrulaması yapın. +Doğrula'yı kullanın). Codex havuz hesapları yukarıdaki OAuth veya API anahtarı +sağlayıcılarından biri değildir, ancak `ocx login codex` onlara ulaşır: komut +hesap havuzu girişine yönlendirilir, bu yüzden `ocx login codex --reauth` ile +`ocx account reauth codex` aynı şeydir. Kontrol panelindeki Codex hesap havuzu +(Yeniden Kimlik Doğrula) da aynısını yapar. Bu yol proxy içinde çalışır, bu +nedenle çalışan bir proxy gerektirir. ```bash ocx login xai @@ -450,4 +452,3 @@ kapalı bir enum olarak ayrıştırır ve başka herhangi bir değer içeren tü kataloğu reddeder, bu nedenle `add`, `edit` ve yönetim API'si katalog yazıcısının daha sonra çıkarması gereken bir şeyi saklamak yerine hatalı değeri reddeder (#759). - diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 7f1e9f1205..bcb4e3f3e9 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -77,8 +77,9 @@ ChatGPT 透传目录也会加入 GPT-5.6 Sol/Terra/Luna 的裸 slug(`gpt-5.6-s 有九个提供商预设使用 OAuth 登录,另加通过实验性非官方设备流桥接的 GitHub Copilot。 opencodex 会把凭据存入 `~/.opencodex/auth.json`:可刷新的令牌会自动轮换;OrcaRouter -这类持久密钥会复用到提供商撤销为止。登录 CLI 也接受 `chatgpt`: -它会获取一份 ChatGPT 凭据,并创建一个 `forward` 模式的提供商条目。 +这类持久密钥会复用到提供商撤销为止。登录 CLI 也接受 `ocx login codex`,但它并不是上面这些提供商: +它会转到 Codex 账号池登录(与 `ocx account login codex` 相同的流程)。该账号池有独立的账号台账, +这条路径需要代理正在运行。`chatgpt` 和 `openai` 是同一条路径的别名。 ```bash ocx login xai # xAI Grok @@ -91,7 +92,7 @@ ocx login cursor # 独立的 Cursor PKCE 登录 ocx login command-code # Command Code 浏览器 OAuth(或导入 ~/.commandcode/auth.json) ocx login orcarouter-oauth # OrcaRouter 浏览器授权 + PKCE ocx login github-copilot # GitHub 设备流 → Copilot 令牌(Copilot Pro/Business) -ocx login chatgpt # 独立的 ChatGPT OAuth 登录 +ocx login codex # Codex 账号池(别名:chatgpt、openai;需要代理正在运行) ocx logout ``` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index a6fe332390..6418792429 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -63,9 +63,10 @@ ocx models live --provider ark --json 打印当前可接受的 OAuth 和 API 密钥提供方 id。 在 `ocx status` / `ocx doctor` 报告需要重新认证或终端刷新失败后,也可用同一条 -命令执行**重新认证**(或者在仪表盘中使用 Reauthenticate)。Codex 池账号不是一个 -公开的 `ocx login` 提供方 - 请通过仪表盘里的 Codex 账号池(Reauthenticate)或 -无头模式的 `ocx account reauth` 流程重新认证。 +命令执行**重新认证**(或者在仪表盘中使用 Reauthenticate)。Codex 池账号不是上面 +那些 OAuth / API key 提供方,但 `ocx login codex` 可以到达:它会转到账号池登录, +因此 `ocx login codex --reauth` 与 `ocx account reauth codex` 等价。仪表盘里的 +Codex 账号池(Reauthenticate)同样可以。这条路径跑在代理内部,需要代理正在运行。 ```bash ocx login xai diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 1b189cd91b..82ec32f008 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -84,8 +84,9 @@ ChatGPT passthrough catalog 也會加入 GPT-5.6 Sol/Terra/Luna 的裸 slug:`g ## 2. 帳號登入(OAuth) 有八個 provider preset 使用 OAuth 登入,另加透過實驗性非官方 device-flow bridge 的 GitHub Copilot。 -opencodex 會把 credential 存在 `~/.opencodex/auth.json` 並自動 refresh。登入 CLI 也接受 `chatgpt`; -它會取得 ChatGPT credential,同時建立 `forward` 模式的 provider 條目。 +opencodex 會把 credential 存在 `~/.opencodex/auth.json` 並自動 refresh。登入 CLI 也接受 `ocx login codex`, +但它不是上面的 provider:它會轉到 Codex 帳號池登入(與 `ocx account login codex` 相同的流程)。該帳號池 +有獨立的帳號 ledger,這條路徑需要 proxy 正在執行。`chatgpt` 與 `openai` 是同一條路徑的別名。 ```bash ocx login xai # xAI Grok @@ -97,7 +98,7 @@ ocx login google-antigravity ocx login cursor # 獨立 Cursor PKCE 登入 ocx login command-code # Command Code browser OAuth(或匯入 ~/.commandcode/auth.json) ocx login github-copilot # GitHub device flow → Copilot token(Copilot Pro/Business) -ocx login chatgpt # 獨立 ChatGPT OAuth 登入 +ocx login codex # Codex 帳號池(別名:chatgpt、openai;需要 proxy 正在執行) ocx logout ``` @@ -184,7 +185,8 @@ credential。caller 沒有送出時,opencodex **不會**捏造官方 client id **診斷與重新認證。** 一般 `ocx status` 會印出 OAuth health 區塊,只顯示遮蔽後 account id,不含 token。 `ocx doctor` 會新增 OAuth reliability 區段,包含 writable-store/single-flight check,以及帶 recovery Action 的 WARN row。OAuth provider 帳號需要重新認證時,執行 `ocx login `,或在儀表板使用 -Reauthenticate。Codex pool 帳號不是 `ocx login` provider,請透過儀表板 Codex account pool 重新認證。 +Reauthenticate。Codex pool 帳號不是那些 provider 之一,但 `ocx login codex --reauth` 會轉到它們的帳號池 +重新認證,儀表板的 Codex account pool 也做同一件事。 相關命令請參見 CLI 參考的 [`ocx status` / `ocx doctor`](/zh-tw/reference/cli/)。 ### Kiro credential 匯入 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index fbf1ff186c..5677e79852 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -44,7 +44,7 @@ ocx models live --provider ark --json 啟動供應商已註冊的登入流程。OAuth 供應商會開啟瀏覽器並在 `~/.opencodex/` 下儲存自動重新整理的憑證;API-key 登入供應商會開啟其金鑰儀表板、提示輸入金鑰、在可能時驗證它,並儲存產生的供應商設定。當名稱缺失或未知時,指令會印出目前接受的 OAuth 與 API-key 供應商 id。 -在 `ocx status` / `ocx doctor` 回報需要重新認證或終端 refresh 失敗後,請使用相同指令**重新認證**(或在儀表板中使用 Reauthenticate)。Codex pool 帳號不是公開的 `ocx login` 供應商——請改由儀表板 Codex 帳號池(Reauthenticate)或無頭的 `ocx account reauth` 流程重新認證。 +在 `ocx status` / `ocx doctor` 回報需要重新認證或終端 refresh 失敗後,請使用相同指令**重新認證**(或在儀表板中使用 Reauthenticate)。Codex pool 帳號不是上面那些 OAuth/API key 供應商,但 `ocx login codex` 可以到達:它會轉到帳號池登入,所以 `ocx login codex --reauth` 等同於 `ocx account reauth codex`。儀表板的 Codex 帳號池(Reauthenticate)也可以。這條路徑跑在 proxy 內部,需要 proxy 正在執行。 ```bash ocx login xai diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 9ecc637a7a..5ab8f94b72 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -49,8 +49,20 @@ visible to anyone who can run ps: pbpaste | ocx account code --flow ocx account login --code - (same, for the login flow)`; +/** + * The Codex account pool answers to three spellings, and a user reaches for whichever + * one they already have a word for. `ocx login codex` routes here as well (dispatch.ts): + * the pool is deliberately not an `ocx login` provider -- it keeps its own account + * ledger and runs its browser flow inside the proxy -- but that is an implementation + * boundary, not something a user should have to know before they can log in. + */ const CODEX_NAMES = new Set(["openai", "codex", "chatgpt"]); +/** True for every spelling that means "the Codex account pool" rather than an OAuth provider. */ +export function isCodexAccountLoginName(name: string): boolean { + return CODEX_NAMES.has(name.trim().toLowerCase()); +} + interface LoginStart { url?: string; flowId?: string; @@ -100,7 +112,12 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { const id = takeOption(args, "--id"); const suppliedCode = takeOptionWithSyntax(args, "--code"); if (!provider) throw new CliUsageError("provider is required", USAGE); - rejectArgs(args, USAGE); + // A bare leftover here is plausibly the authorization code itself: this flow takes one + // through --code, and a user who pastes it as a positional would otherwise see it echoed + // back in the usage error. `ocx login codex` reaches this parser too, so the paste lands + // one word away from a command people run constantly. Flag-shaped leftovers stay visible, + // because a mistyped flag is exactly what the message has to name. + rejectArgs(args, USAGE, { redactValues: true }); // kimi, nous, and github-copilot are already device flows, so --device is a // true statement about them and is accepted as a no-op rather than an error. // Anything else has no device grant at all and must fail loudly. diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 768c86bf78..2cd69d59ed 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -302,8 +302,20 @@ const commandRunners: Record = { return Number(process.exitCode ?? 0); }, login: async deps => { + const loginArgs = deps.args.slice(1); + // 'ocx login codex' is the command people type first, and until now it answered with + // the full provider wall because the Codex pool lives behind 'ocx account login'. + // Route the three Codex spellings to that flow instead of making the user discover + // a second noun. Everything else stays on the local OAuth/API-key path. + const { isCodexAccountLoginName, handleAccountAuthCommand } = await import("./account-auth"); + if (isCodexAccountLoginName(loginArgs[0] ?? "")) { + // null means "unknown subcommand", which "login" never is; the coalesce exists because + // the shared signature serves callers that do pass an unknown one. + const code = await handleAccountAuthCommand("login", loginArgs, { findLiveProxy: deps.findLiveProxy }); + return code ?? 1; + } const { handleLogin } = await import("../oauth/login-cli"); - await handleLogin(deps.args[1]); + await handleLogin(loginArgs[0]); return 0; }, logout: async deps => { diff --git a/src/cli/help.ts b/src/cli/help.ts index 764029b63e..c8f71cdbbd 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -52,7 +52,7 @@ Usage: ocx doctor --recover-zero-byte-coordinator --yes Back up a proven zero-byte Codex coordinator after stopping the proxy ocx debug provider/usage/injection/claude on|off|status|reset - ocx login OAuth or API-key provider login + ocx login OAuth or API-key provider login (ocx login codex for Codex/ChatGPT) ocx logout Remove a stored OAuth login ocx gui [pair --origin [--json]] Open the dashboard or create a single-use remote pairing grant diff --git a/src/cli/registry.ts b/src/cli/registry.ts index f219043254..929aa7f1fe 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -152,7 +152,16 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Env default: OCX_DEBUG=1 (legacy OCX_DEBUG_FRAMES still works)", ], }, - { name: "login", usage: "ocx login ", summary: "OAuth or API-key login for a provider." }, + { + name: "login", + usage: "ocx login ", + summary: "OAuth or API-key login for a provider.", + details: [ + "Codex/ChatGPT: ocx login codex runs the Codex account-pool login (same flow as ocx account login codex).", + "That one needs a running proxy; the OAuth and API-key providers log in locally.", + "'chatgpt' and 'openai' are the same route; an OpenAI platform API key is 'ocx login openai-apikey'.", + ], + }, { name: "logout", usage: "ocx logout ", summary: "Remove a stored provider login." }, { name: "gui", diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 79a3aa6eca..6d1de3abe6 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -65,15 +65,29 @@ export function warnIfLiveReloadSkipped(result: LocalProviderReloadResult | null ); } +/** + * The provider wall is the first thing an unfamiliar user sees, so it names the Codex + * route before the ~90 provider ids. 'codex' is not in either list on purpose: it is + * routed to the account-pool login in dispatch.ts, and 'chatgpt' stays off the public + * OAuth surface (isPublicOAuthProvider) because the pool owns that credential. + * + * It names 'openai-apikey' for the same reason it exists at all: 'openai' now routes to + * the pool, so someone who typed it looking for a platform key no longer sees the list + * that used to be their only pointer to it. + */ +export function loginUsageMessage(): string { + return `Usage: ocx login \n` + + ` Codex / ChatGPT: ocx login codex (account pool, needs a running proxy; 'chatgpt' and\n` + + ` 'openai' are the same route. An OpenAI platform key is 'openai-apikey'.)\n` + + ` OAuth login: ${listOAuthProviders().join(", ")}\n` + + ` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`; +} + export async function handleLogin(provider?: string): Promise { const name = (provider ?? "").trim().toLowerCase(); if (isPublicOAuthProvider(name)) return handleOAuthLogin(name); if (isKeyLoginProvider(name)) return handleKeyLogin(name); - console.error( - `Usage: ocx login \n` + - ` OAuth login: ${listOAuthProviders().join(", ")}\n` + - ` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`, - ); + console.error(loginUsageMessage()); process.exit(1); } diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index ef940dcf83..970f8f5bd6 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -4,6 +4,10 @@ import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCo import type { CliDispatchDeps } from "../../src/cli/dispatch"; import type { OcxConfig } from "../../src/types"; import { runGuiCommand } from "../../src/cli/gui"; +import { isCodexAccountLoginName } from "../../src/cli/account-auth"; +import { listOAuthProviders } from "../../src/oauth"; +import { isKeyLoginProvider } from "../../src/oauth/key-providers"; +import { loginUsageMessage } from "../../src/oauth/login-cli"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -721,3 +725,136 @@ describe("GUI command delegation", () => { } }); }); + +describe("login routes the Codex account names instead of printing the provider wall", () => { + /** + * `ocx login codex` used to fall through to handleLogin, which knows only the public + * OAuth and API-key providers, and answered with a ~90-name usage list that never + * contains the word the user typed. The Codex pool is reachable (`ocx account login + * codex`), so the dead end was vocabulary, not capability. + * + * The observable proof that the routing happened is the account path's own precondition: + * that flow runs inside the proxy, so with no live proxy it reports "Proxy is not + * running" and exits 1. handleLogin would have printed "Usage: ocx login " + * and killed the process with process.exit(1) instead, which is also why these cases + * cannot simply assert on a non-Codex name here. + */ + const runLogin = async (args: string[]): Promise<{ code: number; err: string }> => { + const err: string[] = []; + const errorSpy = spyOn(console, "error").mockImplementation((...v: unknown[]) => { err.push(v.join(" ")); }); + try { + const argv = ["login", ...args]; + const code = await dispatchCommand( + { kind: "command", command: "login", args: argv }, + { ...fakeDeps, args: argv, findLiveProxy: async () => null } as unknown as CliDispatchDeps, + ); + return { code, err: err.join("\n") }; + } finally { + errorSpy.mockRestore(); + } + }; + + test("every Codex spelling reaches the account-pool login", async () => { + for (const name of ["codex", "chatgpt", "openai", "CODEX", " codex "]) { + const result = await runLogin([name]); + expect(result.code, `${name} must route to the account login`).toBe(1); + expect(result.err).toContain("Proxy is not running"); + expect(result.err).not.toContain("Usage: ocx login "); + } + }); + + test("account-login flags ride into the request body, not just past the parser", async () => { + // An earlier version of this case asserted the 503 path with --reauth/--id attached and + // called that "flags survive". It could not fail: dropping the flags at the dispatch seam + // leaves an empty leftover list, so rejectArgs stays quiet and the liveness probe prints + // the same message. The only falsifiable proof is the request the flags are supposed to + // reach, so this one answers the probe with a live proxy and reads the POST body. + const calls: { url: string; method?: string; body?: string }[] = []; + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), method: init?.method, body: typeof init?.body === "string" ? init.body : undefined }); + return new Response(JSON.stringify({ flowId: "flow-1", url: "https://example.invalid/auth" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + try { + const argv = ["login", "codex", "--reauth", "--id", "acct-1", "--no-wait", "--json"]; + const code = await dispatchCommand( + { kind: "command", command: "login", args: argv }, + { + ...fakeDeps, + args: argv, + findLiveProxy: async () => ({ hostname: "127.0.0.1", port: 65500 }), + } as unknown as CliDispatchDeps, + ); + expect(code).toBe(0); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toContain("/api/codex-auth/login"); + expect(calls[0]?.method).toBe("POST"); + expect(JSON.parse(calls[0]?.body ?? "{}")).toEqual({ id: "acct-1", reauth: true }); + } finally { + logSpy.mockRestore(); + fetchSpy.mockRestore(); + } + }); + + test("an unsupported flag is still rejected as a usage error", async () => { + const result = await runLogin(["codex", "--nope"]); + expect(result.code).toBe(2); + expect(result.err).toContain("Unexpected argument(s): --nope"); + }); + + test("a name that is not a Codex spelling still gets the provider wall, not the account path", async () => { + // Closes the other half of the routing claim: the predicate is the gate, so a regression + // that sent every 'ocx login' through the account command would print "Proxy is not + // running" here instead of the wall. handleLogin ends in process.exit, which a test + // cannot survive, so the exit is spied and turned into a throw. + const err: string[] = []; + const errorSpy = spyOn(console, "error").mockImplementation((...v: unknown[]) => { err.push(v.join(" ")); }); + const exitSpy = spyOn(process, "exit").mockImplementation(((exitCode?: number) => { + throw new Error(`process.exit:${exitCode}`); + }) as never); + try { + const argv = ["login", "definitely-not-a-provider"]; + await expect(dispatchCommand( + { kind: "command", command: "login", args: argv }, + { ...fakeDeps, args: argv, findLiveProxy: async () => null } as unknown as CliDispatchDeps, + )).rejects.toThrow("process.exit:1"); + const printed = err.join("\n"); + expect(printed).toContain("Usage: ocx login "); + expect(printed).toContain("ocx login codex"); + expect(printed).toContain("openai-apikey"); + expect(printed).not.toContain("Proxy is not running"); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + + test("the provider wall names the Codex route without joining the public OAuth surface", () => { + const usage = loginUsageMessage(); + expect(usage).toContain("ocx login codex"); + // The wall is what the production path prints (asserted above through console.error); + // this reads the same source so a wording regression names the field that changed. + expect(usage).toContain("openai-apikey"); + // Routing must not re-open the generic OAuth path for the pool credential: + // tests/oauth/oauth-public-surface.test.ts owns that exclusion. + expect(listOAuthProviders()).not.toContain("chatgpt"); + expect(listOAuthProviders()).not.toContain("codex"); + // The other table the routing silently shadows: if a key-login provider ever took one of + // these ids, 'ocx login ' would become unreachable with no other failing test. + for (const name of ["openai", "codex", "chatgpt"]) expect(isKeyLoginProvider(name)).toBe(false); + expect(isKeyLoginProvider("openai-apikey")).toBe(true); + expect(isCodexAccountLoginName("codex")).toBe(true); + expect(isCodexAccountLoginName("xai")).toBe(false); + }); + + test("the registry entry keeps documenting the Codex route", () => { + // help.ts and registry.ts carry the only discoverability text a user sees before typing; + // the existing help/registry suites only require that an 'ocx login' line exists at all. + const details = (CLI_COMMANDS.find(entry => entry.name === "login")?.details ?? []).join(" "); + expect(details).toContain("ocx login codex"); + expect(details).toContain("openai-apikey"); + }); +}); From 16f18d6543234839b6eab4f6979b81d7ef837f63 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 11 Sep 2026 20:54:45 +0900 Subject: [PATCH 020/231] test(oauth): await guardian fixture ACL hardening before cleanup (#4104) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- tests/codex-integration/token-guardian.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/token-guardian.test.ts b/tests/codex-integration/token-guardian.test.ts index 3e0ef632d8..f007211340 100644 --- a/tests/codex-integration/token-guardian.test.ts +++ b/tests/codex-integration/token-guardian.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveCredential } from "../../src/oauth/store"; import { getConfigPath } from "../../src/config"; +import { flushConfigDirHardening } from "../../src/config/paths"; import { markCodexAccountValidated, readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; import { __resetGuardianState, guardianSweep } from "../../src/oauth/token-guardian"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; @@ -46,7 +47,9 @@ beforeEach(() => { __resetGuardianState(); }); -afterEach(() => { +afterEach(async () => { + // Optional Windows ACL work can outlive credential writes and keep this home open. + await flushConfigDirHardening(join(tmp, "ocx")); resetLifecycleDrainStateForTests(); if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome; if (origOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = origOcxHome; From bd1864905f4a83566d1a17047be9a150257eae53 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 21:33:23 +0900 Subject: [PATCH 021/231] docs(structure): restructure the maintainer SOT and gate it mechanically (#4276) * docs(structure): restructure the maintainer SOT and gate it mechanically structure/ was 13 numbered files and 457KB that nothing verified. 04_transports-and-sidecars.md alone was 1,860 lines, two docs shared the number 09, 95 [Decision Log] blocks sat inline in contract text, and four paths the docs named had already been moved or deleted. No test read the folder, so none of that could be caught. Sections move verbatim into 27 topic docs, at most one directory deep, ordered by structure/manifest.json instead of by filename. The NN_ prefix is what produced the 09 collision and made splitting a doc cost a renumber; ordering now has one author. The 95 decision records move to structure/decisions/ADR-NNNN-*.md, each linked from exactly one owning doc, so a doc states the contract that holds now and links the reasoning rather than carrying it inline. scripts/structure-ssot.ts is the gate, blocked into CI by tests/ci-workflows/structure-ssot.test.ts. It fails on a doc missing from the manifest, a numeric prefix, a doc over the 600-line budget, an unresolvable link, a backticked repository path this tree does not have, an inline [Decision Log], a decision record with zero or two owners, a hole in record numbering, an invariant whose test is gone, and a src/ area no doc claims. INDEX.md is generated by bun run structure:index and compared byte for byte, so the reading order and the source-ownership table cannot contradict the manifest. The nine invariants in overview.md now carry INV-* ids bound to the test that holds them, and each of those tests names its id back, so splitting or renaming the test fails the gate instead of silently unbinding the invariant. structure/AGENTS.md carries the rules the gate enforces. Fixed in passing, each found by the new gate: gui/src/pages/CodexAuth.tsx (now codex-set-multiauth.tsx) and two devlog units documented under _plan after they closed into _fin. * docs(structure): make the SSOT gate survive its own review Three read-only review passes over the previous commit found the gate claiming more than it checked, and one paragraph lost in the split. Fixed here rather than shipped. Content: clients/integrations.md lost its opening contract paragraph, the only sentence stating the subsystem's reversibility promise. The split preserved preambles for docs migrated whole and dropped them for docs assembled from a section list; that paragraph is restored. Ownership: the map claimed every source area had exactly one owning doc. That is false in this repository - src/server/ is genuinely described by the management-API doc, the Responses transport doc and the Images doc - and the gate could not see it, because collision detection compared exact strings while every real overlap was a nesting. The model is now many-docs-per-area, the manifest lists what each doc actually names, and the five areas no doc names at all are recorded with reasons instead of being assigned to a doc that never mentions them. Invariants: five of nine were bound to tests that do not enforce them. INV-AGENT-01 named the picker-order band test while the five-featured rule lives in catalog-full-picker-order.test.ts; INV-AUTH-01 named a file whose nearest assertions run one credential against both planes, while server-management-auth.test.ts has a describe block for the separation itself. Those two are rebound, INV-RESTORE-01 is narrowed to the path its test covers, and INV-HOME-01 and INV-SLUG-01 have no honest test in this repository, so they are stated without a binding and recorded in grace.unboundInvariants. Naming a test that would pass while the rule was violated is worse than admitting the gap. Gate: repository paths are resolved against the git index rather than the filesystem, because existsSync cannot tell a tracked file from untracked local leftovers and is case-insensitive on Windows and case-sensitive on Linux CI. Decision records are no longer checked against the present tree - a record describes a past one. Fenced blocks are stripped before scanning. Link anchors are validated. Record numbering requires uniqueness but no longer contiguity, which would have made two branches collide on merge. Owners are counted per doc, not per mention. Leading digits are rejected in one rule instead of two, so 01-overview.md fails like 01_overview.md. Inline decision-log detection is case-insensitive and also catches the bullet template. The budget counts content lines and drops a grace entry once the doc is back under it. Tests: the suite proved one rule and shipped ten unproven. It now drives every rule red once against a synthetic tree, including the three cases that must NOT fire - a stale path inside a decision record, a path inside a fenced example, and a doc naming one record twice. * docs(structure): carry the Z.ai quota section into the new layout dev added a Z.ai quota-destination section to 05_gui-and-management-api.md while this branch was open. Rebase rename-detection moved it into gui-and-management-api.md and brought its inline [Decision Log] with it, which the gate refused - the first thing it caught that was not planted. The reasoning is now ADR-0096, linked from the section that owns it. --- AGENTS.md | 11 +- CONTRIBUTING.md | 2 +- docs/README.md | 3 +- package.json | 2 + scripts/structure-ssot.ts | 426 ++++ scripts/test-layout/layout.json | 1 + src/AGENTS.md | 2 +- src/adapters/google.ts | 2 +- src/lib/local-destinations.ts | 2 +- structure/02_config-and-codex-home.md | 514 ----- structure/03_catalog-and-subagents.md | 543 ----- structure/04_transports-and-sidecars.md | 1859 ----------------- structure/AGENTS.md | 116 + structure/INDEX.md | 147 ++ .../compatibility-contracts.md} | 20 +- .../compatibility-lab.md} | 2 +- .../registry.md} | 22 +- structure/catalog.md | 263 +++ structure/clients/claude-desktop.md | 77 + .../integrations.md} | 85 +- structure/codex-home.md | 225 ++ structure/config.md | 194 ++ structure/data-planes/images.md | 68 + structure/data-planes/inbound-compat.md | 84 + structure/data-planes/search.md | 19 + .../decisions/ADR-0001-product-boundary.md | 12 + structure/decisions/ADR-0002-lifecycle.md | 12 + structure/decisions/ADR-0003-lifecycle.md | 12 + structure/decisions/ADR-0004-lifecycle.md | 12 + structure/decisions/ADR-0005-codex-home.md | 12 + structure/decisions/ADR-0006-codex-home.md | 12 + structure/decisions/ADR-0007-codex-home.md | 12 + structure/decisions/ADR-0008-codex-home.md | 12 + structure/decisions/ADR-0009-codex-home.md | 12 + structure/decisions/ADR-0010-codex-home.md | 12 + structure/decisions/ADR-0011-codex-home.md | 12 + structure/decisions/ADR-0012-codex-home.md | 12 + structure/decisions/ADR-0013-codex-home.md | 12 + structure/decisions/ADR-0014-codex-home.md | 12 + structure/decisions/ADR-0015-codex-home.md | 12 + .../decisions/ADR-0016-config-surface.md | 12 + .../decisions/ADR-0017-config-injection.md | 12 + .../decisions/ADR-0018-config-injection.md | 12 + .../decisions/ADR-0019-config-injection.md | 12 + .../ADR-0020-provider-validation-ownership.md | 12 + .../decisions/ADR-0021-shared-catalog.md | 12 + ...routed-tool-discovery-and-hosted-search.md | 12 + .../ADR-0023-ultra-reasoning-level.md | 19 + .../ADR-0024-ultra-reasoning-level.md | 19 + .../ADR-0025-ultra-reasoning-level.md | 19 + .../ADR-0026-ultra-reasoning-level.md | 21 + structure/decisions/ADR-0027-subagents.md | 24 + ...28-background-service-command-selection.md | 12 + ...windows-startup-ownership-listing-reuse.md | 12 + ...le-service-launcher-launchd-and-systemd.md | 12 + .../decisions/ADR-0031-responses-http-sse.md | 12 + .../decisions/ADR-0032-responses-http-sse.md | 12 + .../decisions/ADR-0033-responses-http-sse.md | 13 + .../decisions/ADR-0034-responses-http-sse.md | 12 + .../decisions/ADR-0035-responses-http-sse.md | 12 + .../decisions/ADR-0036-responses-http-sse.md | 12 + .../decisions/ADR-0037-responses-http-sse.md | 12 + .../decisions/ADR-0038-responses-http-sse.md | 12 + .../decisions/ADR-0039-responses-http-sse.md | 23 + .../decisions/ADR-0040-responses-http-sse.md | 12 + .../decisions/ADR-0041-responses-http-sse.md | 12 + .../decisions/ADR-0042-responses-http-sse.md | 12 + .../decisions/ADR-0043-responses-http-sse.md | 12 + .../decisions/ADR-0044-responses-http-sse.md | 20 + .../decisions/ADR-0045-standalone-images.md | 12 + ...laude-desktop-config-library-resolution.md | 12 + .../decisions/ADR-0047-cursor-native-exec.md | 12 + .../decisions/ADR-0048-cursor-native-exec.md | 12 + .../ADR-0049-heartbeat-and-stall-deadline.md | 21 + .../ADR-0050-heartbeat-and-stall-deadline.md | 12 + ...reasoning-and-tool-result-compatibility.md | 12 + ...reasoning-and-tool-result-compatibility.md | 12 + .../ADR-0053-cursor-active-context-usage.md | 12 + ...54-cursor-conversation-checkpoint-reuse.md | 12 + ...google-thought-text-visibility-boundary.md | 12 + ...056-google-response-part-field-boundary.md | 12 + ...ogle-tool-call-thought-signature-replay.md | 12 + ...058-google-tool-result-adjacency-repair.md | 23 + ...-hardening-official-grok-build-contract.md | 23 + ...ADR-0060-kiro-client-parallel-tool-hint.md | 12 + .../ADR-0061-kiro-responses-text-controls.md | 12 + ...aming-client-with-a-json-upstream-resul.md | 12 + ...ngine-ark-assistant-continuation-shapes.md | 12 + ...64-chat-structured-output-compatibility.md | 12 + ...65-chat-structured-output-compatibility.md | 12 + ...thropic-structured-output-compatibility.md | 12 + ...ning-display-parity-hidethinkingsummary.md | 12 + ...ning-display-parity-hidethinkingsummary.md | 12 + ...at-to-responses-message-phase-inference.md | 12 + ...0070-same-provider-combo-quota-fallback.md | 12 + ...DR-0071-combo-streaming-commit-boundary.md | 12 + .../decisions/ADR-0072-transport-inventory.md | 12 + .../ADR-0073-authentication-boundaries.md | 12 + structure/decisions/ADR-0074-api-ownership.md | 12 + .../decisions/ADR-0075-startup-safety.md | 12 + .../decisions/ADR-0076-startup-safety.md | 12 + .../decisions/ADR-0077-startup-safety.md | 12 + .../decisions/ADR-0078-usage-accounting.md | 12 + .../decisions/ADR-0079-usage-accounting.md | 12 + structure/decisions/ADR-0080-github-pages.md | 12 + .../ADR-0081-container-deployment-recipe.md | 12 + ...-service-wrapper-and-incomplete-updates.md | 12 + .../ADR-0083-maintenance-governance.md | 12 + .../ADR-0084-public-provider-contract.md | 12 + .../ADR-0085-public-provider-contract.md | 22 + .../ADR-0086-public-provider-contract.md | 18 + .../ADR-0087-model-and-wire-identity.md | 19 + .../ADR-0088-model-and-wire-identity.md | 21 + ...0089-process-local-affinity-diagnostics.md | 12 + .../ADR-0090-hermes-model-capabilities.md | 12 + .../decisions/ADR-0091-ownership-axes.md | 12 + .../ADR-0092-zcode-runtime-metadata.md | 12 + ...oonshot-ref-with-siblings-normalization.md | 24 + ...nonical-forward-continuation-extensions.md | 12 + ...nonical-forward-continuation-extensions.md | 12 + ...R-0096-z-ai-quota-destination-ownership.md | 12 + ...n-methodology.md => design-methodology.md} | 4 +- ...ement-api.md => gui-and-management-api.md} | 84 +- structure/manifest.json | 402 ++++ .../docs-and-release.md} | 38 +- structure/ops/service-and-sidecars.md | 134 ++ structure/{00_overview.md => overview.md} | 83 +- structure/providers/chat-compat.md | 266 +++ structure/providers/cursor.md | 84 + structure/providers/google.md | 47 + structure/providers/kiro.md | 61 + .../openai-tiers.md} | 88 +- structure/providers/xai-grok.md | 43 + structure/{01_runtime.md => runtime.md} | 26 +- structure/subagents.md | 197 ++ structure/transports/inventory.md | 59 + structure/transports/responses.md | 468 +++++ structure/transports/streaming-health.md | 191 ++ .../openai/openai-provider-option.test.ts | 1 + tests/ci-workflows/structure-ssot.test.ts | 282 +++ .../catalog-full-picker-order.test.ts | 1 + .../codex-catalog-restore.test.ts | 1 + tests/codex-integration/codex-catalog.test.ts | 1 + tests/codex-integration/codex-inject.test.ts | 1 + tests/fixtures/test-layout-expected.json | 1 + tests/server/server-management-auth.test.ts | 1 + tests/test-layout.test.ts | 1 + 147 files changed, 5252 insertions(+), 3291 deletions(-) create mode 100644 scripts/structure-ssot.ts delete mode 100644 structure/02_config-and-codex-home.md delete mode 100644 structure/03_catalog-and-subagents.md delete mode 100644 structure/04_transports-and-sidecars.md create mode 100644 structure/AGENTS.md create mode 100644 structure/INDEX.md rename structure/{11_compatibility-contracts.md => adapters/compatibility-contracts.md} (62%) rename structure/{09_compatibility-lab.md => adapters/compatibility-lab.md} (99%) rename structure/{10_adapter-registry.md => adapters/registry.md} (61%) create mode 100644 structure/catalog.md create mode 100644 structure/clients/claude-desktop.md rename structure/{09_client-integrations.md => clients/integrations.md} (57%) create mode 100644 structure/codex-home.md create mode 100644 structure/config.md create mode 100644 structure/data-planes/images.md create mode 100644 structure/data-planes/inbound-compat.md create mode 100644 structure/data-planes/search.md create mode 100644 structure/decisions/ADR-0001-product-boundary.md create mode 100644 structure/decisions/ADR-0002-lifecycle.md create mode 100644 structure/decisions/ADR-0003-lifecycle.md create mode 100644 structure/decisions/ADR-0004-lifecycle.md create mode 100644 structure/decisions/ADR-0005-codex-home.md create mode 100644 structure/decisions/ADR-0006-codex-home.md create mode 100644 structure/decisions/ADR-0007-codex-home.md create mode 100644 structure/decisions/ADR-0008-codex-home.md create mode 100644 structure/decisions/ADR-0009-codex-home.md create mode 100644 structure/decisions/ADR-0010-codex-home.md create mode 100644 structure/decisions/ADR-0011-codex-home.md create mode 100644 structure/decisions/ADR-0012-codex-home.md create mode 100644 structure/decisions/ADR-0013-codex-home.md create mode 100644 structure/decisions/ADR-0014-codex-home.md create mode 100644 structure/decisions/ADR-0015-codex-home.md create mode 100644 structure/decisions/ADR-0016-config-surface.md create mode 100644 structure/decisions/ADR-0017-config-injection.md create mode 100644 structure/decisions/ADR-0018-config-injection.md create mode 100644 structure/decisions/ADR-0019-config-injection.md create mode 100644 structure/decisions/ADR-0020-provider-validation-ownership.md create mode 100644 structure/decisions/ADR-0021-shared-catalog.md create mode 100644 structure/decisions/ADR-0022-routed-tool-discovery-and-hosted-search.md create mode 100644 structure/decisions/ADR-0023-ultra-reasoning-level.md create mode 100644 structure/decisions/ADR-0024-ultra-reasoning-level.md create mode 100644 structure/decisions/ADR-0025-ultra-reasoning-level.md create mode 100644 structure/decisions/ADR-0026-ultra-reasoning-level.md create mode 100644 structure/decisions/ADR-0027-subagents.md create mode 100644 structure/decisions/ADR-0028-background-service-command-selection.md create mode 100644 structure/decisions/ADR-0029-windows-startup-ownership-listing-reuse.md create mode 100644 structure/decisions/ADR-0030-stable-service-launcher-launchd-and-systemd.md create mode 100644 structure/decisions/ADR-0031-responses-http-sse.md create mode 100644 structure/decisions/ADR-0032-responses-http-sse.md create mode 100644 structure/decisions/ADR-0033-responses-http-sse.md create mode 100644 structure/decisions/ADR-0034-responses-http-sse.md create mode 100644 structure/decisions/ADR-0035-responses-http-sse.md create mode 100644 structure/decisions/ADR-0036-responses-http-sse.md create mode 100644 structure/decisions/ADR-0037-responses-http-sse.md create mode 100644 structure/decisions/ADR-0038-responses-http-sse.md create mode 100644 structure/decisions/ADR-0039-responses-http-sse.md create mode 100644 structure/decisions/ADR-0040-responses-http-sse.md create mode 100644 structure/decisions/ADR-0041-responses-http-sse.md create mode 100644 structure/decisions/ADR-0042-responses-http-sse.md create mode 100644 structure/decisions/ADR-0043-responses-http-sse.md create mode 100644 structure/decisions/ADR-0044-responses-http-sse.md create mode 100644 structure/decisions/ADR-0045-standalone-images.md create mode 100644 structure/decisions/ADR-0046-claude-desktop-config-library-resolution.md create mode 100644 structure/decisions/ADR-0047-cursor-native-exec.md create mode 100644 structure/decisions/ADR-0048-cursor-native-exec.md create mode 100644 structure/decisions/ADR-0049-heartbeat-and-stall-deadline.md create mode 100644 structure/decisions/ADR-0050-heartbeat-and-stall-deadline.md create mode 100644 structure/decisions/ADR-0051-reasoning-and-tool-result-compatibility.md create mode 100644 structure/decisions/ADR-0052-reasoning-and-tool-result-compatibility.md create mode 100644 structure/decisions/ADR-0053-cursor-active-context-usage.md create mode 100644 structure/decisions/ADR-0054-cursor-conversation-checkpoint-reuse.md create mode 100644 structure/decisions/ADR-0055-google-thought-text-visibility-boundary.md create mode 100644 structure/decisions/ADR-0056-google-response-part-field-boundary.md create mode 100644 structure/decisions/ADR-0057-google-tool-call-thought-signature-replay.md create mode 100644 structure/decisions/ADR-0058-google-tool-result-adjacency-repair.md create mode 100644 structure/decisions/ADR-0059-xai-grok-hardening-official-grok-build-contract.md create mode 100644 structure/decisions/ADR-0060-kiro-client-parallel-tool-hint.md create mode 100644 structure/decisions/ADR-0061-kiro-responses-text-controls.md create mode 100644 structure/decisions/ADR-0062-chat-streaming-client-with-a-json-upstream-resul.md create mode 100644 structure/decisions/ADR-0063-volcengine-ark-assistant-continuation-shapes.md create mode 100644 structure/decisions/ADR-0064-chat-structured-output-compatibility.md create mode 100644 structure/decisions/ADR-0065-chat-structured-output-compatibility.md create mode 100644 structure/decisions/ADR-0066-anthropic-structured-output-compatibility.md create mode 100644 structure/decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md create mode 100644 structure/decisions/ADR-0068-reasoning-display-parity-hidethinkingsummary.md create mode 100644 structure/decisions/ADR-0069-chat-to-responses-message-phase-inference.md create mode 100644 structure/decisions/ADR-0070-same-provider-combo-quota-fallback.md create mode 100644 structure/decisions/ADR-0071-combo-streaming-commit-boundary.md create mode 100644 structure/decisions/ADR-0072-transport-inventory.md create mode 100644 structure/decisions/ADR-0073-authentication-boundaries.md create mode 100644 structure/decisions/ADR-0074-api-ownership.md create mode 100644 structure/decisions/ADR-0075-startup-safety.md create mode 100644 structure/decisions/ADR-0076-startup-safety.md create mode 100644 structure/decisions/ADR-0077-startup-safety.md create mode 100644 structure/decisions/ADR-0078-usage-accounting.md create mode 100644 structure/decisions/ADR-0079-usage-accounting.md create mode 100644 structure/decisions/ADR-0080-github-pages.md create mode 100644 structure/decisions/ADR-0081-container-deployment-recipe.md create mode 100644 structure/decisions/ADR-0082-windows-service-wrapper-and-incomplete-updates.md create mode 100644 structure/decisions/ADR-0083-maintenance-governance.md create mode 100644 structure/decisions/ADR-0084-public-provider-contract.md create mode 100644 structure/decisions/ADR-0085-public-provider-contract.md create mode 100644 structure/decisions/ADR-0086-public-provider-contract.md create mode 100644 structure/decisions/ADR-0087-model-and-wire-identity.md create mode 100644 structure/decisions/ADR-0088-model-and-wire-identity.md create mode 100644 structure/decisions/ADR-0089-process-local-affinity-diagnostics.md create mode 100644 structure/decisions/ADR-0090-hermes-model-capabilities.md create mode 100644 structure/decisions/ADR-0091-ownership-axes.md create mode 100644 structure/decisions/ADR-0092-zcode-runtime-metadata.md create mode 100644 structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md create mode 100644 structure/decisions/ADR-0094-canonical-forward-continuation-extensions.md create mode 100644 structure/decisions/ADR-0095-canonical-forward-continuation-extensions.md create mode 100644 structure/decisions/ADR-0096-z-ai-quota-destination-ownership.md rename structure/{07_design-methodology.md => design-methodology.md} (94%) rename structure/{05_gui-and-management-api.md => gui-and-management-api.md} (81%) create mode 100644 structure/manifest.json rename structure/{06_docs-and-release.md => ops/docs-and-release.md} (80%) create mode 100644 structure/ops/service-and-sidecars.md rename structure/{00_overview.md => overview.md} (55%) create mode 100644 structure/providers/chat-compat.md create mode 100644 structure/providers/cursor.md create mode 100644 structure/providers/google.md create mode 100644 structure/providers/kiro.md rename structure/{08_openai-provider-tiers.md => providers/openai-tiers.md} (78%) create mode 100644 structure/providers/xai-grok.md rename structure/{01_runtime.md => runtime.md} (80%) create mode 100644 structure/subagents.md create mode 100644 structure/transports/inventory.md create mode 100644 structure/transports/responses.md create mode 100644 structure/transports/streaming-health.md create mode 100644 tests/ci-workflows/structure-ssot.test.ts diff --git a/AGENTS.md b/AGENTS.md index ca17bc4a0a..3f3cd09350 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,14 @@ Bun-native TypeScript with no separate server compile step. - `go/` — retired Go native-runtime experiment; kept only where the TypeScript runtime still references it. New work does not go here. - `structure/` — maintainer invariants and architecture notes; read before - changing shared subsystems. + changing shared subsystems. [`structure/INDEX.md`](./structure/INDEX.md) is the + reading order and the source-ownership table, and + [`structure/AGENTS.md`](./structure/AGENTS.md) holds the rules for changing + anything in there. Ownership is not advisory: changing an owned source area + obliges the same change to update its doc, and `bun run structure:check` + (wired into the suite by `tests/ci-workflows/structure-ssot.test.ts`) fails on a + doc that names a path this tree no longer has, on an invariant whose test is + gone, and on a new `src/` area nobody claimed. - `scripts/` — release and maintenance tooling; `scripts/release.ts` is the release authority. - `devlog/` — planning and investigation notes, tracked in this repository. See @@ -187,6 +194,8 @@ bun run test:changed # import-graph tests against the resolved `dev` merge bas bun run test # full tests/ suite (PR-ready / explicit ask only) bun run lint:gui # GUI eslint bun run privacy:scan # credential/privacy scan used by CI +bun run structure:check # structure/ doc-map, ownership, and invariant-binding gate +bun run structure:index # regenerate structure/INDEX.md from structure/manifest.json bun run build:gui # Vite GUI build ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08db5a6bbb..ea36e08eb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Thanks for helping with opencodex. - Start with the canonical guide: [Contributing](https://opencodex.me/contributing/) - Pull-request quality contract: [Review readiness and author responsibility](https://opencodex.me/contributing/pr-quality/) - Public user docs live in [`docs-site/`](./docs-site) -- Current maintainer invariants live in [`structure/`](./structure) +- Current maintainer invariants live in [`structure/`](./structure); start at [`structure/INDEX.md`](./structure/INDEX.md) - Maintainer roles and merge policy live in [`MAINTAINERS.md`](./MAINTAINERS.md) - Attribution for work landed through a maintainer carry lives in [`CREDITS.md`](./CREDITS.md) - Historical investigations live in [`docs/`](./docs) diff --git a/docs/README.md b/docs/README.md index 6a72059024..00e5bd352f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,5 +4,6 @@ This folder contains investigations and diagnostic notes. It is not the primary is not the maintainer source of truth for current invariants. - Public user workflows live in [`../docs-site/`](../docs-site). -- Current maintainer invariants live in [`../structure/`](../structure). +- Current maintainer invariants live in [`../structure/`](../structure); start at + [`INDEX.md`](../structure/INDEX.md). - Keep files here when the detail is useful for archaeology, debugging, or source research. diff --git a/package.json b/package.json index 221e03b24b..6fae3e4d49 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "privacy:scan": "bun scripts/privacy-scan.ts", "skill:surface": "bun scripts/generate-ocx-skill-surface.ts", "skill:surface:check": "bun scripts/generate-ocx-skill-surface.ts --check", + "structure:index": "bun scripts/structure-ssot.ts --fix", + "structure:check": "bun scripts/structure-ssot.ts", "generate:model-metadata": "bun scripts/generate-model-metadata.ts", "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package", "prepare:package": "bun scripts/prepare-package.ts", diff --git a/scripts/structure-ssot.ts b/scripts/structure-ssot.ts new file mode 100644 index 0000000000..81a4e416cc --- /dev/null +++ b/scripts/structure-ssot.ts @@ -0,0 +1,426 @@ +#!/usr/bin/env bun +/** + * structure/ single-source-of-truth gate. + * + * The maintainer docs under structure/ are a SECOND description of this tree, and a second + * description drifts. This module is the proof that it has not: it validates the doc map, the + * source-to-doc map, decision-record topology, invariant-to-test bindings, and every link, anchor + * and repository path the docs name. It also generates structure/INDEX.md, so the reading order + * cannot contradict the manifest. + * + * Two deliberate choices, both learned from an earlier revision of this file that got them wrong: + * + * - Paths are checked against the GIT INDEX first, not the filesystem. existsSync cannot tell a + * tracked file from untracked local leftovers, and it is case-insensitive on Windows and + * case-sensitive on Linux, so a filesystem-only gate gives a different verdict per machine and + * fails a maintainer who still has a retired directory on disk. + * - A source area may be described by MORE THAN ONE doc. An earlier design demanded exactly one + * owner per area; in this repository that claim was simply false (src/server/ is described by the + * management-API doc, the Responses transport doc and the Images doc), and a rule that is false + * is worse than no rule because the gate reports green while the map misdirects. + * + * Usage: + * bun scripts/structure-ssot.ts # report findings, exit 1 on failure + * bun scripts/structure-ssot.ts --fix # rewrite structure/INDEX.md from the manifest + */ +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +const BT = "\u0060"; + +export type Manifest = { + version: number; + sizeBudgetLines: number; + generatedPaths: string[]; + absentPaths: { path: string; reason: string }[]; + tiers: { id: number; name: string; purpose: string }[]; + docs: { path: string; tier: number; title: string; scope: string; documents: string[] }[]; + grace: { + undocumentedSourceAreas: { path: string; reason: string }[]; + unboundInvariants: { id: string; reason: string }[]; + oversizeDocs: string[]; + staleRefs: string[]; + }; +}; + +const REPO_ROOTS = ["src", "tests", "gui", "scripts", "docs", "docs-site", "bin", "go", "devlog", ".github", "structure"]; +const GENERATED_DOCS = ["INDEX.md"]; +const RULE_DOCS = ["AGENTS.md"]; + +const toPosix = (p: string) => p.split("\\").join("/"); +const trimSlash = (p: string) => p.replace(/\/+$/, ""); + +function listMarkdown(dir: string, root: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) listMarkdown(full, root, out); + else if (entry.name.endsWith(".md")) out.push(toPosix(relative(root, full))); + } + return out.sort(); +} + +/** Tracked files plus every directory on their way up, read once from the git index. */ +function trackedPaths(repoRoot: string): Set | null { + let stdout: string; + try { + const run = Bun.spawnSync(["git", "ls-files", "-z"], { cwd: repoRoot, stdout: "pipe", stderr: "pipe" }); + if (run.exitCode !== 0) return null; + stdout = new TextDecoder().decode(run.stdout); + } catch { + return null; + } + const set = new Set(); + for (const file of stdout.split("\0")) { + if (!file) continue; + set.add(file); + const parts = file.split("/"); + for (let i = 1; i < parts.length; i += 1) set.add(parts.slice(0, i).join("/")); + } + return set.size > 0 ? set : null; +} + +/** Markdown body with fenced blocks blanked out, so examples inside a fence are not scanned. */ +function withoutFences(body: string): string { + let fenced = false; + return body + .split("\n") + .map((line) => { + if (/^\s{0,3}(?:\u0060\u0060\u0060|~~~)/.test(line)) { + fenced = !fenced; + return ""; + } + return fenced ? "" : line; + }) + .join("\n"); +} + +/** GitHub-style heading anchor. */ +function headingAnchors(body: string): Set { + const out = new Set(); + for (const line of withoutFences(body).split("\n")) { + const m = /^#{1,6}\s+(.*)$/.exec(line); + if (!m) continue; + const slug = m[1] + .replace(/\u0060/g, "") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N} _-]/gu, "") + .replace(/\s+/g, "-"); + if (slug) out.add(slug); + } + return out; +} + +export function renderIndex(manifest: Manifest): string { + const lines: string[] = []; + lines.push("# opencodex Structure Index"); + lines.push(""); + lines.push("This folder is the maintainer source of truth for the current system shape. Public user workflows"); + lines.push("belong in " + BT + "docs-site/" + BT + ". Development work is recorded in " + BT + "devlog/" + BT + " units — " + BT + "_plan/" + BT + " while open,"); + lines.push(BT + "_fin/" + BT + " once closed — while " + BT + "docs/" + BT + " keeps investigations and diagnostic notes worth retaining for"); + lines.push("archaeology, debugging, or source research."); + lines.push(""); + lines.push( + "Generated from " + BT + "structure/manifest.json" + BT + " by " + BT + "bun run structure:index" + BT + ". Do not edit by hand; " + + BT + "bun run structure:check" + BT + " fails when this file and the manifest disagree. The rules for changing anything", + ); + lines.push("in this folder are in [" + BT + "AGENTS.md" + BT + "](AGENTS.md)."); + lines.push(""); + lines.push("## Reading order"); + for (const tier of manifest.tiers) { + const docs = manifest.docs.filter((d) => d.tier === tier.id); + if (docs.length === 0) continue; + lines.push(""); + lines.push("### Tier " + tier.id + " — " + tier.name); + lines.push(""); + lines.push(tier.purpose); + lines.push(""); + lines.push("| Doc | Scope |"); + lines.push("| --- | --- |"); + for (const doc of docs) lines.push("| [" + BT + doc.path + BT + "](" + doc.path + ") | " + doc.scope + " |"); + } + lines.push(""); + lines.push("## Which doc describes which source"); + lines.push(""); + lines.push("A source area can be described by more than one doc, because these docs are organised by topic and"); + lines.push(BT + "src/" + BT + " is organised by module. Changing an area obliges the same change to update every doc listed"); + lines.push("for it; see [" + BT + "AGENTS.md" + BT + "](AGENTS.md)."); + lines.push(""); + lines.push("| Source path | Described by |"); + lines.push("| --- | --- |"); + const byPath = new Map(); + for (const doc of manifest.docs) { + for (const area of doc.documents) byPath.set(area, [...(byPath.get(area) ?? []), doc.path]); + } + for (const area of [...byPath.keys()].sort()) { + const docs = byPath.get(area)!.map((d) => "[" + BT + d + BT + "](" + d + ")").join("
"); + lines.push("| " + BT + area + BT + " | " + docs + " |"); + } + lines.push(""); + lines.push("### Not described by any doc"); + lines.push(""); + lines.push("| Source path | Why |"); + lines.push("| --- | --- |"); + for (const row of [...manifest.grace.undocumentedSourceAreas].sort((a, b) => a.path.localeCompare(b.path))) { + lines.push("| " + BT + row.path + BT + " | " + row.reason + " |"); + } + lines.push(""); + lines.push("## Decision records"); + lines.push(""); + lines.push("Superseded reasoning lives in " + BT + "decisions/" + BT + " as numbered records. A doc states the contract that holds now and"); + lines.push("links the record that explains why; it never carries the reasoning inline."); + lines.push(""); + return lines.join("\n") + "\n"; +} + +export function runStructureChecks(repoRoot: string): string[] { + const structureDir = join(repoRoot, "structure"); + const failures: string[] = []; + const fail = (message: string) => failures.push(message); + + const manifestPath = join(structureDir, "manifest.json"); + if (!existsSync(manifestPath)) return ["structure/manifest.json is missing"]; + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; + + const tracked = trackedPaths(repoRoot); + const trackedLower = new Map(); + if (tracked) for (const p of tracked) trackedLower.set(p.toLowerCase(), p); + + /** A repository path is real when git tracks it, or when it exists and is not merely a case variant. */ + const pathIsReal = (raw: string): "ok" | "missing" | string => { + const p = trimSlash(raw); + if (tracked?.has(p)) return "ok"; + if (tracked) { + const variant = trackedLower.get(p.toLowerCase()); + if (variant && variant !== p) return variant; + } + return existsSync(join(repoRoot, p)) ? "ok" : "missing"; + }; + const isTracked = (raw: string) => tracked?.has(trimSlash(raw)) ?? existsSync(join(repoRoot, trimSlash(raw))); + + const present = listMarkdown(structureDir, structureDir); + const sotOnDisk = present.filter((p) => !p.startsWith("decisions/") && !GENERATED_DOCS.includes(p) && !RULE_DOCS.includes(p)); + const declared = manifest.docs.map((d) => d.path); + + // 1. doc map parity + for (const p of sotOnDisk) if (!declared.includes(p)) fail("structure/" + p + " is not listed in manifest.json"); + for (const p of declared) if (!sotOnDisk.includes(p)) fail("manifest.json lists structure/" + p + " but the file is missing"); + const seenDoc = new Set(); + for (const p of declared) { + if (seenDoc.has(p)) fail("manifest.json lists structure/" + p + " twice"); + seenDoc.add(p); + } + for (const doc of manifest.docs) { + if (!manifest.tiers.some((t) => t.id === doc.tier)) fail("structure/" + doc.path + " claims unknown tier " + doc.tier); + // Leading digits are rejected outright: 09_x.md and 01-x.md are the same mistake. + if (!/^(?:[a-z][a-z0-9-]*\/)?[a-z][a-z0-9-]*\.md$/.test(doc.path)) { + fail("structure/" + doc.path + " must be kebab-case, start with a letter, and sit at most one directory deep"); + } + } + + // 2. size budget + for (const doc of manifest.docs) { + const p = join(structureDir, doc.path); + if (!existsSync(p)) continue; + const count = readFileSync(p, "utf8").replace(/\n$/, "").split("\n").length; + const graced = manifest.grace.oversizeDocs.includes(doc.path); + if (count > manifest.sizeBudgetLines && !graced) { + fail("structure/" + doc.path + " is " + count + " lines, over the " + manifest.sizeBudgetLines + "-line budget; split it or add it to grace.oversizeDocs with a plan"); + } + if (count <= manifest.sizeBudgetLines && graced) { + fail("grace.oversizeDocs still lists structure/" + doc.path + ", which is now " + count + " lines; drop the grace entry"); + } + } + for (const p of manifest.grace.oversizeDocs) { + if (!declared.includes(p)) fail("grace.oversizeDocs names structure/" + p + ", which is not a declared doc"); + } + + // 3. links, anchors, repository paths, and the inline-decision ban + const linkRe = /\]\(([^)\s]+)\)/g; + const pathRe = new RegExp(BT + "((?:" + REPO_ROOTS.join("|") + ")/[A-Za-z0-9_.@/-]*)" + BT, "g"); + const anchorCache = new Map>(); + for (const rel of present) { + const abs = join(structureDir, rel); + const raw = readFileSync(abs, "utf8"); + const body = withoutFences(raw); + const isRecord = rel.startsWith("decisions/"); + for (const line of body.split("\n")) { + if (/\[decision log\]/i.test(line) || /^-\s*목적과 의도\s*:/.test(line.trim())) { + if (!isRecord) fail("structure/" + rel + " carries inline decision-log reasoning; move it to decisions/ and link the record"); + } + } + let m: RegExpExecArray | null; + linkRe.lastIndex = 0; + while ((m = linkRe.exec(body))) { + const target = m[1]; + if (/^(?:https?|mailto):/.test(target) || target.startsWith("#")) continue; + const [file, fragment] = target.split("#"); + const resolved = resolve(dirname(abs), file); + if (!existsSync(resolved)) { + fail("structure/" + rel + " links " + target + ", which does not exist"); + continue; + } + if (fragment && resolved.endsWith(".md")) { + if (!anchorCache.has(resolved)) anchorCache.set(resolved, headingAnchors(readFileSync(resolved, "utf8"))); + if (!anchorCache.get(resolved)!.has(fragment)) { + fail("structure/" + rel + " links " + target + ", but that heading anchor does not exist"); + } + } + } + // A decision record describes a PAST state, so its prose is not held against the present tree. + if (isRecord) continue; + pathRe.lastIndex = 0; + while ((m = pathRe.exec(body))) { + const named = trimSlash(m[1]); + if (manifest.generatedPaths.some((g) => named === trimSlash(g) || named.startsWith(trimSlash(g) + "/"))) continue; + if (manifest.absentPaths.some((a) => trimSlash(a.path) === named)) continue; + if (manifest.grace.staleRefs.map(trimSlash).includes(named)) continue; + const verdict = pathIsReal(named); + if (verdict === "missing") fail("structure/" + rel + " names " + named + ", which this tree does not have"); + else if (verdict !== "ok") fail("structure/" + rel + " names " + named + ", but the tracked path is " + verdict); + } + } + for (const stale of manifest.grace.staleRefs) { + if (pathIsReal(stale) === "ok") fail("grace.staleRefs still lists " + stale + ", which now exists; drop the grace entry"); + } + for (const absent of manifest.absentPaths) { + if (isTracked(absent.path)) fail(absent.path + " is declared absent in manifest.json but is tracked; the docs describing its absence are wrong"); + } + + // 4. decision records + const adrFiles = present.filter((p) => p.startsWith("decisions/")); + const referenced = new Map>(); + for (const doc of manifest.docs) { + const abs = join(structureDir, doc.path); + if (!existsSync(abs)) continue; + for (const hit of readFileSync(abs, "utf8").match(/decisions\/ADR-[0-9]{4}-[a-z0-9-]*\.md/g) ?? []) { + const key = "decisions/" + hit.split("/")[1]; + referenced.set(key, (referenced.get(key) ?? new Set()).add(doc.path)); + } + } + const ids = new Set(); + for (const adr of adrFiles) { + const name = adr.slice("decisions/".length); + const match = /^ADR-([0-9]{4})-[a-z0-9-]+\.md$/.exec(name); + if (!match) { + fail("structure/" + adr + " does not match ADR-NNNN-slug.md"); + continue; + } + if (ids.has(match[1])) fail("decision record number " + match[1] + " is used twice"); + ids.add(match[1]); + const owners = [...(referenced.get(adr) ?? new Set())]; + if (owners.length === 0) fail("structure/" + adr + " is not linked from any doc; every record needs a contract owner"); + if (owners.length > 1) fail("structure/" + adr + " is linked from " + owners.join(" and ") + "; a record has one owner"); + } + for (const key of referenced.keys()) if (!adrFiles.includes(key)) fail("a doc links structure/" + key + ", which does not exist"); + + // 5. invariant-to-test bindings + const overviewPath = join(structureDir, "overview.md"); + if (existsSync(overviewPath)) { + const body = readFileSync(overviewPath, "utf8"); + const blocks: { id: string; text: string }[] = []; + let current: { id: string; text: string } | null = null; + for (const line of body.split("\n")) { + const start = /^-\s+\*\*(INV-[A-Z0-9-]+)\*\*/.exec(line); + if (start) { + if (current) blocks.push(current); + current = { id: start[1], text: line }; + } else if (/^-\s/.test(line)) { + if (current) blocks.push(current); + current = null; + } else if (current) current.text += "\n" + line; + } + if (current) blocks.push(current); + if (blocks.length === 0) fail("overview.md declares no invariants; the invariant index cannot be empty"); + const seen = new Set(); + const unbound = new Map(manifest.grace.unboundInvariants.map((u) => [u.id, u.reason])); + for (const block of blocks) { + if (seen.has(block.id)) fail("overview.md declares " + block.id + " twice"); + seen.add(block.id); + const bound = new RegExp("Enforced by " + BT + "([^" + BT + "]+)" + BT).exec(block.text); + if (!bound) { + if (!unbound.has(block.id)) { + fail(block.id + " has no Enforced by binding and is not recorded in grace.unboundInvariants with a reason"); + } + continue; + } + if (unbound.has(block.id)) { + fail(block.id + " is bound to a test and also listed in grace.unboundInvariants; drop the grace entry"); + continue; + } + const test = bound[1]; + if (!/^tests\/.+\.test\.ts$/.test(test)) { + fail(block.id + " names " + test + ", which is not a tests/**.test.ts file"); + continue; + } + if (pathIsReal(test) !== "ok") { + fail(block.id + " names " + test + ", which this tree does not have"); + continue; + } + const source = readFileSync(join(repoRoot, test), "utf8"); + if (!new RegExp(block.id + "(?![A-Z0-9-])").test(source)) { + fail(test + " does not name " + block.id + "; the binding has to be readable from the test side too"); + } + } + for (const id of unbound.keys()) { + if (!seen.has(id)) fail("grace.unboundInvariants names " + id + ", which overview.md does not declare"); + } + } + + // 6. source-to-doc map + const described = new Map(); + for (const doc of manifest.docs) { + const own = new Set(); + for (const area of doc.documents) { + if (own.has(area)) fail("structure/" + doc.path + " lists " + area + " twice"); + own.add(area); + described.set(area, [...(described.get(area) ?? []), doc.path]); + const verdict = pathIsReal(area); + if (verdict === "missing") fail("structure/" + doc.path + " claims " + area + ", which this tree does not have"); + else if (verdict !== "ok") fail("structure/" + doc.path + " claims " + area + ", but the tracked path is " + verdict); + } + } + const graced = new Map(manifest.grace.undocumentedSourceAreas.map((g) => [g.path, g.reason])); + for (const g of graced.keys()) { + if (pathIsReal(g) !== "ok") fail("grace.undocumentedSourceAreas lists " + g + ", which this tree does not have"); + if (described.has(g)) fail(g + " is both described and listed as undescribed"); + } + const srcDir = join(repoRoot, "src"); + if (existsSync(srcDir)) { + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + const area = entry.isDirectory() ? "src/" + entry.name + "/" : "src/" + entry.name; + if (!entry.isDirectory() && !entry.name.endsWith(".ts")) continue; + // A claim on one file inside a directory does not cover the directory. + if (described.has(area) || graced.has(area)) continue; + fail(area + " is described by no doc; add it to a doc's " + BT + "documents" + BT + " list or record it in grace.undocumentedSourceAreas with a reason"); + } + } + + // 7. generated index parity + const indexPath = join(structureDir, "INDEX.md"); + const expected = renderIndex(manifest); + if (!existsSync(indexPath)) fail("structure/INDEX.md is missing; run bun run structure:index"); + else if (readFileSync(indexPath, "utf8").replace(/\r\n/g, "\n") !== expected) { + fail("structure/INDEX.md drifted from manifest.json; run bun run structure:index"); + } + + return failures; +} + +if (import.meta.main) { + const repoRoot = resolve(import.meta.dir, ".."); + if (process.argv.includes("--fix")) { + const manifest = JSON.parse(readFileSync(join(repoRoot, "structure/manifest.json"), "utf8")) as Manifest; + writeFileSync(join(repoRoot, "structure/INDEX.md"), renderIndex(manifest), "utf8"); + console.log("wrote structure/INDEX.md"); + } + const failures = runStructureChecks(repoRoot); + if (failures.length === 0) { + console.log("structure/ SSOT checks passed"); + process.exit(0); + } + for (const f of failures) console.error(" - " + f); + console.error(failures.length + " structure/ SSOT failure(s)"); + process.exit(1); +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f83a0a8f49..7b6e068d60 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1180,6 +1180,7 @@ "sidecar-settings-web-search-stream.test.ts": "vision", "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", + "structure-ssot.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", "sponsor-presets.test.ts": "providers", "sse-client-frame-bounds.test.ts": "responses", diff --git a/src/AGENTS.md b/src/AGENTS.md index 9347a655fe..69b2a703fa 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -8,7 +8,7 @@ This file applies to `src/` and inherits the repository-wide rules in `/AGENTS.m - Do not assume a separate server compilation step. - Prefer Bun and Web-platform APIs. Introduce a Node-only runtime dependency only when the task explicitly requires compatibility code and the owning module already has that role. - Preserve existing public exports and configuration compatibility unless the task explicitly changes them. -- Read the applicable documents in `structure/` before changing shared routing, adapters, transports, sidecars, authentication, configuration, or server architecture. +- Read the applicable documents in `structure/` before changing shared routing, adapters, transports, sidecars, authentication, configuration, or server architecture. [`structure/INDEX.md`](../structure/INDEX.md) maps each source area to its owning doc, and that doc is updated in the same change that changes the area. ## Implementation rules diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9e1b46307e..7fcc88ba59 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -109,7 +109,7 @@ function stripAntigravityRejectedClaudeSdkParagraph(systemText: string): string * Unknown ids return `undefined` deliberately. An earlier revision returned a 16,384 floor for * anything unmatched, which silently truncated aliases, gateway ids, and any model added after * this table was written — the operator asked for N tokens and got 16,384 with no signal. A cap - * we cannot justify is worse than no cap: `structure/02_config-and-codex-home.md` is explicit + * we cannot justify is worse than no cap: `structure/config.md` is explicit * that an explicit request value wins, so an unrecognized model passes through untouched and the * upstream remains the authority on its own limit. * diff --git a/src/lib/local-destinations.ts b/src/lib/local-destinations.ts index 56e9b3c056..d4699754d1 100644 --- a/src/lib/local-destinations.ts +++ b/src/lib/local-destinations.ts @@ -7,7 +7,7 @@ * 1. `localManagementOrigin` — authenticated management discovery/state (`/api/*`). It is * served by the public listener and, on a hub, additionally by the loopback-only * `hub.managementIngress`. Callers must still send a management credential: management - * authentication has no loopback bypass (structure/05), and the unauthenticated loopback + * authentication has no loopback bypass (structure/gui-and-management-api.md), and the unauthenticated loopback * listener deliberately does not serve `/api/*` at all. * 2. `localInferenceDestination` — the data plane a client wire actually speaks. * diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md deleted file mode 100644 index d7aa214a43..0000000000 --- a/structure/02_config-and-codex-home.md +++ /dev/null @@ -1,514 +0,0 @@ -# Config And Codex Home SOT - -## Codex home - -`src/codex/paths.ts` resolves Codex state from `CODEX_HOME` when set and valid, otherwise from -`~/.codex`. An unset `CODEX_HOME` falls back to `~/.codex`, including WSL discovery. An explicitly -set path that is unreadable or not a directory is an error, not a fallback: silently using a -different home than the operator named would write provider state where nobody is looking for it. -The managed files are: - -```text -$CODEX_HOME/config.toml -$CODEX_HOME/opencodex.config.toml -$CODEX_HOME/opencodex-catalog.json -$CODEX_HOME/opencodex-journal.json -$CODEX_HOME/models_cache.json -$CODEX_HOME/.opencodex-native-main-profiles/ -``` - -Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on -the resolved `CODEX_HOME`. - -Journal restoration compares config and profile independently against their saved originals and -recorded injected hashes. If either changed artifact lacks its injected hash, the config/profile -pair and journal remain untouched and the result is explicitly unverified; callers must not -convert that refusal into successful fallback cleanup. Already-original bytes need no rewrite, -and absence is distinct from an empty file. The injector checks a retained hashless journal against -the same `baselineContent` it snapshots, plus the current profile, before writing or assigning a new -injected hash. Native content can establish a fresh snapshot; routed content cannot promote an -unverified older original. Existing hash-backed edit preservation and external-provider opt-out -remain separate paths. - -The source-built Docker image explicitly keeps `CODEX_HOME=/home/bun/.codex` separate -from `OPENCODEX_HOME=/home/bun/.opencodex`. Compose persists them in `codex-state` and -`ocx-state` respectively, retaining a read-only root. The image creates owner-only -writable homes for `bun`; existing volume ownership and permissions are not repaired. -The catalog resolver is unchanged; a writable empty home is not a materialized catalog. - -[Decision Log] -- 목적과 의도: Make the container's catalog location persistent and writable without changing native home semantics. -- 기존 구현 및 제약 조건: Compose persisted only the OCX home, leaving Codex state on a read-only root; both products use incompatible auth.json formats. -- 검토한 주요 대안: Merge the homes, nest Codex under an existing volume with a new startup initializer, or persist the existing separate Codex home. -- 선택한 방식: Add a separate codex-state volume and create both owner-only directories in the image. -- 다른 대안 대신 이 방식을 선택한 이유: It preserves existing paths, avoids credential-file collisions, and works when an older ocx-state volume hides the image's seeded directory tree. -- 장점, 단점 및 영향: Two volumes must be backed up, but no automatic credential migration or runtime resolver change is needed. Catalog import/materialization remains an explicit prerequisite. - -`docker compose down` retains both volumes. `docker compose down --volumes` deletes -both `ocx-state` and `codex-state`, including their credentials and catalog/state; -treat it as destructive, not as an upgrade or restart command. - -Service install-state ownership uses this same resolver. In WSL, an unset `CODEX_HOME` may resolve -to the single discoverable Windows Desktop home; recording Linux `~/.codex` instead would make a -later repair or uninstall look foreign even though the service and runtime were started from the -same environment. An explicit `CODEX_HOME` remains authoritative, and existing foreign ownership -records are never migrated implicitly. - -[Decision Log] -- 목적과 의도: Keep service ownership metadata aligned with the Codex home the proxy actually uses. -- 기존 구현 및 제약 조건: The runtime performed narrow WSL Windows-home discovery, while service state used `CODEX_HOME || ~/.codex`. -- 검토한 주요 대안: Bake `CODEX_HOME` into every service, migrate old state automatically, or reuse the runtime resolver. -- 선택한 방식: Resolve service install and comparison state through the existing runtime Codex-home resolver. -- 다른 대안 대신 이 방식을 선택한 이유: It preserves explicit overrides and the existing WSL ambiguity rules without rewriting user environment or foreign state. -- 장점, 단점 및 영향: New installs and same-environment repairs agree with runtime targeting; genuinely foreign or ambiguous state remains fail-closed. - -SQLite-backed thread state may live outside `CODEX_HOME`. The one resolver in `src/codex/paths.ts` -uses Codex's precedence: root `sqlite_home` in the effective `config.toml`, then -`CODEX_SQLITE_HOME`, then the effective `CODEX_HOME`; relative SQLite homes resolve from the current -working directory. History jobs resolve the database and its hashed backup identity together at -call time, and admission/residue checks consume the same database path. Storage retention still -owns the Codex-home tree separately and does not gain deletion authority over an external SQLite -root from this resolver alone. Durable service launchers preserve an explicitly supplied -`CODEX_SQLITE_HOME` so a background service resolves the same split state as the installing shell. -An absent `config.toml` or absent root `sqlite_home` permits the environment/home fallback. Any -other read failure, malformed TOML, wrong-typed or blank `sqlite_home` is indeterminate and fails -closed so history code cannot select a different database by accident. This strict parse is scoped -to SQLite ownership; the tolerant root-string helper used by injection and catalog reads is unchanged. - -[Decision Log] -- 목적과 의도: Make every history safety check and mutation address the SQLite database Codex actually opened. -- 기존 구현 및 제약 조건: History code rebuilt `CODEX_HOME/state_5.sqlite`, while Codex supports a config or environment-selected SQLite root for split Windows/WSL layouts. -- 검토한 주요 대안: Copy the database into CODEX_HOME, teach only the writer about the override, or centralize the call-time target. -- 선택한 방식: Add one Codex-compatible SQLite resolver, fail closed when its authoritative config is unreadable or its present `sqlite_home` cannot be parsed as a non-empty string, and share it across history jobs, provider defaults, admission, and residue classification. -- 다른 대안 대신 이 방식을 선택한 이유: A writer-only override would let ownership checks authorize one database while the mutation touched another. -- 장점, 단점 및 영향: Split-home history remains correct and backup identities stay database-specific; storage cleanup of an external root remains out of scope. - -Native-main profile ownership is bound to the real `CODEX_HOME`, not to an OpenCodex instance. -Its encrypted vault, transaction journal, recovery marker, and referenced quarantine files live in -the owner-only `.opencodex-native-main-profiles` directory. The unchanged -`.opencodex-native-profile.lock.sqlite` beside that directory serializes every process sharing the -home. Only plaintext login staging is instance-local under -`$OPENCODEX_HOME/native-main-profile-staging`; a stage from one instance is invalid in another. -These paths and the OS keyring are owner-only: the operating-system account that owns them is the -trust boundary and already has direct access to active native credentials. OpenCodex detects and -fails closed on file identities that change during an operation, but it does not claim isolation -from a malicious process already running as that same trusted OS account. - -Startup and the periodic stage cleaner do not acquire the profile transaction lock when both the -stage registry and this instance's staging tree are proven absent. This keeps an unused profile -subsystem from fencing native traffic or creating lock contention. Presence, an unsafe entry type, -or any observation error still takes the locked sweep and fails closed; the fast path is based only -on proven absence, never on an unreadable path. - -[Decision Log] -- 목적과 의도: Keep zero-profile and zero-stage installations out of the native-profile transaction path without weakening staged-credential cleanup. -- 기존 구현 및 제약 조건: Every live server swept stages at startup and every minute, and a failed sweep closed the global native-main gate even when no stage artifact existed. -- 검토한 주요 대안: Disable native-main ownership entirely when the vault is empty, add a stale-lock deletion command, or skip only the stage sweep when both artifact paths are absent. -- 선택한 방식: Preserve owner and claim protection, but bypass `sweepStages()` only after proving the registry and staging tree are both absent. -- 다른 대안 대신 이 방식을 선택한 이유: Physical credential ownership remains cross-process safe, while an inert optional subsystem can no longer create the reported lock/recovery catch-22. -- 장점, 단점 및 영향: Fresh installs avoid the SQLite profile lock; any present or uncertain stage state retains the existing locked fail-closed cleanup and recovery behavior. - -The native-write coordinator is keyed by the canonical `CODEX_HOME` in the effective-user runtime -namespace. A pathname alone is not authority: SQLite can expose a zero-byte file before its first -schema write, and a terminated process can leave that remnant behind. Eligibility treats the file -as non-authoritative only after an immutable SQLite read proves version zero with no tables, the -filesystem identity remains unchanged, and the file has been settled for at least one second; a -fresh zero-byte creator stays on the coordinated path so its lock cannot be bypassed. `ocx doctor` inspects the -coordinator with immutable read-only SQLite flags so diagnosis never creates WAL/SHM sidecars. It -distinguishes absent, zero-byte, unversioned, rowless, valid, unsupported, changed, unsafe, and -unreadable states and prints the exact path. Explicit recovery is available only after the proxy is -stopped and only for a proven zero-byte state. The command revalidates the same private -regular-file identity under a non-blocking SQLite write lock and moves it to a same-directory -backup; it never deletes or auto-adopts legacy routed residue. - -[Decision Log] -- 목적과 의도: Recover a crashed zero-byte coordinator without mistaking SQLite's normal creation window for stale authority. -- 기존 구현 및 제약 조건: Eligibility treated every existing pathname as coordinated, while initialization correctly refused a missing row over routed residue; catalog sync could therefore succeed before config injection failed permanently. -- 검토한 주요 대안: Delete zero-byte files automatically, initialize a new row over residue, require a manual filesystem command, or add observe-only classification plus explicit guarded quarantine. -- 선택한 방식: Treat only a settled, identity-stable, immutably verified zero-byte database like the existing legacy-uncoordinated boundary; keep fresh creators coordinated, diagnose all other database states immutably, and expose an opt-in zero-byte-only same-directory backup move with identity, ownership, sidecar, liveness, and SQLite-lock checks. -- 다른 대안 대신 이 방식을 선택한 이유: Automatic deletion or adoption can race a live creator or erase transition evidence; a guarded backup preserves evidence and makes the operator action reproducible. -- 장점, 단점 및 영향: A stale zero-byte file no longer wedges sync, valid/unrecognized databases remain fail-closed, and recovery requires the proxy to be stopped before `ocx sync` retries injection. - -OpenCodex never overrides an explicit `CODEX_HOME`. On Windows, `ocx doctor` and `ocx status` -nevertheless diagnose the high-confidence Orca dual-home case: both `CODEX_HOME` and -`ORCA_CODEX_HOME` select Orca's `orca/codex-runtime-home/home`, while the ChatGPT/Codex app uses the -default `%USERPROFILE%\\.codex`. Sync and restore output always prints the exact target Codex home; -display and JSON paths redact the OS username. The diagnostic tells users to invoke OpenCodex with -the app home explicitly rather than silently claiming that an unrelated app was configured. If a -service was installed under the Orca home, it must first be uninstalled from that original Orca -environment and then reinstalled under the app home; changing only the current shell cannot migrate -the recorded service ownership. - -[Decision Log] -- 목적과 의도: Make multi-home injection truthful without taking ownership of user environment variables. -- 기존 구현 및 제약 조건: CODEX_HOME is an intentional override, but Orca exports it for its own bundled runtime and the Windows app reads a different home. -- 검토한 주요 대안: Rewrite CODEX_HOME automatically, warn for every custom home, or detect only the Orca-owned signature and report the target path. -- 선택한 방식: Preserve the override, add a narrow Windows/Orca diagnostic, and qualify sync/restore success output with the effective home. -- 다른 대안 대신 이 방식을 선택한 이유: It fixes the silent failure while avoiding destructive or noisy behavior for intentional custom homes. -- 장점, 단점 및 영향: Orca users get an actionable warning; other multi-home products remain unchanged until they have an equally reliable signature. - -`atomicWriteFile` uses a temp file named `{path}.ocx.{pid}.{seq}.tmp` (process ID + incrementing -sequence number) to avoid collisions when concurrent writers (e.g. `ocx stop` and the proxy's own -shutdown handler) both restore Codex config simultaneously. The temp is renamed atomically into place. -Storage cleanup run metadata uses the field-scoped persisted-config mutation path, so a background -Worker cannot restore unrelated API keys or provider settings from a snapshot read before the lock. -If that metadata write is unavailable after cleanup has already completed, the job retains the -cleanup outcome and exposes a bounded persistence error instead of relabeling the run as a Worker failure. - -Cleanup manifests and satellite backups share the stage-local atomic publisher: an exclusive -private temporary file is fully written and file-synced before the existing Windows-tolerant -rename replaces the destination. Handled publication failures retain the previous record; -directory syncing remains best-effort. This does not make a partial permanent purge reversible: -restore still fails closed when a recorded logical entry has no surviving file. - -Windows secret-file hardening resolves the effective token SID through an absolute, trusted -PowerShell path before granting the owner and removing inherited broad ACL entries. The normal -path obtains System32 from `GetSystemDirectoryW`. Windows ARM64 Bun builds that cannot execute -`bun:ffi` use a narrower ACL-only fallback to the fixed protected default installation path -`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`. The fallback never applies to UAC or -Task Scheduler launch, never consults environment variables or `PATH`, and fails closed when the -fixed executable is absent. - -Direct PowerShell children rely on the process launcher's `windowsHide`/hidden-host mechanism and -must not also receive the PowerShell CLI pair `-WindowStyle Hidden`. On affected Windows 11 systems, -Bun 1.3.14 exits that direct invocation before the command runs, which turns a valid SID or process -lookup into `EACLIDENTITY` or a failed sync. This does not apply to `Start-Process -WindowStyle -Hidden` inside an already-running PowerShell script, nor to .NET/VBS process-window settings. - -[Decision Log] -- 목적과 의도: keep trusted Windows identity and process probes console-less without triggering Bun's direct PowerShell `-WindowStyle Hidden` failure. -- 기존 구현 및 제약 조건: the calls already used `windowsHide: true` or a hidden VBS host, but redundantly passed PowerShell's window-style CLI option; the same option remains valid inside `Start-Process` and must not be removed there. -- 검토한 주요 대안: decode the generic failure specially, retry after failure, remove all hidden-window controls, or remove only the redundant direct CLI pair. -- 선택한 방식: retain trusted executable resolution, non-interactive flags, timeouts, and process-level hiding; remove `-WindowStyle Hidden` only from direct PowerShell argv. -- 다른 대안 대신 이 방식을 선택한 이유: the command executes on affected Bun/Windows combinations, no console window is introduced, and working elevated/detached child-process behavior stays unchanged. -- 장점, 단점 및 영향: SID, process-owner, tray, update, and sync probes share the compatible launch contract; a future call must use launcher-level hiding rather than reintroducing the PowerShell CLI pair. - -[Decision Log] -- 목적과 의도: Preserve required Windows ACL hardening on the bundled Windows ARM64 runtime without weakening executable trust. -- 기존 구현 및 제약 조건: The effective-SID query depended on the shared `GetSystemDirectoryW` FFI resolver; Bun 1.3.14 Windows ARM64 has no working `bun:ffi`, so config mutation reached `EACLIDENTITY` before PowerShell could start. -- 검토한 주요 대안: Restore `USERDOMAIN\\USERNAME`; trust `SystemRoot`, `WINDIR`, or `PATH`; weaken required ACL writes; broaden the shared elevation resolver; or add a fixed-path fallback only for the non-elevated SID query. -- 선택한 방식: Keep FFI authoritative, then allow only Windows ARM64 to use the existing default `C:\Windows\System32` PowerShell binary for the SID query when that exact file exists. -- 다른 대안 대신 이 방식을 선택한 이유: Names and environment paths are caller-controlled, required secret writes must not silently skip ACLs, and elevation has a larger authority boundary that should remain FFI-only. -- 장점, 단점 및 영향: Default Windows ARM64 installations can start and harden secrets; non-default Windows roots continue to fail closed until Bun exposes a trustworthy native system-directory API without FFI. - -The durable response-spill directory `~/.opencodex/responses-state-spill/` is bounded in -aggregate, not only per file. Continuation state demoted out of the in-memory cap -(`MAX_STORED_RESPONSE_BYTES`) is written there, and eviction past -`MAX_SPILLED_RESPONSE_BYTES` removes oldest-first through the same deletion point that serves -TTL and count eviction, so an evicted entry unlinks its file. One function owns that ceiling and -three callers drive it: mutation pruning, the lazy load that follows a restart, and the periodic -sweep. The periodic caller is not redundant — the mutation path runs only when traffic arrives, so a -process that comes up over budget from a snapshot written under a larger ceiling would otherwise -stay over it while idle. - -The ceiling bounds what the store can account for, which is every entry in the map plus the -superseded generations queued for unlink, and deliberately not the directory as a whole. Spill files -orphaned by a crash are absent from the map, so this accounting can neither see nor price them; they -remain with the `recoverOrphanedResponseSpills` grace sweep described below, which is the only -mechanism that reclaims them. A host that crashes repeatedly can therefore hold spill bytes above -this ceiling for up to `RESPONSE_SPILL_ORPHAN_GRACE_MS` past each crash. Without that aggregate bound the -directory was limited only per file (256 MiB) and per entry (1000) — a 250 GiB product — which -left `RESPONSE_TTL_MS` as the only effective limit and made disk use a function of client -request rate rather than of anything the process controls. - -[Decision Log] -- 목적과 의도: Bound the durable spill directory in aggregate so demoted continuation state cannot consume the host disk. -- 기존 구현 및 제약 조건: The resident map has an unconditional byte cap and demotes past it, but the disk it demotes onto had only a per-file ceiling and the shared 1000-entry count cap. Retention itself worked — the hour-long TTL did evict — so the gap was a missing budget, not a leak. -- 검토한 주요 대안: Lower the per-file ceiling; shorten the TTL; sweep the directory on a timer; add a configurable budget key; carry a running byte counter. -- 선택한 방식: A constant aggregate ceiling checked at the end of the existing prune, evicting oldest-first, with the total recomputed per prune rather than carried as a counter. -- 다른 대안 대신 이 방식을 선택한 이유: Per-file or TTL changes alter retention semantics other bounds depend on; a timer adds a second owner for eviction; a config key would surface a knob the sibling bounds (count, TTL, per-file) do not have; and a running counter could silently disable the cap if any of the several insertion paths missed an increment, where a walk over at most 1000 entries cannot drift. -- 장점, 단점 및 영향: Disk use stops tracking client request rate. Ordinary traffic is unaffected because the count cap binds at a comparable point for median-sized payloads; a workload of unusually large continuations loses its oldest spills earlier than the TTL would, surfacing as the existing `previous_response_not_found` continuation miss. - -Response-state loading performs a bounded recovery pass for interrupted snapshot writes. It only -matches regular files named `responses-state.json.ocx...tmp`, waits at least 15 -minutes, and skips the current or any live PID. Eligible files are truncated before unlinking so a -matching stale path is unlinked without following it. Path-based truncation is intentionally avoided: -a same-user replacement could otherwise turn cleanup into a write through a symlink. Unrelated -temporary files, symlinks, directories, and young/active writes are never touched; directory entries -are consumed incrementally and at most 512 stale files are attempted per process start. - -[Decision Log] -- 목적과 의도: Bound disk and conversation-state retention after abrupt process termination. -- 기존 구현 및 제약 조건: Ordinary write failures clean up immediately, but a killed process cannot run that path and Windows may temporarily lock files. -- 검토한 주요 대안: Delete every `.tmp`, rely on manual cleanup, or recover only exact response-state remnants with age and PID guards. -- 선택한 방식: Run a capped, best-effort, unlink-only sweep on lazy response-state startup. -- 다른 대안 대신 이 방식을 선택한 이유: It repairs known remnants without broad authority over unrelated temp files or active writers. -- 장점, 단점 및 영향: Old dead-PID files are reclaimed automatically; locked or conservatively classified files remain for a later retry. - -Windows runtime response spills never wait on `icacls` through `Bun.spawnSync`. Linux and macOS -retain the immediate synchronous publication path. On Windows, the resident continuation enters one -serialized publication queue and remains replayable while `hardenSecretDirAsync` and -`hardenSecretPathAsync` run. Publication installs a spill stub only when the map still contains the -same resident object; a superseded job deletes its newly published file instead of overwriting newer -state. Pending payloads are pinned and capped at 256 MiB, so an ACL outage cannot grow an unbounded -queue or be misreported as evictable memory. One caller-owned retry is allowed after a real -`ETIMEDOUT`; the first timeout does not install a `spill-failed` tombstone. Required ACL failures -remain fail-closed after that bounded recovery. Optional config-directory hardening uses a separate -per-directory async single-flight, while required config mutation writers retain their existing -awaited or synchronous fail-closed boundary. - -Each ordinary async spill write attempt owns one 30-second ACL budget shared across directory, temp, -and exclusive-copy destination hardening; the single timeout retry receives one fresh whole-attempt -budget. No harden step may reopen an independent 30-second window inside either attempt. -Both icacls and effective-principal subprocess waits are settlement-bounded: at deadline the child is -killed, unref'd, and abandoned without awaiting `proc.exited`. The caller-level deadline also bounds -injected/shared runners, so a child that ignores termination cannot pin the serialized spill queue. - -Graceful shutdown drains that serialized publication queue to a stable fixed point before snapshot -serialization. The drain has a wall-clock cap with a reserved synchronous fallback budget; expiry -supersedes the async writer, claims and removes any temp or destination it still owns, and only then -starts fallback publication. The writer rechecks supersession before no-replace publication, while -the fallback splits its reserve across the directory and file ACL hardens. This ordering is -load-bearing because resident entries over 2 MiB are deliberately excluded from -`responses-state.json`: serializing first could omit the resident before its durable spill stub -exists, losing the continuation on restart. Cleanup is attempted for every abandoned writer; any -failure is retained while fallback and snapshot persistence continue, then returned through the -shutdown status so process exit is non-zero without sacrificing unrelated replay state. -If the fallback reserve expires, every remaining resident candidate is terminalized as a bounded -`spill-failed` tombstone before pruning, so no payload remains eligible for shutdown requeue and the -snapshot flush always regains control. -The terminalization pass itself is hard-capped at `MAX_STORED_RESPONSES + 1`; exceeding that -structural bound records a bounded failure, fail-closes every remaining resident, and returns control -to snapshot persistence instead of relying on the progress argument alone. - -[Decision Log] -- 목적과 의도: Keep `/healthz` and unrelated requests responsive during intermittent Windows ACL stalls without publishing an unhardened continuation. -- 기존 구현 및 제약 조건: Response demotion called the synchronous spill writer from request-time state mutations; `Bun.spawnSync(icacls)` could block the only Bun event loop for the full timeout and immediately replace replayable state with a tombstone. -- 검토한 주요 대안: Increase the ACL timeout, weaken required ACL checks, publish before hardening, move every platform to async state mutation, or isolate only the Windows ACL-dependent publication boundary. -- 선택한 방식: Preserve non-Windows behavior; serialize Windows publications through async ACL APIs, retain the exact resident generation until compare-before-swap succeeds, cap pending bytes, and retry one proven timeout. -- 다른 대안 대신 이 방식을 선택한 이유: Longer waits worsen liveness, early publication weakens secret-file ACLs, and a cross-platform async rewrite would disturb mature immediate memory and crash-ordering contracts that do not cause this incident. -- 장점, 단점 및 영향: Windows health stays schedulable and transient ACL stalls retain continuation replay; pending payloads can temporarily exceed the 64 MiB resident target but are pinned under a 256 MiB local ceiling and remain inside the documented 512 MiB process-owned worst case. - -## Config surface - -### OpenCodex home and live process state - -`initializePersistedConfigIfMissing` in `src/config.ts` is the create-only path consumed by -`src/cli/init.ts`. It rechecks absence under the existing config-mutation lock and publishes through -`src/config/initialize.ts`: a private descriptor is hardened before secret bytes are written, then -linked without replacing an occupied destination. Existing invalid or unsafe entries are preserved. -The initializer never truncates a staged inode or rolls back by unlinking the destination; cleanup -only removes its own temporary name. Unsupported/denied links and incomplete cleanup fail explicitly, -and publication followed by a later failure can leave a complete config or private residue. Ordinary -`saveConfig` replacement behavior remains unchanged. This protects init-time config bytes, not a -foreign winner's ownership under future uninstall; the existing ownership manifest and global CLI -shim preflight keep their separate contracts. - -Initial publication diagnostics distinguish required permission-hardening failures from denied -hard-link publication without exposing raw filesystem causes. Both identify `OPENCODEX_HOME` -as the supported-location recovery path; uncertain publication and cleanup warnings remain in -the CLI. The quickstart documents inspection before retry, private-permission requirements, -and fresh-location examples. Diagnostics do not introduce a fallback or alter file I/O ordering. - -`src/config/paths.ts` is the single owner of `OPENCODEX_HOME` expansion and resolution. It exposes -the config directory and `config.json` path and retains the existing cache rule: a relative home is -resolved once for each distinct raw environment value, so a later working-directory change cannot -silently move the active installation. - -`src/config/process-state.ts` derives `ocx.pid` and `runtime-port.json` from that resolved directory. -It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command -identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` remains optional, -owner-only state and is validated before a record is returned. `src/config.ts` re-exports the same -symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. - -Replacing config and process-state writes use `src/config/atomic-write.ts`. The leaf preserves the shared -process-wide temp sequence, symlink target resolution, real-home test guard, owner manifest, -Windows ACL hardening, scrub-before-unlink failure path, and explicit residual-temp errors. A caller -must not replace it with a local temp-and-rename shortcut. - -[Decision Log] -- 목적과 의도: Make persisted config, path resolution, atomic file publication, and live process state distinct ownership boundaries. -- 기존 구현 및 제약 조건: All four concerns lived in `src/config.ts`; process-state extraction could not safely import the facade without a cycle and could not copy the atomic writer without creating two security/correctness contracts. -- 검토한 주요 대안: Keep one file, tolerate the cycle, duplicate only PID/runtime writes, or extract the minimal dependency leaves. -- 선택한 방식: Preserve one implementation per concern under `src/config/` and keep facade re-exports for downstream compatibility. -- 다른 대안 대신 이 방식을 선택한 이유: The dependency graph stays acyclic and every existing path, serialized shape, error, identity probe, and cleanup guard remains reusable from one owner. -- 장점, 단점 및 영향: Internal lifecycle imports become narrow and testable; review must still treat changes to `atomic-write.ts` and `process-state.ts` as shared cross-platform runtime changes. - -`src/types.ts` is the shape and `src/config.ts` is the loader; neither is reproduced here. What -matters for maintainers is which groups exist and who resolves them: - -| Group | Keys | Resolution rule | -| --- | --- | --- | -| Listener | `port`, `hostname` | The listener owns the port; `runtime-port.json` reports where it actually landed. | -| Routing | `defaultProvider`, `providers`, per-provider `selectedModels` | Explicit `provider/model` wins over `defaultProvider`. | -| Catalog | `disabledModels`, `customModels`, `modelCacheTtlMs`, `providerContextCaps`, `contextCapValue`, per-provider `modelDisplayNames`, `codexAccountNamespaces`, `codexAccountPickerEnabled` | Catalog state is derived; config only records intent. Exact provider model display names are durable display only overlays. The picker flag is an explicit visibility override, while selector mappings remain the durable exact-routing contract. | -| Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. | -| Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | -| Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | -| Lifecycle | `codexAutoStart`, shim/start behavior, resume-history sync, storage cleanup | Startup safety reads these; see [`05_gui-and-management-api.md`](05_gui-and-management-api.md). | - -Env values are resolved through `src/config.ts`, so a config value naming an env var never persists -the secret itself. - -## Config injection - -`src/codex/inject.ts` writes one of two forms. The choice is not cosmetic: it decides whether Codex -keeps its native provider id, which decides whether existing thread history still resolves. - -**Loopback (default).** A single marker-owned root override, no provider table: - -```toml -model_catalog_json = "/absolute/path/to/opencodex-catalog.json" -openai_base_url = "http://127.0.0.1:10100/v1" -``` - -Codex keeps the native `openai` provider id, so new threads stay under that identity instead of -being re-tagged. History restore is manifest-authoritative: only rows whose original provider, -source, and event marker were backed up for the same state database are restored exactly. A bare -`opencodex` row is never assumed to have originated at OpenAI; it stays unchanged unless the user -explicitly runs legacy OpenAI recovery. A user-owned root `openai_base_url` is preserved instead of -overwritten, and that case also blocks managed sub-agent defaults rather than fighting the user for -ownership. - -Client-compaction mode can retain that user-owned root URL alongside an injected provider table. -Its status must distinguish ownership from destination: an unmarked user-owned line may already -point to this proxy. Report that existing `openai` threads follow the configured root URL and new -threads use the injected table, without inferring a foreign endpoint or prescribing URL removal. -This diagnostic distinction does not change URL ownership, journal entries, or session history. - -**API auth header (non-loopback).** The built-in `openai` provider cannot carry the -`x-opencodex-api-key` env header, so this form re-tags the root provider and appends the table: - -```toml -model_provider = "opencodex" -model_catalog_json = "/absolute/path/to/opencodex-catalog.json" - -[model_providers.opencodex] -name = "OpenCodex Proxy" -base_url = "http://:/v1" -wire_api = "responses" -requires_openai_auth = true -env_key = "OPENCODEX_API_AUTH_TOKEN" -``` - -Root TOML keys must be written before the first `[table]`. Re-injection strips the stale form of -both shapes — opencodex blocks, injected root base-url overrides, stale root context-window -overrides, and stale catalog paths — before rewriting, so switching between forms leaves no residue. - -Read-only doctor and project-routing diagnostics use a lightweight root/table TOML reader rather -than mutating or normalizing the user's file. That reader must lexically skip both basic and literal -multiline string bodies: instruction prose can contain key-shaped examples and `[table]` snippets, -which are data rather than configuration. Diagnostic result objects may retain the real path for -local correlation, but every formatted doctor line must pass it through the shared user-path -redaction boundary before display. - -[Decision Log] -- 목적과 의도: Keep strict-config diagnostics useful without interpreting instruction prose as TOML or exposing OS account names in shareable output. -- 기존 구현 및 제약 조건: The diagnostic reader intentionally covers only Codex root keys and tables; a full TOML dependency is not otherwise required. -- 검토한 주요 대안: Add a full TOML parser, scan raw lines for one legacy key, or preserve the lightweight parser with multiline lexical state. -- 선택한 방식: Preserve the bounded reader, skip multiline string bodies before key/table matching, and redact paths only at the formatting boundary. -- 다른 대안 대신 이 방식을 선택한 이유: All consumers keep one root/table interpretation while internal diagnostics retain actionable local paths. -- 장점, 단점 및 영향: False positives and username disclosure are removed; unsupported exotic TOML syntax remains outside this diagnostic reader's contract. - -Native Codex sub-agent defaults are a separate, explicit opt-in. When -`syncCodexSubagentDefaults` is true and `injectionModel` is set, injection writes marker-owned -`agents.default_subagent_model` and, when configured, -`agents.default_subagent_reasoning_effort`. Unmarked values are user-owned and must never be -overwritten. Disabling the option and fallback restore remove only marker-owned values; journal -restore must preserve later user edits while stripping those managed values. - -### History backup manifest contract - -`src/codex/history-manifest.ts` is the pure schema-and-identity leaf for the versioned history -backup manifest. It owns the accepted provider/source provenance tuples, platform-aware database -path identity, backup filename id, and validation from unknown JSON to a typed manifest. It does -not read files, inspect rollouts, open SQLite, retry, fingerprint, write, or delete anything. - -`history-provider.ts` remains the strict mutation owner and maps shared validation failures to its -restore/no-op integrity states. `native-residue.ts` remains a read-only observer and maps the same -result to clean, residue, or indeterminate before inspecting referenced rollout files. - -[Decision Log] -- 목적과 의도: Make restore and native-residue inspection accept and reject exactly the same versioned history provenance contract. -- 기존 구현 및 제약 조건: Both modules independently checked version, database identity, entry ids, absolute rollout paths, provider/source tuples, and event markers; drift could make one module restore a manifest that the other refused to classify. -- 검토한 주요 대안: Keep duplicate validators synchronized through review, import the mutation-heavy history provider into residue inspection, or extract a pure shared leaf. -- 선택한 방식: Extract only types, path identity, filename id, provenance, and unknown-data validation; keep all filesystem, rollout, SQLite, retry, and mutation policy in the existing callers. -- 다른 대안 대신 이 방식을 선택한 이유: A pure leaf removes schema drift without pulling write-side effects or database ownership into the read-only startup inspection graph. -- 장점, 단점 및 영향: Format changes now have one validator and shared invalid fixtures; callers still intentionally own different user-facing failure mappings, so contract changes require updating both mappings and this document. - -If the root config selects a provider other than `openai` or `opencodex`, injection must leave the -config byte-for-byte unchanged and skip profile creation/updates and history metadata restoration. External -provider managers own that routing configuration, and replacing their provider id can hide -otherwise intact Codex sessions. This ownership check must run before catalog/cache refresh, -journal creation, and the background history restoration guardian. - -`ocx sync` and `ocx restore back` run the injector's non-writing preflight before provider -discovery or catalog/cache replacement. Deterministic config and ownership refusals therefore -leave the existing catalog and cache untouched, and their concrete messages are emitted on stderr. -The real injection still revalidates under its normal write boundary after catalog convergence; -the preflight is an early no-write guard, not an authorization token for a later write. - -[Decision Log] -- 목적과 의도: Prevent a refused Codex config injection from degrading a previously usable model catalog and make the refusal actionable from the CLI. -- 기존 구현 및 제약 조건: Catalog discovery and replacement ran before injection, while the injector alone owned the authoritative TOML transforms and write-coordination eligibility checks. -- 검토한 주요 대안: Roll back catalog and cache bytes after a later refusal, duplicate a partial TOML validator in the CLI, or run the injector's existing planning path without committing before discovery. -- 선택한 방식: Add a non-writing mode to the injector and call it before catalog work; keep the normal injector call as the final under-lock authority check. -- 다른 대안 대신 이 방식을 선택한 이유: Post-hoc rollback can overwrite a concurrent catalog writer, and a second validator would drift from the real refusal rules. Reusing the injector keeps one policy path and avoids compensating writes. -- 장점, 단점 및 영향: Deterministic refusals preserve catalog/cache bytes and print their reason on stderr. A concurrent state change can still make the final injection refuse, but catalog and injection retain their existing independent revalidation and serialization boundaries. - -`supports_websockets = true` is appended to the provider table only when `websocketsEnabled(config)` -returns true. - -## Codex-home diagnostics - -Some Codex-home conditions are reported rather than repaired, because repairing them would overwrite -a deliberate user choice: - -- Bundled-plugin marketplace state on Windows (`src/codex/plugins-doctor.ts`), surfaced by - `ocx status`. -- Project-level Codex config that bypasses managed routing - (`src/codex/project-config-warnings.ts`), surfaced by `ocx doctor` as a warning rather than an - override. - -## Profile and fast tier - -When opencodex owns routing, it also writes `$CODEX_HOME/opencodex.config.toml` as an explicit profile -target. Codex config uses `service_tier = "fast"` and `[features].fast_mode = true`; -catalog/request tier metadata may use `priority`. Do not collapse these spellings into one value. - -## Provider output defaults - -`OcxProviderConfig.defaultMaxOutputTokens` and `modelMaxOutputTokens` are OpenAI Chat wire defaults, -not context-window metadata. They are applied only when a Responses request omits -`max_output_tokens`; an explicit request value wins, then a model-specific configured value, then -the provider default, then the adapter omits `max_tokens`. - -Both fields must stay positive finite integers at disk-config and management validation boundaries. -Registry entries may seed them through `providerConfigSeed`, key-login derivation, OAuth reconcile, -and `routeModel`, but user config overrides registry defaults per field/key. - -## Provider validation ownership - -`src/config/provider-validation.ts` owns the pure provider payload checks shared by persisted config, -CLI writes, and management DTO validation. `src/config.ts` imports those checks for Zod refinement -and re-exports them as a compatibility facade; it must not grow a second copy. Validation error text, -ordering, and cross-field rules are part of the write/load contract because management requests and -hand-edited `config.json` must accept and reject the same provider shapes. - -[Decision Log] -- 목적과 의도: Separate reusable provider payload validation from config file persistence without changing accepted configuration or error behavior. -- 기존 구현 및 제약 조건: The Zod schema, CLI, and management API shared helpers defined inside `src/config.ts`, so callers needing one pure check depended on the full persistence module. -- 검토한 주요 대안: Keep validation in the persistence module; duplicate checks per caller; extract one leaf and retain compatibility re-exports. -- 선택한 방식: Use one pure validation leaf, consume it from config refinement and direct DTO callers, and keep `src/config.ts` re-exports during migration. -- 다른 대안 대신 이 방식을 선택한 이유: One implementation preserves load/write parity while reducing dependency breadth and avoiding a flag-day import rewrite. -- 장점, 단점 및 영향: Validation can be characterized independently and config persistence becomes smaller; a temporary facade remains until all internal callers migrate. - -## Restore - -`ocx stop`, `ocx restore` / `ocx eject`, `ocx service stop`, and `ocx service uninstall` must strip -opencodex config and routed catalog entries without damaging native Codex state. - -Full `ocx uninstall` config cleanup is ownership-manifest based. A fresh config directory receives a -root-bound owner marker and an uninstall manifest before its first atomic config write. Uninstall -validates both bounded metadata files, rejects path traversal and a symlink/junction config root, -and removes only normalized manifest entries. Manifest-owned directory links are unlinked without -traversing their targets. Unknown files remain in place and make the command report a partial -uninstall with their exact paths. - -Legacy nonempty config directories are deliberately not retroactively claimed. If either ownership -file is missing, malformed, or bound to another root, uninstall refuses config deletion and reports -the residual directory for manual review; there is no recursive-delete fallback. - -## Remote client key files - -Client connection metadata stores a stable `apiKeyId` and a non-secret rotation `pendingOperation`. The current data secret remains only in `service-api-token`; a bounded rotation temporarily keeps the old secret in owner-only `service-api-token.prev`. Commit or recovery clears the marker before orphan cleanup. `ocx disconnect` is local-only and leaves remote revocation to the hub's **Integrations → API Keys** page. Hub and local usage stores are not mirrored. diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md deleted file mode 100644 index 9769012e1d..0000000000 --- a/structure/03_catalog-and-subagents.md +++ /dev/null @@ -1,543 +0,0 @@ -# Catalog And Subagents SOT - -## Shared catalog - -`src/codex/catalog.ts` builds a shared Codex-shaped catalog for CLI, TUI, App, and SDK. It: - -- preserves native OpenAI entries from the live catalog or static fallback, and emits - gpt-5.6 natives from the pinned upstream models.json snapshot - (`src/codex/data/upstream-models.json` — exact per-slug ladders: luna has no ultra); -- upgrades either an observed selector-qualified `*/gpt-daybreak-blue-latest` account row or an - explicitly configured canonical `openai/gpt-daybreak-blue-latest` Codex-forward row from the - pinned Sol capability metadata while preserving its selector and Daybreak wire identity; - this never expands the bare/API-key model lists or rewrites the wire model to `gpt-5.6-sol`; -- clones a native template for routed `provider/model` entries; -- forces strict Codex catalog fields required by the current parser; -- hides `disabledModels` without blocking direct routing (routed provider ids are excluded; - account-qualified native ids hide only that selector row; BARE native slugs hide the bare row - and all account-selector clones and drop that model family from raw `/v1/models`); -- applies exact provider/model compatibility exclusions after live discovery and metadata - augmentation, so upstream-advertised but uncallable rows never enter dashboard or Codex pickers; -- strips native-only service tier and WebSocket metadata unless the final routed provider/model - explicitly enables the verified OpenAI-compatible service tier; -- backs up the pristine catalog once per catalog: the copy is keyed by a hash of the catalog path - (`catalog-backup-.json`), and the legacy unsuffixed `catalog-backup.json` is retained in - addition for the default catalog, so a restore resolves the backup for the catalog it is restoring - rather than assuming a single file; -- invalidates `$CODEX_HOME/models_cache.json` when model visibility changes. - -On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's -bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies -the rows being merged. This split is required because empty or partial provider discovery must -preserve routed entries and genuine user-native rows from the file that will be overwritten; a -bundled catalog never contains those rows. Retained sync and evidence-bound convergence share an -explicit observed-state merge policy and restore native priorities from the once-only pristine -backup rather than from a catalog whose priorities may already have been rewritten. A configured -custom catalog remains the native metadata/template authority even when a bundled-catalog memo is -warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability -evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. - -Custom Astra and Daybreak rows acquire native reasoning capability only through the existing -canonical `openai` forward destination and explicit capability-source predicate. The shared -custom-row producer bounds their merged effort lists against pinned per-model Codex metadata, -preserves an explicit empty list without a default, and recovers an incompatible nonempty list -to the native default singleton. A default must belong to the projected list. Other custom rows -keep their declaration precedence; a GPT model name, display alias, or arbitrary gateway is not -native provenance. Stored configuration and native capability maps are unchanged. - -The observed-state merge tracks the current invocation's freshly generated custom row objects -after detaching its inputs. Those rows already own their complete reasoning projection, so the -merge does not append `max` again. This also keeps a generic none-only custom row none-only; -ordinary retained provider rows still receive the existing mock-tier policy. A persisted custom -marker alone never grants this exemption. Both gather entry points, retained sync, management -convergence and direct Codex model discovery use the same producer. The legacy runtime effort -union clamp remains separate; it is not a per-model or per-client-version grammar oracle. -Existing thread settings and the reported Desktop 0.153.4 gateway rejection require separate -runtime evidence. Codex's native `ultra` mode is preserved and is not a literal API wire promise. - -When account selectors are enabled, the sync path may also observe exact, visible, API-supported -OpenAI-family ids from Codex's user-owned catalog/cache. Only rows with native catalog provenance -are trusted; unknown ids are carried through startup cache invalidation as hidden observations and -are emitted only as selector-qualified rows whose account provenance matches. They never expand -the bare native or API-key model list. This keeps account-scoped upstream ids such as -`gpt-daybreak-blue-latest` callable without treating them as a static release allowlist. - -Account-gated native ids are a stricter subset. Their authenticated ChatGPT `/models` roster is -cached per credential generation with a bounded timeout. A bare gated row is emitted only when at -least one confirmed eligible account reports it; a selector-qualified row is emitted only when the -mapped account reports it. A failed or malformed discovery is not positive evidence and therefore -hides the gated row until a later refresh. The same snapshot gates Pool selection, so the catalog -and runtime cannot disagree by advertising through one account and dispatching through another. - -The app-server's model list comes from this shared catalog, not from patching the App. Codex Desktop -may still apply its remote native-only allowlist after `model/list`; an explicitly configured combo -`nativeAlias` is the bounded compatibility path. It replaces one supported bare native row with a -routed, labeled row, routes the bare id before canonical OpenAI, and keeps account-qualified native -selectors genuine. Missing target discovery capabilities inherit the replaced native row's metadata, -while explicit target limits remain authoritative. Because the affected renderer ignores `visibility: "hide"`, the presence of any -native alias also omits disabled bare native rows from the effective catalog. Dashboard rows remain -derived from the static native set, and sync retains bundled/pristine native recovery sources so a -later re-enable or alias removal restores native metadata. - -Provider live-model lists are cached with a configured TTL (`src/codex/model-cache.ts`). Adding, -deleting, or editing a provider's shape clears that per-provider cache; a disabled-only change -deliberately does not, because a disabled provider is already excluded from the catalog gather -instead. Codex's own `models_cache.json` is a different cache, invalidated by catalog refresh. - -For `liveModels: false`, a static provider publishes the ordered union of `models` and -`retainModels`. When `models` is absent or empty, its configured `defaultModel` seeds that -union before retained ids; a nonempty explicit list does not import a different default. -Without any default or configured/retained ids, the static result stays empty. The existing -forward-auth native path remains separate. Static gathering does not refresh OAuth or call -the provider's model endpoint, and normal selection and visibility filters still apply. - -The provider workspace uses the existing `/api/models` projection for displayed rows, -model identity and inventory counts. Counts cover distinct non-disabled selectors within -each provider, before search or the render cap; they are not selected-model or live-discovery -counts. The full available list and discovery provenance remain separate inputs. - -Deleting a custom definition uses its stable record id and does not also hide the underlying -model. Native or discovered metadata can therefore reappear without changing the inventory -count. Hide uses the represented row's native/routed identity and changes visibility only. -The Models page can restore existing hidden rows; adding a definition does not implicitly -clear a previous hide or provider allowlist. Actions wait for current row and custom-ownership -observations, and mutations reconcile those observations instead of retaining browser-only -removal markers. These presentation operations do not grant routing or account entitlement. - -### Windows request-path catalog-state discovery - -[Decision Log] -- 목적과 의도: Prevent Windows PowerShell/CIM process discovery from blocking Bun's event loop while v2 sub-agent guidance is assembled. -- 기존 구현 및 제약 조건: The stale-catalog check is advisory on the request path, but CLI/service lifecycle operations use the same process evidence before warning or terminating narrowly matched app-servers. -- 검토한 주요 대안: Remove stale-catalog guidance, move every platform collector into workers, or isolate only the Windows request path behind asynchronous child processes. -- 선택한 방식: Keep the synchronous fail-closed collector for explicit lifecycle operations; v2 requests use asynchronous trusted-System32 PowerShell, one identity-scoped in-flight refresh, and the existing short cache. Cache invalidation advances a generation so a pre-write CIM result cannot repopulate post-write state. -- 다른 대안 대신 이 방식을 선택한 이유: This preserves process ownership and matching invariants while preventing a slow CIM query from starving `/healthz` and unrelated proxy traffic. -- 장점, 단점 및 영향: Concurrent v2 turns do not multiply CIM walks and the event loop remains responsive. A cold request can still await the bounded advisory check, and collection failure suppresses OpenCodex-authored model guidance as `unknown`. - -## Startup readiness - -Each `startServer` invocation owns a private, one-shot readiness gate created before the listener -binds. `handleStart` supplies its gate and transitions it only after the shared catalog sync and -best-effort Claude Code roster reconciliation have both settled. The catalog sync remains the -authority for ready versus failed; a roster warning does not make an otherwise healthy proxy fail. -Calls without a supplied gate receive a fresh private gate that intentionally remains pending. Only -`ok: true` with no nonempty warning becomes ready; `null`, a throw, `ok !== true`, or a nonempty -warning becomes failed. State is isolated per server instance. - -Exact unauthenticated `GET /readyz` returns sanitized identity fields plus pending, ready, or failed: -`200` for ready, or `503` with `Retry-After: 1` for pending and terminal failed. The full CLI syntax -is `ocx ready [--json] [--wait [--timeout ]]`. The probe validates the service, version, -uptime, PID, port, status, and HTTP/status pairing. The default is one probe. With `--wait`, it -applies one absolute deadline (45 seconds by default) across discovery, readiness probes, polling, -and sleeps, but exits immediately on terminal failed. `--timeout ` requires `--wait` and -accepts positive integer seconds from 1–300. CLI `--json` emits -`{ready, status, pid, port}`, with status in `ready|pending|failed|unreachable`. Exit 0 means ready; -exit 1 covers not-ready, pending, failed, timeout, and unreachable; exit 64 means invalid arguments. -Older proxies without `/readyz` fail closed as unreachable. `/healthz` remains the separate -liveness contract. - -## Entry shape - -Routed entries keep Codex-required metadata such as reasoning levels, shell type, API support flags, -base instructions, modalities, auto-compact fields, and strict parser booleans. The public slug uses -the canonical `provider/model`. Its display name uses the provider's exact `modelDisplayNames` override first, -then trusted catalog metadata such as a configured qualified provider/model alias, then the public slug. -This overlay never changes route identity or the upstream wire model, and its catalog fingerprint makes -a label edit refresh Codex output. - -Supported bare native GPT rows also consume `providers.openai.modelDisplayNames`. Retained sync -and convergence pass the same map to the observed-state merge. After native normalization and -ordering, the merge applies the exact nonblank trimmed label and saves -`opencodex_native_display_name: { slug, original, applied }` in the local catalog only. The next -merge detaches its inputs, removes that marker, and restores `original` only if the native slug -still matches and the current name equals `applied`. Removing or blanking the override therefore -restores the owned name before normal native metadata upgrades. Divergent external names remain -subject to those upgrades: Astra still replaces non-pinned names with its pinned native name. -Template-derived rows discard the marker. The overlay leaves model IDs, metadata (including -capabilities), ordering, routed combo aliases, custom rows and account-qualified rows unchanged; -it does not relabel HTTP model listings or virtual `*-pro` rows. - -## Native passthrough - -Astra has its own pinned native row: 272,000 default context, 872,000 opt-in ceiling, -low-through-ultra effort, low default, and native multi-agent effort `xhigh`. The native-alias -fallback passes the same configured limits to context, max input and compaction. Unrelated routed -templates clear the native multi-agent effort; canonical Astra-forward custom rows retain it and -the pinned Fast speed description. Sync repairs only the exact old built-in Astra Fast description, -preserving custom descriptions and other stored row fields. - -The API registry separately owns Astra's 1,050,000 context / 922,000 input / 128,000 output and -five API effort levels. Trusted discovery snapshots carry the output ceiling as well as input -and context, so reconstruction cannot drop it. User output limits may only lower that ceiling. -Pricing remains provider-scoped and API-referenced for every built-in dollar estimate, including -Codex-login routes. Both OpenAI identities use the same Astra/Sol API base and cache prices, -API Fast multipliers and published long-context bands; Fast stacks with long context for Astra, -GPT-5.6 and the Daybreak Blue selectors. No subscription-specific exception or credit multiplier -enters the estimate. Explicit user price overrides remain authoritative. See the public provider -reference for the dated source table. - -Native bare OpenAI entries form one `openai` group. The provider's Pool(default)/Direct option -changes account selection without changing those ids; `openai-apikey/` creates the separate -API-key identity. The API GPT-5.6 rows use 1,050,000 context / 922,000 max input; their `*-pro` virtual rows -rewrite to the base upstream model with `reasoning.mode: "pro"` while public state keeps the virtual -slug. Routed non-OpenAI models must not -inherit native-only service tier or WebSocket metadata unless the user explicitly enables that -capability. Detailed invariants live in [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md). - -Native passthrough entries depend on the enabled provider set. With at least one enabled provider, -they appear only while an enabled canonical OpenAI forward provider exists — disabling every such -provider removes the native rows rather than leaving entries that resolve to no credential. With no -enabled provider at all, the native rows remain as bootstrap so a fresh install still has something -to route. - -## Accounts, namespaces, and pool rotation - -Pool mode routes across main plus added Codex credentials. Key rules: - -- **A namespace is a public selector mapped to an internal target.** Generated selectors are how a - caller names an account — the main login's selector is `main` (collision-suffixed if taken), - which maps to the config-only sentinel `@main`; the sentinel deliberately sits outside the - pool-account id grammar. Selector initialization requires an explicit opt-in and fills only an - absent or empty map; a non-empty user map keeps its object identity and insertion order. Generated - selectors avoid provider, combo, routing-policy, and slash-qualified routing-profile namespaces. - Collision checks normalize provider and reserved namespace keys, while account and - routing-profile selector prefixes are exact-case (`src/codex/account-namespaces.ts`, - `src/codex/account-namespace-match.ts`, `src/routing/profile-namespace.ts`). -- **Selector labels carry no account-role semantics.** When at least one selector is advertisable, - the Codex catalog clones each supported native row per selector and hides the bare picker rows; - bare ids remain routable and stay in raw `/v1/models` unless explicitly disabled. Missing stored - account targets are not advertised, and private account ids never become catalog labels. - `codexAccountPickerEnabled: false` hides generated rows without deleting exact routing bindings; - an omitted flag preserves the established behavior of a nonempty hand-written selector map. -- **Rotation is sticky.** A conversation stays on its selected account while that account is - usable; failure moves it, success does not (`src/codex/pool-rotation.ts`). -- **The credential store is generation-guarded.** A refresh takes a lock and persists only if the - generation it started from still holds; a lost race raises a generation-conflict error rather - than overwriting the newer credential (`src/codex/account-store.ts`). Callers handle that error; - they do not assume a silent retry. - -Warmup issues a bounded request with a fallback model so a cold account reports usability before a -real turn depends on it (`src/codex/warmup.ts`). - -## Multi-agent surface mode (3-state) - -`OcxConfig.multiAgentMode` controls the `multi_agent_version` field stamped on catalog entries: - -| Mode | Behavior | -| --- | --- | -| `"v1"` | Force ALL entries to `multi_agent_version = "v1"` — overrides upstream pins (sol/terra included). | -| `"default"` (install default) | Respect upstream model pins (sol/terra=v2, luna=v1, others=null → codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. | -| `"v2"` | Force ALL entries to `multi_agent_version = "v2"` — overrides upstream pins (luna included). | - -The override is applied as a final pass in both `buildCatalogEntries` (live `/v1/models` path) and -`mergeCatalogEntriesForSync` (on-disk sync), AFTER all normalization and visibility processing. This -ensures `normalizeRoutedCatalogEntry` (which deletes `multi_agent_version` from routed entries) does -not clobber the forced value. - -CLI: `ocx v2 mode v1|default|v2`. GUI: segmented control on the Models page. API: `GET/PUT /api/v2` -with `multiAgentMode` field. - -The `multi_agent_v2` feature flag and the logical maximum thread count are separate from -`multiAgentMode` (`src/codex/features.ts`): the mode decides which surface Codex advertises, while -the flag and thread count decide what the native runtime allows. - -`keepNativeChatGptOnV1` makes mode `v2` a catalog-driven hybrid: OpenCodex disables the global -`multi_agent_v2` override because codex-rs resolves that override before a model row's explicit -`multi_agent_version`. Native ChatGPT rows then select v1 from the catalog and routed rows select -v2. An explicit attempt to enable the global flag while the hybrid pin is active is rejected. - -### What the five-model `spawn_agent` window is, and how V1 differs from V2 - -`MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5` (mirrored in `src/codex/catalog/sync.ts`) is **not** a -subagent concurrency limit and **not** an eligibility limit. Upstream uses it in exactly two -places: the model list rendered into the `spawn_agent` tool description -(`multi_agents_spec.rs:789`) and the "Available models:" suggestions in an unknown-model error -(`multi_agents_common.rs:448`, inside the `ok_or_else` closure that runs only *after* the lookup -already failed). The success path `find_spawn_agent_model_name` (`:431-442`) scans the whole -catalog with neither the cap nor a `show_in_picker` filter, so a model outside the advertised -five is still accepted when named exactly. - -Three different numbers, often conflated: - -| Quantity | Value | Source | -| --- | --- | --- | -| Models **advertised** as overrides | `min(5, picker-visible eligible rows)` | `multi_agents_spec.rs:785-790` | -| Models **eligible** as targets | no numeric cap (only `"disabled"` is excluded, and only on V2) | `multi_agents_common.rs:36-42` | -| **Concurrent** subagents | V1 6 children (root excluded); V2 total 4 including root → 3 children | `config/mod.rs:211-212`, `:1497-1506` | - -**The cap is the same 5 on both surfaces, but the window's contents are not.** The eligibility -filter runs *before* `.take(5)`, and it behaves differently per surface: on a V1 call -`model_supports_multi_agent_backend` short-circuits true for every row (including `disabled` -ones), while a V2 call drops `Some(Disabled)` first — which lets a later row move into the five. -Same catalog, different advertised list: - -| # | Model | pin | V1 advertises | V2 advertises | -| ---: | --- | --- | :---: | :---: | -| 1 | `v2-a` | `v2` | ✅ | ✅ | -| 2 | `disabled-a` | `disabled` | ✅ | — | -| 3 | `v1-a` | `v1` | ✅ | ✅ | -| 4 | `null-a` | absent | ✅ | ✅ | -| 5 | `v2-b` | `v2` | ✅ | ✅ | -| 6 | `disabled-b` | `disabled` | — | — | -| 7 | `null-b` | absent | — | ✅ | - -opencodex already matches this: `effectiveSubagentRoster` filters with -`surface !== "v2" || isEligibleV2SubagentEntry(entry)`, so the V1 path skips the eligibility -filter exactly as upstream does. opencodex also injects no roster on V1 -(`src/server/responses/collaboration.ts` emits only proactive text at the top effort tier), so -the upstream tool description remains the authority there. - -Two further V1/V2 differences worth knowing: the list gate is -`hide_agent_type_model_reasoning` on V1 (hard-coded `false` at registration, so V1 always -advertises) but `expose_spawn_agent_model_overrides` on V2 (default `true`; when false the list -is omitted *and* the `model`/`reasoning_effort` schema fields are removed). And V2's -`hide_spawn_agent_metadata` defaults true, which removes `service_tier`. - -`modelPickerOrder` (#1649) separates **OpenCodex guidance** from native advertisement. -`SPAWN_PRIORITY_FIELD` preserves the natural priority used by `effectiveSubagentRoster`, so -OpenCodex's preferred/guidance candidate calculation stays independent of display order. -Native Codex ignores that private field: its advertised five on V1 and exposed V2 follow the -native `priority` and may change when the picker is reordered. Exact-name override lookup is -not restricted to those five advertised rows. V1 receives no OpenCodex preferred-roster -injection; V2 can additionally receive natural-priority guidance when its catalog state permits. -The helper tests pin guidance behavior, not native tool-description equivalence. - -A nonblank bare id in `modelPickerOrder` opts into complete-picker display ordering. Exact -ids take precedence over raw/encoded equivalents; routed-only and empty lists keep the legacy -ordering behavior. This does not change the separate `opencodex_spawn_priority` contract. -Retained rows recompute their natural ranks from the current featured roster and account-selector -stride before display order is applied, so a discovery outage cannot preserve an obsolete -featured or picker rank. Canonical `opencode-go` rows retain their configured reasoning ladder -both when generated and when merged from retained catalog state; synthetic max/ultra choices -are not added to that provider's declared ladder. - -Full derivation with per-line citations: `devlog/_plan/260816_codexrs_multiagent_v2_and_history_perf/013_five_cap_v1_vs_v2.md`. - -## Routed tool discovery and hosted search - -All routed catalog rows advertise `supports_search_tool: true` together with -`tool_mode: "code_mode_only"` — the pair is load-bearing. The field selects Codex's deferred -tool-discovery surface; it does not describe the hosted web-search sidecar. Under code mode, -deferred MCP tools remain callable through exec's `tools` global / `ALL_TOOLS` without a -`tool_search` round-trip (upstream codex-rs code_mode suite; live canary 2026-08-13: routed -kimi/k3 executed `tools.mcp__node_repl__js`, devlog `260813_tool_catalog_deferral/010+020`). -Stamping `false` instead forces every MCP declaration into `exec.description` — a measured 2.7x -turn-1 payload regression (96,699 → 258,929 chars). For Cursor this can also make the unified -`exec` exceed the 120,000-byte serialized `McpTools` ceiling; the budget then drops `exec` and -its companion `wait` (#1830). Hosted search remains independent: non-Cursor routes keep -`web_search_tool_type: "text_and_image"`, while Cursor omits it because runTurn bypasses the -search sidecar. - -[Decision Log] -- 목적과 의도: keep routed plugin/MCP tools reachable without paying the full-catalog turn-1 payload tax or starving Cursor's unified execution bridge. -- 기존 구현 및 제약 조건: #1596 restored deferred discovery only for non-Cursor rows because Cursor bypasses the hosted-search sidecar; codex-rs treats deferred exposure and hosted search as separate capabilities, and Cursor independently enforces a 120,000-byte serialized tool-catalog limit. -- 검토한 주요 대안: keep Cursor opted out, raise/disable Cursor's transport ceiling, synthesize another execution bridge, or enable Cursor-native local exec only when the bridge disappears. -- 선택한 방식: enable Codex deferred exposure for Cursor code-mode rows too, while continuing to omit Cursor's hosted `web_search_tool_type`. -- 다른 대안 대신 이 방식을 선택한 이유: it removes the known exec-description inflation before Cursor budgeting without weakening the measured transport limit, inventing caller tools, or turning bridge absence into local-execution authority. -- 장점, 단점 및 영향: Cursor keeps a compact Responses-owned `exec` path under rich tool catalogs and hosted-search behavior remains unchanged; the existing Cursor budget and native-local-exec fail-closed policy remain authoritative. - -## Ultra reasoning level - -Ultra is always advertised in the catalog regardless of the `multi_agent_v2` toggle. The v2 toggle -controls only the multi-agent collab surface, not ultra visibility. The `nativeEffortClamp` function -wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhigh on the wire). - -`effortCap` and `subagentEffortCap` are hard ceilings applied on the V2 path -(`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting -the request, and they never raise it. - -The `ocx effort` CLI accepts only the same canonical cap ladder before live probing or persistence. -Its status output preserves unsupported legacy cap values and reports that those fields are ignored; -the read does not normalize or migrate them, and an ignored subagent field does not disable a valid -main cap. Injection-effort input remains a separate contract. - -Operator-owned `pinnedReasoningEffort`, `modelPinnedReasoningEfforts`, and root -`modelPinnedEfforts` resolve before applicable effort caps at the final destination. -Provider model pins precede provider-wide pins, then global selector/destination pins. -A pin can raise the effective caller effort; the later cap can still lower or omit it. -`none` means explicit-effort omission (provider default), not guaranteed reasoning disablement. -Compaction maintenance is exempt. Pins are user overlays and do not alter registry seeds, -model discovery or advertised ladders. Native Chat normalizes newly pinned values through -provider wire mapping; unpinned native requests retain their existing pass-through contract. - -[Decision Log] -- 목적과 의도: Xiaomi MiMo의 공식 OpenAI Chat endpoint가 실제로 받지 않는 `max`/ - `ultra` reasoning tier를 catalog에 노출하지 않도록 한다. -- 기존 구현 및 제약 조건: `xiaomi`는 Anthropic endpoint, `mimo`는 token-plan endpoint를 - 소유하며, 공식 `https://api.xiaomimimo.com/v1`은 generic custom provider로 처리됐다. -- 검토한 주요 대안: 기존 `xiaomi`/`mimo` contract를 확장하기, 모든 custom provider의 ladder를 - 일괄 축소하기, 공식 public endpoint만을 별도 registry row로 소유하기. -- 선택한 방식: `xiaomi-mimo`를 고정 목적지의 `openai-chat` preset으로 등록하고 - `low`/`medium`/`high`만 노출하며 높은 direct request는 `high`로 clamp한다. -- 다른 대안 대신 이 방식을 선택한 이유: 서로 다른 auth/wire/host를 하나의 preset으로 - 합치지 않으면서 upstream error로 확인된 계약만 적용할 수 있다. -- 장점, 단점 및 영향: 공식 endpoint에서 안전한 picker/wire 계약을 제공하고, - `preserveCustomDestination`으로 같은 이름의 다른 host/key를 보호한다. 대신 새 preset 표면을 - 문서와 registry parity에서 함께 유지해야 한다. - -[Decision Log] -- 목적과 의도: Xiaomi token-plan에서 image input을 거부하는 `mimo-v2.5-pro`만 vision - sidecar로 우회하고, 실제 image input을 받는 `mimo-v2.5`는 native vision 경로에 남긴다. -- 기존 구현 및 제약 조건: upstream `/v1/models`는 input modality를 제공하지 않으며, - `noVisionModels`는 text-only 모델을 sidecar로 보내면서 Codex catalog에는 image input을 - 광고하는 provider-scoped 계약이다. -- 검토한 주요 대안: MiMo 전체를 text-only로 분류하기, live discovery에서 modality를 - 추측하기, `mimo-v2.5-pro` 하나만 registry에 고정 분류하기. -- 선택한 방식: canonical `mimo` preset의 `noVisionModels`에 `mimo-v2.5-pro`만 추가한다. -- 다른 대안 대신 이 방식을 선택한 이유: live endpoint 검증으로 확인된 최소 범위만 - 적용하며, 정상 동작하는 `mimo-v2.5`의 native image 경로를 훼손하지 않는다. -- 장점, 단점 및 영향: Pro image 요청의 404를 sidecar 설명 경로로 바꾸고 base 모델은 - 그대로 유지한다. `preserveCustomDestination` guard 때문에 같은 provider id를 다른 host에 - 연결한 사용자 설정에는 이 capability 분류가 전파되지 않는다. - -[Decision Log] -- 목적과 의도: GitHub Copilot의 live model catalog가 명시하는 모델별 image-input 지원을 - Codex catalog에 정확히 보존한다. -- 기존 구현 및 제약 조건: 공용 discovery parser는 직접 `capabilities.vision`과 표준 modality - 필드는 읽었지만 Copilot의 `capabilities.supports.vision` 중첩 boolean은 읽지 않아 모든 - Copilot 모델이 text-only fallback으로 축소되었다. -- 검토한 주요 대안: 모든 Copilot 모델에 정적 vision seed를 추가하기, 모델 이름을 외부 - metadata alias에 연결하기, live 모델별 boolean을 공용 parser에서 해석하기. -- 선택한 방식: 직접 vision boolean이 없을 때만 중첩 `supports.vision`의 명시적 boolean을 - 사용하고, `false`도 보존하며 malformed 값은 추론하지 않는다. -- 다른 대안 대신 이 방식을 선택한 이유: live 응답이 모델별 capability의 가장 좁은 근거라서 - 새 모델에도 적용되며 text-only 모델을 image-capable로 과장하지 않는다. -- 장점, 단점 및 영향: Copilot vision 모델은 image attachment를 받을 수 있고 명시적 text-only - 모델은 계속 차단된다. Capability를 제공하지 않는 모델은 기존 fallback을 유지한다. - -[Decision Log] -- 목적과 의도: bare `defaultModel` selectors that route into third-party providers must keep their - adapter-owned effort ladder; only true ChatGPT-native requests should receive the mock-max repair. -- 기존 구현 및 제약 조건: `nativeEffortClamp` already needed the original request id because - routing strips `provider/`, but bare third-party selectors like `glm-5.2-fast-preview` still look - native after that strip. -- 검토한 주요 대안: (1) infer nativeness from the bare slug prefix alone, (2) gate clamping by the - resolved provider identity, (3) disable the clamp for all off-snapshot slugs. -- 선택한 방식: request-time clamp entry is allowed only when the resolved route is the canonical - built-in OpenAI/Codex forward provider and the original request id is still bare. -- 다른 대안 대신 이 방식을 선택한 이유: provider identity is the only durable signal that - distinguishes true native ChatGPT traffic from third-party `defaultModel` routes when both share a - bare model id shape. -- 장점, 단점 및 영향: preserves `gpt-5.5 max -> xhigh` repair for native traffic, removes false - clamps for bare routed models, and keeps adapter-specific effort mapping as the single source of - truth for third-party providers. - -## Subagents - -New non-OAuth provider registrations carry `initialModelSelection` with a unique -registration identity. Until reliable live/static discovery completes, public -catalogs and model candidates withhold those providers' models; the provider itself -stays active. At 20 or more canonical Models switch rows, initialization appends -all corresponding disabled selectors once. Existing registrations and later manual -choices are not reinitialized. OAuth/ChatGPT forwarding is exempt using the same -usable-key override predicate as routing. Display aliases do not add switch rows. - -`src/providers/initial-model-selection-runtime.ts` commits the decision against a -matching registration/inventory snapshot before catalog authority is captured. -Ordinary management discovery also completes it with Codex integration OFF. The -final catalog merge fences pending retained rows, including delete/re-add recovery. -Raw management rows remain visible as pending/OFF. Config listener bindings are -excluded from inventory identity because live and persisted bindings may differ. - -Codex `spawn_agent` advertises only the highest-priority first five picker-visible catalog rows. -Use at most five configured `subagentModels` ids; they may contain bare catalog ids, routed -`provider/model` ids, or exact account-qualified `/` ids. The -dashboard offers bare native and routed choices; exact account-qualified choices are configured -through `ocx agent subagents set` or the opencodex configuration. - -When account selectors are active, one featured bare native id expands into a complete selector row -group. Catalog priorities use the selector count as a stride so each group stays together without -widening Codex's five-row advertisement window. Fresh defaults are Astra, Sol, Terra, Luna, 5.5. -Startup upgrades unmarked rosters once: prepend `gpt-6-astra`, retain the first four unique -non-Astra choices, then move retained bare `gpt-5.5` last. The old fifth choice is dropped; -an unmarked empty list becomes Astra only, and an unset list receives the fresh defaults. -`subagentModelsVersion: 1` records completion, so later user edits (including an empty list or -removing Astra) persist. The migration rebases on the latest disk config under the existing -mutation lock; failed persistence degrades to an in-memory roster for that run without a stale -whole-config overwrite. Existing disabled-model visibility rules remain unchanged. - -Quota-aware fallback walks a configured chain when the featured model is exhausted, probing -availability on a bounded interval (default 60 s, `src/codex/subagent-model-fallback.ts`). It rewrites -the requested model id only; effort remains owned by the caps described under -[Ultra reasoning level](#ultra-reasoning-level). - -`injectionModel` and `injectionEffort` are shared selections with two independent consumers. -`multiAgentGuidanceEnabled` controls only OpenCodex-authored delegation guidance. -`syncCodexSubagentDefaults` is a separate, default-off opt-in that applies the selected values to -Codex's native `[agents]` defaults on sync/restart for newly created Codex tasks when OpenCodex owns -the active Codex routing; external user-managed provider configs remain untouched. It does not itself -cause delegation. The TOML edit owns only marker-tagged values, preserves existing unmarked -user-owned `[agents]` defaults rather than overwriting them, and rejects ambiguous table shapes -without changing the file. - -V2 proxy guidance uses `` for both built-in metadata and -custom `injectionPrompt` bodies. The built-in text reports the resolved preferred model, -effort, roster and fallback chain without prescribing delegation, spawn overrides or -`fork_turns`. Custom bodies retain their placeholder behavior. The guidance switch and -catalog-state gates still apply; stale or unknown catalog state suppresses proxy guidance. -V1 uses the shared `MULTI_AGENT_MODE_HINT_RECOMMENDATION.text` inside `` -at `max` or `ultra`. Only the separate explicit delegation-request trigger changes; user, -authority, task-scope and collaboration-tool rules remain applicable. This is guidance, -not an enforcement mechanism or a change to native settings or tool access. - -Replay deduplication compares the latest exact generated developer text separately for -each tag family, preserving built-in → custom → built-in transitions without duplicating -unchanged proxy metadata after a native policy change. Native and legacy-tagged history -remain intact: tags do not establish historical authorship or revoke old instructions, -and mixed-version transition detection is not guaranteed. - -The native mode hint is separate from proxy guidance and native `[agents]` defaults. -`src/codex/multi-agent-mode-policy.ts` owns the proactive recommendation; the dashboard -obtains it from `/api/v2` rather than maintaining its own preset. An explicit dashboard, -API or CLI hint write passes through `setMultiAgentModeHintText`, which replaces only -the two byte-exact released OpenCodex presets with the current recommendation. Other -valid custom text, including whitespace variants, is preserved. Reads, unrelated writes -and upgrades do not migrate stored hints. The writer retains its native capability check -and stores only `features.multi_agent_v2.multi_agent_mode_hint_text` in Codex TOML; -`null` removes that key. The hint affects new native Codex sessions when their v2 surface -is active, without changing reasoning effort or the proxy guidance switch. - -Claude Code `ocx-*` agent definitions consume the same effective `claudeCode.blockedSkills` policy -as inbound bundle elision. When the list is non-empty (default: `claude-api`), generated definitions -whose marker-stripped model resolves to a routed id receive a preventive instruction not to invoke -those skills. Direct `provider/model` selectors are routed even when their inbound resolution is -identity. The only unguarded `ocx-self` case is an identity-resolved `claude|anthropic` model while -native passthrough is enabled; `modelMap` claims and `nativePassthrough:false` restore the guard. The -guard avoids creating oversized skill messages before the proxy can intervene; inbound elision remains -the fallback if a client still sends a blocked bundle. An explicit empty list disables both routed-model -behaviors. - -[Decision Log] -- 목적과 의도: keep generated Claude Code `ocx-*.md` roster files synchronized when the proxy is - started or ensured on Linux, Windows, and macOS, including background service restarts. -- 기존 구현 및 제약 조건: explicit `ocx claude` launches and Management API writes reconciled the - files, while the startup call inside `injectSystemEnv` ran only on macOS with system-env enabled. - `startServer` is also used as an in-process library/test primitive and cannot safely mutate the - real user home on every invocation. -- 검토한 주요 대안: write from `startServer`; duplicate hooks in each OS service manager; reconcile - once from the owning CLI lifecycle after the listener becomes available. -- 선택한 방식: the foreground/service start and live-proxy ensure paths call one best-effort helper - after bind, using the live Management API context-window map and the existing marker-verified - atomic roster writer. macOS system-env startup keeps its existing shared-window sync and skips the - duplicate call. -- 다른 대안 대신 이 방식을 선택한 이유: it covers every supported service entrypoint without - adding home-directory side effects to server-library consumers or creating a second roster format. -- 장점, 단점 및 영향: stale OpenCodex-owned definitions converge on every daemon start, disabled integration - prunes them without provider discovery, and catalog failure falls back to unmarked definitions so - startup remains available. A later dashboard save or `ocx claude` launch restores missing context - markers after a transient failure. - - -### Saved picker presets - -The Models page saves routed snapshots in `modelPickerOrder` and records their origin in -`modelPickerOrderMode` (`alphabetical`, `provider`, `most-used`). Mode is UI provenance, not a -catalog sorting policy: catalog writers consume the saved array. Routed-only featured/native -bands and complete-picker natural-rank preservation remain as described above. Public -`buildCatalogEntries` accepts the order as its final argument and applies the complete-order -pass after building. On-disk convergence retains its existing post-merge final pass. - -Claude ModelInfo ordering receives optional `{ modelPickerOrder, featured }` after `fastRows`. -It orders routed output groups after alias deduplication, preserving the collision winner and -base/1M/Fast siblings. Native groups and explicit Desktop profile ownership are unchanged. -Native Codex advertisements still follow display priority; private guidance ranks do not freeze them. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md deleted file mode 100644 index 3aac18b6ef..0000000000 --- a/structure/04_transports-and-sidecars.md +++ /dev/null @@ -1,1859 +0,0 @@ -# Transports And Sidecars SOT - -## Background service command selection - -A bare `ocx service` is an idempotent install-or-repair command. Argument validation happens before -any platform status probe. macOS and Linux choose from the registration file's proven presence; -Windows combines the Task Scheduler and WinSW probes into `installed`, `absent`, or `unknown`. -Only proven absence enters registration. A query failure refuses the bare command with status -guidance, because treating `unknown` as absent can rerun elevated `schtasks /create` against an -existing task. Explicit `ocx service install` remains the operator-owned registration request. - -[Decision Log] -- 목적과 의도: Make a bare service refresh safe and idempotent without converting a localized or transient Windows status failure into an elevated re-registration. -- 기존 구현 및 제약 조건: The command defaulted to install and later used a boolean diagnostic whose scheduler query fallback could collapse unknown into absent; repair must preserve the existing Windows launcher and Bun stability workarounds. -- 검토한 주요 대안: Always repair; keep a boolean installed check; infer presence from saved state alone; use a tri-state live registration probe. -- 선택한 방식: Validate arguments first, then use a narrow tri-state platform probe only for a bare backend-neutral invocation; route installed to repair, absent to install, and unknown to a refusal. -- 다른 대안 대신 이 방식을 선택한 이유: Saved state can be stale and unconditional repair breaks first install, while a boolean cannot represent the exact uncertainty that must fail closed. -- 장점, 단점 및 영향: Healthy existing services avoid UAC and registration churn; stale Windows scheduler definitions may be refreshed and require elevation. Invalid input performs no status I/O, and uncertain Windows hosts require one explicit status/installation decision instead of risking a destructive guess. - -## Windows startup ownership listing reuse - -One proxy startup asks service-home ownership twice before listen: once before cache invalidation and -again immediately before native-main lifecycle preparation. The second targeted Task Scheduler query -is a deliberate race check and remains mandatory. On a localized host, however, the same nonzero -targeted answer can require a full task listing with a 20-second ceiling; running that identical -enumeration twice made a measured 12.3-second fallback cost roughly 25 seconds before listen. - -[Decision Log] -- 목적과 의도: Preserve the race-sensitive Windows ownership recheck while paying for an unchanged locale-neutral full task listing only once during synchronous startup. -- 기존 구현 및 제약 조건: Localized `schtasks /query /tn ... /xml` failures need a full listing to prove absence, the listing may legitimately take more than two seconds, and `unknown` must never become `absent` merely to reduce latency. -- 검토한 주요 대안: Delete the second ownership check; lower the listing timeout; keep a process-wide or TTL cache; reuse an earlier absence regardless of the fresh targeted result; or scope a memo to the two pre-listen checks and key it to the complete targeted result. -- 선택한 방식: Create one cache inside `startServer`, run every targeted query, and reuse its listing result only when status, timeout/spawn flags, stdout, and stderr are byte-identical. Runtime ownership retries do not receive the startup cache. -- 다른 대안 대신 이 방식을 선택한 이유: Removing or weakening revalidation widens the install race, while a global/TTL cache can outlive startup and stale absence can authorize the wrong home. Exact targeted-result identity lets the ordinary no-task locale fallback coalesce without hiding changed evidence. -- 장점, 단점 및 영향: The reported stable zh-CN absence path performs two cheap targeted queries and one full listing. A task that appears is detected by the second targeted query; changed or failed evidence triggers a fresh fail-closed decision, so unusual churn may still pay for two listings rather than guess. - -## Stable service launcher (launchd and systemd) - -Launchd and systemd installation resolve the first absolute `ocx` PATH candidate that is both a regular file -and executable, keeps that path lexical so a version-manager shim remains an indirection, and -records the same single resolution in the service definition and service state. Definition -construction (`buildPlist`, `buildUnit`) never performs PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI -fallback, keeping diagnostics and tests independent of the host PATH. - -Launcher mode omits the package-local Bun provenance pair because an upgrade may delete that -versioned tree. The only runtime path carried through the launcher is a pre-Bun, proof-bound -`OPENCODEX_BUN_PATH` whose durable runtime source is `override`; bundled and process fallbacks are -rediscovered by the current launcher. The API-auth token remains file-backed and is loaded only by -the service shell at start. On macOS, `start` and detailed `status` compare the live launchd job -against `expectedLaunchdCommand`, which follows the recorded `launcherPath` rather than re-walking -PATH, so a launcher-backed job is never misreported as an older plist (#3464). - -[Decision Log] -- 목적과 의도: Keep systemd services upgrade-stable without losing an explicitly trusted Bun override or accepting a non-executable PATH placeholder. -- 기존 구현 및 제약 조건: Version managers replace package trees but retain lexical shims; Bun dotenv makes ambient override values untrustworthy unless the Node launcher already stamped matching runtime provenance. -- 검토한 주요 대안: Bake the package Bun and CLI forever; resolve the shim target; accept the first existing PATH entry; drop every runtime override in launcher mode; or preserve only a proof-bound override. -- 선택한 방식: Require a regular executable lexical launcher, resolve it once during installation, preserve only `durableBunRuntime().source === "override"`, and keep token loading in the existing file-backed shell preamble. -- 다른 대안 대신 이 방식을 선택한 이유: Resolving or pinning package paths recreates upgrade restart loops, existence-only selection can name a directory or non-executable file, and dropping a trusted override silently changes an operator's runtime. -- 장점, 단점 및 영향: Mise/asdf-style upgrades keep working and explicit Bun selection survives; source installs still use the direct pair, while a removed or non-executable launcher requires `ocx service repair`. - -## Provider diagnostic outbound safety - -Provider connection tests and live model discovery share the GET-only provider outbound wrapper. -Direct HTTP(S) resolves once and pins the validated address; HTTPS preserves the original Host/SNI -and always verifies certificates. Proxy-configured requests stay on Bun fetch so HTTP(S)_PROXY, -ALL_PROXY, and NO_PROXY semantics remain authoritative. The wrapper classifies successful local DNS answers, but -only a typed DNS-resolution failure degrades to proxy resolution; every literal, metadata, and -resolved-address policy error still rejects. Proxy mode logs once that the proxy-selected peer -cannot be pinned. Private destinations additionally require allowPrivateNetwork plus NO_PROXY. - -Two fake-IP DNS accommodations exist, both for resolved answers only (a literal address in the URL -still rejects). The IANA benchmark range (198.18/15 and its IPv4-mapped IPv6 spellings) is admitted -whenever any outbound proxy applies to the host, because the range itself marks the answer synthetic. -Mihomo's default IPv6 fake-IP range (fdfe:dcba:9876::/48) is ULA and carries no such mark, so it is -admitted only when the proxy variable that matches the URL scheme is set (HTTPS_PROXY for https:, -HTTP_PROXY for http:; ALL_PROXY is not consulted because Bun fetch does not honour it), the host is -not in NO_PROXY, and the request is then bound to that proxy through Bun's explicit `proxy` option -rather than environment inference. Both gates live in the outbound wrapper, not in classification: -`classifyIpv6` and config-time validation (`providerDestinationResolvedError`) never admit the -ULA, so provider save-time checks are unaffected (#3462). - -Both paths reject redirects and expose only credential-stripped final-address guidance. This phase -does not cover ordinary requests, streaming, retries, or per-hop redirect review on those paths. -Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and -redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer -executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. - -## Responses HTTP/SSE - -`/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a -provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to -Responses-compatible streaming output. - -### Credential-bearing HTTP redirects - -Credential/body-bearing HTTP sends use `redirect: "manual"` at the final executor boundary, -including dispatch overrides and adapter/sidecar retries. `fetchWithHeaderTimeout` retains its -legacy final argument for callers but no longer permits default-follow sends. Both same-origin -and cross-origin redirects remain observable responses: retry helpers must not synthesize a 502 -before the owning route can apply its existing response and health policy. Native Responses and -compact retain their 3xx/Location relay contract; image and search sidecar owners consume 3xx -through their existing upstream-error path without relaying Location. This server policy does not govern client-side -redirect following; providers requiring a redirect must be configured with their final API URL. - -### Fetch-helper import boundary - -`src/server/responses/fetch-helpers.ts` is a transport leaf shared by Responses, compact, and native -Chat. Its runtime imports are limited to the Codex WebSocket transport, provider request pacing, and -the upstream HTTP-version helper. Server, provider, and WebSocket data types remain type-only edges. -It must not import routing, combos, OAuth, adapters, sidecars, response parsing, logging, or relay -modules merely because those imports existed in the pre-split `responses.ts` monolith. - -### Semantic progress ownership - -The Responses proxy does not treat transcript growth as repository progress. It can observe request -boundaries, response items, tool names and payloads, adapter events, retained bytes, and elapsed -silence. It cannot observe the client's workspace or prove whether a successful tool result changed -repository state. Consequently, the active-turn and session-lane gates are concurrency admission -limits, the translator budget is a live retained-byte limit, the response-state caps are cache -retention limits, and the stall watchdog is a silence limit. None is a cumulative continuation or -semantic no-progress budget. - -[Decision Log] -- 목적과 의도: Keep long but progressing client-driven tool continuations valid while locating repository-semantic loop detection at the layer that owns the workspace and continuation policy. -- 기존 구현 및 제약 조건: Issue #2600 recorded 18 persisted Cursor continuations whose transcript and tool counters grew while the worktree did not. Every proxy-local liveness and capacity bound was therefore satisfied, but the proxy had no workspace delta to compare. -- 검토한 주요 대안: Stop after a fixed continuation count; classify read-like tool names as no progress; compare assistant prose; emit a new proxy-only terminal code after a time budget; or leave semantic progress to the client while preserving transport cancellation for objective proxy failures. -- 선택한 방식: Do not add a proxy semantic cutoff without a client-supplied progress contract. Keep objective transport, byte, concurrency, and silence bounds typed and cancellable; require the workspace-owning client to bound repeated continuations using repository state plus its own side-effect ledger. -- 다른 대안 대신 이 방식을 선택한 이유: Calls and prose are not a repository oracle, and tool names do not prove side effects. A proxy cutoff would either miss the reported loop because items kept changing or terminate legitimate slow work. Retrying after the cutoff could also replay side-effecting work. -- 장점, 단점 및 영향: OpenCodex does not manufacture a root cause or silently terminate healthy long turns. The combined route still needs a client-side semantic boundary; if a future client sends an explicit privacy-safe progress marker, the proxy may enforce that contract without inferring workspace state. - -[Decision Log] -- 목적과 의도: Keep transport helpers reusable without making every consumer evaluate the full routed Responses and sidecar graph at module load. -- 기존 구현 및 제약 조건: The original `responses.ts` split copied the monolith import header into `fetch-helpers.ts`; seven helper exports therefore retained 39 distinct runtime import specifiers and reached 326 modules even though the implementations used only three runtime dependencies. -- 검토한 주요 대안: Leave the imports because current modules have limited top-level side effects; move the helpers again; prune the copied imports and lock the direct runtime boundary. -- 선택한 방식: Preserve the file and all public exports, remove unused runtime edges, and enforce an explicit three-specifier allowlist with a source-level regression that also proves type-only imports are ignored. -- 다른 대안 대신 이 방식을 선택한 이유: Relying on unrelated modules to remain side-effect-free makes startup ownership accidental, while another move adds churn without changing the responsibility boundary. -- 장점, 단점 및 영향: Ordinary native Chat and compact consumers no longer load unrelated routing, combo, OAuth, web-search, vision, and relay modules through this leaf. The allowlist is intentionally strict, so a future helper that needs a new runtime dependency must make that ownership decision explicit in code, tests, and this document. - -[Decision Log] -- 목적과 의도: Prevent routed models from turning invented or neighboring-agent tool names into client-executable Responses calls. -- 기존 구현 및 제약 조건: The request catalog already controlled custom-tool restoration and the non-OpenAI prompt nudge, but an undeclared upstream name still fell through as an ordinary `function_call`; Codex then reduced the mismatch to a bare `aborted` result. -- 검토한 주요 대안: Rely only on prompt guidance; automatically translate undeclared `apply_patch` into Code Mode; validate returned names against the request-visible catalog at the final bridge. -- 선택한 방식: Retain the allowed wire-name set with the existing bridge maps and fail the turn with an explicit compatibility error before emitting any undeclared tool item. -- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` stays in its upstream function-call form unless the destination explicitly denies Responses custom tools; tools replaced by hosted-provider policy also stay in their upstream function-call form. -- 다른 대안 대신 이 방식을 선택한 이유: Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation. -- 장점, 단점 및 영향: Streaming and non-streaming routed responses now fail closed with an actionable provider-contract error; providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior. - -[Decision Log] -- 목적과 의도: Accept a routed model's decorated outer `apply_patch` delimiter lines without changing the executable meaning of any provider-returned program. -- 기존 구현 및 제약 조건: Routed custom tools arrive through a public function wrapper and are restored at the response boundary, but arbitrary `exec` JavaScript is caller-executable source whose strings, comments, templates, and helper arguments cannot be safely rewritten with text patterns. -- 검토한 주요 대안: Regex-rewrite nested helper calls in `exec`; wrap a raw `exec` patch body as a helper call; reject every decorated patch; or normalize only the outer lines of a complete top-level `apply_patch` custom-tool payload. -- 선택한 방식: After unwrapping the request-authorized custom-tool function shape, normalize only exact decorated Begin/End lines when the entire `apply_patch` input is one structurally recognizable patch with a file operation. Keep `exec` and all other freeform bodies byte-identical. -- 다른 대안 대신 이 방식을 선택한 이유: A top-level `apply_patch` call already carries explicit executable intent, so its unambiguous outer-line spelling can be repaired without inventing a call or parsing JavaScript. Every broader rewrite could reinterpret ordinary data as code. -- 장점, 단점 및 영향: Decorated top-level patches regain compatibility while strings, comments, generated source, raw `exec` text, incomplete envelopes, and patch-file content remain untouched. Nested malformed helper source must be corrected by the provider instead of being guessed at the response boundary. - -[Decision Log] -- 목적과 의도: Stop wasting a turn when a routed model submits one complete patch envelope as the entire code-mode `exec` body. -- 기존 구현 및 제약 조건: The decision above rejected "wrap a raw `exec` patch body as a helper call" because a text rewrite could reinterpret data as code. Rollout evidence then showed about 55 such bodies across four models, each a guaranteed isolate throw. Measurement added the missing fact: a complete envelope is never valid JavaScript, since `*** Begin Patch` fails to parse at the leading `**`. -- 검토한 주요 대안: Keep failing closed; rewrite decorated delimiters inside `exec` JavaScript; parse `exec` bodies as JavaScript; or retarget only a body that is itself one complete operation-bearing envelope. -- 선택한 방식: Retarget only that complete-envelope shape to the existing apply_patch helper, through one shared resolver used by all four restore paths. Delimiter-repair functions stay unchanged and every other `exec` body, including JavaScript that mentions an envelope, stays byte-identical. Streaming holds a buffer that could still become an envelope so the live preview is never rewound. -- 다른 대안 대신 이 방식을 선택한 이유: This narrows the earlier rejection rather than reversing it. The rejection protected bodies with a competing executable reading; a complete envelope has none, so it is the same one-faithful-reading rule the delimiter repair already follows. Rewriting inside JavaScript remains rejected: there the marker is a delimiter or a string or a comment, and no lexical or parse-based rule separates them safely. -- 장점, 단점 및 영향: A previously wasted turn now performs the edit the model intended. This does convert a hard failure into a real filesystem write, so the predicate stays anchored and operation-bearing; prefixed, suffixed, incomplete, namespaced, and JavaScript bodies still fail closed. The write itself is the same `apply_patch` capability code mode already grants, reached by payload shape instead of tool name. - -[Decision Log] -- 목적과 의도: Keep Codex client-side deferred tool discovery usable through third-party Responses-compatible gateways that implement public function tools but reject the private `tool_search` declaration. -- 기존 구현 및 제약 조건: The chat translation path already exposed search as a function and bridged its call back to `tool_search_call`; passthrough only promoted definitions returned by an earlier search, so it could not initiate discovery on a strict third-party Responses endpoint. -- 검토한 주요 대안: Require every gateway to implement Codex-private tool types; route affected models through `openai-chat`; lower the declaration only; lower the noncanonical request and restore both JSON and SSE response lifecycles. -- 선택한 방식: On noncanonical Responses passthrough only, lower an actually declared `tool_search` to a collision-free public function name, translate its replayed call/output history to public function pairs, record only caller-authorized request-local conversions, and restore matching JSON/SSE calls to client `tool_search_call` items. Canonical OpenAI forward remains byte-shape native. -- 다른 대안 대신 이 방식을 선택한 이유: Provider-specific workarounds fragment the contract, while unconditional restoration could turn an untrusted ordinary function call into a privileged client discovery action. -- 장점, 단점 및 영향: Strict third-party Responses gateways can start and continue deferred discovery without changing native ChatGPT behavior; ordinary same-named functions remain distinct, and the proxy performs a capped SSE lifecycle rewrite only when the request actually required compatibility translation. - -[Decision Log] -- 목적과 의도: Keep Codex 0.147 namespace tool catalogs usable after a routed provider adopts native Responses but implements only the public flat tool variants. -- 기존 구현 및 제약 조건: Chat translation already flattened namespace children, while native Responses passthrough forwarded the private `namespace` variant unchanged. xAI therefore rejected Grok requests before inference after its OAuth Grok 4.5/4.6 route moved to Responses. -- 검토한 주요 대안: Move Grok back to Chat; special-case only xAI or the reserved `functions` group; flatten every complete namespace on noncanonical Responses and restore request-authorized aliases on return. -- 선택한 방식: Noncanonical Responses lowers `functions` children to their bare top-level names and every other complete namespace to collision-checked `__` aliases after custom/tool-search conversion. It rewrites matching replay calls and tool selectors, records the aliases on the built request, and restores only those aliases in JSON/SSE call items before custom/tool-search lifecycle repair. Canonical OpenAI forward preserves native namespace shapes. -- 다른 대안 대신 이 방식을 선택한 이유: A transport regression should not discard Responses streaming or create a provider-specific fork, and restoration without request-local authorization could reinterpret an unrelated upstream function as a client namespace call. -- 장점, 단점 및 영향: Grok and other public-schema Responses gateways accept current Codex catalogs while Codex still receives explicit namespace routing. No `type: "namespace"` value survives the boundary: a group the layer cannot express — empty, nested, or with an unusable child name — is dropped along with the children it cannot represent, because relaying the private shape costs the whole request rather than one tool. Genuinely ambiguous wire names still fail closed, now as a 400 rather than an unstructured 500. - -Two coordinates that lower to the same wire name are treated as one tool when they denote one: -`buildTools` flattens the reserved `functions` group without a namespace, so a bare declaration and -a `functions` child of the same name are the duplicate the parser already tolerates — and the one -`promoteClientLoadedTools` produces. The declaration is emitted once instead of failing the request. - -Replayed call items are lowered whether or not this turn declares the group they name. A catalog can -be absent or change mid-session, but the client is still replaying items this layer's own response -restoration stamped with a private `namespace`. Routed compaction runs this boundary before removing -the tool surface so request-local aliases remain available for response restoration. Only -`tool_choice` resolves a bare name through the catalog: a history -item records which tool actually ran, so re-pointing it at a same-named namespace child would -rewrite that record on a coincidence rather than translate it. - -Codex-private tool fields are removed at the same boundary from one table -(`CANONICAL_ONLY_TOOL_FIELDS`) rather than one bespoke pass each: `external_web_access` on either -web-search variant, and `defer_loading` on any declaration, which `activateDeferredTool` clears only -for tools a `tool_search_output` already loaded. A new private bit is a row there. - -After that namespace boundary has produced public function tools, the Grok CLI Responses transport -applies the same root-schema policy as its Chat transport. A root `oneOf`/`anyOf` is flattened only -when the shared xAI normalizer can preserve its meaning; an unsafe function is omitted instead of -letting one incompatible declaration reject the entire request before inference. This is scoped to -`cli-chat-proxy.grok.com`: public `api.x.ai` keeps native root unions, as do unrelated Responses -gateways. Both top-level `tools` and Responses Lite `additional_tools` pass through this policy. - -Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening -it: a root `oneOf` whose branches differ in one property becomes that property's `oneOf`, or its -`anyOf` when the branches are provably disjoint and the two keywords describe the same set. That -property is also promoted into `required`, because absent it matched every branch — which the root -`oneOf` rejects. Branches that are wholly identical validate nothing and have no faithful -flattening, so they omit the tool. The walk carries depth, node, and variant budgets, since nested -unions are combinatorial and a `$ref` diamond amplifies the same way without ever cycling; -exceeding a budget omits that one function rather than expanding until memory is gone. - -Omitting a function makes `tool_choice` the loose end. A selector naming a dropped tool would reach -Grok as a dangling reference, and relaxing it to `auto` is worse — the turn would quietly run -without the tool the caller required. So an `allowed_tools` list drops the omitted entries while any -remain, and a selection with nothing left to point at fails locally with the same 400 a tool catalog -this proxy cannot lower already returns. - -The same noncanonical boundary strips ChatGPT's private `external_web_access` bit from routed -`web_search` declarations. The public tool remains enabled and all other options remain intact; -canonical OpenAI forwarding preserves the bit. xAI's public Responses schema enables browsing by -the presence of `web_search` and rejects the private argument, so forwarding it made the first -post-namespace request fail with HTTP 400. - -The option-aware `openai` provider uses `openai-responses` with `authMode: "forward"`. Pool mode -resolves main plus added accounts through affinity/quota/cooldown ownership; Direct forwards only -the allowed Codex/OpenAI auth/session headers from the current request and short-circuits pool -state. `openai-apikey` uses its configured key and canonical API base URL. Missing credentials fail -within their route; neither route falls through to the other. See -[`08_openai-provider-tiers.md`](08_openai-provider-tiers.md). - -### Routed service-tier capability - -OpenAI-compatible service-tier support is resolved only after the final provider/model wire is -known. `supportsServiceTier` remains the provider fallback, while the exact -`modelSupportsServiceTier` map can override it per upstream model, including an explicit `false`. -The catalog and request path share this decision: a routed row publishes `service_tiers` only when -the resolved policy is eligible, and the final-route normalizer applies the same gate to -`service_tier`. Both `openai-responses` and `openai-chat` use the resolved provider/model capability -for catalog publication, routing evidence, and fingerprints. Canonical Fast injection additionally -requires a compatible FastWire mapping on the final adapter and an eligible policy. Setting -`fastMode: false` drops it. On classified Chat routes, `chatServiceTier` separately authorizes -foreign caller values; an exact-model `true` does not grant that forwarding permission. On -unclassified Chat routes it gates every caller tier because no canonical Fast capability has been -validated. An object-form registry wire default may also set `forwardCallerServiceTier: false` to -close a known subscription gateway while leaving generic unclassified Responses passthrough -unchanged. Exact `false` -narrows provider defaults, and provider-level `supportsServiceTier: false` cannot be reopened. -Capability is namespaced by the selected provider and model; model-name similarity and adapter type -alone never opt a gateway in. - -`POST /v1/responses/compact` handles remote compaction v1 before the generic `/v1/responses` branch -and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through -to GUI static serving. - -Combo compaction recall uses accepted completed-response callbacks to record the final client-visible -model and originating combo target. The existing child callback gate defers publication until an -attempt is accepted and drops discarded/failed attempts. Both compaction entry points preserve -explicit configured selectors before consulting bounded lane state. The existing state-store -reconciliation owns removal of obsolete targets and generation fencing; core imports no registration -composition root or Lab code. Recall retains routing identity only, never account credentials. - -[Decision Log] -- 목적과 의도: Complete Cursor turns at the protocol terminal instead of waiting for a separate HTTP-body EOF that may never arrive. -- 기존 구현 및 제약 조건: Cursor can send turnEnded followed by a clean Connect END_STREAM envelope while RunSSE remains open or later closes through an abort-shaped transport error. The adapter logged the clean envelope but did not settle its terminal owner, so a completed-looking turn could remain open until the Responses stall watchdog. -- 검토한 주요 대안: Shorten the global stall timeout; treat every later abort as success; settle only when the HTTP stream emits end; make the clean Connect envelope authoritative. -- 선택한 방식: Process preceding frames in order, preserve an already-emitted terminal, run any already-armed drained client-tool finalizer before protocol cleanup clears its grace timer only while the call set is still drained, otherwise finalize once through the existing fail-closed tool-call logic, and settle the transport successfully on a clean Connect END_STREAM. -- 다른 대안 대신 이 방식을 선택한 이유: The protocol envelope is upstream's explicit terminal signal. Timeout changes only hide the race, and globally swallowing aborts would mask genuine mid-turn cancellation. -- 장점, 단점 및 영향: Completed Cursor responses no longer wait for the 300-second watchdog when the HTTP body stays open; incomplete tool calls still emit their existing truncation error, and error-bearing Connect terminals remain failures. - -A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, -and the client replays it on every later turn. The proxy's own `ocx1:` envelopes are transparent -base64, so they always lower to plain user messages. A native blob is relayed only when there is no -known serving-identity mismatch and the destination is known to decode native blobs — the canonical -ChatGPT forward surface, the official OpenAI API, or a provider with the explicit -`decodesNativeCompactionBlobs` capability. The destination gate alone is insufficient because more -than one backend, including OpenAI and xAI, mints native blobs: a destination can decode its own blob -without being able to decode the previous backend's. The same serving-identity mismatch signal -therefore strips reasoning `encrypted_content` and degrades native compaction blobs through the -existing opaque-note path. When the thread has no recorded identity, the destination-only behavior -is deliberately unchanged. Forward auth alone is not evidence: noncanonical forward providers -receive no caller credentials and may point at any backend. On any other routed destination the blob -also degrades to the same opaque note the bridged parser uses, because forwarding it there fails the -turn and the item outlives the failure in the client transcript, repeating on every later turn -including the compaction turn the proxy itself drives. With `store: false`, request sanitization -strips ids from every input item, including compact-wire items, matching codex-rs -(`core/src/client.rs:918-925`). Compact-wire items remain exempt from response-side field backfill. - -[Decision Log] -- 목적과 의도: Keep a session usable after its history crosses backends, instead of wedging it on a - compaction blob the current upstream cannot decode. -- 기존 구현 및 제약 조건: Compaction handling was binary — `ocx1:` envelopes were ours, everything - else was treated as a native blob and gated only by the destination, even though multiple backends - mint mutually incompatible blobs. Response-side field backfill exempted only `compaction`, so its - two sibling types received synthesized ids the client then replayed. -- 검토한 주요 대안: Tag every compaction item with its minting provider/credential/model identity; - drop compaction items on any route change; gate relay on the destination that would decode them. -- 선택한 방식: Reuse the thread's recorded serving identity to degrade native blobs after a known - route change; otherwise retain the destination capability gate, and treat the compact wire family - as one enumeration so id-bearing passes cannot diverge per type. -- 다른 대안 대신 이 방식을 선택한 이유: Full per-item provenance tagging is unnecessary when the - existing thread identity proves a route change, while dropping the item would silently discard - compacted context and widening unknown-identity behavior needs a separate decision. -- 장점, 단점 및 영향: A cross-backend session degrades one compaction summary to a note instead of - failing every later turn. A self-hosted OpenAI relay keeps its blobs only when explicitly opted in; - other routed gateways see a note because routed compaction produces an `ocx1:` envelope. - -### Mixed-wire provider defaults - -Registry `modelWireDefaults` select an evidence-backed upstream protocol for an exact model without -changing the provider-wide adapter. Explicit, allowed `modelAdapters` configuration always wins, -including an entry that opts the model back into the provider-wide wire. Defaults are applied only -while the configured provider still matches the registry transport, so reusing a preset name for a -different custom destination does not inherit its upstream assumptions. Object-form defaults may -also narrow the decision by inbound protocol and authentication mode; an auth-scoped default must -not leak from a subscription transport into an API-key or forwarded-credential route. - -xAI keeps `openai-chat` as its provider-wide compatibility wire, but Grok 4.5/4.6 subscription -Responses requests default to native `openai-responses`. Existing namespace, hosted-search and -reasoning-replay normalization remains in force. The reserved `xai` OAuth transport is name-pinned -to the Grok CLI gateway even if its saved base URL differs; custom provider IDs do not inherit this -default. API-key requests, translated Chat/Anthropic defaults and other Grok models retain their -existing wire and tier policy. OAuth still drops caller-owned `service_tier` on either wire. - -Native Responses participates in the same pre-stream OAuth HTTP-429 account rotation as the Chat -bridge. It uses the existing account quorum, cooldown and three-rotation request cap, refreshes -the complete credential/transport/replay identity, and attributes usage to the serving account. -Single-account installs do not retry; a missing alternate credential preserves the original error. - -Startup removes legacy Grok 4.5/4.6 Chat overrides once and persists the provider-owned -`xaiResponsesDefaultVersion` marker. Later explicit Chat choices survive restarts. The migration -rebases under the config mutation lock; unavailable persistence warns and uses an isolated in-memory -projection without overwriting invalid disk state. Read-only config loading does not migrate. - -The dashboard's Chat Completions switch and `ocx provider edit xai --xai-chat on|off` share the -existing `modelAdapters` lane. On writes Chat for both models; off writes Responses. Unrelated -overrides remain intact. The legacy PATCH field `xaiResponsesOptIn` retains its direction: -true selects Responses, false now writes explicit Chat rather than deleting entries. Its derived -`xaiResponsesOptInState` reflects effective Responses-inbound routing, including registry defaults; -only genuinely different effective wires report mixed. A switch write also records the migration -version (without lowering a future version), and provider-form overwrites retain omitted choices. - -Native routed Responses code-mode turns also receive the shared result-emission contract in both -instructions and the lowered exec input description: a bare awaited helper return is discarded by -the host, so visible results need `text(...)` or `notify(...)` in that first call. Paired exec outputs -containing only an empty completion/failure wrapper use the shared explanatory annotation. The -whole result is examined; populated text, image/file parts, unpaired results, shell-only catalogs, -compaction and OpenAI-operated destinations are untouched. This does not rewrite valid JavaScript -or reconstruct output that the code-mode host never emitted. - -Routed code-mode turns also carry the host contract for the nested helpers, stated in the same three -injection sites as the result-emission rule (shared catalog nudge, Cursor code-mode guidance, native -routed Responses instructions): `tools.apply_patch` takes one string that opens and closes with the -bare patch marker lines (blank lines or indentation around them are tolerated; a decorated or missing -marker is rejected), the isolate has no `import`/`require`, and a command that outlives -`yield_time_ms` is polled through `write_stdin` with empty `chars` rather than a shell sleep loop. -When a code-mode exec result still carries one of the host's failure strings ("expects a string -input", "The first line of the patch must be", "The last line of the patch must be", "Unsupported -import in exec"), the native routed Responses, Kiro, and Cursor result paths append a one-line -recovery hint naming the broken rule; flat shell bridges and foreign MCP namespaces are never -annotated, Responses and Kiro additionally require the request's verified code-mode catalog, Cursor -matches the exact `exec` name under its `opencodex-responses` provider without catalog context, and -Cursor's error classification and Kiro's whitespace and failed-wrapper grouping are unchanged. Both -halves live in `src/adapters/exec-tool-result-normalize.ts` -so the pre-call and post-hoc wording cannot drift. This guidance and annotation change rewrites -neither the model's JavaScript nor its patch payload; the existing name-alias delimiter -normalization in `src/responses/code-mode-helper-compat.ts` is unchanged, and the host still rejects a -malformed call exactly as before. Anthropic, Google, OpenAI-chat and command-code result paths -have no exec-result seam today and are not annotated. - -[Decision Log] -- 목적과 의도: Stop routed models from abandoning `apply_patch` after the Codex host rejects an object argument or a decorated marker, and from blocking a turn in a shell sleep loop when the host offers `session_id` polling. -- 기존 구현 및 제약 조건: The shared nudge, Cursor guidance and native Responses instructions already carry the result-emission rule from `exec-tool-result-normalize.ts`, but none stated the helper's argument type, the marker rule, the import ban, or the polling protocol; `260905_apply_patch_envelope_gap` refused to rewrite JavaScript bodies (MODE B), so payload repair is off the table. -- 검토한 주요 대안: Repair the argument shape inside the proxy (rejected: same body ambiguity as MODE B and it turns a rejected write into a performed one); Cursor-only guidance (rejected: the incident was native routed Responses on xAI); annotate every adapter's tool results (rejected: Anthropic/Google/OpenAI-chat/command-code have no exec-result seam and would need a new one). -- 선택한 방식: One pre-call sentence and one marker→recovery table in the module that already owns the echo pair; inject the sentence at the three existing code-mode sites; annotate at the three existing exec-result seams with an exec-gated, idempotent helper that never changes error status. -- 다른 대안 대신 이 방식을 선택한 이유: The safe repair for a host contract the model broke is to state it before the call and name it after the failure; keeping both halves in one file is what keeps them consistent. -- 장점, 단점 및 영향: Code-mode system prompts grow by roughly 600 characters on routed turns; OpenAI destinations, flat catalogs and compaction requests are untouched. An exec result that legitimately prints one of the four phrases gains a recovery line, which is additive text and never an error flip. On Cursor, a structured tool literally named `exec` whose output quotes one of those phrases would also gain that line. The effect on the live Grok defect rate is unmeasured until a re-probe. - -[Decision Log] -- 목적과 의도: Keep Codex hosted web search usable on xAI's public Responses endpoint without forwarding private OpenAI-only fields that xAI rejects. -- 기존 구현 및 제약 조건: Codex emits `external_web_access`, `search_context_size`, `search_content_types`, and `user_location`; xAI documents a live-only `web_search` tool with domain filters and image flags, while Codex cached mode explicitly forbids external access. -- 검토한 주요 대안: Strip only the first rejected field; pass every hosted-search field unchanged; disable web search for all xAI turns; normalize only the exact official xAI API destination. -- 선택한 방식: On `https://api.x.ai` Responses traffic, lower live search to xAI's public shape, map image content requests to `enable_image_search`, remove unsupported OpenAI-private fields, and omit cached/index-only search plus stale selectors because xAI has no non-live equivalent. -- 다른 대안 대신 이 방식을 선택한 이유: One-field stripping exposes the next schema mismatch and turning `external_web_access:false` into xAI live search widens the caller's network policy; destination scoping leaves custom gateways and canonical OpenAI byte-shape native. -- 장점, 단점 및 영향: Grok 4.5/4.6 no longer fail every default Codex turn with an unsupported-argument 400; live search remains available when explicitly enabled, while cached search degrades to no hosted search on xAI rather than silently going live. - -### xAI string agent-message continuation - -`normalizeRoutedAgentMessages` owns raw Responses `agent_message` lowering. Its existing -nonempty all-readable array behavior remains shared by non-forward destinations. The optional -`allowStringContent` argument defaults to false and is enabled only by the non-forward adapter -call when `isXaiResponsesDestination` recognizes HTTPS `api.x.ai` or `cli-chat-proxy.grok.com` -on the standard port. A nonblank string becomes one `input_text` part with the original text; -the same author/recipient attribution is retained and the private transport item id is removed. - -This addresses readable child-result delivery (#3907), not scheduling or decryption. Blank, -malformed, ciphertext-only and mixed unknown/encrypted content retains the existing fail-closed -path. Forward destinations never enable the option. The parser and encrypted-task recovery -owners are unchanged, and no broad content-schema validation or adapter-wide string conversion -is introduced. Mocked server fixtures cover parent, child, and parent-result continuation over -SSE and JSON while preserving actual tool-call/result pairs. - -OpenCode Go documents `gpt-5.6-luna` on `/zen/go/v1/responses` while sibling models use its Chat or -Anthropic endpoints. The built-in preset therefore selects `openai-responses` only for Luna and -keeps the provider-wide `openai-chat` default for other non-pinned models. This endpoint correction -does not set `modelResponsesUpstreamStreaming`: client `stream: true` remains real upstream -streaming until a current-runtime reproduction justifies a separate bounded-JSON compatibility -policy. - -Go's non-forward Responses request path moves valid `additional_tools` wrappers into top-level -`tools` through `src/adapters/opencode-go-additional-tools.ts`. Placement runs after existing -custom/search/namespace lowering and before code-mode, compaction and final hosted-tool pruning. -It does not recalculate wire identities or response aliases. The matcher reads the constructed -send URL, resolving it with URL semantics, and requires HTTPS `opencode.ai`, the standard port -and exact `/zen/go/v1/responses`. Normal and endpoint-inclusive bases or split `responsesPath` -configurations agree; a custom path resolving to Zen or elsewhere does not acquire Go placement. -Credentials, query, fragment, foreign hosts and other resource paths are excluded. The existing -URL constructor canonicalizes trailing base slashes before this check. Malformed wrappers remain unchanged and -the shared mixed-ciphertext agent-message gate remains fail-closed. - -The canonical `opencode-go` registry entry defaults to `statelessResponses: true` because Go -rejects reasoning ciphertext combined with `previous_response_id` (#3838). Existing derive -logic fills absent values and preserves explicit false; renamed custom configurations receive -no new destination-based migration. The existing stateless pass sets `store: false`, removes -stored continuation parameters, and repairs orphan calls/results without claiming execution -success. A local replay-cache hit supplies history; a miss cannot reconstruct it, so callers -must resend complete history without `previous_response_id`. This flag also enables the existing -visible content-to-summary rewrite for SSE and JSON; summary-channel items and opaque reasoning -blobs keep their existing response handling. The shared recording callback applies the same -reasoning rewrite under the exact client-visible predicate before caching output, after tool -restoration and function normalization. This keeps full-content replay fingerprints comparable -for both full-history-plus-ID and delta continuations without weakening identity checks. Hidden -summaries and opaque blobs keep their existing cache representation. It does not change streaming selection or Chat -model routes. Go fixtures cover Luna, Grok and Muse against both response formats. - -The canonical OpenCode Go transport also derives `x-opencode-session` from the existing hashed -session lane before per-model wire selection. One conversation keeps one opaque affinity value -across Responses, Chat, retries, and key rotation, while sibling subagents remain distinct. An -operator-supplied header wins case-insensitively. Renamed providers are covered only when their -fixed key-auth destination still matches the registry; custom and lookalike URLs receive nothing. -Muse Spark's Responses sanitizer also drops the provider-rejected `search_content_types` and -`indexed_web_access` fields from plain `web_search` tools while preserving preview tools and -unrelated models. - -[Decision Log] -- 목적과 의도: Match OpenCode Go's model-specific Luna endpoint without changing sibling model behavior. -- 기존 구현 및 제약 조건: The preset had one Chat default even though the upstream publishes a mixed Chat, Responses, and Anthropic matrix; operators must retain explicit override precedence. -- 검토한 주요 대안: Move the whole preset to Responses; infer from the model name; declare one exact registry default; also force bounded JSON from an older conditional terminal report. -- 선택한 방식: Use one exact Luna wire default and leave upstream streaming unchanged. -- 다른 대안 대신 이 방식을 선택한 이유: The endpoint mismatch is reproducible from current code and upstream documentation, whereas a current-dev live canary has not established the separate terminal-delivery policy. -- 장점, 단점 및 영향: Luna reaches its documented endpoint across inbound surfaces and explicit opt-out still works; any future stream workaround remains a separately reviewed compatibility decision. - -[Decision Log] -- 목적과 의도: Give OpenCode Go the stable per-conversation header it requires for prompt-cache routing without exposing raw Codex identifiers. -- 기존 구현 및 제약 조건: Codex already supplies task and subagent identity, but Go requests reached every adapter without `x-opencode-session`; one static provider header would collapse unrelated conversations. -- 검토한 주요 대안: Forward a raw thread header; reuse `prompt_cache_key`; configure one global value; inject separately in Chat and Responses adapters; enrich the canonical provider before wire selection. -- 선택한 방식: Hash the existing parent-qualified session lane with a provider-specific domain, attach it as runtime-only provider metadata before wire selection, and preserve an explicit operator override. -- 다른 대안 대신 이 방식을 선택한 이유: The lane already separates sibling subagents, while cache keys may represent shared cohorts and adapter-local changes would drift across Go's mixed wire matrix. -- 장점, 단점 및 영향: Go requests gain stable opaque affinity across normal retries and key rotation without persisted config changes; requests with no stable lane remain headerless rather than receiving a per-request value that defeats affinity. - -### Passthrough SSE stream shapes (#314) - -Native passthrough SSE has TWO shapes, selected per request in -`src/server/responses/core.ts`: - -- **Default outside Windows: tee + background inspection.** `upstreamResponse.body.tee()` sends - branch[0] through a terminal-aware client relay while branch[1] is - drained eagerly by `consumeForInspection`/`consumeForResponseLogMetadata` - for terminal-outcome recording, quota, the passthrough continuation cache, - and request logs. This remains the default shape on bundled Bun 1.3.14. -- **Terminal-aware eager bounded relay** (`src/server/relay-eager.ts`). Windows - uses this single-reader shape for rewrite traffic and for no-rewrite traffic - selected by `selectEagerPath` in `src/lib/bun-stream-caps.ts`; the latter keeps - `legacy-tee` and known-bad-runtime `auto` on tee as documented. When selected, - `response.completed` closes the client stream even if upstream keeps HTTP/SSE - alive. Darwin uses it for no-client-rewrite traffic only (neither image-gen - aliases nor item-id repair) and is explicit-only: `auto` stays tee even after - a future threshold bump. One eager reader + byte-bounded - client queue + post-cancel bounded discard-drain replaces the tee and goes - directly to the response without a JS rewrite wrapper, preserving the full - inspection side-effect set (shared `createSseInspector` factory in `relay.ts`) - including the #44 late-terminal semantics. - -Both client readers also retain a bounded, redacted message from a bare upstream -`error` event. If EOF arrives without a real Responses terminal, they synthesize -one `response.failed` with that message instead of replacing it with `adapter_eof`. -The delivering reader owns this evidence; an asynchronous tee inspection branch -cannot reliably supply it before EOF. Inspection independently applies the same -bare-error rule when EOF arrives, so account health records failure instead of -clearing avoidance as if the turn had succeeded. Existing real terminals and -caller cancellation retain precedence on both branches. Native recovery preflight -also preserves a rejected body reader and its bounded prefix for the normal -mid-stream failure path; it does not turn that rejection into a decrypt retry. - -Native Responses may rebuild once when encrypted function/custom-tool output or -agent-message content receives the exact known decrypt rejection before output -commits. Recovery replaces only encrypted parts with an omission marker, preserves -the raw request object used by continuation persistence guards, and uses the same -adapter and cancellation path. A missing Content-Type is allowed only under the -existing successful streaming condition. Default combo preflight classification -is unchanged; only the native recovery caller supplies the exact error predicate. - -Both shapes carry the inbound caller-abort signal separately from the turn/shutdown -controller. A caller-driven read rejection is 499/client_cancel without pool penalty; -a genuine upstream reset remains synthetic 502. An already received terminal, including -one completed by the error-path parser flush, retains its real outcome. Eager relays -remove the caller listener when done and close signal-cancelled downstream streams even -when the response-body cancel hook has not run. - -The two-shape contract is mirror-commented in `src/server/index.ts`; the real -`core.ts` gate is source-invariant-tested by `tests/responses/passthrough-abort.test.ts`, -and the platform matrix lives in `tests/lib/bun-stream-caps.test.ts`. Keep all three -in lockstep with any passthrough-policy change. - -Canonical ChatGPT forward streaming has one transport-specific exception. A -stable Bun runtime at or above 1.4.0 may use Codex's upstream -`responses_websockets` transport; bundled Bun 1.3.14, prereleases, and -unverifiable runtime identities stay on HTTP/SSE. A successful upstream WS -response is re-encoded to the same SSE surface and forced through the bounded -eager single-reader relay instead of `tee()`: raw and enveloped frames are capped -at 4 MiB and the WS producer queue at 8 MiB. Overflow closes the upstream and -the downstream relay emits its terminal `response.failed` event plus `[DONE]`. -Pre-open HTTP fallback remains unmarked and follows the ordinary configured -stream path. - -At the canonical ChatGPT destination, HTTP Responses Lite intent is copied into -the native per-frame WS metadata key, and the routing hint is derived from the -final outgoing model/tier. No caller identity is synthesized. Noncanonical -opt-in gateways keep their own metadata policy. Oversized/unsupported-runtime -HTTP fallback preserves the original HTTP body and Lite header. - -Canonical WS quota and response metadata preceding the first Responses event -are projected into bounded, allowlisted HTTP headers before the response is -committed. Later quota observations update only the captured serving account; -they cannot retroactively change HTTP headers already sent to the client. -Control frames remain bounded, and provider credential/cookie headers are not -forwarded. Once a WS create may have been sent, a missing prelude, overflow or -disconnect settles as an errored SSE body rather than a retryable fetch failure, -so HTTP fallback cannot duplicate that inference. A standalone no-response -exchange has a 90-second prelude deadline in addition to the upgrade deadline. -That prelude deadline is a ceiling, not a floor: the exchange runs under the -caller's abort signal, so a `connectTimeoutMs` shorter than 90 seconds cancels -an already-sent create before the prelude timer fires. -These are transport-fidelity guarantees, not a provider-billing guarantee. - -Eligible complete-input creates can retain a canonical upstream socket within -one selected account, credential, thread and turn. Model/tier and immutable -handshake headers and the selected outbound proxy must also match. Turn-state and turn-metadata headers are -projected into their same-name per-frame metadata slots; explicit body values win. -The pool retains at most 32 sockets, expires idle sockets after 30 seconds, and -retires a socket after five minutes or 32 successful exchanges (after active work -finishes). Cancellation, errors, idle unsolicited frames and shutdown dispose it. -A busy key uses a separate one-shot connection rather than interleaving requests. - -This is connection reuse, not native incremental-input synthesis: complete HTTP -inputs are never trimmed and no previous response id is invented. Explicit -continuation IDs, named lanes, warmup and background requests remain outside this -pool. A fresh credential-dispatch guard runs before every warm send. Per-exchange -listeners, response/item correlation and metadata ownership detach before release. -No pool timer or shutdown registration exists before eligible traffic activates it. - -Translated response request-log tracking and the heartbeat relay also reuse -`createSseInspector`. This keeps every client-facing SSE observation path on -the same byte-bounded, discard-and-resynchronize frame policy and ensures the -request-log, first-output, and terminal observers share one payload parse. -The inspector records a structured `response.failed` status before invoking the -terminal observer. Native Responses, Chat Completions, Claude Messages, and WebSocket -request logs must therefore finalize through the context-aware terminal mapper; recognized -`cyber_policy` terminals stay `400 / cyber_policy` rather than collapsing to a generic 502. - -The client-facing boundary treats the first Responses terminal as authoritative in both relay -shapes. High-confidence policy errors carried as `response.incomplete`, `response.failed`, or a -top-level `error` are normalized to one `response.failed / cyber_policy` event without changing the -refusal outcome; later bytes cannot create a second terminal. A clean HTTP 200 EOF with no terminal -instead emits one `response.incomplete` with `adapter_eof`, followed by one `[DONE]`. Delimiter-less -EOF candidates follow the owning repair policy: the native boundary accepts a structurally valid -terminal tail, while an opted-in terminal repair keeps its unframed suffix tainted and emits -`missing_terminal_event`. Pull/tee and eager relays therefore agree on terminal, sentinel, and -request-log accounting without promoting a truncated repair candidate. - -[Decision Log] -- 목적과 의도: Turn upstream terminal variants and bare EOF into one deterministic Responses - outcome instead of a retryable disconnect or duplicate terminal. -- 기존 구현 및 제약 조건: Policy refusals can arrive in several SSE envelopes, while a clean EOF, - an unterminated final frame, and a read error exercise different pull/tee and eager cleanup paths. -- 검토한 주요 대안: Forward every byte unchanged; classify only request logs; synthesize a failure - after every EOF or read error; normalize the bounded terminal at the client output boundary. -- 선택한 방식: Rewrite only high-confidence policy terminal shapes, preserve their bounded metadata, - flush native terminal candidates before transport-error classification, keep repair-owned - delimiter-less candidates tainted, and synthesize `adapter_eof` only when no real terminal exists. -- 다른 대안 대신 이 방식을 선택한 이유: Log-only classification leaves Codex retry behavior - unchanged, while unconditional synthesis can create two contradictory outcomes for one turn. -- 장점, 단점 및 영향: Both native relay shapes expose exactly one terminal and one sentinel with - matching accounting. Ordinary upstream errors remain fail-closed, and policy refusals remain - refusals rather than becoming successful model output. - -## Standalone Search and exact account selectors - -`POST /v1/alpha/search` retains the selected model in its request body. When that value is an -account-qualified native selector, the server resolves the public namespace, uses only the mapped -stored Codex credential, and sends the bare native model upstream. That exact path is fail-closed: -it does not consult Pool active state or affinity when selecting, and its outcomes cannot rotate -the active Pool account. An account-wide credential failure still quarantines that credential and -clears stale ordinary Pool affinities so they cannot reappear after reauthentication. Quota and -transient outcomes from an exact request leave Pool affinities untouched. Ordinary search requests -keep the normal Direct/Pool sidecar behavior. - -Standalone Images and Live requests currently carry neither the account-qualified model selector -nor a trustworthy thread correlation from the Codex client. They therefore retain normal provider -routing. Do not infer an exact account from caller-supplied account headers, process-global last -selection, connection identity, or other ambient state; concurrent threads could cross-route -credentials. Extending exact routing to those endpoints requires an opaque client correlation that -can be bound server-side to a previously validated selector. - -## Standalone Images - -Codex's local `image_gen.imagegen` tool makes a second Images request after the model calls it: -`POST /v1/images/generations` for generation or `POST /v1/images/edits` for reference-image edits. -These are standalone Images API routes, not the hosted Responses `image_generation` tool. - -`src/server/images.ts` uses the existing ChatGPT/OpenAI fallback unless `images.provider` explicitly -selects a custom API-key `openai-responses` provider. Explicit selection fails closed when the -provider is missing, disabled, registry-managed, incompatible, or lacks a usable key; it never -falls through to another paid upstream. The relay accepts bounded JSON generation and edit requests, -then forwards the decoded JSON without rewriting Codex's edit schema. Each paid Images POST receives -one upstream attempt; client cancellation aborts the upstream and pool-only failures update the -existing account-health state. Unknown Images subpaths still reach the JSON `/v1/*` 404 guard. - -When the OpenAI credential path is unavailable or its authentication fails, `generations` (not -`edits`) may fall back to Google Antigravity if that provider is logged in. The fallback is -credential-driven: it exists so an image request reaches a real upstream answer rather than dying on a -local credential error, and it does not apply when the caller selected an explicit keyed custom -provider, because a configured pool owns its own authentication failure rather than hiding it behind -separately billed generation. - -On non-loopback binds, data-plane authentication and origin policy cover both Images routes. An -explicit keyed Images provider accepts the proxy admission secret as either an OpenAI-style bearer -or `x-opencodex-api-key` because the provider key replaces caller authorization before fetch. The -ChatGPT forward path still requires the dedicated header so its upstream bearer remains distinct. - -The API-key `openai-responses` path also adapts Codex's private standalone image tool to the public -Responses tool surface. A complete `image_gen` namespace is lowered to safe -`image_gen__` function aliases even when no hosted image tool is present, because public -Responses runtimes may reserve the namespace itself and reject dotted function names. Native and -legacy dotted calls replayed in `body.input` are encoded to the same aliases. When any client -image-gen declaration is replaced by a usable `image_gen__` alias, the adapter also drops -hosted `image_generation` and deduplicates aliases in stable container order. Empty or malformed -namespaces do not remove the hosted fallback. Discovery and normalization span both top-level -`body.tools` and Codex Desktop Responses Lite `input[].type = "additional_tools"` containers. - -For a model explicitly listed in `modelPreferHostedTools`, a non-forward Responses provider may opt -to remove colliding client `image_gen` declarations before this normalization and rewrite their -selectors to hosted `image_generation`, so a provider-reserved hosted tool takes precedence without -loosening a caller's tool-choice restriction. The opt-in is intentionally model-scoped: the default -alias path remains safest for ordinary public Responses endpoints. - -For OpenAI API virtual `-pro` models, preference lookup checks the selected public ID first and -uses the resolved base wire-model ID as a fallback. `modelAdapters` resolves the public ID first and -the base ID second; the second pass selects the final adapter, and configuration validation mirrors -both steps. - -Client-facing API-key responses perform the inverse mapping: JSON output and SSE function-call -items restore `{ namespace: "image_gen", name: "" }` so Codex can dispatch the local -extension. When item-id repair is also enabled, both transforms compose in one SSE parse/stringify -pass (`src/server/sse-payload-rewrite.ts`) rather than chaining separate JS pull wrappers. -Inspection and continuation-cache branches keep the raw upstream alias, allowing stored -replays to return upstream without leaking a client-only namespace shape. The image-gen layer itself -leaves malformed and empty image-gen namespaces untouched, but on a noncanonical route the general -namespace boundary above runs after it and lowers whatever remains, so no private group reaches the -wire. ChatGPT forward mode preserves the private namespace and hosted tool because that backend -understands their native semantics. - -Per-model `modelReasoningSummaryDelivery` is a narrow compatibility layer for -`openai-responses` gateways whose summary capability is real but whose accepted delivery enum -differs from Codex. Presence advertises reasoning summaries in the routed catalog and rewrites only -an already-present `stream_options.reasoning_summary_delivery` at the adapter boundary. It never -injects summary generation into a request, and config validation rejects a delivery map that -conflicts with `modelSupportsReasoningSummaries: false` for the same model. - -[Decision Log] -- 목적과 의도: Preserve Codex Desktop reasoning summaries while adapting only the delivery enum rejected by a specific Responses-compatible upstream. -- 기존 구현 및 제약 조건: The existing boolean capability either passed Codex's enum unchanged or disabled summaries entirely; stale running clients can keep sending the old enum after a catalog refresh. -- 검토한 주요 대안: Disable summaries; rewrite the enum globally; inject a delivery field when absent; configure a provider-wide value. -- 선택한 방식: Use a validated per-model allowlisted map, imply summary capability for that model, and rewrite only a caller-provided delivery field at the Responses adapter boundary. -- 다른 대안 대신 이 방식을 선택한 이유: Upstream enum support differs by model and provider, while global rewriting or injection would change unrelated requests and disabling summaries removes Desktop UX. -- 장점, 단점 및 영향: Configured models retain the native summary UI and stale clients self-heal; each incompatible model needs an explicit map entry and contradictory opt-out configuration now fails closed. - -## Claude Desktop config-library resolution - -The Desktop profile writer and the management status probe share -`resolveDesktop3pConfigLibraryPath`. The resolver reproduces Desktop's own rule rather than a guess: -an explicit `CLAUDE_USER_DATA_DIR` (or the opencodex override) wins; on Windows -`%LOCALAPPDATA%\Claude-3p` wins; otherwise the Electron user-data path gains a `-3p` suffix if it -does not already have one. `configLibrary` is appended to that root. - -`Claude-3p` is Desktop's real directory name, assembled at runtime from `"Claude" + "-3p"`, which is -why searching the app bundle for the literal string finds nothing. It is not a legacy path to migrate -away from. Resolution stays a pure function of (env, platform, home) so the Windows branch is -testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. - -[Decision Log] -- 목적과 의도: 생성된 Claude Desktop 프로필이 설치된 Desktop이 실제로 읽는 디렉터리에 떨어지고, 대시보드 상태가 그 쓰기 대상과 일치하게 한다. -- 기존 구현 및 제약 조건: 두 호출자가 경로 계산을 각자 복제했고, Desktop이 실제로 참조하는 `CLAUDE_USER_DATA_DIR`와 Windows `LOCALAPPDATA` 분기가 빠져 있었다(#539). 사용자가 프로필 루트를 직접 지정하는 경우도 있다. -- 검토한 주요 대안: `-3p` 접미사를 구버전 잔재로 보고 제거; 두 디렉터리를 모두 스캔; 레거시 파일을 자동 이전; 크로스플랫폼 해석기를 한 곳에 둔다. -- 선택한 방식: Desktop 번들의 해석 규칙을 그대로 이식한 override 인지 해석기를 한 곳에 두고, 쓰기 경로와 상태 조회가 같은 함수를 쓴다. -- 다른 대안 대신 이 방식을 선택한 이유: `-3p`는 Desktop의 정상 동작이므로 제거는 회귀였다. 해석기를 한 곳에 두면 두 호출자의 드리프트가 불가능해지고, 파괴적 이전 없이 상태와 쓰기 대상이 일치한다. -- 장점, 단점 및 영향: 지원 플랫폼 전부에서 apply 결과가 Desktop에 보인다. 비표준 레이아웃 사용자는 문서화된 override를 써야 하고, 해석기는 Desktop 번들의 규칙 변경을 따라가야 한다. - -## Cursor Native Exec - -Cursor's experimental live transport can receive server-driven local read/write/delete/ls/grep, -shell, and fetch exec frames. These frames are denied by default because they bypass Codex's normal -approval and sandbox path. `nativeLocalExec: "on"` is the explicit config-owner opt-in for trusted -local experiments; `off` and the backwards-compatible `codex-sandbox` spelling both fail closed. -MCP, screen recording, and computer-use stay on their separate explicit executor/MCP config paths. - -[Decision Log] -- 목적과 의도: prevent caller-controlled Responses text from authorizing Cursor native local shell, filesystem, or fetch execution. -- 기존 구현 및 제약 조건: the adapter preserved top-level `instructions`, system messages, and developer messages, then treated a `sandbox_mode ... danger-full-access` prose marker as an exec allow signal in `codex-sandbox` mode. -- 검토한 주요 대안: keep marker-based authorization, require a future trustworthy attestation channel, or restrict authorization to server-local config. -- 선택한 방식: keep marker detection only as diagnostic/context and make `nativeLocalExec: "on"` the only non-legacy mode that enables built-in local exec; unset, `off`, and `codex-sandbox` all deny. -- 다른 대안 대신 이 방식을 선택한 이유: opencodex has no trustworthy per-request sandbox attestation in request text or headers, so any prompt-carried marker is spoofable by data-plane callers. -- 장점, 단점 및 영향: this closes prompt-to-native-exec escalation while preserving an explicit operator escape hatch; existing configs that relied on `codex-sandbox` must switch to `nativeLocalExec: "on"` for trusted local experiments. - -Cursor's generic tool-use prompt filter must preserve every Responses-owned execution-path tool -that survives the transport budget: unified Desktop `exec` as well as the legacy -`exec_command`/`shell_command` aliases. The legacy aliases receive Cursor-specific shell guidance; -unified `exec` keeps its own schema and is surfaced back to Codex as a client tool. It must never -fall through to the separate native-local-exec dispatcher. - -[Decision Log] -- 목적과 의도: keep fresh Cursor-routed Codex Desktop subagents able to invoke the actual unified `exec` tool exposed by their client catalog. -- 기존 구현 및 제약 조건: catalog truncation already pinned `exec`, but the later generic-tool filter recognized only bare `exec_command`/`shell_command` and could erase the sole executable client tool while also naming aliases that were absent. -- 검토한 주요 대안: synthesize a legacy alias, execute `exec` through Cursor native-local-exec, disable generic filtering, or treat every Responses-owned execution-path tool as eligible. -- 선택한 방식: preserve the existing client tool and schema by filtering with `isCursorExecutionPathTool`; keep alias-specific prompt guidance gated on an alias actually being present. -- 다른 대안 대신 이 방식을 선택한 이유: Codex Desktop remains the execution and approval authority, no unavailable tool name is invented, and the existing Responses MCP suspension path can relay the call without widening native execution privileges. -- 장점, 단점 및 영향: unified `exec` survives the filter and returns to Desktop for execution; legacy aliases behave as before; `wait` and unrelated tools remain excluded from generic tool-count prompts. - -## WebSocket - -The WebSocket endpoint exists at `/v1/responses`, but discovery is opt-in: - -```json -{ - "websockets": false -} -``` - -`websocketsEnabled(config)` is true only for an explicit `true`. When false, opencodex removes -`supports_websockets` from injected provider tables and routed catalog entries, keeping Codex on -HTTP/SSE. When true, Codex may use Responses WebSocket frames handled by `src/server/ws-bridge.ts`. -If Codex still attempts a WebSocket upgrade while the feature is disabled, `/v1/responses` rejects -the upgrade with 426 so Codex falls back to HTTP cleanly. - -That setting controls the client-facing upgrade only. The transparent upstream -ChatGPT WS optimization described above is selected independently and still -returns the same downstream SSE contract. Its WSS route checks NO_PROXY first, then selects the -first non-empty HTTPS_PROXY, https_proxy, ALL_PROXY, or all_proxy value. HTTP_PROXY alone does not -route WSS. Unsupported or malformed selected proxy values skip the WebSocket attempt and use the -existing SSE path immediately; they never fall through to a lower-priority proxy or direct WebSocket -egress. HTTP/SSE fallback retains Bun fetch's own proxy rules, which do not consult ALL_PROXY. - -The endpoint handles `response.create`, ignores `response.processed`, supports warmup -`generate: false`, and feeds the same request pipeline as HTTP/SSE. - -Registry-declared per-model compatibility hints (`modelResponsesUpstreamStreaming`) may ask the -upstream Responses endpoint for bounded JSON on ANY client transport — WebSocket or ordinary -HTTP/SSE. The bridge reframes that JSON into the same Responses event sequence -(`src/server/responses-json-events.ts`): WS turns send the frames as WebSocket messages, while -HTTP clients that requested streaming receive a synthesized terminal SSE body (created → -output_item.done → terminal → `[DONE]`). No production registry entry currently opts in: -DeepSeek V4 Flash used this path while its public-beta Responses stream was suspected of not -closing on the terminal event, but the official guide documents a -`response.completed`/`response.incomplete`/`response.failed` terminal with no `data: [DONE]` -sentinel, and live probes (2026-08-07) confirm the stream closes on the terminal. The relay's -terminal-output boundary (`src/server/relay.ts`) cuts the stream at that event and synthesizes -`[DONE]` itself, so DeepSeek streams live again; the registry knob remains as a one-line -rollback for upstreams that regress, kept suite-reachable by a synthetic-registry fixture in -`tests/providers/deepseek-inbound-wire.test.ts`. -Synthesized output is capped at 10,000 items across HTTP and WebSocket reframing. HTTP frames are -encoded incrementally, so bounded upstream JSON cannot expand into an unbounded event array or SSE string. - -DeepSeek V4 Flash keeps native Responses streaming for progressive reasoning, text, and tool-call -delivery. Its registry entry enables a model-scoped terminal repair before the existing -inspection/client split. A real `response.completed`, `response.failed`, or `response.incomplete` -event always passes through unchanged. If every opened output item has a structurally complete -`output_item.done` and no real terminal arrives for five seconds, the repair emits exactly one -`response.completed` snapshot and closes the upstream reader. EOF or `[DONE]` uses the same strict -completion check; open, malformed, duplicate, contradictory, or unknown output graphs fail closed -as `response.incomplete`, never synthetic success. The repair shares the per-turn translator byte -budget, preserves backpressure, and composes ahead of item-id/snapshot rewrites so HTTP/SSE and -WebSocket clients observe the same canonical lifecycle. - -`ws-bridge.ts` preserves upstream `failed` and `incomplete` status values in the final WebSocket -frame rather than always emitting `response.completed`. If the response status is `failed`, a -`response.failed` frame is sent; otherwise `response.completed` carries through the original status. - -## Heartbeat and stall deadline - -The HTTP/SSE bridge emits an SSE comment-line keep-alive (`: opencodex heartbeat`) during upstream -silence to re-arm Codex's idle timer (Codex's default `stream_idle_timeout` is 300 s and ANY SSE -bytes re-arm it). A comment line is discarded by every eventsource parser without producing an event, -so strict Responses decoders never see an unknown variant. Those bridge-enqueued keepalive frames do -NOT count as activity for the bridge's own watchdog: a bounded stall deadline (default 300 s, -configurable via `stallTimeoutSec`, checked on the 2 s heartbeat tick) closes the stream with -`response.incomplete` / `upstream_stall_timeout` and cancels the upstream request if no real -adapter events arrive. Adapter-yielded `{ type: "heartbeat" }` events DO reset the watchdog. - -Top-level `emptyCompletionRetry: true` opts Responses turns into one identical replay when an -upstream turn produces neither output text nor a tool call, including a stream that ends before a -terminal event. A terminal-less stream is replayed only before actionable output; post-output EOF -remains incomplete so text or tool calls cannot be duplicated. The default is off because the replay -may be billable; `OCX_EMPTY_COMPLETION_RETRY=0` is a disable-only emergency override. Streaming and -buffered HTTP adapters plus `runTurn` transports share the same guard, while combo attempts and -routed compaction stay excluded. Pre-content reasoning is retained under named event-count and byte -caps and emits liveness heartbeats while held. A second empty result or retry failure becomes typed -502 `empty_completion_retry_failed`; usage is merged across sends, and the Logs attempt records -recovery kind `empty-completion`. - -The web-search loop requests `stream: true` for every routed-model iteration, but buffers the events -needed to decide whether to intercept a synthetic search call. Text explicitly phased as -`commentary` is safe to forward live because it cannot terminate the turn; this keeps Kiro's -progress visible. A Kiro stream EOF after user-facing text or reasoning gets one bounded completion -retry, because neither the upstream text event nor `END_TURN` / `STOP_SEQUENCE` reliably distinguishes -progress from a final answer. Those two clean-stop reasons prove only that the inference ended; on a -tool-enabled turn, only the private completion tool authorizes `final_answer`. Any other explicit -reason already terminated the inference upstream and is reported as a terminal state rather -than converted into another model request: output-token limits become continuable incomplete output, -context-window exhaustion becomes a non-retryable `context_length_exceeded` error, filtering becomes -filtered incomplete output, and a `TOOL_USE` without an actual tool call is a contradiction. Since -the stop reason arrives only at the end of the stream, `required`-mode assistant text is held inside -the adapter until a real tool call starts or the stream ends, then released as `commentary` unless a -private completion call supplied the final answer. Each held event yields a `heartbeat` in its place -so the stall watchdog stays armed. Synthetic search calls, real tool calls, -and terminal events remain buffered until the iteration validates. Only the first iteration's final -response headers/status and any 429 key rotations are handled eagerly. A failure before downstream -SSE starts returns non-2xx JSON; once headers have started the final response, a generation failure -is emitted as `response.failed` SSE. - -### Pre-stream provider input overflow - -A provider HTTP 413 received before streaming starts is unambiguous request-size refusal, but raw -relay is not compatible with Codex: Codex classifies the unknown status as retryable and resends the -same oversized turn through its reconnect budget. For a streaming Responses caller, OpenCodex -therefore converts the final 413 (after any adapter-owned bounded image retry) into one HTTP-200 SSE -`response.failed` event with `error.code = context_length_exceeded` and `retryable = false`. Codex -recognizes that terminal contract, marks the context as full, and can run its own compaction policy -on the next turn. Combo routing treats 413 as a stop condition and performs the conversion only at -the outer client boundary, so the failed target is never recorded as a successful combo attempt. - -Non-streaming Responses callers retain HTTP 413 and receive a JSON `error` with -`type: invalid_request_error` and `code: context_length_exceeded`, including routed synthetic -compaction. The upstream body is replaced with the same bounded, proxy-owned message used by SSE. -Combo attempts retain their existing internal failure accounting; classification happens only at -the outer client boundary. Local admission and configured outbound-byte refusals keep their own -distinct codes. Classification does not shrink input or automatically retry compaction. -The proxy never silently drops -prompts or images: it does not own the client's transcript, and deleting input would hide data that -was never analyzed. The streaming error message is proxy-owned and bounded instead of relaying the -upstream 413 body, which may echo request content. - -[Decision Log] -- 목적과 의도: Stop Codex from replaying a provider-rejected oversized turn and hand the failure - to the client's existing context-compaction semantics. -- 기존 구현 및 제약 조건: Providers can reject before SSE starts; Codex retries raw HTTP 413, - while it recognizes terminal `response.failed` `context_length_exceeded`; the proxy cannot edit - Codex's persisted transcript safely. -- 검토한 주요 대안: Relay 413 unchanged; return HTTP 400 JSON; silently remove media or old turns; - synthesize a successful assistant warning. -- 선택한 방식: Preserve HTTP 413 with typed JSON for non-streaming clients, and map the final - streaming 413 to one redacted non-retryable Responses failure at the outer request boundary. -- 다른 대안 대신 이 방식을 선택한 이유: Raw 413 causes a retry loop, HTTP JSON does not enter - Codex's context-window path, and silent deletion or fake success loses user intent without fixing - transcript ownership. -- 장점, 단점 및 영향: Codex stops reconnecting and can compact on the next turn; no input is - silently lost. The failed turn itself is not auto-replayed, and callers must retry after Codex - compacts or reduce the current input. - -Kiro transient HTTP 429 recovery is coordinated process-wide after the first throttle: healthy -traffic remains parallel, but throttled followers wait behind one abort-aware probe and share a -deadline that is re-checked after every sleep. Event-stream `ThrottlingException` records the same -deadline for the next client replay. Retries are bounded to three attempts; hard quota responses and -ordinary 5xx errors are not replayed. Completion fallback rebuilds only replayable text, preserves -the original user/tool-result turn for reasoning-only attempts, supplies neutral non-empty carriers -for empty tool output, and validates role alternation plus tool-use/result pairing before transport. - -Provider-level `retryOn429` (devlog 260802_429_same_target_retry) is the generic, opt-in -same-target 429 retry for API-key providers (`authMode: "key"`), primarily single-key pools -that cannot use multi-key failover. In the pre-stream recovery loop, a 429 waits (`Retry-After` -or the fixed interval, capped at `maxIntervalMs`) and replays the identical request on the same -key before any failover, up to `attempts` extra times per request (the budget lives outside the -recovery loop, so a 413/401 replay cannot re-arm it). The same wait-and-replay applies to every -other key-auth surface that bypasses that loop: the Responses passthrough wire (e.g. the -built-in DeepSeek preset), the image/video bridge and web-search sidecar loops (before their -`on429` key rotation), and Anthropic terminal-guard continuations (before key/account -failover). The policy covers HTTP-capable adapters only: custom `runTurn` transports in the -image loop run through an event queue and never receive an HTTP status, so they are outside -the HTTP retry scope and cannot replay a 429. Codex never retries 429 client-side (openai/codex#30471), so this is the only -defense for those providers; the final 429 still carries `Retry-After` for clients that honor -it. Concurrent requests each honor their own policy — there is no process-wide shared cooldown -(unlike the Kiro pattern), so a rate-limit storm multiplies upstream volume by at most -`attempts + poolKeys` per request (same-key replays, then failover keys; the pool size is the -operator-configured `apiKeyPool` length, fixed for the duration of the request). Every surface -releases (and awaits the cancellation of) the unread 429 body before the backoff, records the -`rate-limit-429` recovery kind on replay sends, and the bridge loops clear the old -response-header deadline before the wait and start a fresh one afterward — client cancellation -is re-checked after the wait, so 499 always wins over a stale-deadline edge, and backoffs never -consume the connect budget or surface as a 504. The wait is abort-aware: -once the server observes the client disconnect (Bun propagates it asynchronously, observed -1–10 s), the sleep is interrupted, the unread 429 body is released, and the request is -cancelled with 499 before any replay; because the propagation is async, a replay may precede -the cancel if the interval elapses first (bounded by the same `attempts` budget). - -Provider-level `requestPacing` is the proactive companion to `retryOn429`. It reserves outbound -request-start slots before transport work begins, so a known RPM ceiling does not have to fail once -before the proxy reacts. One provider-wide lane enforces the aggregate ceiling. Exact model lanes -may add a slower interval without lowering the provider-wide interval or blocking an otherwise -eligible sibling model. Queue wait is abort-aware and happens before the response-header timeout is -armed. The shared fetch boundary covers HTTP and Responses WebSocket sends; explicit adapter -`fetchResponse` and `runTurn` dispatches reserve the same lane at their call sites. Image-bridge -iterations reserve before arming their per-attempt response-header deadline. - -[Decision Log] -- 목적과 의도: Prevent Kiro progress from becoming a false final answer, reject invalid empty completion retries, and stop concurrent transient 429s from consuming independent retry budgets. -- 기존 구현 및 제약 조건: Kiro text has no trustworthy phase; stop metadata arrives only at stream end; the private completion tool is adapter-owned; normal parallel tool traffic must remain parallel; client cancellation must interrupt all waits. -- 검토한 주요 대안: Trust native `END_TURN`; infer completion from wording; serialize every Kiro request; leave throttling entirely to the client; manufacture empty assistant turns to preserve alternation. -- 선택한 방식: Require the private completion tool on tool-enabled turns, rebuild only valid replayable wire turns, validate the final conversation, and activate a shared cooldown plus single probe only after a transient throttle. -- 다른 대안 대신 이 방식을 선택한 이유: Native stop metadata has mislabeled progress, wording is language-dependent, global serialization harms healthy concurrency, client-only retries amplify bursts, and empty structural turns are rejected upstream. -- 장점, 단점 및 영향: Completion phase is deterministic and throttled concurrency recovers without a request storm; some clean Kiro stops pay one bounded validation call and an exactly repeated completion answer may be shown twice to preserve `final_answer` semantics. - -Historical `web_search_call` output items from previous Responses turns are not converted into -assistant text. They are UI/search-cell evidence, not a replayable search result payload; turning -them into strings risks routed models echoing an internal marker or implying a current search ran -when the sidecar is unavailable. The active sidecar path is the only place that emits new -`web_search_call_begin` / `web_search_call_end` events. - -Four independent clocks bound this path. `stallTimeoutSec` is the base bridge event-stall budget. -`connectTimeoutMs` (default 200 s) covers only DNS/TCP/TLS and the wait for final response headers, -not response-body generation. Config-file-only -`webSearchSidecar.routedModelStallTimeoutMs` (default 200 s, integer 1..2147483647) bounds continuous -raw response-byte inactivity for a routed-model iteration and resets on every non-empty byte. -`webSearchSidecar.timeoutMs` (default 60 s) separately bounds one hosted search request (lowered -from 200 s so an unavailable/limit-exhausted search backend degrades within ~1 min instead of -hanging the whole turn, #398). The -effective web-search bridge watchdog is -`max(base stall, connect timeout, routed-model stall, sidecar timeout) + 30 s` (230 s at defaults, -dominated by the routed-model stall clock), -with seam heartbeats between bounded units. None of these clocks is a total generation deadline. - -## Reasoning and tool-result compatibility - -Kiro groups only consecutive original-message tool results whose raw call ID exactly matches -the originating call. Its wire-ID map retains the original ID privately so replacement or -truncation collisions cannot join unrelated results. Every non-tool message ends the group, -including a reasoning-only assistant omitted from the Kiro turns. Group finalization preserves -single-result normalization, ordered meaningful raw text and whitespace in multi-result output, -failure text, image order and sticky error status. Empty hints are applied once for an entirely -text-empty group, not once per chunk; local grouping state never enters the wire payload. - -`src/responses/task-input.ts` recognizes complete external Codex task-input envelopes -before translated Responses adapters: `function_call_output`, no `call_id` property, -nonblank `id`/`name`/`namespace`, and fully representable nonempty text/image output. -`parser.ts` emits a user turn, clears pending reasoning and includes that turn in the -existing continuation conversation-boundary calculation. The metadata is structural, -not authentication. Unknown/opaque/malformed parts reject the entire conversion; -ordinary missing/empty tool call ids retain the existing translated-route 400 guard. -Native passthrough and compaction retain raw-body handling. The leaf reuses the input -content converter after validation and imports no optional subsystem. -Stateful developer-guidance injection reuses that validator for its raw insertion -boundary, so parsed messages and stored raw history retain the same task/guidance order. - -Native OpenAI passthrough sanitizes routed reasoning history so `reasoning` input items do not send -non-empty `content` arrays to upstream models that reject them. Chat Completions bridging repairs -orphan `toolResult` messages by inserting a synthetic assistant `tool_call` before tool messages. -It also repairs the opposite direction (260718): an assistant `tool_calls` round left dangling — -by an intervening user/developer barrier or an interrupted turn — is closed by deferring barrier -messages until the round completes, reattaching real results to their original call occurrence, -and synthesizing explicit "no tool result was recorded" answers only when no real result exists -(Kimi/Moonshot 400 `ocx-mrqaiw05-269`; unit `devlog/_fin/260718_dangling_toolcall_hardening`). - -Forward-mode OpenAI passthrough also repairs replayed `call_id` values longer than the Responses -API's 64-character limit. Sidechat/fork replay can namespace routed-provider ids beyond that limit, -so each oversized id and all matching call/output items receive the same deterministic, -request-local alias. Raw API-key continuations deliberately preserve ids because an output-only -continuation may reference a call stored upstream under its original id; proxy-expanded API-key -replays are explicit and receive the same repair. - -These compatibility guards are covered by focused tests and should stay close to the adapters that -need them. - -Responses passthrough always removes output-only `status` from `reasoning` input items, including -items that retain opaque `encrypted_content`. The prior retains-blob-keeps-status invariant was -defensive rather than observed: measured OpenAI reasoning items never contain `status`, and Grok -accepts its own blob with `status` removed. Keeping it on a cold cross-backend replay instead made -OpenAI reject the unknown field before validating the blob, starving opaque-blob recovery of the -provenance error it needs. The established raw-`content` rule remains separate: ChatGPT accepts -reasoning input only with empty `content`, so a native blob plus raw content keeps the blob but still -blanks `content`. The blob is kept unless the in-process thread record proves that the current -provider, destination, adapter, model, or credential differs from the route recorded for the prior -request on that client thread. On a proven change the blob is removed while the reasoning item and -its summary survive; `status` has already been removed on every path. Missing, expired, or evicted -identity state is unknown. The comparison uses the durable destination and credential identities -with the provider, adapter, and model, so OAuth token-generation refreshes do not look like backend -changes; when either durable dimension is unavailable it refuses to record rather than falling back -to a volatile identity. Route binding only compares: it does not replace the recorded identity until -the destination successfully serves the turn. Bridged streams commit on a completed or incomplete -terminal; native passthrough streams use the non-error upstream status before relay as their success -boundary so the proxy does not retain request state across the whole stream. This deterministic -pre-flight is the primary path and covers threads the process has served while their record remains -inside the TTL/LRU bounds. Missing, expired, evicted, and -pre-process history stays fail-soft on the first send. If a Responses upstream then returns its own -self-identifying opaque-blob 4xx (`invalid_encrypted_content`, or xAI's two `invalid-argument` -decoder errors), the proxy rebuilds once through the same sanitation path: reasoning -`encrypted_content` is removed and compaction blobs use the existing text degradation. A one-shot -guard makes a second rejection terminal, and a successful recovery records the current serving -identity so later route changes return to deterministic pre-flight. A cold-record cross-backend -switch therefore costs one extra upstream round trip and one turn of degraded reasoning, rather than -wedging the thread; unrelated 4xx responses and requests whose outbound body carries no blob never -enter this recovery. - -After a self-identified opaque-blob rejection, the proxy also keeps a five-minute rejection memo. -The memo key is the resolved conversation identity plus the durable serving identity: provider, -destination, adapter, model, and credential. It is recorded only when the blobless recovery resend -succeeds. A missing durable destination or credential prevents memo creation and lookup. On a later -request with the same key, pre-flight sanitation removes opaque reasoning `encrypted_content` and -degrades compaction blobs before the first upstream send. This skips the rejected first send and -the recovery round trip. A different serving identity does not match the memo. Route changes still -follow the normal pre-flight stripping rule. Memo expiry returns to the fail-soft recovery path. - -A combo target rotation between turns legitimately changes that serving identity, so the following -turn drops blobs minted by the prior target. This is correct because the new target cannot decode -them, but it is intentionally unobvious to the client: `pickComboTarget` keys selection state only by -combo id, without a conversation dimension, and the SSE model-name rewrite preserves the requested -combo name instead of exposing the concrete target switch. A user can therefore observe a reasoning -cache drop with no visible model change. - -The image and web-search auxiliary loops consume `_reasoningReplayScope` for bridge-level replay but -never call `bindRouteReasoningReplayScope`, so their internal small-model requests do not update the -serving-identity record. That omission is intentional: binding those routes would poison the main -conversation's last-serving identity and cause a later main-model turn to strip valid blobs. - -[Decision Log] -- 목적과 의도: Keep same-backend opaque reasoning replay while preventing backend-private blobs and output-only fields from breaking the first turn after a route change. -- 기존 구현 및 제약 조건: Reasoning-input sanitation already handled raw content and `ocxr1:` envelopes; the replay cache already supplied a bounded, thread-scoped physical-route identity, but no record connected that identity to native `encrypted_content` provenance. -- 검토한 주요 대안: Strip every opaque blob, persist provenance across restarts, trust generic 4xx prose, rely only on a retry, or combine deterministic route comparison with a narrowly identified recovery. -- 선택한 방식: Remove output-only `status` from every reasoning input item without changing the pre-existing raw-`content` blanking rule; compare a 64-entry/256 KiB/one-hour in-process serving-identity record using durable destination and credential dimensions before sending, commit it only after the selected destination succeeds, pass a proven change into the Responses adapter before the first send, and use one self-identified opaque-blob recovery only when provenance was unknown. -- 다른 대안 대신 이 방식을 선택한 이유: The former blob/status coupling was defensive rather than observed, and live backends showed that removing `status` preserves same-backend Grok replay while allowing cold cross-backend requests to reach blob validation. Unknown provenance can still be valid after restart, durable storage is unnecessary for this bounded compatibility hint, and deterministic pre-flight avoids the extra paid or stateful upstream attempt whenever the process has evidence. The upstream's narrow error identity supplies authoritative evidence only for histories the process could not observe. -- 장점, 단점 및 영향: Same-route and unknown replay retain cached reasoning on the first send without replaying an output-only field, known cross-route replay keeps the reasoning item without its undecodable blob, and a cold cross-route replay can reach opaque-blob recovery instead of failing early on `status`. A repeated blob rejection is surfaced unchanged after exactly one recovery attempt. - -DeepSeek's stateless Responses compatibility pass normalizes only unambiguous tool-call batches. -Calls emitted before the first matched output stay together as one assistant batch, followed by -their outputs in call order; hook-injected messages that split the batch move after it without being -dropped. This preserves #1292's single-call adjacency repair without splitting a same-turn parallel -batch away from its preceding plaintext reasoning (#1477). Tolerant providers never enter this pass, -and duplicate, missing, or backwards call/result pairs are left for the upstream to reject rather than guessed. - -[Decision Log] -- 목적과 의도: Preserve DeepSeek reasoning replay for parallel tool calls while retaining the provider-scoped repair for hook-interleaved results. -- 기존 구현 및 제약 조건: Pair-by-pair adjacency fixed one call but split parallel calls into separate assistant turns; DeepSeek always enables parallel tool calling and merges adjacent reasoning and calls into one assistant message. -- 검토한 주요 대안: Disable parallel calls, duplicate reasoning, remove the #1292 repair, or normalize one unambiguous call/output batch. -- 선택한 방식: Group calls that occur before the first matched output, emit the call batch followed by outputs in call order, and retain intervening non-tool items after the batch. -- 다른 대안 대신 이 방식을 선택한 이유: The batch shape matches the documented Responses contract without inventing reasoning or reintroducing hook-interleaving failures. -- 장점, 단점 및 영향: Sequential and parallel tool continuations both retain their reasoning contract; only the declared strict provider changes order, and ambiguous histories still fail closed upstream. - -## Cursor parameterized models - -Cursor Router's parameterized `default` model is represented in Codex by four catalog rows: -`cursor/auto` preserves Cursor's team/account default, while `cursor/auto-cost`, -`cursor/auto-balance`, and `cursor/auto-intelligence` make each optimization level explicit. -All four route to the `default` Cursor wire model. Explicit variants additionally populate -`AgentRunRequest.requested_model.parameters` with the `optimization` parameter; this is the same -parameterized-model channel used by current Cursor clients. Router rows are static capabilities and -must survive a live `GetUsableModels` response that omits `default`. - -`cursor/grok-4.5-fast` and `cursor/grok-4.6-fast` are stable Codex-facing rows, but current Cursor -clients do not request them as flat model slugs. OpenCodex sends the matching Grok base id through -`requested_model` with separate `effort` and `fast=true` parameters, leaving legacy `model_details` -unset for that parameterized external selection. Grok 4.5 stops at `high`; Grok 4.6 additionally -advertises and sends `xhigh`. Live discovery recognizes Cursor's flattened -`cursor-grok-{version}-{effort}-fast` variants, plus the older -`grok-{version}-fast-{effort}` ordering, as availability evidence only. - -## Cursor active-context usage - -Cursor's `conversationCheckpointUpdate.tokenDetails.usedTokens` is treated as the authoritative -absolute active-context size for a Cursor conversation. Some client-tool suspension turns must end -before Cursor emits a new checkpoint; those turns carry forward the last observed total for the same -Cursor conversation instead of reporting only the tiny current-turn output delta. The carry-forward -cache is process-local, numeric-only, bounded, and keyed by Cursor conversation id. Compaction -boundaries clear the carry so pre-compaction totals are not reused after Codex replaces history. -Historical compaction markers restored by `previous_response_id` expansion are acknowledged as a -replayed prefix and do not clear a fresh post-compaction checkpoint again on every later turn. -Compaction summarizer turns may still report their own checkpoint for that response, but their -pre-compaction checkpoint is not persisted for later carry-forward. - -```text -[Decision Log] -- 목적과 의도: Keep Codex's visible "context left" indicator aligned with Cursor's active-context usage on client-tool turns that finalize before a checkpoint arrives. -- 기존 구현 및 제약 조건: Checkpoint turns reported totalTokens correctly, but no-checkpoint client-tool finalize fell back to output-only usage and could overwrite a meaningful prior total with values like 109 tokens. -- 검토한 주요 대안: Add a longer wait for late checkpoints; infer prior+output totals; store full prompt/history state; carry forward only the last numeric checkpoint per Cursor conversation. -- 선택한 방식: Carry forward the last numeric absolute checkpoint per Cursor conversation with bounded LRU/TTL storage, update it only from live checkpoint frames, and clear/suppress it once when a newly appended compaction boundary starts an epoch; previous_response replay provenance acknowledges historical markers without serializing private metadata upstream. -- 다른 대안 대신 이 방식을 선택한 이유: It fixes the UI regression without delaying tool turns, fabricating token growth, storing prompt/tool content, or repeatedly clearing valid post-compaction usage when historical markers replay; one-time compaction resets still prevent stale over-report when history is replaced. -- 장점, 단점 및 영향: Active-context reporting stays monotonic within an uncompacted Cursor conversation; no-checkpoint turns remain estimated; a process restart loses the numeric cache, and when neither a checkpoint nor a carry-forward is available the turn reports a request-local estimate derived from the same pruned payload sent to Cursor (#373 — reporting output-only usage made Codex read the context as nearly empty). Estimates are never persisted or promoted into checkpoint carry-forward; only live checkpoint frames update the cache. -``` - -## Cursor conversation checkpoint reuse - -After a successful no-tool turn, the Cursor adapter keeps the returned ConversationStateStructure in -a process-local store and reuses that snapshot on the next validated linear continuation instead of -rebuilding rootPromptMessagesJson and conversationTurns. Tool-result turns reuse the last completed -checkpoint plus only the uncovered suffix. A request without checkpointRef may use the prefix index -only when a remembered Cursor conversation or stable client thread owns the resolved conversation id. -The stable owner may be the Codex parent-thread header or the existing bounded process-local HMAC of -the complete Desktop session-id/thread-id pair. The request must also have a covered message prefix -and system/developer digest that match exactly one snapshot for that same -conversation. Headerless requests without a stable owner full-replay. Isolated helper/shadow turns -never join the parent or sibling conversation. An explicit missing checkpointRef full-replays. Compaction, account or model mismatch, missing refs, decode failures, and -invalid_argument recovery keep the existing full-replay path. previous_response_id may select a -branch's opaque checkpointRef; it is never a Cursor conversation ownership key. Cursor Connect still -does not expose authoritative cache_read_tokens. - -```text -[Decision Log] -- 목적과 의도: Reuse Cursor's returned ConversationStateStructure on validated linear continuations so OpenCodex does not rebuild the full root history every turn. -- 기존 구현 및 제약 조건: Stable conversation ids already exist (#366), but every turn still reconstructed rootPromptMessagesJson and conversationTurns. Cursor Connect still reports only usedTokens/maxTokens, so cache_read_tokens cannot be treated as authoritative (#275). -- 검토한 주요 대안: Keep full replay; copy Pi's live MCP bridge immediately; store raw protobuf in Responses JSON; key checkpoints only by conversation id. -- 선택한 방식: Keep an opaque process-local checkpointRef on OcxProviderContinuationState.cursor, bind the snapshot to conversation/account/model affinity, and require a remembered provider conversation or stable client thread before a ref-less prefix lookup. Reuse the bounded process-local Desktop session/thread HMAC when the canonical parent-thread header is absent. Pin referenced blobs for the checkpoint lifetime, and fall back to the existing full-replay path for unowned headerless requests, isolation, compaction, restart, missing refs, and invalid_argument recovery. Tool-result turns reuse the last completed checkpoint plus an uncovered suffix. previous_response_id is a branch anchor, never a Cursor conversation ownership key. -- 다른 대안 대신 이 방식을 선택한 이유: It removes avoidable replay cost without claiming cache-hit rates, without changing OAuth, and without collapsing helper/compaction isolation or tool-call replay safety. -- 장점, 단점 및 영향: Validated no-tool follow-ups stop growing local rootBytes with history; a process restart or missing blob lease falls back to full replay; large-context 429 / premature-completion acceptance for #1527 is still unproven; a stateful live MCP bridge remains out of scope. -``` - -## Google thought-text visibility boundary - -Google-family responses may represent model-internal reasoning as a text-bearing part with -`thought: true`. The Google adapter maps that text to the internal `reasoning_raw_delta` event; -only text without the marker becomes visible `text_delta`. Streaming SSE and buffered JSON share -one classifier so transport selection cannot change whether provider-declared reasoning is shown -as assistant output. Thought-signature observation still runs on the original parts before text -classification, preserving the opaque continuation state independently of display semantics. - -[Decision Log] -- 목적과 의도: Prevent provider-marked internal reasoning from appearing as ordinary assistant text while preserving reasoning and tool-call continuation. -- 기존 구현 및 제약 조건: Both Google response paths emitted every non-empty `Part.text` as visible text; function calls, inline images, and Antigravity/Vertex thought-signature replay already depended on the original part ordering. -- 검토한 주요 대안: Drop thought text; classify it separately in each parser; remove the marker and keep visible text; use one shared classifier without mutating the provider parts. -- 선택한 방식: Map `thought: true` text to `reasoning_raw_delta` through one helper used by streaming and buffered parsing, leaving part order and signature observation unchanged. -- 다른 대안 대신 이 방식을 선택한 이유: Dropping the text loses reasoning replay/display policy input, while duplicated parser rules can drift and exposing marked thoughts violates the provider's visibility boundary. -- 장점, 단점 및 영향: Internal reasoning no longer leaks into normal answers and both transports stay consistent; downstream reasoning policy still decides whether raw reasoning is rendered or only preserved, and malformed non-boolean markers remain ordinary text rather than broadening hidden-content inference. - -## Google response-part field boundary - -Google-family adapters validate the values inside an otherwise well-formed response part before -they become `AdapterEvent`s. A present `functionCall` must be an object with a nonblank string -`name`; because Gemini delivers that call atomically rather than across deltas, an invalid name is a -terminal protocol error and is never dispatched. A non-string optional `text` value is dropped -without coercion, while the rest of the part and turn continue. Structured `functionCall.args` -remain provider-native and are serialized as before. - -[Decision Log] -- 목적과 의도: Keep malformed Google-compatible response fields from violating the internal string-only text and tool-name contract or dispatching an unidentified tool. -- 기존 구현 및 제약 조건: Container validation guaranteed object parts, but truthy string/number/array functionCall values emitted a nameless tool call and truthy non-string text values crossed as text or reasoning events. Gemini supplies a complete call in one part, so there is no later name fragment to await. -- 검토한 주요 대안: Pass malformed values through; coerce them to strings; silently drop every malformed field; terminate the turn for every malformed field; distinguish dispatch identity from optional text. -- 선택한 방식: Prevalidate function calls and terminate on a non-object, non-string, empty, or whitespace name; drop only non-string text; leave arguments untouched. -- 다른 대안 대신 이 방식을 선택한 이유: Passing or coercing can execute the wrong tool or fabricate transcript text, while terminating for optional malformed text discards an otherwise usable response. An invalid call name cannot be recovered or safely ignored once the model selected a tool. -- 장점, 단점 및 영향: Streaming and buffered paths enforce the same AdapterEvent contract and invalid calls cannot enter thought-signature replay. Nonconforming third-party Google-compatible text fields are ignored rather than surfaced, and operators receive a structured terminal error for call identity failures. - -## Google tool-call thought-signature replay - -Gemini may attach an opaque `thoughtSignature` to a `functionCall` and requires that exact value on -the matching model turn when its tool result is submitted. Antigravity and Vertex share the existing -bounded TTL/LRU replay store, keyed by compiled function-call name plus canonical arguments. Vertex -prefixes its cache model key with the transport, project, and location identity, so a signature -minted by Vertex cannot be sent to Antigravity even when both routes expose the same public model id. -Vertex prefers Codex's opaque `prompt_cache_key` for session identity and falls back to the existing -first-user-message derivation for clients that omit it; only the fixed hash is retained. -Both streaming and non-streaming responses feed the store; request compilation happens before replay -so matching uses the provider-visible tool name. - -[Decision Log] -- 목적과 의도: Preserve Vertex Gemini tool-call continuation without exposing opaque signatures to Codex or another Google backend. -- 기존 구현 및 제약 조건: Responses history does not carry a safe Gemini signature field; Antigravity already used a bounded in-process replay cache, while Vertex bypassed it and received HTTP 400 after the first tool call. -- 검토한 주요 대안: Serialize the signature into Responses item ids or reasoning content; create an unbounded Vertex map; reuse the bounded cache with or without a transport namespace. -- 선택한 방식: Reuse the bounded cache for Vertex, observe both response shapes, apply after wire-name compilation, and scope Vertex by transport/project/location plus the opaque client session key when available. -- 다른 대안 대신 이 방식을 선택한 이유: Responses ids are not Gemini signatures and previously caused Base64/TYPE_BYTES failures; a second cache duplicates limits; an unscoped cache could send provider-private state across destinations. -- 장점, 단점 및 영향: Tool loops continue with exact opaque state and bounded memory while cross-transport reuse fails closed. Replay remains process-local, matching the existing Antigravity contract. - -## Google tool-result adjacency repair - -Google-family requests serialize a model tool-call turn and its results as one adjacent -`model -> user` pair. The user turn contains exactly one `functionResponse` for every representable -call in original call order. Missing results use an explicit unknown-history marker; duplicate, -mismatched, and standalone results become marked text instead of unpaired function responses. -Representable data-URL images remain sibling `inline_data` parts in either case. - -[Decision Log] -- 목적과 의도: prevent interrupted or replayed Claude-on-Antigravity histories from reaching the - Google wire with unanswered `functionCall` or unpaired `functionResponse` parts. -- 기존 구현 및 제약 조건: `messagesToGeminiFormat` emitted every internal message independently; - Antigravity translates the resulting Gemini shape back into strict Anthropic tool-use blocks, and - rejects malformed adjacency with HTTP 400. Tool-result images cannot live inside a - `functionResponse` and already rely on sibling `inline_data` parts. -- 검토한 주요 대안: repair the shared internal history; synthesize fake calls for orphan results; - repair only the Google adapter serialization boundary. -- 선택한 방식: group only consecutive results after a model call batch, match by the normalized - request-scoped call id, emit responses in call order, synthesize an explicit missing result, and - degrade remaining results to marked text while retaining image siblings. -- 다른 대안 대신 이 방식을 선택한 이유: shared-history mutation could change other adapters, - while fabricating a successful call would invent model behavior. The adapter boundary owns the - strict upstream wire contract and can repair it without changing client-visible history. -- 장점, 단점 및 영향: normal histories remain byte-shape equivalent, parallel and interrupted - histories become provider-valid, and orphan data is not lost. A result separated by a non-tool - barrier is intentionally not reattached across that boundary. - -## OpenRouter provider routing - -The canonical OpenRouter `openai-chat` transport may carry optional provider-routing preferences -from `OcxProviderConfig.openRouterRouting`, with exact model-id replacements in -`modelOpenRouterRouting`. The adapter maps camel-case config to OpenRouter's request wire -(`order`, `only`, `allow_fallbacks`) after the Codex-facing routed slug has been decoded to the -native model id. - -Preferences are accepted only for `https://openrouter.ai/api/v1` (an optional trailing slash is -equivalent) and the `openai-chat` adapter. Alternate ports, credentials, query strings, fragments, -lookalike hosts, and custom proxy paths fail validation. A model override replaces rather than -merges the provider-wide default, keeping precedence deterministic. With no preference configured, -the request body is byte-for-byte unchanged in this area and OpenRouter retains its default routing. - -## Kimi Coding Plan prompt-cache affinity - -The canonical `kimi` OAuth and `kimi-code` API-key presets opt into forwarding the internal -request's `prompt_cache_key` to Kimi's Chat Completions body. Kimi Code Plan documents a stable -session/task key as required to improve cache hit rates. The chat adapter never invents a key of -its own: it forwards what the request already carries — Codex's session key on -`/v1/responses`, or the session-scoped key the Claude `/v1/messages` inbound derives -(metadata.user_id hash, else the system+tools cohort hash) — and a request with no key stays -keyless. An explicit provider-level `promptCacheKey: false` continues to opt out, and the flag is -persisted through `providerConfigSeed`/`enrichProviderFromRegistry` for new configs; key-pool 429 -rotation keeps it — along with every other registry backfill — because the retry starts from the -fresh committed provider row and routes it again (`rotateProviderTransportOn429` in -src/providers/key-failover.ts). Stale request-time config fields are deliberately discarded so a -concurrent deletion stays authoritative; only runtime `fetch` state and generated OpenCode session -affinity survive the rebuild. If an opted-in upstream rejects the field, OpenCodex does not strip it and retry or mutate the -saved configuration. Other OpenAI-compatible providers remain deny-by-default because strict -backends may reject the OpenAI-specific field. - -## xAI Grok hardening (official Grok Build contract parity) - -Grounded in the open-sourced official client (xai-org/grok-build); unit + evidence: -`devlog/_fin/260716_grok_build_hardening/`. - -- **Reasoning folding:** the Responses parser folds `reasoning` items into the FOLLOWING - assistant turn (`pendingReasoning` in `src/responses/parser.ts`) so the Grok chat wire carries - ONE assistant message with `reasoning_content` — exact-prefix cache stability. Unsigned - siblings newline-join; `ocxr1`-signed siblings stay separate parts (Anthropic replay keeps - each signature on its own text); boundaries (user/tool-result/agent) clear pending state; - call items fold pending reasoning into the same turn. -- **Grok CLI credential ownership:** `source:"local-cli"` xAI credentials re-read - `~/.grok/auth.json` (read-only) before any refresh and adopt a newer usable generation with - zero IdP calls (`shouldAdoptGrokGeneration`, later-expiresAt authority); an IdP refresh - detaches the credential to `source:"oauth"`. -- **Two-lock refresh transaction:** per-provider+account intent lock held across the IdP - exchange plus a short global store-write lock + async mutation funnel around every - `auth.json` load-merge-persist (`src/oauth/store.ts`); generation-guarded persist - (`expectedGeneration` → superseded adoption), conditional `needsReauth`, bounded jittered - retry for transient token-endpoint failures. -- **Reactive 401 replay:** both the adapter recovery loop and native Responses passthrough branch - force-refresh once (singleflight, generation-checked) and replay OAuth-backed xAI requests - exactly once with a re-resolved transport; API-key/BYOK paths are excluded - (`src/server/responses/core.ts`). -- **Header parity:** per-attempt `x-grok-req-id` (fresh UUID inside the transport fetch - wrapper), stable session/conv affinity headers, always-set User-Agent, and a single - compatibility profile const for the Grok client version (`src/providers/xai-transport.ts`); - `fetchWithHeaderTimeout` takes an executor so provider fetch wrappers stay inside the - timeout race. - -The generated Grok client marker also enables a client-facing sparse-terminal repair for native -Responses streams. Grok Build renders text deltas immediately but derives its durable assistant -turn from `response.completed.response.output`; an OpenAI-compatible stream may instead place the -complete items in `response.output_item.done` and finish with an explicit empty output array. For -that marked client only, OpenCodex uses a terminal-only tracker: it retains bounded, contiguous, -unique and semantically valid raw completed items, then backfills a missing or empty terminal -snapshot. It never promotes locally synthesized or merely repaired items. Unmarked callers continue -to treat an explicit empty array as authoritative. Within this marked client-facing repair, -malformed, gapped, oversized, contradictory, failed, or incomplete streams stay fail-closed. - -[Decision Log] -- 목적과 의도: Prevent Grok Build from classifying a visibly streamed answer as empty and replaying - the same billable turn when the terminal snapshot is sparse. -- 기존 구현 및 제약 조건: OpenCodex already reconstructed missing terminal output for provider - opt-ins, but preserved explicit empty arrays; Grok Build discarded ordinary completed-item events - when constructing its final conversation response. -- 검토한 주요 대안: Change every caller's empty-array semantics; accept a turn merely because a - text delta was visible; reuse the provider's broader lifecycle synthesis; add a strict repair at - the generated Grok client boundary. -- 선택한 방식: Use the existing generated client marker to opt Grok into a terminal-only repair and - backfill only from unique, contiguous, bounded real done items whose raw semantics are valid. -- 다른 대안 대신 이 방식을 선택한 이유: A global rewrite would alter valid provider semantics, - while accepting deltas without durable items would leave persistence and continuation empty. The - marker is already the client-specific compatibility boundary; keeping the provider repair separate - also prevents synthesized or permissively normalized items from overriding an explicit empty terminal. -- 장점, 단점 및 영향: Grok receives one durable completed answer without a paid retry; ordinary - clients remain byte-semantics compatible. The proxy retains bounded item state for marked streams - and intentionally refuses ambiguous reconstruction. - -## Kiro client parallel-tool hint - -Kiro's wire remains serialized even when an OpenAI Responses client sends -`parallel_tool_calls: true`. That request field is permissive: it allows parallel calls but does not -require the routed transport to expose a matching flag. The Kiro catalog therefore continues to -advertise `supports_parallel_tool_calls: false`, and the adapter emits no parallel-control field, -while accepting the client hint and translating the ordinary tool catalog normally. - -[Decision Log] -- 목적과 의도: Keep current Codex clients usable with Kiro without claiming or inventing parallel execution on the CodeWhisperer wire. -- 기존 구현 및 제약 조건: Codex can send `parallel_tool_calls: true` even for catalog rows that advertise false; Kiro has no verified parallel-control request field and serializes tool execution. -- 검토한 주요 대안: Reject the client hint, rewrite it to false before routing, or accept it as permission while leaving the Kiro wire unchanged. -- 선택한 방식: Accept either request value, preserve the parsed client intent internally, and omit all parallel-control fields from the Kiro payload. -- 다른 대안 대신 이 방식을 선택한 이유: Rejection interprets permission as a requirement and blocks valid turns, while rewriting shared request state hides caller intent and can affect later policy or diagnostics. -- 장점, 단점 및 영향: Codex tool turns reach Kiro again and the adapter contract stays honest; Kiro still cannot produce true parallel tool batches through this transport. - -## Kiro Responses text controls - -Kiro refuses structured output and tolerates every other Responses `text` member. `text.format` -of type `json_schema` or `json_object` is a contract the CodeWhisperer wire cannot honour, so the -adapter rejects it rather than returning prose to a caller expecting JSON. `text.verbosity` and -`text.format: {"type":"text"}` are preferences, not contracts; they are accepted and dropped, -because `buildKiroPayload` composes `conversationState` from parsed fields and never forwards the -raw body. - -[Decision Log] -- 목적과 의도: Stop rejecting valid Kiro turns whose only offence is carrying a Responses text control the wire ignores. -- 기존 구현 및 제약 조건: The guard tested `_rawBody.text !== undefined`, so `text.verbosity`, `text.format:{"type":"text"}`, and even `text:{}` produced HTTP 400 with sendCount 0 while identical turns without `text` succeeded; `_structuredOutput` already distinguishes real structured output, and the catalog's `support_verbosity: false` helps neither a client holding a cached catalog nor the default text format, which no capability flag governs. -- 검토한 주요 대안: Keep the presence check, add an openai-responses-style stripper before serialization, or narrow the guard to `_structuredOutput` alone. -- 선택한 방식: Narrow the condition to `_structuredOutput`; no stripper is needed because the Kiro payload never spreads the raw body. -- 다른 대안 대신 이 방식을 선택한 이유: The presence check reads a preference as a requirement — the same error `db040e70f` removed for parallel-tool hints — and a stripper would add a serialization stage to defend against a body Kiro already ignores by construction. -- 장점, 단점 및 영향: Kiro-routed Codex turns stop failing intermittently and structured output stays honestly refused; a future `text` member Kiro genuinely cannot ignore would need its own condition. - -## Kiro reasoning round-trip (`redactedContent`) - -Kiro never returns plaintext reasoning for its **GPT-5.6 family** (`gpt-5.6-sol`, `-terra`, -`-luna`): `reasoningContentEvent` carries a KMS-encrypted `redactedContent` blob, never `text`. -Their `additionalModelRequestFieldsSchema` (`ListAvailableModels`) accepts only `reasoning.effort` -with `additionalProperties: false` — there is no display/summary opt-in, so this is the only -reasoning these models can return. Kiro's own CLI replays the blob on the matching -`assistantResponseMessage.reasoningContent` to preserve model reasoning across turns; dropping it -makes every turn restart without the previous turn's reasoning. Verified on kiro-cli 2.14.1 and -2.16.0, all three models. - -The Claude 4.6+/5 entries advertise a different, richer contract (`thinking.type` adaptive/disabled, -`thinking.display` summarized/omitted, `output_config.effort`, `max_tokens`) and are not covered by -that measurement; older Claude, deepseek, minimax, glm, and qwen entries advertise no additional -fields at all. The handling below keys off the wire field, not the model id, so any model that -sends `redactedContent` round-trips. - -- The blob rides the existing `ocxr1:` envelope as `krc` (`src/responses/reasoning-envelope.ts`) on - an envelope-only reasoning item — `summary: []`, no text deltas — so it stays invisible in the - Codex app while round-tripping, exactly like the hidden-thinking path. -- **Pairing is backwards.** Kiro emits `reasoningContentEvent` at the END of an assistant turn, - after content AND tool calls. A `krc`-only item therefore belongs to the turn that already - closed, so the parser attaches it to the PRECEDING assistant message rather than folding it into - the following turn like ordinary reasoning (`src/responses/parser.ts`). With no assistant turn to - own it, the blob is dropped rather than mis-paired. -- The blob lives on `OcxAssistantMessage.kiroRedactedReasoning`, not on a thinking content part, so - no other adapter replays provider-private state if the conversation switches providers. - -Kiro reports context pressure in its own `contextUsageEvent`, which is the authoritative source. On -every capture taken (2.14.1 and 2.16.0) `metadataEvent` carried only `stopReason` — which is why -reading the percentage from `metadataEvent` alone never saw a value — but the parser still accepts a -finite `contextUsagePercentage` (and a `tokenUsage` block) there as a fallback, so a value parsed -from `metadataEvent` is legitimate rather than impossible. Both feed the same field, and any -positive value overwrites an earlier one. - -Spend arrives in `meteringEvent` as **credits, not tokens**. No captured response carried -`tokenUsage` on any event, which is why Kiro usage stays estimated; `meteringEvent` is currently -ignored because a credit is not a token count. - -## Chat Completions inbound native path - -`POST /v1/chat/completions` sends eligible `openai-chat` routes directly to the provider's Chat -Completions endpoint. Route selection reads the raw Chat body and the native request keeps that body -as its wire source; a Responses projection is constructed only after the native route is declined -and is never converted back into Chat. Request construction remains owned by `src/adapters/openai-chat.ts`, including model -normalization, credential and provider headers, capability-specific fields, and the canonical -`openaiChatCompletionsUrl()` path. The passthrough builder uses an explicit Chat-field whitelist so -messages (including `name` and separate `system`/`developer` entries), Chat token controls, -sampling/logprob fields, caller identity/metadata, and caller stream options retain their wire -shape. For streams, caller `stream_options` are merged with mandatory `include_usage: true`. On -the native passthrough there is no canonical Fast injection and no wire mapping: every caller -`service_tier` — canonical or foreign — is forwarded raw and only under `chatServiceTier: true`, -and `fastMode` injects nothing here. Resolved-Fast-policy injection applies only to routes that -take the Chat -> Responses -> Chat bridge below. `parallel_tool_calls` is emitted only for providers opted into -parallel tools (or pinned false by the existing provider opt-out contract). -Combo/policy routes and requests that need Responses-only hosted tools, continuation, background, -or storage semantics retain the existing Chat -> Responses -> Chat bridge. - -The direct SSE relay accepts CRLF and arbitrary transport chunk boundaries while retaining at most -one bounded event. EOF with an unterminated event and an event above the translator limit are typed -upstream failures, never successful partial completions. Provider-controlled structured error -messages are redacted before either JSON or SSE reaches the client. The native path uses the same -request-attempt logging, reset retry, same-key 429 replay, key rotation, usage extraction, and -request-signal cancellation contracts as routed Responses transport. - -## Chat streaming client with a JSON upstream result - -The translated inbound path in `src/server/chat-completions.ts` may receive a complete JSON -Responses result even when the Chat client requested SSE. Its synthetic stream reuses -`responsesJsonToChatCompletion` as the semantic authority: converted text, reasoning, available -refusal content, tool calls, finish reason, and usage must survive this final delivery conversion. -Tool calls gain their array-order stream `index`; the stream retains one assistant-role frame, -one terminal choice, and one `[DONE]`. Both native and translated JSON fallbacks share -`jsonCompletionSse`; its temporary frame strings and final body ownership are charged to the -existing translator budget. Known incomplete limits take precedence over tool finish reasons; -unmapped incomplete boundaries remain errors. The existing response-body lifecycle owns translation-budget -release on consumption or cancellation. Actual upstream SSE and native Chat bypass this fallback. - -[Decision Log] -- 목적과 의도: Keep tool execution and incomplete-response detection working when a streaming client receives a JSON upstream result. -- 기존 구현 및 제약 조건: The existing fallback copied only text and forced `stop`, despite the JSON converter already retaining tool calls, reasoning, and incomplete status. -- 검토한 주요 대안: Duplicate Responses parsing in the emitter; perform another inference request; preserve the already-converted Chat completion. -- 선택한 방식: Copy supported converted message fields into one delta, assign tool-call stream indexes, and retain the converted finish reason. -- 다른 대안 대신 이 방식을 선택한 이유: One conversion authority prevents the streaming fallback from drifting from non-streaming semantics without changing routing or retry behavior. -- 장점, 단점 및 영향: No additional upstream request or dependency; this remains buffered delivery, not token-by-token upstream streaming. Handler regressions cover tools, reasoning, length, ordinary and empty completions, and budget release. - -### Chat refusal projection - -`src/chat/outbound.ts` keeps Responses refusal parts separate from ordinary content. JSON output -and the stream collector expose nullable `message.refusal`; `jsonCompletionSse` preserves it as -`delta.refusal`, while the native SSE relay remains opaque. The translated live stream keys refusal -state by raw `output_index` / `content_index`, validates present item IDs as correlation constraints, -and emits buffered parts in that order only at a valid completed/incomplete terminal. Deltas append; -equal, empty, absent, and shorter-prefix snapshots preserve existing text; extending snapshots add -only new text. Non-string or contradictory snapshots fail with a content-free typed error. - -The existing turn budget accounts for refusal text and map metadata, including empty entries, and -releases that state on terminal, failure, or cancellation. Pending role/tool/refusal/finish/`[DONE]` -frames form one terminal batch: all serialized strings and encoded frames must be admitted before -any batch frame is enqueued. Admission failure releases the batch and refusal state, cancels upstream, -and emits only the bounded overflow error. Collector processing failures cancel their reader before -releasing its lock, so upstream translation cannot continue after failed JSON collection. The outer -response finalizer continues to own retained response bytes. These are projection rules, not new -refusal policy or changes to ordinary content/tool semantics. - -## Parallel tool calls (default-on for chat providers) - -The openai-chat adapter buffers ALL streamed `tool_calls` deltas (keyed by `index`, falling back to -`id`, then last-seen) and flushes them as atomic start/delta/end sequences at the terminal signal. -This is required by the bridge's sequential tool-call contract and makes interleaved parallel -deltas, id-only-first-chunk continuations, and whole-chunk multi-call frames all safe. - -Parallel tool calls are DEFAULT-ON for openai-chat providers: the adapter follows Codex's -request-level `parallel_tool_calls` bit (default true) and routed catalog entries advertise -`supports_parallel_tool_calls`. `OcxProviderConfig.parallelToolCalls: false` is the per-provider -opt-out (registry-seeded, router-backfilled; an explicit user value always wins). Non-chat -adapters advertise the catalog bit only on explicit `true`; cursor keeps its own special-casing. -Providers with flaky parallel streaming can be opted out individually. Evidence and provider -ledger: `devlog/_fin/260709_parallel_tool_calls/`. - -## Volcengine Ark assistant continuation shapes - -The `openai-chat` adapter keeps Volcengine's pay-as-you-go Chat endpoint and Coding Plan endpoint -on separate empty-assistant contracts. The pay-as-you-go `/api/v3` route retains the structured -`[{ "type": "text", "text": "" }]` placeholder inferred for #796, while `/api/coding/v3` uses the -ordinary empty string accepted by its live tool-call continuation contract (#1571). Matching only -the shared Ark hostname is too broad because the two endpoint families reject opposite shapes. - -[Decision Log] -- 목적과 의도: Preserve multi-turn tool-call continuations across both Ark Chat endpoint families. -- 기존 구현 및 제약 조건: The #796 workaround was host-wide and unverified; live Coding Plan evidence shows its structured placeholder returns HTTP 400 while an empty string succeeds. -- 검토한 주요 대안: Remove the workaround globally, select by model ID, or scope it by endpoint path. -- 선택한 방식: Apply the structured placeholder only to recognized Ark hosts whose normalized base path is exactly `/api/v3`. -- 다른 대안 대신 이 방식을 선택한 이유: Global removal would reopen #796, while model IDs can appear behind multiple Ark products and therefore do not identify the wire contract. -- 장점, 단점 및 영향: Coding Plan regains its accepted continuation shape without changing generic providers; any future Ark endpoint family must provide evidence before inheriting the pay-as-you-go quirk. - -## Chat structured-output compatibility - -First-party Kimi and Moonshot Chat destinations normalize a `$ref` with sibling keywords because -their wire rejects that valid JSON Schema 2020-12 shape. Inlining preserves conjunction semantics: -`required` members are unioned, lower numeric bounds take the maximum, upper numeric bounds take the -minimum, and overlapping `properties` recurse with the same rules. The walk remains depth-, node-, -and expansion-bounded. Unresolvable or cyclic references keep the existing bare-`$ref` fallback, -and unrelated OpenAI-compatible providers retain the caller's schema unchanged. - -[Decision Log] -- 목적과 의도: Make Moonshot's compatibility rewrite remove rejected sibling `$ref` shapes without silently weakening a tool schema. -- 기존 구현 및 제약 조건: The target and sibling both apply under JSON Schema 2020-12, but a shallow shared-property merge let sibling bounds replace stricter target bounds; Moonshot still requires the local bounded rewrite. -- 검토한 주요 대안: Keep shallow sibling precedence; emit `allOf`; intersect only top-level bounds; recursively compose the supported set-valued and ordered assertions. -- 선택한 방식: Reuse the existing bound and required intersection rules recursively for overlapping object properties inside the first-party destination gate. -- 다른 대안 대신 이 방식을 선택한 이유: Shallow precedence weakens constraints, while a new `allOf` wire shape needs separate provider evidence; recursive composition fixes the demonstrated loss without broadening normalization to custom providers. -- 장점, 단점 및 영향: Looser siblings cannot relax nested constraints and tighter siblings still narrow them; non-ordered conflicting keywords retain the existing sibling precedence and are not treated as a complete JSON Schema algebra. - -The `openai-chat` adapter translates Responses `text.format` and Chat Completions -`response_format` through one internal format, then emits `response_format` on the upstream chat -wire. That remains the default because silently returning prose breaks clients that requested a -JSON object or schema. A mixed-capability gateway may list exact native model ids in -`noStructuredOutputModels`; only those models omit the wire field, while siblings keep the normal -translation. The proxy does not infer this from provider names, localhost destinations, or a model -family shared by unrelated upstreams. - -[Decision Log] -- 목적과 의도: Recover chat models that reject `response_format` without removing structured output from models that support it. -- 기존 구현 및 제약 조건: The adapter forwarded the field to every routed chat model after #1137, while the same model id may sit behind gateways with different capabilities. -- 검토한 주요 대안: Revert translation globally; blacklist a model id globally; detect a proxy by name or URL; add an explicit provider/model opt-out. -- 선택한 방식: Preserve default translation and omit it only for exact ids in `noStructuredOutputModels`. -- 다른 대안 대신 이 방식을 선택한 이유: Global or heuristic rules regress supported providers and make custom gateway names part of the wire contract. -- 장점, 단점 및 영향: Compatible siblings retain schema enforcement and explicitly incompatible models avoid the upstream 400; operators must classify each unsupported model they route. - -## MiniMax Anthropic-compatible clients - -The MiniMax platform CLI's text resource posts Anthropic Messages to -`/anthropic/v1/messages`. `ocx mmx` adapts that hard-coded client path with a temporary -loopback bridge instead of adding another server route. The bridge accepts only POSTs to the -messages and count-tokens paths, rewrites them to the existing `/v1/messages` data plane, -preserves the query and streaming body, strips all incoming credential headers, and pins the -public loopback placeholder. It stops as soon as the MMX child exits, so the server's -`AUTH_MATRIX` and authentication surface remain unchanged. - -`ocx mmx` exposes only the text resource because the other MMX resources use MiniMax-specific -image, video, speech, music, vision, search, quota and file endpoints. The launcher isolates -`~/.mmx` credentials behind a temporary config, removes ambient proxy variables so loopback -traffic cannot be sent off-machine, owns the temporary bridge lifecycle, and refuses -destination, region and credential overrides. It is -loopback-only because MMX cannot carry the dedicated remote-admission header. MiniMax Code uses -the separate reversible `custom_provider.opencodex` file integration and is likewise -loopback-only; its generated block never changes `defaultModel`. Each generated MCode model -copies an authoritative catalog context window into `limit.context` and a nonempty canonical -reasoning ladder into `thinking.effortOptions`. Missing capabilities stay absent instead of -falling back to OpenCodex guesses, and the integration does not write the removed -`thinking.effort` / `defaultEffort` fields because MCode owns the active effort per session. - -## Anthropic structured-output compatibility - -The Anthropic adapter lowers Responses `text.format` and Chat Completions `response_format` JSON -Schema requests to `output_config.format`. The local transform follows Anthropic's TypeScript SDK -subset so upstream rejects neither OpenAI-only envelope fields nor unsupported schema constraints. -The adapter merges `format` into an existing adaptive-thinking `output_config` rather than replacing -it, so a compatible `output_config.effort` remains alongside the structured-output format. -Routed Anthropic Messages input carries `output_config.format` through internal `text.format`, so -stored-OAuth requests regain the same native format when the Anthropic adapter rebuilds the wire body. -Unsupported constraints remain in `description` as model guidance instead of disappearing. Root -`$defs` stay beside a root `$ref`, intentionally differing from the current SDK transform's early -`$ref` return so local references remain resolvable. - -[Decision Log] -- 목적과 의도: Preserve schema-constrained output when OpenAI-shaped Responses or Chat Completions requests route to Anthropic Messages. -- 기존 구현 및 제약 조건: The parser retained the requested schema, but the Anthropic adapter dropped it; forwarding the OpenAI schema unchanged fails when it includes constraints outside Anthropic's supported subset. -- 검토한 주요 대안: Keep tool-call emulation; forward the raw schema; depend on the full Anthropic SDK; maintain a local compatibility transform based on the SDK. -- 선택한 방식: Merge Anthropic `output_config.format` into compatible adaptive-thinking configuration, mirror the SDK transform locally with strict `unknown` narrowing, move unsupported constraints into descriptions, and preserve root `$defs` before returning a root `$ref`. -- 다른 대안 대신 이 방식을 선택한 이유: Native structured output avoids synthetic tools, raw forwarding produces upstream 400s, and importing the full SDK only for a small wire transform would duplicate the adapter's direct HTTP ownership. -- 장점, 단점 및 영향: Both OpenAI-shaped input surfaces gain native Anthropic schema enforcement and unsupported intent remains visible to the model; the copied subset must track upstream SDK changes, description-carried constraints are guidance rather than hard validation, and the root-reference fix is an intentional divergence to keep definitions reachable. - -## Reasoning display parity (hideThinkingSummary) - -Reasoning-envelope serialization uses preflight byte sizing and transient reservations before -creating JSON, UTF-8, or base64 copies. Encoding also admits the matching decode projection, so -a successfully encoded standalone envelope fits the standalone decoder's limit. Callers retain -ownership of returned values; the helper releases only its temporary reservation. Inbound -Anthropic translation carries one budget across all assistant blocks and accounts for retained -envelopes until the response lifecycle disposes it. Standalone translation owns a temporary -budget and disposes it on success or failure. Final translated-request sizing uses plain-JSON -measurement rather than allocating a serialized copy just to measure it. - -[Decision Log] -- 목적과 의도: Keep reasoning replay bounded while preserving opaque values exactly. -- 기존 구현 및 제약 조건: Reasoning continuity needs JSON/base64 envelopes, and existing callers already own retained accounting and typed overflow handling. -- 검토한 주요 대안: Per-field truncation, an independent fixed field limit, or shared transient admission plus cumulative inbound ownership. -- 선택한 방식: Reserve conservative copy projections in the envelope helpers and use the existing request budget across inbound blocks. -- 다른 대안 대신 이 방식을 선택한 이유: Truncation changes signed values; one field limit does not describe aggregate ownership. Existing budget errors retain the established HTTP and stream error contracts. -- 장점, 단점 및 영향: Normal replay is unchanged; envelope admission includes copy overhead and is stricter than a raw-string length ceiling. These are translator accounting limits, not a process-wide RSS guarantee. - -`hideThinkingSummary` (request reasoning summary absent/"none" — the routed catalog default) is -honored by BOTH reasoning paths: anthropic `thinking_delta` AND raw `reasoning_raw_delta` -(openai-chat `reasoning_content`, kiro tags). Hidden reasoning emits an envelope-only reasoning -item (`summary: []`, txt-only `ocxr1:` `encrypted_content`, no text deltas) — invisible in the -Codex app, so tool cells group like native models — while the text still round-trips for -`preserveReasoningContentModels` replay. Visible mode (summary "auto") keeps the raw -`content[reasoning_text]` shape. Diagnosis and codex-rs grouping evidence: -`devlog/_fin/260709_native_response_pattern/`. - -The content-to-summary channel rewrite skips any reasoning item that carries a native -`encrypted_content` blob. The blob is opaque, state-bearing provider data, so the item must -round-trip unchanged unless that backend has an explicit replay contract permitting a rewrite. -This defensively protects providers that issue blobs and later join the route through -`preserveReasoningContentModels`. The rewrite's round trip was verified against DeepSeek, which is -`statelessResponses` and issues no blob. Grok is unaffected in practice because it natively emits -summary-channel reasoning and no `reasoning_text` events, so this content-to-summary item rewrite -does not engage on its route. Only the stored item is exempt — `reasoning_text` delta events carry -no blob and still route to the summary channel, so the live expandable trace is unchanged. - -The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client -thread plus an exact provider destination, wire adapter, final model, and physical credential -identity. API-key material is represented only by a process-keyed HMAC; OAuth replay is bound to the -existing credential slot and exact credential generation, and an authentication-header override is -folded into that identity without retaining the raw value. A token refresh intentionally starts a -new fail-closed replay namespace. The destination is likewise process-HMACed because a configured -base-URL path may itself be a credential. Header-only/keyless routes cannot establish a physical -credential identity and therefore fail closed. Parsed-request copies and already-created bridges -share one scope holder, and key/account rotation replaces its current identity before rebuilding -the request. A retry may therefore reuse reasoning on the same physical target, but a provider, model, or -credential failover receives the provider's configured placeholder instead of another target's raw -reasoning. - -[Decision Log] -- 목적과 의도: Preserve tool-call continuation compatibility without forwarding one provider or physical account's private reasoning to another fallback target. -- 기존 구현 및 제약 조건: Conversation-only scoping stopped process-global call-id collisions, but combo and 429 failover can reuse the same thread and provider-generated call id across destinations or credentials. -- 검토한 주요 대안: Disable replay on every failover-capable provider; key only by provider name; use persisted or truncated secret-derived ids; bind the in-memory cache to an exact process-local route and credential tuple. -- 선택한 방식: Keep a shared mutable scope holder and key entries by thread, provider name, an opaque destination HMAC, adapter, final model, and an opaque HMAC/account identity; incomplete identities read and write nothing. -- 다른 대안 대신 이 방식을 선택한 이유: Exact binding preserves same-generation same-target retries while making account switches and OAuth token refreshes fail closed, without logging, persisting, or exposing credential material. -- 장점, 단점 및 영향: Cross-provider/account replay is blocked and rotations are visible to live bridges; providers without a stable credential identity lose cache replay and use the existing minimal placeholder path. - -## Chat-to-Responses message phase inference - -Chat Completions streams do not carry the Responses `message.phase` field. The bridge keeps an -unphased live message provisional while its deltas arrive, then assigns `commentary` when a later -tool, search, reasoning, or assistant boundary proves that more work follows, and assigns -`final_answer` only when a clean terminal `done` closes the current message. Explicit adapter -phases always win. Streaming `output_item.added` remains unphased until that future boundary is -known; `output_item.done` and the terminal response snapshot carry the authoritative inferred phase -with the same item id. The batch/non-streaming bridge follows the same rule. - -```text -[Decision Log] -- 목적과 의도: Prevent Codex App from rendering one bridged Chat Completions answer as both live commentary and a second persisted final answer. -- 기존 구현 및 제약 조건: openai-chat emits text deltas without phase, the bridge streamed them immediately, and whether text is pre-tool commentary or the terminal answer is unknowable until a later boundary arrives. -- 검토한 주요 대안: Mark every delta final_answer; mark every delta commentary; buffer the entire answer before emitting; infer phase only when the message is finalized. -- 선택한 방식: Keep the live added item provisional and infer commentary or final_answer at the authoritative close boundary, preserving explicit phases and item identity in done/completed output. -- 다른 대안 대신 이 방식을 선택한 이유: Eager defaults misclassify either tool preambles or final answers, while full buffering removes live streaming; close-time inference provides correct persisted semantics without adding latency. -- 장점, 단점 및 영향: Codex App receives a definitive phase for persisted bridged messages and avoids the duplicate-final rendering path; the provisional output_item.added event intentionally has no phase because its classification is not yet knowable. -``` - -## Upstream reset retry - -`src/lib/upstream-retry.ts` guards upstream fetches against stale pooled keep-alive sockets -(Cloudflare closes idle connections; Bun's fetch reuses the dead socket and rejects with -`ECONNRESET` before any response bytes). `fetchWithResetRetry` retries only -connection-reset-shaped rejections (up to 3 total attempts, jittered backoff, warn-logged); -timeouts, aborts, `ECONNREFUSED`, HTTP error statuses, and mid-stream SSE failures are never -retried. Guarded paths: the ChatGPT passthrough and generic adapter fetch in -`src/server/responses.ts`, the vision/web-search sidecars, and the web-search loop's direct-fetch -fallback. Adapters with their own `fetchResponse` (kiro, cursor, google) keep their own retry -policies; kiro imports the shared abort/sleep helpers from this module. - -## Same-provider combo quota fallback - -For a failover combo with multiple models on the same Codex-login OpenAI provider, a pre-stream -429/402 carrying only `x-codex-*-reset-at` may advance to the later model on the same account. The -failed physical combo target still enters its normal target cooldown. An explicit `Retry-After` -remains an account-wide instruction and blocks the later target; a quota response with neither an -explicit retry delay nor a usable reset timestamp keeps the conservative default account cooldown. -This exception is request-scoped and is not applied to direct requests, round-robin combos, or a -combo whose remaining eligible targets use other providers. - -```text -[Decision Log] -- 목적과 의도: Let an ordered combo recover when one model-specific Codex quota window is exhausted but another model on the same account remains usable. -- 기존 구현 및 제약 조건: Account health is shared across models, and recording a reset-derived 429 before combo advancement rejected the later model locally. -- 검토한 주요 대안: Make every quota cooldown model-scoped; ignore all combo 429 cooldowns; or defer only reset-derived cooldown recording for an eligible later same-provider failover target. -- 선택한 방식: Use the narrow request-scoped deferral while retaining target cooldown and all explicit Retry-After/default account cooldown behavior. -- 다른 대안 대신 이 방식을 선택한 이유: Reset timestamps identify quota windows rather than a literal account-wide retry instruction, but widening the exception would risk hot retries and provider abuse. -- 장점, 단점 및 영향: Same-account model fallback works without weakening explicit upstream backoff; the account health map intentionally does not remember that one deferred reset-derived failure, while the combo target map does. -``` - -## Combo streaming commit boundary - -An HTTP 200 does not by itself commit a streaming combo child. The combo parent runs the child's -downstream Responses SSE through `src/server/responses/combo-stream-preflight.ts`, which owns one -reader and buffers only until one of these boundaries: - -- a non-control Responses event begins client-visible output or a tool/action item, after which the - target is committed and cross-target replay is forbidden; -- a `response.failed` terminal arrives first, in which case the terminal is converted back through - the ordinary bounded combo-failure classifier and may advance to the next declared target; -- a completed/incomplete terminal or the aggregate preflight byte or retained-chunk cap is reached, - in which case the current target is committed conservatively. - -The buffered bytes are replayed unchanged before the reader continues. Native passthrough and eager -relay identity markers are restored on the wrapped response so Windows/Bun stream paths and deferred -logging retain their existing owners. A failed child keeps its physical attempt receipt and usage, -while the successful child remains the logical request result. - -HTTP 410 remains terminal by default. It advances and cools only the exact combo target when the -structured code or message explicitly identifies a model lifecycle event (end-of-life, retired, -deprecated, sunset, decommissioned, or no longer available). An unrelated application-level 410 is -not retried. - -```text -[Decision Log] -- 목적과 의도: Recover a failover combo from a provider-local SSE or model-lifecycle failure only while replay is provably free of duplicate client output and tool calls. -- 기존 구현 및 제약 조건: The parent committed every HTTP-200 child before reading its SSE body, while terminal stream errors were classified only later by logging; generic 410 responses stopped the chain. -- 검토한 주요 대안: Retry every failed stream, buffer the complete turn, inspect only HTTP status, or preflight a bounded prefix until an explicit output/terminal boundary. -- 선택한 방식: Put the one-reader bounded preflight in a dedicated module, commit on any non-control event, and treat only explicit model-lifecycle 410 evidence as target-local. -- 다른 대안 대신 이 방식을 선택한 이유: Replaying after output can duplicate text or tools, full-turn buffering destroys streaming and grows memory, and making every 410 retryable hides caller/application errors. -- 장점, 단점 및 영향: Zero-output provider failures can reach a healthy target with ordered receipts and cooldown; ambiguous or oversized pre-output streams keep the current fail-closed behavior instead of consuming unbounded memory. -``` - -## Transport inventory - -The sections above cover the transports with load-bearing invariants. The rest of the transport -surface is listed here so a maintainer can find the owner without grepping: - -| Transport | Owner | Invariant worth knowing | -| --- | --- | --- | -| Azure OpenAI Responses | `src/adapters/azure.ts` | Deployment-shaped URLs on top of the Responses contract. | -| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses the shared abort/deadline helpers (`src/lib/upstream-retry.ts`), wire-body repair, and upstream error normalization. | -| Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | -| Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. | -| Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | -| Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/http1-bidi.ts`, `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts`, `src/adapters/cursor/checkpoint-store.ts` | Thread continuity is the point: a retry must not start a new Cursor thread, and a validated checkpoint must not rebuild the full root history. HTTP/2 remains the default; an explicit `http1.1`/`h1` pin maps the bidi run onto Cursor's `RunSSE` receive stream plus sequenced `BidiAppend` sends, and applies to live discovery too. | -| Claude Messages | `src/server/claude-messages.ts` | Routed translation, a native Anthropic passthrough branch, and `count_tokens`. | -| Chat Completions inbound | `src/server/chat-completions.ts`, `src/chat/` | Inbound translation onto the same routing pipeline. The content mapper preserves image URLs and supported detail, including screenshot-bearing tool results; target adapters own image placement on their wire. Image-free tool results stay strings. | -| Hosted search relay | `src/server/search.ts` | Direct relay; distinct from the web-search sidecar loop below. | -| Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. | -| GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | -| API-key pools | `src/providers/api-key-selection.ts`, `src/providers/key-failover.ts` | A 429 rotates the active key and records a cooldown; `provider.apiKey` keeps mirroring the active entry so routing stays single-key. | -| OAuth account failover | `src/oauth/generic-account-failover.ts`, `src/oauth/anthropic-routing.ts` | Reactive pre-output 429 recovery is presence-driven with 2+ eligible accounts. Pool and `oauthAccountFailover` flags govern proactive routing, not the reactive retry: a disabled Anthropic pool recovers through quota ordering rather than its dormant strategy, and a per-provider `enabled` beats the global default in either direction. | -| Alibaba regions | `src/providers/alibaba-region-backup.ts`, `src/providers/alibaba-region-migration.ts`, `src/providers/alibaba-region-startup.ts` | Region migration backs up before rewriting and is idempotent across restarts. | -| Discovery and quota | `src/providers/model-discovery.ts`, `src/providers/quota.ts` | Discovery rejects a response over 4 MiB or past 2,000 raw rows before caching it. | - -[Decision Log] -- 목적과 의도: Keep reactive OAuth 429 recovery available without silently enabling proactive account-routing policy the operator switched off. -- 기존 구현 및 제약 조건: #3495 made reactive recovery presence-driven, but a disabled Anthropic pool still consulted its dormant strategy on the reactive path, and a per-provider `oauthAccountFailover.enabled: true` could no longer beat a global `false`. -- 검토한 주요 대안: Restore the old all-or-nothing enable flag; leave the merged behavior and document the gaps; or keep the reactive/proactive split and repair the exact policy boundaries. -- 선택한 방식: Keep presence-driven reactive recovery, apply proactive precedence only before dispatch, and use quota ordering for disabled-pool Anthropic recovery. -- 다른 대안 대신 이 방식을 선택한 이유: This preserves the merged product decision without letting disabled proactive settings influence a retry, and it restores the published narrow-over-broad precedence in both directions. -- 장점, 단점 및 영향: 429 recovery stays automatic for operators with multiple eligible accounts; operators who require no automatic account switch must keep one eligible account, which the GUI and public docs state explicitly. - -Cursor external-model continuations attach data-URL screenshots from the contiguous active -tool-result batch through the existing image preparation and selected-context owners. The batch -shares the 12-image active cap. Bounded source labels are emitted in active user-action text so -root pruning cannot erase attachment provenance; the same text participates in token estimation. -Native Composer/MCP behavior and text-only historical replay remain unchanged. - -## Chat streamed tool-call identity - -`src/adapters/openai-chat.ts` retains a call's first observed non-negative safe integer -index as an alias when the call started by ID. Every present, non-null index must -be a number in that range: strings (including numeric and empty strings), booleans, -objects, arrays, negative numbers, fractions and unsafe integers terminate the stream -before any key, alias, ID or last-call matching. `Number.MAX_SAFE_INTEGER` is accepted; -larger integers are rejected because distinct wire literals can parse to the same number. -The invalid-index error releases all pending call reservations without emitting -those calls or a successful completion; invalid indexes are never treated as absent. -Only missing and null indexes are absent-index placeholders. Repeated ID, name and -argument string-field tolerance retains its existing rules. - -For valid indexes, lookup preserves direct-key precedence, then index alias, then -ID fallback. The initial key continues to own all translator budget reservations -and release; learning an alias creates no additional owner. Unassociated index-only -fragments are not guessed onto pending ID-only calls. -`tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, -parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum -safe-integer boundary, invalid index types, missing/null continuations and UTF-8 -byte-limit boundaries. - -## Cursor executable tool schema ownership - -`src/adapters/cursor/tool-schemas.ts` owns advertised and argument-normalization -schemas; `tool-definitions.ts` remains the public facade and protobuf encoder. -Advertisement and normalization intentionally differ for shell bridges: Cursor may -emit `cmd`, while the declared Responses contract decides whether it becomes -`command`. Both paths preserve execution-control fields. Freeform tools use one -required string `input` in a closed object, retaining that tool's string-valued -input description from the parser (including patch-envelope guidance). Other input -constraints cannot widen the canonical shape. Bare shell bridge names are rejected -on the freeform path. -Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in -`tests/providers/cursor/cursor-tool-definitions.test.ts`. - -## Sidecars - -Web search and vision sidecars run only when the main request needs that capability and a usable -sidecar authority exists. Vision has two possible backends; web search's config union additionally -admits `xai`, `gemini`, and `exa`. xAI is a live explicit-only backend through stored Grok OAuth; -Gemini and Exa remain inert until their executors ship. Selection differs per sidecar: - -| Sidecar | Backend selection | Default model | Activation | -| --- | --- | --- | --- | -| `web-search/` | Explicit configuration only: unset always resolves to the OpenAI forward path. No backend — Anthropic or otherwise — is auto-selected from credential availability (doing so once sent OpenAI model ids to the Anthropic API). Explicit xAI requires usable stored Grok OAuth and may add hosted `x_search`; explicit Gemini/Exa remain fail-closed until their executors land. | `gpt-5.6-luna` (OpenAI), `claude-sonnet-5` (Anthropic), `grok-4.6` (xAI) | Hosted `web_search` requested by a non-passthrough routed model. | -| `vision/` | Explicit configuration wins for both backends. Only an unset backend auto-selects: Anthropic when a usable Anthropic OAuth provider exists, otherwise the OpenAI forward authority. An explicitly selected backend whose authority is unavailable produces no plan rather than falling back. | `claude-sonnet-5` (Anthropic), `gpt-5.4-mini` (OpenAI) | Input contains images for a model listed in `noVisionModels`. | - -The asymmetry is in the unset case only: vision may describe an image with whichever model can see -it, while a hosted search tool is tied to a provider-specific tool contract, so search never infers -Anthropic from credentials alone. - -On the OpenAI path there is one deterministic `openai` sidecar candidate and its current account mode -owns credential selection; API-key OpenAI is not a ChatGPT forward sidecar candidate. - -Sidecar failures must degrade to text markers or skipped capability, not abort the main request. - -### Grok snapshot module ownership - -The client-specific tracker lives in `grok-responses-snapshot-repair.ts`; the -provider-opt-in tracker remains in `responses-snapshot-repair.ts`. Their unchanged -object guard, JSON block encoder and retained-item shape live in the dependency- -free `responses-snapshot-codec.ts`. Core imports each tracker directly. No existing -snapshot export moves, and neither tracker imports the core dispatcher. The Grok -marker selects compatibility behavior and conveys no authenticated client identity. - -Manual and automatic OAuth/API-key selection commit through their shared selection owners before -dispatch. Selection revisions fence stale retries and reselection; request identity includes the -actual committed account/key. Generic proactive selection is opt-in and preserves a healthy active -account, while reactive429 recovery remains enabled even with the pool off. Post-commit selection -events immediately invalidate dashboard roster state; see`05_gui-and-management-api.md`. - - -### Incomplete quota terminals - -A native forward response that ends with quota or rate-limit evidence in an -`incomplete` terminal records account quota failure and spawn-fallback health. -Structured `incomplete_details.reason` and error codes are accepted without a -message; ordinary output-limit, filtering, steering and stall incompletes do not -cool an account. Cyber-policy classification retains precedence. The terminal is -not replayed after output, and fixed-account request selection remains fixed. - -Remote compact requests release the server request-idle timeout only after a complete -JSON object with a valid model has been read. Partial or invalid uploads retain -the listener guard; admitted compaction then uses the upstream operation's own -deadlines and client cancellation. - -Buffered routed compaction treats nonempty text and reasoning deltas as progress -without exposing partial summary text. Comments, empty deltas and gateway -keepalives do not reset the adapter-event stall watchdog. The default stall -timeout stays 300 seconds; encrypted compaction content is preserved unchanged. - -Native compact response buffering also enforces a body-byte inactivity deadline -using `stallTimeoutSec` (300 seconds by default). Nonempty chunks reset that -deadline; a stalled body returns HTTP 504, client cancellation retains HTTP 499, -and cleanup does not wait for a stuck upstream cancellation promise. The 32 MiB -response ceiling and the original body bytes are preserved. - -A canonical upstream WebSocket refused-create error can become an HTTP 4xx only -before the response is committed and after stream correlation checks. Permitted -quota headers are bounded and rebuilt without upstream framing headers; the JSON -response is not cacheable. Post-commit and 5xx errors keep the no-resend path. - -When encrypted agent-task recovery refuses a routed task, its existing 400 error -can include a bounded `recovery_reason`: `unsupported_envelope`, -`admission_denied`, `recovery_unavailable`, `caller_cancelled`, `input_changed`, -`recovery_http_rejected`, `recovery_timeout`, `recovery_aborted`, -`recovery_transport_error`, or `recovery_invalid_output`. -HTTP rejection requires an observed non-success response. Invalid output includes -invalid UTF-8, oversized bodies, malformed or incomplete recovery streams, and -invalid or conflicting assignments. A caller's cancellation takes precedence over -an owned deadline, which takes precedence over decode/transport failures. -`recovery_aborted` describes a shared recovery cancelled independently of that caller. -Shared-flight waiters receive the same underlying failure unless individually cancelled; -only successful plaintext is cached. Diagnostics contain no upstream error or payload text. -The field is omitted when no classified recovery result exists, and existing combo -branches that return the original target failure keep that response. -`recovery_unavailable` includes cache/singleflight capacity and does not prove an -upstream request was attempted. No retry or broader envelope acceptance is enabled. - -## Voice diagnostic metadata - -`src/server/live.ts` owns optional `OCX_LIVE_FRAME_LOG` diagnostics for both sideband directions. -The JSONL schema contains only `ts`, `dir`, `kind`, `bytes`, and `fffd`. It never stores frame -content or transcript excerpts, and logging failures do not affect transparent frame delivery. -Binary detection decodes only the supplied buffer view; malformed UTF-8 can itself produce U+FFFD, -so the flag does not identify the peer responsible for corruption. Existing diagnostic files are -not rewritten. Audio devices, WebRTC media negotiation, captions and spoken handoff delivery remain -client responsibilities. diff --git a/structure/AGENTS.md b/structure/AGENTS.md new file mode 100644 index 0000000000..6ea1e3286d --- /dev/null +++ b/structure/AGENTS.md @@ -0,0 +1,116 @@ +# Rules for `structure/` + +This file applies to `structure/` and inherits the repository-wide rules in [`AGENTS.md`](../AGENTS.md). +[`INDEX.md`](INDEX.md) is the reading order and the source-to-doc map; it is generated from +[`manifest.json`](manifest.json), so it is never the file you edit to record something. + +## What belongs here + +A doc in this folder states **the contract that holds right now**, in the present tense, for one +subsystem. That is the whole job. + +- Public user workflows belong in `docs-site/`. +- Open work, triage, and investigation belong in `devlog/`. +- Superseded or alternative reasoning belongs in `decisions/`, not in the doc body. +- Unreleased security findings belong in scratch space and nowhere in this repository. The rule in + the root [`AGENTS.md`](../AGENTS.md) binds this folder without exception. + +If you cannot write a sentence in the present tense about how the system behaves today, it is not a +structure doc. + +## Layout rules + +- File names are kebab-case, start with a letter, and sit at most one directory deep: + `providers/google.md`, not `providers/google/wire.md`, and not `04_transports.md` or `04-transports.md`. +- **Ordering lives in `manifest.json`, never in a filename.** Leading digits are rejected outright. + The old `NN_topic.md` scheme produced two `09_` files and made splitting a doc cost a renumber, which + is how `04_transports-and-sidecars.md` reached 1,860 lines before this folder was reorganised. +- A doc stays under the line budget in `manifest.json`. Over budget, split it along a topic boundary + and give each half its own manifest entry. A `grace.oversizeDocs` entry is for a split already + planned; the gate drops it again once the doc is back under budget. +- Know what the budget does and does not do: it is a line count, so a doc written as a wide table can + carry far more prose per line than one written as paragraphs. It bounds the runaway-file failure, + not density. + +## The source-to-doc map + +Each doc lists the source areas it describes, in its `documents` array. [`INDEX.md`](INDEX.md) publishes +the inverse. + +- **An area can be described by more than one doc, and usually is.** These docs are organised by + topic; `src/` is organised by module. `src/server/` is genuinely described by the management-API doc, + the Responses transport doc and the Images doc. An earlier revision of this folder demanded exactly + one owner per area, and that rule was simply false here — a false rule is worse than none, because + the gate reports green while the map sends a maintainer to the wrong doc. +- **Changing an area obliges the same change to update every doc listed for it.** Not a follow-up, + not a later cleanup pass. +- Describing an area means naming a path inside it. If a doc explains a subsystem without ever citing + a path, the map cannot see it, and the area lands in `grace.undocumentedSourceAreas` instead — which + is a signal to add the path reference, not a place to park work. +- A new `src//` or top-level `src/*.ts` either joins a doc's `documents` list or is recorded in + `grace.undocumentedSourceAreas` with a reason. The gate rejects one that is neither. + +What the map still does not do: it cannot tell you that two docs describe the same behavior in +contradictory words. Avoiding that is a review judgement. Prefer one statement and a link over two +statements that will drift apart. + +## Decision records + +`decisions/ADR-NNNN-.md` holds the reasoning: intent, prior constraints, alternatives, the +choice, why, and consequences. + +- One record has exactly one owning doc, which links it with a `> Decision record:` line. +- Numbers are permanent and unique. They are deliberately **not** required to be contiguous: two + branches that each add a record would otherwise both take the next number and collide on merge. +- Records are historical. When the contract changes, edit the doc body and add a new record; do not + rewrite an old one to match. For the same reason the gate does not validate the repository paths a + record names — a record describes a past tree, and holding it against the present one would force + you to falsify it. +- Records extracted during the 2026-09-11 reorganisation are titled after the doc section they were + taken from, which is where they belonged, not necessarily what they decided. Read the body. + +## Invariants + +[`overview.md`](overview.md) is the invariant index. Each entry needs a stable `INV--NN` id, and a +bound entry adds an `Enforced by` line naming exactly one `tests/**.test.ts` path, with that id repeated +in a comment inside the test. + +Be precise about the strength of that binding, because it is easy to overstate: + +- It proves the test file exists and claims the id. Deleting or renaming the file fails the gate. +- It does **not** prove the assertions inside still cover the rule. Moving the assertions to another + file while leaving the comment behind passes. Only review catches that. + +An invariant with no honest test goes in the index **without** an `Enforced by` line and with an entry +in `grace.unboundInvariants` explaining why. Naming a test that would pass while the rule was +violated is worse than admitting the gap: it converts an open question into false assurance. + +## Adding or changing a doc + +1. Write or move the file. +2. Add or update its `manifest.json` entry: `path`, `tier`, `title`, `scope`, `documents`. +3. `bun run structure:index` to regenerate [`INDEX.md`](INDEX.md). +4. `bun run structure:check` until it is green. + +## What the gate checks + +`bun run structure:check` — also run by `tests/ci-workflows/structure-ssot.test.ts`, so it blocks CI — +verifies that: + +- every doc on disk is in the manifest and every manifest doc exists, exactly once; +- file names are kebab-case, letter-initial, and at most one directory deep; +- no doc exceeds the line budget, and no grace entry outlives the split it promised; +- every relative link resolves, including its `#anchor`; +- every backticked repository path a doc names is real, checked against the **git index** rather than + the filesystem — `existsSync` cannot tell a tracked file from untracked local leftovers, and it is + case-insensitive on Windows and case-sensitive on Linux CI, which would make the gate mean + something different on each machine; +- no doc body carries inline decision-log reasoning; +- every decision record is linked from exactly one doc, and no number is reused; +- every bound invariant names an existing test that names the id back, and every unbound one is + recorded with a reason; +- every `src/` directory and top-level module is described by a doc or recorded as undescribed; +- `INDEX.md` matches the manifest byte for byte. + +Checks are scanned with fenced code blocks removed, so an example inside a fence does not trip a rule +it is only illustrating. diff --git a/structure/INDEX.md b/structure/INDEX.md new file mode 100644 index 0000000000..2a310d083c --- /dev/null +++ b/structure/INDEX.md @@ -0,0 +1,147 @@ +# opencodex Structure Index + +This folder is the maintainer source of truth for the current system shape. Public user workflows +belong in `docs-site/`. Development work is recorded in `devlog/` units — `_plan/` while open, +`_fin/` once closed — while `docs/` keeps investigations and diagnostic notes worth retaining for +archaeology, debugging, or source research. + +Generated from `structure/manifest.json` by `bun run structure:index`. Do not edit by hand; `bun run structure:check` fails when this file and the manifest disagree. The rules for changing anything +in this folder are in [`AGENTS.md`](AGENTS.md). + +## Reading order + +### Tier 1 — Foundation + +What opencodex is, what it owns on disk, and the invariants nothing may break. + +| Doc | Scope | +| --- | --- | +| [`overview.md`](overview.md) | Product boundary, local state ownership, and the non-negotiable invariants index. | +| [`runtime.md`](runtime.md) | Entrypoints, process lifecycle, CLI surface, and provider/adapter selection. | + +### Tier 2 — Configuration and catalog + +Persisted config, the Codex home it writes into, and the model catalog it publishes. + +| Doc | Scope | +| --- | --- | +| [`config.md`](config.md) | Persisted config schema, both injection forms, provider validation, and restore. | +| [`codex-home.md`](codex-home.md) | CODEX_HOME resolution, the files opencodex manages there, and Codex-home diagnostics. | +| [`catalog.md`](catalog.md) | Shared Codex catalog assembly, account namespaces, pool rotation, and effort ladders. | +| [`subagents.md`](subagents.md) | Multi-agent surface mode and subagent roster ordering. | + +### Tier 3 — Data planes and transports + +The wire surfaces a client actually talks to. + +| Doc | Scope | +| --- | --- | +| [`transports/responses.md`](transports/responses.md) | The Responses HTTP/SSE data plane, combo failover, and streaming commit boundaries. | +| [`transports/streaming-health.md`](transports/streaming-health.md) | Heartbeat and stall deadlines, plus the opt-in WebSocket transport. | +| [`transports/inventory.md`](transports/inventory.md) | The per-provider transport table and diagnostic outbound safety. | +| [`data-planes/images.md`](data-planes/images.md) | Standalone image generation and edit relay. | +| [`data-planes/search.md`](data-planes/search.md) | Hosted search relay and exact account selectors. | +| [`data-planes/inbound-compat.md`](data-planes/inbound-compat.md) | Chat Completions inbound, Anthropic-shaped clients, and JSON-upstream streaming clients. | + +### Tier 4 — Providers and adapters + +Per-vendor contracts and the adapter authority that constructs them. + +| Doc | Scope | +| --- | --- | +| [`providers/openai-tiers.md`](providers/openai-tiers.md) | Pool/Direct account modes, API-key separation, wire identity, and quota evidence. | +| [`providers/cursor.md`](providers/cursor.md) | Cursor native exec, parameterized models, checkpoints, and active-context usage. | +| [`providers/google.md`](providers/google.md) | Gemini thought-text, response parts, thought-signature replay, and adjacency repair. | +| [`providers/kiro.md`](providers/kiro.md) | Kiro parallel-tool hints, Responses text controls, and reasoning round-trip. | +| [`providers/xai-grok.md`](providers/xai-grok.md) | Grok Build contract parity and hardening. | +| [`providers/chat-compat.md`](providers/chat-compat.md) | Cross-vendor Chat Completions behavior: reasoning, tool results, structured output, parallel tools. | +| [`adapters/registry.md`](adapters/registry.md) | The single adapter construction authority and contract inheritance. | +| [`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | Versioned provider compatibility claims and fixture-evidence boundaries. | +| [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | Optional Lab evidence, automation, and its core-runtime isolation boundary. | + +### Tier 5 — Surfaces and clients + +The dashboard, the management API, and third-party client config ownership. + +| Doc | Scope | +| --- | --- | +| [`gui-and-management-api.md`](gui-and-management-api.md) | Dashboard serving, authentication boundaries, /api/* ownership, and usage accounting. | +| [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | +| [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. | + +### Tier 6 — Operations and process + +Background service, docs, release, and design discipline. + +| Doc | Scope | +| --- | --- | +| [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | Service install/repair, platform launchers, tray, and sidecar processes. | +| [`ops/docs-and-release.md`](ops/docs-and-release.md) | Docs site, workflow map, branch policy, release flow, and cross-platform CI. | +| [`design-methodology.md`](design-methodology.md) | Stage ordering for new GUI, CLI, and user-facing surfaces. | + +## Which doc describes which source + +A source area can be described by more than one doc, because these docs are organised by topic and +`src/` is organised by module. Changing an area obliges the same change to update every doc listed +for it; see [`AGENTS.md`](AGENTS.md). + +| Source path | Described by | +| --- | --- | +| `.github/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `bin/` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `docs-site/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `gui/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`design-methodology.md`](design-methodology.md) | +| `scripts/` | [`overview.md`](overview.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/adapters/` | [`runtime.md`](runtime.md)
[`transports/responses.md`](transports/responses.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/cursor.md`](providers/cursor.md)
[`providers/chat-compat.md`](providers/chat-compat.md)
[`adapters/registry.md`](adapters/registry.md) | +| `src/chat/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md) | +| `src/claude/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | +| `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/client/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | +| `src/clients/` | [`clients/integrations.md`](clients/integrations.md) | +| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/combos/` | [`runtime.md`](runtime.md) | +| `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | +| `src/config.ts` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`config.md`](config.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md) | +| `src/config/` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | +| `src/generated/` | [`runtime.md`](runtime.md) | +| `src/github/` | [`runtime.md`](runtime.md) | +| `src/grok/` | [`runtime.md`](runtime.md) | +| `src/images/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md) | +| `src/index.ts` | [`runtime.md`](runtime.md) | +| `src/integrations/` | [`clients/integrations.md`](clients/integrations.md) | +| `src/lab/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | +| `src/lib/` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`transports/responses.md`](transports/responses.md)
[`transports/inventory.md`](transports/inventory.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`clients/integrations.md`](clients/integrations.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/oauth/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | +| `src/providers/` | [`runtime.md`](runtime.md)
[`subagents.md`](subagents.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | +| `src/reasoning-effort.ts` | [`runtime.md`](runtime.md) | +| `src/remote/` | [`runtime.md`](runtime.md) | +| `src/responses/` | [`runtime.md`](runtime.md)
[`transports/responses.md`](transports/responses.md)
[`providers/kiro.md`](providers/kiro.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | +| `src/router.ts` | [`runtime.md`](runtime.md) | +| `src/routing/` | [`catalog.md`](catalog.md) | +| `src/server/` | [`runtime.md`](runtime.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/responses.md`](transports/responses.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | +| `src/service.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/stall-timeout.ts` | [`runtime.md`](runtime.md) | +| `src/storage/` | [`runtime.md`](runtime.md) | +| `src/tray/` | [`runtime.md`](runtime.md) | +| `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | +| `src/update/` | [`runtime.md`](runtime.md) | +| `src/usage/` | [`runtime.md`](runtime.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | +| `src/vision/` | [`runtime.md`](runtime.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | +| `src/web-search/` | [`runtime.md`](runtime.md) | + +### Not described by any doc + +| Source path | Why | +| --- | --- | +| `src/bridge.ts` | no doc names this file; it is the legacy adapter bridge entry and its behavior is described under the adapter registry without a path reference | +| `src/quota/` | no doc names a path here; quota evidence is described in providers/openai-tiers.md in prose only | +| `src/service-manager-probe.ts` | no doc names this file; service probing is described in ops/service-and-sidecars.md without a path reference | +| `src/sidecar/` | no doc names a path here; ops/service-and-sidecars.md describes sidecar behavior in prose only | +| `src/types/` | shared declarations plus the tool-name and wire-pin resolvers, which no doc currently describes | + +## Decision records + +Superseded reasoning lives in `decisions/` as numbered records. A doc states the contract that holds now and +links the record that explains why; it never carries the reasoning inline. + diff --git a/structure/11_compatibility-contracts.md b/structure/adapters/compatibility-contracts.md similarity index 62% rename from structure/11_compatibility-contracts.md rename to structure/adapters/compatibility-contracts.md index c503cc0991..ef60067797 100644 --- a/structure/11_compatibility-contracts.md +++ b/structure/adapters/compatibility-contracts.md @@ -1,4 +1,4 @@ -# Compatibility Contracts SOT +# Compatibility Contracts ## Purpose @@ -52,21 +52,9 @@ This is destination-scoped compatibility behavior. Key-auth public Responses pro forward gateways keep both extensions unchanged because their contracts may accept or interpret them independently. -[Decision Log] -- 목적과 의도: Preserve Posit Assistant tool continuation semantics while preventing canonical ChatGPT Codex forwarding from sending client-only cache markers or unresolvable stored-item references. -- 기존 구현 및 제약 조건: The existing `store: false` sanitizer removed item ids but left `item_reference` shells, and no bounded pass recognized markers nested inside content; tool `call_id` pairing and reasoning effort are continuation-critical. -- 검토한 주요 대안: Strip the extensions for every Responses destination; delete only reference ids; expand references from local state; or normalize only the canonical forward destination with bounded recursive marker removal. -- 선택한 방식: Apply the bounded marker pass only to canonical forward `input`, and omit `item_reference` rows only when `store` is exactly `false`. -- 다른 대안 대신 이 방식을 선택한 이유: Public and custom gateways may implement these extensions, while id-only deletion creates an invalid reference shell and local expansion would invent unavailable persistence authority. -- 장점, 단점 및 영향: Posit continuations retain tool pairing and reasoning controls without widening public-provider behavior; hostile nesting fails closed to the original input, so an over-limit request may still be rejected upstream rather than partially rewritten. - -[Decision Log] -- 목적과 의도: Make provider compatibility explicit and machine-readable before larger routing or Responses refactors. -- 기존 구현 및 제약 조건: Adapter-wide conformance tests already protect tool translation, and Compatibility Lab owns broader protocol evidence, but neither publishes an exact provider/destination/auth/model claim table. Lab must remain outside the ordinary request import graph. -- 검토한 주요 대안: Infer capabilities directly from registry flags; publish prose only; add a broad all-provider matrix immediately; introduce the schema with one exact fixture-backed subject. -- 선택한 방식: Add a passive versioned schema and one exact `openai`/canonical Codex URL/forward/`gpt-5.6-sol` manifest whose claims reference assertion-level fixtures executed against the production adapter. -- 다른 대안 대신 이 방식을 선택한 이유: Registry flags do not capture transformations such as local continuation expansion or orphan-output degradation. A broad first matrix would turn unverified assumptions into public promises. -- 장점, 단점 및 영향: The first contract is small but trustworthy and can feed future CLI/GUI surfaces. Coverage expands only as fixtures are added; no request behavior changes in this slice. +> Decision record: [ADR-0094](../decisions/ADR-0094-canonical-forward-continuation-extensions.md) + +> Decision record: [ADR-0095](../decisions/ADR-0095-canonical-forward-continuation-extensions.md) ## Routed code-mode patch completion diff --git a/structure/09_compatibility-lab.md b/structure/adapters/compatibility-lab.md similarity index 99% rename from structure/09_compatibility-lab.md rename to structure/adapters/compatibility-lab.md index 6634a08e84..a1633860a0 100644 --- a/structure/09_compatibility-lab.md +++ b/structure/adapters/compatibility-lab.md @@ -1,4 +1,4 @@ -# Compatibility Lab SOT +# Compatibility Lab ## CL-03 live-route execution boundary diff --git a/structure/10_adapter-registry.md b/structure/adapters/registry.md similarity index 61% rename from structure/10_adapter-registry.md rename to structure/adapters/registry.md index 2c36481f54..9e9176489b 100644 --- a/structure/10_adapter-registry.md +++ b/structure/adapters/registry.md @@ -1,4 +1,4 @@ -# Adapter registry authority +# Adapter Registry Authority ## Decision @@ -41,25 +41,7 @@ Moonshot/Kimi enforce the draft-07 reading where `$ref` must stand alone and 400 request when a node carries both. Codex's own deferred tool catalog emits exactly that shape, so the schema is not something a user can fix from configuration (issue #2673). -[Decision Log] -- 목적과 의도: Codex가 내보내는 `$ref` + 형제 키워드 스키마를 Moonshot이 받아들이는 형태로 - 바꾸되, 도구가 실제로 요구하는 제약을 잃지 않는다. -- 기존 구현 및 제약 조건: JSON Schema 2020-12에서 `$ref`는 in-place applicator라 형제 - 키워드와 함께 적용된다. Moonshot은 이를 거부하므로 참조 대상을 노드 아래로 인라인해야 하고, - 재귀 스키마는 유한해야 하며, 어댑터는 요청 경로에 있으므로 지연이 그대로 사용자에게 간다. -- 검토한 주요 대안: (1) 형제 키워드를 버리고 순수 `$ref`만 남긴다. (2) 참조를 인라인하되 - 형제 키워드가 대상을 덮어쓴다. (3) 인라인하되 집합형 어서션은 합집합으로 합치고 나머지는 - 좁히는 쪽이 이긴다. (4) `allOf`로 감싼다. -- 선택한 방식: (3). `required`는 합집합, `properties`는 병합, 나머지 키워드는 노드가 이긴다. - 해석 불가능한 참조는 순수 `$ref`로 남기고, 깊이·노드·확장 예산을 각각 둔다. -- 다른 대안 대신 이 방식을 선택한 이유: (1)은 노드가 좁힌 제약을 통째로 버린다. (2)는 대상이 - 요구하던 `a`를 형제의 `b`가 덮어써서, 양쪽 어느 쪽도 요청하지 않은 더 약한 계약을 조용히 - 내보냈다 — 리뷰가 지적한 정확한 결함이다. (4)는 Moonshot이 `allOf`를 어떻게 다루는지 - 확인된 근거가 없어 검증되지 않은 가정을 계약으로 만든다. -- 장점, 단점 및 영향: 도구 계약이 보존된 채 Moonshot을 통과한다. 인라인은 대상을 복제하므로 - 큰 정의를 여러 노드가 참조하면 출력이 커질 수 있고, 예산이 소진되면 해당 노드는 빈 객체나 - 순수 `$ref`로 닫힌다 — 약해진 스키마를 절반만 내보내는 것보다 낫다. Moonshot 계열 - `openai-chat` baseUrl에만 적용되고 다른 provider는 손대지 않는다. +> Decision record: [ADR-0093](../decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md) 예산은 세 가지다. 확장 횟수만으로는 참조가 하나도 없는 깊은 스키마를 막지 못해서, 깊이와 노드 수를 따로 둔다 — `google-tool-schema.ts`가 이미 쓰는 형태다. 두 가드 모두 제거했을 때 diff --git a/structure/catalog.md b/structure/catalog.md new file mode 100644 index 0000000000..9a0ca08296 --- /dev/null +++ b/structure/catalog.md @@ -0,0 +1,263 @@ +# Model Catalog + +## Shared catalog + +`src/codex/catalog.ts` builds a shared Codex-shaped catalog for CLI, TUI, App, and SDK. It: + +- preserves native OpenAI entries from the live catalog or static fallback, and emits + gpt-5.6 natives from the pinned upstream models.json snapshot + (`src/codex/data/upstream-models.json` — exact per-slug ladders: luna has no ultra); +- upgrades either an observed selector-qualified `*/gpt-daybreak-blue-latest` account row or an + explicitly configured canonical `openai/gpt-daybreak-blue-latest` Codex-forward row from the + pinned Sol capability metadata while preserving its selector and Daybreak wire identity; + this never expands the bare/API-key model lists or rewrites the wire model to `gpt-5.6-sol`; +- clones a native template for routed `provider/model` entries; +- forces strict Codex catalog fields required by the current parser; +- hides `disabledModels` without blocking direct routing (routed provider ids are excluded; + account-qualified native ids hide only that selector row; BARE native slugs hide the bare row + and all account-selector clones and drop that model family from raw `/v1/models`); +- applies exact provider/model compatibility exclusions after live discovery and metadata + augmentation, so upstream-advertised but uncallable rows never enter dashboard or Codex pickers; +- strips native-only service tier and WebSocket metadata unless the final routed provider/model + explicitly enables the verified OpenAI-compatible service tier; +- backs up the pristine catalog once per catalog: the copy is keyed by a hash of the catalog path + (`catalog-backup-.json`), and the legacy unsuffixed `catalog-backup.json` is retained in + addition for the default catalog, so a restore resolves the backup for the catalog it is restoring + rather than assuming a single file; +- invalidates `$CODEX_HOME/models_cache.json` when model visibility changes. + +On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's +bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies +the rows being merged. This split is required because empty or partial provider discovery must +preserve routed entries and genuine user-native rows from the file that will be overwritten; a +bundled catalog never contains those rows. Retained sync and evidence-bound convergence share an +explicit observed-state merge policy and restore native priorities from the once-only pristine +backup rather than from a catalog whose priorities may already have been rewritten. A configured +custom catalog remains the native metadata/template authority even when a bundled-catalog memo is +warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability +evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. + +Custom Astra and Daybreak rows acquire native reasoning capability only through the existing +canonical `openai` forward destination and explicit capability-source predicate. The shared +custom-row producer bounds their merged effort lists against pinned per-model Codex metadata, +preserves an explicit empty list without a default, and recovers an incompatible nonempty list +to the native default singleton. A default must belong to the projected list. Other custom rows +keep their declaration precedence; a GPT model name, display alias, or arbitrary gateway is not +native provenance. Stored configuration and native capability maps are unchanged. + +The observed-state merge tracks the current invocation's freshly generated custom row objects +after detaching its inputs. Those rows already own their complete reasoning projection, so the +merge does not append `max` again. This also keeps a generic none-only custom row none-only; +ordinary retained provider rows still receive the existing mock-tier policy. A persisted custom +marker alone never grants this exemption. Both gather entry points, retained sync, management +convergence and direct Codex model discovery use the same producer. The legacy runtime effort +union clamp remains separate; it is not a per-model or per-client-version grammar oracle. +Existing thread settings and the reported Desktop 0.153.4 gateway rejection require separate +runtime evidence. Codex's native `ultra` mode is preserved and is not a literal API wire promise. + +When account selectors are enabled, the sync path may also observe exact, visible, API-supported +OpenAI-family ids from Codex's user-owned catalog/cache. Only rows with native catalog provenance +are trusted; unknown ids are carried through startup cache invalidation as hidden observations and +are emitted only as selector-qualified rows whose account provenance matches. They never expand +the bare native or API-key model list. This keeps account-scoped upstream ids such as +`gpt-daybreak-blue-latest` callable without treating them as a static release allowlist. + +Account-gated native ids are a stricter subset. Their authenticated ChatGPT `/models` roster is +cached per credential generation with a bounded timeout. A bare gated row is emitted only when at +least one confirmed eligible account reports it; a selector-qualified row is emitted only when the +mapped account reports it. A failed or malformed discovery is not positive evidence and therefore +hides the gated row until a later refresh. The same snapshot gates Pool selection, so the catalog +and runtime cannot disagree by advertising through one account and dispatching through another. + +The app-server's model list comes from this shared catalog, not from patching the App. Codex Desktop +may still apply its remote native-only allowlist after `model/list`; an explicitly configured combo +`nativeAlias` is the bounded compatibility path. It replaces one supported bare native row with a +routed, labeled row, routes the bare id before canonical OpenAI, and keeps account-qualified native +selectors genuine. Missing target discovery capabilities inherit the replaced native row's metadata, +while explicit target limits remain authoritative. Because the affected renderer ignores `visibility: "hide"`, the presence of any +native alias also omits disabled bare native rows from the effective catalog. Dashboard rows remain +derived from the static native set, and sync retains bundled/pristine native recovery sources so a +later re-enable or alias removal restores native metadata. + +Provider live-model lists are cached with a configured TTL (`src/codex/model-cache.ts`). Adding, +deleting, or editing a provider's shape clears that per-provider cache; a disabled-only change +deliberately does not, because a disabled provider is already excluded from the catalog gather +instead. Codex's own `models_cache.json` is a different cache, invalidated by catalog refresh. + +For `liveModels: false`, a static provider publishes the ordered union of `models` and +`retainModels`. When `models` is absent or empty, its configured `defaultModel` seeds that +union before retained ids; a nonempty explicit list does not import a different default. +Without any default or configured/retained ids, the static result stays empty. The existing +forward-auth native path remains separate. Static gathering does not refresh OAuth or call +the provider's model endpoint, and normal selection and visibility filters still apply. + +The provider workspace uses the existing `/api/models` projection for displayed rows, +model identity and inventory counts. Counts cover distinct non-disabled selectors within +each provider, before search or the render cap; they are not selected-model or live-discovery +counts. The full available list and discovery provenance remain separate inputs. + +Deleting a custom definition uses its stable record id and does not also hide the underlying +model. Native or discovered metadata can therefore reappear without changing the inventory +count. Hide uses the represented row's native/routed identity and changes visibility only. +The Models page can restore existing hidden rows; adding a definition does not implicitly +clear a previous hide or provider allowlist. Actions wait for current row and custom-ownership +observations, and mutations reconcile those observations instead of retaining browser-only +removal markers. These presentation operations do not grant routing or account entitlement. + +### Windows request-path catalog-state discovery + +> Decision record: [ADR-0021](decisions/ADR-0021-shared-catalog.md) + +## Startup readiness + +Each `startServer` invocation owns a private, one-shot readiness gate created before the listener +binds. `handleStart` supplies its gate and transitions it only after the shared catalog sync and +best-effort Claude Code roster reconciliation have both settled. The catalog sync remains the +authority for ready versus failed; a roster warning does not make an otherwise healthy proxy fail. +Calls without a supplied gate receive a fresh private gate that intentionally remains pending. Only +`ok: true` with no nonempty warning becomes ready; `null`, a throw, `ok !== true`, or a nonempty +warning becomes failed. State is isolated per server instance. + +Exact unauthenticated `GET /readyz` returns sanitized identity fields plus pending, ready, or failed: +`200` for ready, or `503` with `Retry-After: 1` for pending and terminal failed. The full CLI syntax +is `ocx ready [--json] [--wait [--timeout ]]`. The probe validates the service, version, +uptime, PID, port, status, and HTTP/status pairing. The default is one probe. With `--wait`, it +applies one absolute deadline (45 seconds by default) across discovery, readiness probes, polling, +and sleeps, but exits immediately on terminal failed. `--timeout ` requires `--wait` and +accepts positive integer seconds from 1–300. CLI `--json` emits +`{ready, status, pid, port}`, with status in `ready|pending|failed|unreachable`. Exit 0 means ready; +exit 1 covers not-ready, pending, failed, timeout, and unreachable; exit 64 means invalid arguments. +Older proxies without `/readyz` fail closed as unreachable. `/healthz` remains the separate +liveness contract. + +## Entry shape + +Routed entries keep Codex-required metadata such as reasoning levels, shell type, API support flags, +base instructions, modalities, auto-compact fields, and strict parser booleans. The public slug uses +the canonical `provider/model`. Its display name uses the provider's exact `modelDisplayNames` override first, +then trusted catalog metadata such as a configured qualified provider/model alias, then the public slug. +This overlay never changes route identity or the upstream wire model, and its catalog fingerprint makes +a label edit refresh Codex output. + +Supported bare native GPT rows also consume `providers.openai.modelDisplayNames`. Retained sync +and convergence pass the same map to the observed-state merge. After native normalization and +ordering, the merge applies the exact nonblank trimmed label and saves +`opencodex_native_display_name: { slug, original, applied }` in the local catalog only. The next +merge detaches its inputs, removes that marker, and restores `original` only if the native slug +still matches and the current name equals `applied`. Removing or blanking the override therefore +restores the owned name before normal native metadata upgrades. Divergent external names remain +subject to those upgrades: Astra still replaces non-pinned names with its pinned native name. +Template-derived rows discard the marker. The overlay leaves model IDs, metadata (including +capabilities), ordering, routed combo aliases, custom rows and account-qualified rows unchanged; +it does not relabel HTTP model listings or virtual `*-pro` rows. + +## Native passthrough + +Astra has its own pinned native row: 272,000 default context, 872,000 opt-in ceiling, +low-through-ultra effort, low default, and native multi-agent effort `xhigh`. The native-alias +fallback passes the same configured limits to context, max input and compaction. Unrelated routed +templates clear the native multi-agent effort; canonical Astra-forward custom rows retain it and +the pinned Fast speed description. Sync repairs only the exact old built-in Astra Fast description, +preserving custom descriptions and other stored row fields. + +The API registry separately owns Astra's 1,050,000 context / 922,000 input / 128,000 output and +five API effort levels. Trusted discovery snapshots carry the output ceiling as well as input +and context, so reconstruction cannot drop it. User output limits may only lower that ceiling. +Pricing remains provider-scoped and API-referenced for every built-in dollar estimate, including +Codex-login routes. Both OpenAI identities use the same Astra/Sol API base and cache prices, +API Fast multipliers and published long-context bands; Fast stacks with long context for Astra, +GPT-5.6 and the Daybreak Blue selectors. No subscription-specific exception or credit multiplier +enters the estimate. Explicit user price overrides remain authoritative. See the public provider +reference for the dated source table. + +Native bare OpenAI entries form one `openai` group. The provider's Pool(default)/Direct option +changes account selection without changing those ids; `openai-apikey/` creates the separate +API-key identity. The API GPT-5.6 rows use 1,050,000 context / 922,000 max input; their `*-pro` virtual rows +rewrite to the base upstream model with `reasoning.mode: "pro"` while public state keeps the virtual +slug. Routed non-OpenAI models must not +inherit native-only service tier or WebSocket metadata unless the user explicitly enables that +capability. Detailed invariants live in [`openai-tiers.md`](providers/openai-tiers.md). + +Native passthrough entries depend on the enabled provider set. With at least one enabled provider, +they appear only while an enabled canonical OpenAI forward provider exists — disabling every such +provider removes the native rows rather than leaving entries that resolve to no credential. With no +enabled provider at all, the native rows remain as bootstrap so a fresh install still has something +to route. + +## Accounts, namespaces, and pool rotation + +Pool mode routes across main plus added Codex credentials. Key rules: + +- **A namespace is a public selector mapped to an internal target.** Generated selectors are how a + caller names an account — the main login's selector is `main` (collision-suffixed if taken), + which maps to the config-only sentinel `@main`; the sentinel deliberately sits outside the + pool-account id grammar. Selector initialization requires an explicit opt-in and fills only an + absent or empty map; a non-empty user map keeps its object identity and insertion order. Generated + selectors avoid provider, combo, routing-policy, and slash-qualified routing-profile namespaces. + Collision checks normalize provider and reserved namespace keys, while account and + routing-profile selector prefixes are exact-case (`src/codex/account-namespaces.ts`, + `src/codex/account-namespace-match.ts`, `src/routing/profile-namespace.ts`). +- **Selector labels carry no account-role semantics.** When at least one selector is advertisable, + the Codex catalog clones each supported native row per selector and hides the bare picker rows; + bare ids remain routable and stay in raw `/v1/models` unless explicitly disabled. Missing stored + account targets are not advertised, and private account ids never become catalog labels. + `codexAccountPickerEnabled: false` hides generated rows without deleting exact routing bindings; + an omitted flag preserves the established behavior of a nonempty hand-written selector map. +- **Rotation is sticky.** A conversation stays on its selected account while that account is + usable; failure moves it, success does not (`src/codex/pool-rotation.ts`). +- **The credential store is generation-guarded.** A refresh takes a lock and persists only if the + generation it started from still holds; a lost race raises a generation-conflict error rather + than overwriting the newer credential (`src/codex/account-store.ts`). Callers handle that error; + they do not assume a silent retry. + +Warmup issues a bounded request with a fallback model so a cold account reports usability before a +real turn depends on it (`src/codex/warmup.ts`). + +## Routed tool discovery and hosted search + +All routed catalog rows advertise `supports_search_tool: true` together with +`tool_mode: "code_mode_only"` — the pair is load-bearing. The field selects Codex's deferred +tool-discovery surface; it does not describe the hosted web-search sidecar. Under code mode, +deferred MCP tools remain callable through exec's `tools` global / `ALL_TOOLS` without a +`tool_search` round-trip (upstream codex-rs code_mode suite; live canary 2026-08-13: routed +kimi/k3 executed `tools.mcp__node_repl__js`, devlog `260813_tool_catalog_deferral/010+020`). +Stamping `false` instead forces every MCP declaration into `exec.description` — a measured 2.7x +turn-1 payload regression (96,699 → 258,929 chars). For Cursor this can also make the unified +`exec` exceed the 120,000-byte serialized `McpTools` ceiling; the budget then drops `exec` and +its companion `wait` (#1830). Hosted search remains independent: non-Cursor routes keep +`web_search_tool_type: "text_and_image"`, while Cursor omits it because runTurn bypasses the +search sidecar. + +> Decision record: [ADR-0022](decisions/ADR-0022-routed-tool-discovery-and-hosted-search.md) + +## Ultra reasoning level + +Ultra is always advertised in the catalog regardless of the `multi_agent_v2` toggle. The v2 toggle +controls only the multi-agent collab surface, not ultra visibility. The `nativeEffortClamp` function +wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhigh on the wire). + +`effortCap` and `subagentEffortCap` are hard ceilings applied on the V2 path +(`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting +the request, and they never raise it. + +The `ocx effort` CLI accepts only the same canonical cap ladder before live probing or persistence. +Its status output preserves unsupported legacy cap values and reports that those fields are ignored; +the read does not normalize or migrate them, and an ignored subagent field does not disable a valid +main cap. Injection-effort input remains a separate contract. + +Operator-owned `pinnedReasoningEffort`, `modelPinnedReasoningEfforts`, and root +`modelPinnedEfforts` resolve before applicable effort caps at the final destination. +Provider model pins precede provider-wide pins, then global selector/destination pins. +A pin can raise the effective caller effort; the later cap can still lower or omit it. +`none` means explicit-effort omission (provider default), not guaranteed reasoning disablement. +Compaction maintenance is exempt. Pins are user overlays and do not alter registry seeds, +model discovery or advertised ladders. Native Chat normalizes newly pinned values through +provider wire mapping; unpinned native requests retain their existing pass-through contract. + +> Decision record: [ADR-0023](decisions/ADR-0023-ultra-reasoning-level.md) + +> Decision record: [ADR-0024](decisions/ADR-0024-ultra-reasoning-level.md) + +> Decision record: [ADR-0025](decisions/ADR-0025-ultra-reasoning-level.md) + +> Decision record: [ADR-0026](decisions/ADR-0026-ultra-reasoning-level.md) diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md new file mode 100644 index 0000000000..f8e3691f38 --- /dev/null +++ b/structure/clients/claude-desktop.md @@ -0,0 +1,77 @@ +# Claude Desktop Integration + +## Connected Claude Desktop profiles + +Connected `ocx claude desktop apply` reads the hub's Desktop snapshot and writes the hub origin +and exact hub-issued IDs to the local Desktop configuration. Static/hybrid embed the entries; +discovery-only keeps discovery on the hub. The hub owns family assignments and defaults; local +show/edit/import/export operations do not manage that profile. After hub changes or historical +client-only aliases, apply again and reselect the model. Connected `import --apply` is explicitly +unsupported and refuses before saving the import. + +`src/claude/desktop-discovery-inputs.ts` owns the shared Desktop discovery projection used by +startup registry initialization and server discovery. `src/server/index.ts` exposes the explicit +`GET /v1/models?ids=desktop&format=desktop-config` snapshot, shaped as `{version:1,models:[...]}` +and sent with `Cache-Control: no-store`. `src/client/hub-client.ts` downloads it with the existing +data credential; `src/cli/claude-desktop.ts` selects connected apply, and `src/claude/desktop-3p.ts` +writes the resulting local Desktop configuration. No admin token, hub-profile upload or local +alias regeneration is part of this flow. Unsupported old hubs, invalid snapshots and unavailable +Desktop models fail apply without a local-catalog or loopback fallback. + +Date-shaped Desktop IDs can overlap genuine native model IDs. When available discovery and +mapping evidence cannot resolve one, Messages and count-tokens return HTTP 503 with the fixed +`desktop_model_mapping_unavailable` error rather than classifying it as invalid. Unknown legacy hash aliases +remain HTTP 400; neither case reaches date-stripping or fallback routing. Known/registered IDs, +exact operator mappings and recognized native IDs keep their existing handling. Discovery refresh +or reapplying the connected hub profile may supply the missing mapping; retry alone does not +guarantee resolution. + +The remote-alias slice does not change thinking/redacted-thinking replay or prompt-cache +behavior. Those remain the separate request tracked in #3719; proxy admission alone does not +establish native Anthropic passthrough or imply that translated Anthropic caching is disabled. + +### Desktop ownership across the connection lifecycle + +`src/claude/desktop-remote-store.ts` owns the first protected restoration baseline and the +connection-owned Desktop fields. `src/cli/claude-desktop.ts` handles connected apply, while +`src/client/connect.ts` coordinates key rotation/recovery and disconnect. Reapply and rotation retain the original +baseline. Restoration merges into current user fields, preserves unrelated profiles, and restores +the previous selection only while the managed profile is still selected. A later valid user +selection is not changed. A newly created profile with user additions is retained in readable +standard mode instead of deleting those additions. + +A proven legacy current-hub/recognized-key profile without an original baseline can be adopted +by apply, rotation/recovery or direct disconnect without a new flag or prerequisite reapply. +Its explicit standard-fallback outcome is distinct from original restoration: only owned gateway +settings are removed, with user fields and independent valid selection preserved. Unknown keys, +changed managed fields or damaged restoration records remain conflicts, not permission to capture +new originals or overwrite user data. + +Rotation changes credentials without changing model IDs, family/default choices or selecting the +managed profile again. The CLI reports `rotation: "committed"` only for the new active generation; +`rotation: "rolled_back"` means the previous generation was retained/restored and must not claim +revocation of that previous key. Incomplete recovery keeps the operation unresolved. Disconnect +restores Desktop even with `--keep-catalog`; retries preserve the original catalog choice and must +not clear a newer connection. Authorized uninstall completes or resumes owned Desktop cleanup +before removing OpenCodex state, and preserves recovery state when cleanup conflicts or fails. + +These guarantees concern files on disk. Fully quitting and reopening Desktop is required after +apply, rotation/recovery or restoration; there is no automatic process restart or guarantee that +a running app discarded a key. Local disconnect does not revoke the hub key or remove arbitrary +external copies. Model-list snapshot version 1 remains a read-only contract, not a new lifecycle +or profile-upload API. Thinking replay and prompt caching remain separate in #3719. + +## Claude Desktop config-library resolution + +The Desktop profile writer and the management status probe share +`resolveDesktop3pConfigLibraryPath`. The resolver reproduces Desktop's own rule rather than a guess: +an explicit `CLAUDE_USER_DATA_DIR` (or the opencodex override) wins; on Windows +`%LOCALAPPDATA%\Claude-3p` wins; otherwise the Electron user-data path gains a `-3p` suffix if it +does not already have one. `configLibrary` is appended to that root. + +`Claude-3p` is Desktop's real directory name, assembled at runtime from `"Claude" + "-3p"`, which is +why searching the app bundle for the literal string finds nothing. It is not a legacy path to migrate +away from. Resolution stays a pure function of (env, platform, home) so the Windows branch is +testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. + +> Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) diff --git a/structure/09_client-integrations.md b/structure/clients/integrations.md similarity index 57% rename from structure/09_client-integrations.md rename to structure/clients/integrations.md index 8e8c458580..ae71389ad7 100644 --- a/structure/09_client-integrations.md +++ b/structure/clients/integrations.md @@ -75,13 +75,7 @@ non-empty list without `image` becomes `false`; an absent or empty modality list model object so Hermes receives no guessed capability. OpenCodex does not emit `supports_video` because its authoritative input-modality vocabulary currently has no video value. -[Decision Log] -- 목적과 의도: Preserve catalog-backed image routing when Hermes uses OpenCodex as a custom provider. -- 기존 구현 및 제약 조건: A string array preserved model selection but normalized to empty metadata in Hermes, while OpenCodex has authoritative text/image/audio facts but no video fact. -- 검토한 주요 대안: Keep the array; mark every model vision-capable; infer video from model names; emit a per-model metadata map from declared modalities. -- 선택한 방식: Emit a stable per-model map and include only the `supports_vision` boolean that the catalog can prove. -- 다른 대안 대신 이 방식을 선택한 이유: The map is the Hermes-supported capability boundary, while guesses would misroute attachments or advertise unsupported video. -- 장점, 단점 및 영향: Vision-capable custom models route correctly and text-only rows stay explicit; unknown rows remain unknown, and video routing waits for authoritative source metadata. +> Decision record: [ADR-0090](../decisions/ADR-0090-hermes-model-capabilities.md) ## Ownership Axes @@ -94,13 +88,7 @@ fall back to comparing the recorded generated contribution when the catalog has keeps old records readable while preventing a client's formatting-only key reorder from masquerading as a protected edit. -[Decision Log] -- 목적과 의도: Treat JSON object-key order as formatting while retaining safe ownership proof across upgrades. -- 기존 구현 및 제약 조건: Existing records contain order-sensitive hashes, and replacing their hash format in place would make every installed integration look foreign-edited. -- 검토한 주요 대안: Replace the hash format globally; ignore key order only for ZCode; store a semantic companion beside the existing exact hash. -- 선택한 방식: Preserve the exact hashes for compatibility and add object-key-independent semantic companions to new records, with a bounded desired-contribution fallback for old records. -- 다른 대안 대신 이 방식을 선택한 이유: A global replacement cannot validate old records, while a ZCode-only exception would leave the shared JSON ownership rule inconsistent. -- 장점, 단점 및 영향: New records tolerate key normalization even across catalog refreshes; old records recover when the recorded catalog is still reconstructible, and ambiguous old-record drift remains fail-closed. +> Decision record: [ADR-0091](../decisions/ADR-0091-ownership-axes.md) Clients normally protect every field in every recorded fragment. A client that writes documented, runtime-derived fields back into an owned fragment may additionally record: @@ -130,13 +118,7 @@ only while the desired contribution is still identical to the one recorded at ap catalog also changed, the old record cannot distinguish catalog drift from a foreign edit and must fail closed. A successful refresh writes the new operation-scoped policy. -[Decision Log] -- 목적과 의도: Allow ZCode's documented runtime normalization without turning genuine provider or connection edits into refreshable drift. -- 기존 구현 및 제약 조건: The classifier hashed the whole `provider.opencodex` fragment. That was safe for ordinary JSON clients but made every ZCode save a permanent foreign edit. Refresh and disable both depend on the same ownership proof. -- 검토한 주요 대안: Ignore all model metadata; compare only the provider connection envelope; hard-code a ZCode branch directly in `state.ts`; store explicit operation-scoped mutable paths and a protected fingerprint. -- 선택한 방식: Keep the strict generated contribution hash, add a separate protected fingerprint, and persist the exact ZCode-derived paths with each ownership record through a client-scoped policy module. -- 다른 대안 대신 이 방식을 선택한 이유: Ignoring all model metadata would allow user model edits to be overwritten. Comparing only the connection envelope would stop protecting model membership and capabilities. A state-only special case would disagree with writer behavior. Operation-scoped paths preserve the original grant across later catalog changes. -- 장점, 단점 및 영향: Normal ZCode saves become refreshable, connection edits still fail closed, and later catalog refreshes remain possible. Legacy records with simultaneous catalog drift still require a conservative manual recovery because the old schema did not store enough evidence. +> Decision record: [ADR-0092](../decisions/ADR-0092-zcode-runtime-metadata.md) ## Verification @@ -174,67 +156,6 @@ credential — the `OPENCODEX_API_AUTH_TOKEN` / service-token-file / `apiKeys` l admin token — or logs that it is degrading. Composing `http://127.0.0.1:` by hand is what produced a dead socket on a tailnet-bound hub in the first place. -## Connected Claude Desktop profiles - -Connected `ocx claude desktop apply` reads the hub's Desktop snapshot and writes the hub origin -and exact hub-issued IDs to the local Desktop configuration. Static/hybrid embed the entries; -discovery-only keeps discovery on the hub. The hub owns family assignments and defaults; local -show/edit/import/export operations do not manage that profile. After hub changes or historical -client-only aliases, apply again and reselect the model. Connected `import --apply` is explicitly -unsupported and refuses before saving the import. - -`src/claude/desktop-discovery-inputs.ts` owns the shared Desktop discovery projection used by -startup registry initialization and server discovery. `src/server/index.ts` exposes the explicit -`GET /v1/models?ids=desktop&format=desktop-config` snapshot, shaped as `{version:1,models:[...]}` -and sent with `Cache-Control: no-store`. `src/client/hub-client.ts` downloads it with the existing -data credential; `src/cli/claude-desktop.ts` selects connected apply, and `src/claude/desktop-3p.ts` -writes the resulting local Desktop configuration. No admin token, hub-profile upload or local -alias regeneration is part of this flow. Unsupported old hubs, invalid snapshots and unavailable -Desktop models fail apply without a local-catalog or loopback fallback. - -Date-shaped Desktop IDs can overlap genuine native model IDs. When available discovery and -mapping evidence cannot resolve one, Messages and count-tokens return HTTP 503 with the fixed -`desktop_model_mapping_unavailable` error rather than classifying it as invalid. Unknown legacy hash aliases -remain HTTP 400; neither case reaches date-stripping or fallback routing. Known/registered IDs, -exact operator mappings and recognized native IDs keep their existing handling. Discovery refresh -or reapplying the connected hub profile may supply the missing mapping; retry alone does not -guarantee resolution. - -The remote-alias slice does not change thinking/redacted-thinking replay or prompt-cache -behavior. Those remain the separate request tracked in #3719; proxy admission alone does not -establish native Anthropic passthrough or imply that translated Anthropic caching is disabled. - -### Desktop ownership across the connection lifecycle - -`src/claude/desktop-remote-store.ts` owns the first protected restoration baseline and the -connection-owned Desktop fields. `src/cli/claude-desktop.ts` handles connected apply, while -`src/client/connect.ts` coordinates key rotation/recovery and disconnect. Reapply and rotation retain the original -baseline. Restoration merges into current user fields, preserves unrelated profiles, and restores -the previous selection only while the managed profile is still selected. A later valid user -selection is not changed. A newly created profile with user additions is retained in readable -standard mode instead of deleting those additions. - -A proven legacy current-hub/recognized-key profile without an original baseline can be adopted -by apply, rotation/recovery or direct disconnect without a new flag or prerequisite reapply. -Its explicit standard-fallback outcome is distinct from original restoration: only owned gateway -settings are removed, with user fields and independent valid selection preserved. Unknown keys, -changed managed fields or damaged restoration records remain conflicts, not permission to capture -new originals or overwrite user data. - -Rotation changes credentials without changing model IDs, family/default choices or selecting the -managed profile again. The CLI reports `rotation: "committed"` only for the new active generation; -`rotation: "rolled_back"` means the previous generation was retained/restored and must not claim -revocation of that previous key. Incomplete recovery keeps the operation unresolved. Disconnect -restores Desktop even with `--keep-catalog`; retries preserve the original catalog choice and must -not clear a newer connection. Authorized uninstall completes or resumes owned Desktop cleanup -before removing OpenCodex state, and preserves recovery state when cleanup conflicts or fails. - -These guarantees concern files on disk. Fully quitting and reopening Desktop is required after -apply, rotation/recovery or restoration; there is no automatic process restart or guarantee that -a running app discarded a key. Local disconnect does not revoke the hub key or remove arbitrary -external copies. Model-list snapshot version 1 remains a read-only contract, not a new lifecycle -or profile-upload API. Thinking replay and prompt caching remain separate in #3719. - ## Aside profile ownership Aside discovery projects only registered numeric account IDs, labels and current status. Catalog diff --git a/structure/codex-home.md b/structure/codex-home.md new file mode 100644 index 0000000000..9b5e1ded4a --- /dev/null +++ b/structure/codex-home.md @@ -0,0 +1,225 @@ +# Codex Home + +## Codex home + +`src/codex/paths.ts` resolves Codex state from `CODEX_HOME` when set and valid, otherwise from +`~/.codex`. An unset `CODEX_HOME` falls back to `~/.codex`, including WSL discovery. An explicitly +set path that is unreadable or not a directory is an error, not a fallback: silently using a +different home than the operator named would write provider state where nobody is looking for it. +The managed files are: + +```text +$CODEX_HOME/config.toml +$CODEX_HOME/opencodex.config.toml +$CODEX_HOME/opencodex-catalog.json +$CODEX_HOME/opencodex-journal.json +$CODEX_HOME/models_cache.json +$CODEX_HOME/.opencodex-native-main-profiles/ +``` + +Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on +the resolved `CODEX_HOME`. + +Journal restoration compares config and profile independently against their saved originals and +recorded injected hashes. If either changed artifact lacks its injected hash, the config/profile +pair and journal remain untouched and the result is explicitly unverified; callers must not +convert that refusal into successful fallback cleanup. Already-original bytes need no rewrite, +and absence is distinct from an empty file. The injector checks a retained hashless journal against +the same `baselineContent` it snapshots, plus the current profile, before writing or assigning a new +injected hash. Native content can establish a fresh snapshot; routed content cannot promote an +unverified older original. Existing hash-backed edit preservation and external-provider opt-out +remain separate paths. + +The source-built Docker image explicitly keeps `CODEX_HOME=/home/bun/.codex` separate +from `OPENCODEX_HOME=/home/bun/.opencodex`. Compose persists them in `codex-state` and +`ocx-state` respectively, retaining a read-only root. The image creates owner-only +writable homes for `bun`; existing volume ownership and permissions are not repaired. +The catalog resolver is unchanged; a writable empty home is not a materialized catalog. + +> Decision record: [ADR-0005](decisions/ADR-0005-codex-home.md) + +`docker compose down` retains both volumes. `docker compose down --volumes` deletes +both `ocx-state` and `codex-state`, including their credentials and catalog/state; +treat it as destructive, not as an upgrade or restart command. + +Service install-state ownership uses this same resolver. In WSL, an unset `CODEX_HOME` may resolve +to the single discoverable Windows Desktop home; recording Linux `~/.codex` instead would make a +later repair or uninstall look foreign even though the service and runtime were started from the +same environment. An explicit `CODEX_HOME` remains authoritative, and existing foreign ownership +records are never migrated implicitly. + +> Decision record: [ADR-0006](decisions/ADR-0006-codex-home.md) + +SQLite-backed thread state may live outside `CODEX_HOME`. The one resolver in `src/codex/paths.ts` +uses Codex's precedence: root `sqlite_home` in the effective `config.toml`, then +`CODEX_SQLITE_HOME`, then the effective `CODEX_HOME`; relative SQLite homes resolve from the current +working directory. History jobs resolve the database and its hashed backup identity together at +call time, and admission/residue checks consume the same database path. Storage retention still +owns the Codex-home tree separately and does not gain deletion authority over an external SQLite +root from this resolver alone. Durable service launchers preserve an explicitly supplied +`CODEX_SQLITE_HOME` so a background service resolves the same split state as the installing shell. +An absent `config.toml` or absent root `sqlite_home` permits the environment/home fallback. Any +other read failure, malformed TOML, wrong-typed or blank `sqlite_home` is indeterminate and fails +closed so history code cannot select a different database by accident. This strict parse is scoped +to SQLite ownership; the tolerant root-string helper used by injection and catalog reads is unchanged. + +> Decision record: [ADR-0007](decisions/ADR-0007-codex-home.md) + +Native-main profile ownership is bound to the real `CODEX_HOME`, not to an OpenCodex instance. +Its encrypted vault, transaction journal, recovery marker, and referenced quarantine files live in +the owner-only `.opencodex-native-main-profiles` directory. The unchanged +`.opencodex-native-profile.lock.sqlite` beside that directory serializes every process sharing the +home. Only plaintext login staging is instance-local under +`$OPENCODEX_HOME/native-main-profile-staging`; a stage from one instance is invalid in another. +These paths and the OS keyring are owner-only: the operating-system account that owns them is the +trust boundary and already has direct access to active native credentials. OpenCodex detects and +fails closed on file identities that change during an operation, but it does not claim isolation +from a malicious process already running as that same trusted OS account. + +Startup and the periodic stage cleaner do not acquire the profile transaction lock when both the +stage registry and this instance's staging tree are proven absent. This keeps an unused profile +subsystem from fencing native traffic or creating lock contention. Presence, an unsafe entry type, +or any observation error still takes the locked sweep and fails closed; the fast path is based only +on proven absence, never on an unreadable path. + +> Decision record: [ADR-0008](decisions/ADR-0008-codex-home.md) + +The native-write coordinator is keyed by the canonical `CODEX_HOME` in the effective-user runtime +namespace. A pathname alone is not authority: SQLite can expose a zero-byte file before its first +schema write, and a terminated process can leave that remnant behind. Eligibility treats the file +as non-authoritative only after an immutable SQLite read proves version zero with no tables, the +filesystem identity remains unchanged, and the file has been settled for at least one second; a +fresh zero-byte creator stays on the coordinated path so its lock cannot be bypassed. `ocx doctor` inspects the +coordinator with immutable read-only SQLite flags so diagnosis never creates WAL/SHM sidecars. It +distinguishes absent, zero-byte, unversioned, rowless, valid, unsupported, changed, unsafe, and +unreadable states and prints the exact path. Explicit recovery is available only after the proxy is +stopped and only for a proven zero-byte state. The command revalidates the same private +regular-file identity under a non-blocking SQLite write lock and moves it to a same-directory +backup; it never deletes or auto-adopts legacy routed residue. + +> Decision record: [ADR-0009](decisions/ADR-0009-codex-home.md) + +OpenCodex never overrides an explicit `CODEX_HOME`. On Windows, `ocx doctor` and `ocx status` +nevertheless diagnose the high-confidence Orca dual-home case: both `CODEX_HOME` and +`ORCA_CODEX_HOME` select Orca's `orca/codex-runtime-home/home`, while the ChatGPT/Codex app uses the +default `%USERPROFILE%\\.codex`. Sync and restore output always prints the exact target Codex home; +display and JSON paths redact the OS username. The diagnostic tells users to invoke OpenCodex with +the app home explicitly rather than silently claiming that an unrelated app was configured. If a +service was installed under the Orca home, it must first be uninstalled from that original Orca +environment and then reinstalled under the app home; changing only the current shell cannot migrate +the recorded service ownership. + +> Decision record: [ADR-0010](decisions/ADR-0010-codex-home.md) + +`atomicWriteFile` uses a temp file named `{path}.ocx.{pid}.{seq}.tmp` (process ID + incrementing +sequence number) to avoid collisions when concurrent writers (e.g. `ocx stop` and the proxy's own +shutdown handler) both restore Codex config simultaneously. The temp is renamed atomically into place. +Storage cleanup run metadata uses the field-scoped persisted-config mutation path, so a background +Worker cannot restore unrelated API keys or provider settings from a snapshot read before the lock. +If that metadata write is unavailable after cleanup has already completed, the job retains the +cleanup outcome and exposes a bounded persistence error instead of relabeling the run as a Worker failure. + +Cleanup manifests and satellite backups share the stage-local atomic publisher: an exclusive +private temporary file is fully written and file-synced before the existing Windows-tolerant +rename replaces the destination. Handled publication failures retain the previous record; +directory syncing remains best-effort. This does not make a partial permanent purge reversible: +restore still fails closed when a recorded logical entry has no surviving file. + +Windows secret-file hardening resolves the effective token SID through an absolute, trusted +PowerShell path before granting the owner and removing inherited broad ACL entries. The normal +path obtains System32 from `GetSystemDirectoryW`. Windows ARM64 Bun builds that cannot execute +`bun:ffi` use a narrower ACL-only fallback to the fixed protected default installation path +`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`. The fallback never applies to UAC or +Task Scheduler launch, never consults environment variables or `PATH`, and fails closed when the +fixed executable is absent. + +Direct PowerShell children rely on the process launcher's `windowsHide`/hidden-host mechanism and +must not also receive the PowerShell CLI pair `-WindowStyle Hidden`. On affected Windows 11 systems, +Bun 1.3.14 exits that direct invocation before the command runs, which turns a valid SID or process +lookup into `EACLIDENTITY` or a failed sync. This does not apply to `Start-Process -WindowStyle +Hidden` inside an already-running PowerShell script, nor to .NET/VBS process-window settings. + +> Decision record: [ADR-0011](decisions/ADR-0011-codex-home.md) + +> Decision record: [ADR-0012](decisions/ADR-0012-codex-home.md) + +The durable response-spill directory `~/.opencodex/responses-state-spill/` is bounded in +aggregate, not only per file. Continuation state demoted out of the in-memory cap +(`MAX_STORED_RESPONSE_BYTES`) is written there, and eviction past +`MAX_SPILLED_RESPONSE_BYTES` removes oldest-first through the same deletion point that serves +TTL and count eviction, so an evicted entry unlinks its file. One function owns that ceiling and +three callers drive it: mutation pruning, the lazy load that follows a restart, and the periodic +sweep. The periodic caller is not redundant — the mutation path runs only when traffic arrives, so a +process that comes up over budget from a snapshot written under a larger ceiling would otherwise +stay over it while idle. + +The ceiling bounds what the store can account for, which is every entry in the map plus the +superseded generations queued for unlink, and deliberately not the directory as a whole. Spill files +orphaned by a crash are absent from the map, so this accounting can neither see nor price them; they +remain with the `recoverOrphanedResponseSpills` grace sweep described below, which is the only +mechanism that reclaims them. A host that crashes repeatedly can therefore hold spill bytes above +this ceiling for up to `RESPONSE_SPILL_ORPHAN_GRACE_MS` past each crash. Without that aggregate bound the +directory was limited only per file (256 MiB) and per entry (1000) — a 250 GiB product — which +left `RESPONSE_TTL_MS` as the only effective limit and made disk use a function of client +request rate rather than of anything the process controls. + +> Decision record: [ADR-0013](decisions/ADR-0013-codex-home.md) + +Response-state loading performs a bounded recovery pass for interrupted snapshot writes. It only +matches regular files named `responses-state.json.ocx...tmp`, waits at least 15 +minutes, and skips the current or any live PID. Eligible files are truncated before unlinking so a +matching stale path is unlinked without following it. Path-based truncation is intentionally avoided: +a same-user replacement could otherwise turn cleanup into a write through a symlink. Unrelated +temporary files, symlinks, directories, and young/active writes are never touched; directory entries +are consumed incrementally and at most 512 stale files are attempted per process start. + +> Decision record: [ADR-0014](decisions/ADR-0014-codex-home.md) + +Windows runtime response spills never wait on `icacls` through `Bun.spawnSync`. Linux and macOS +retain the immediate synchronous publication path. On Windows, the resident continuation enters one +serialized publication queue and remains replayable while `hardenSecretDirAsync` and +`hardenSecretPathAsync` run. Publication installs a spill stub only when the map still contains the +same resident object; a superseded job deletes its newly published file instead of overwriting newer +state. Pending payloads are pinned and capped at 256 MiB, so an ACL outage cannot grow an unbounded +queue or be misreported as evictable memory. One caller-owned retry is allowed after a real +`ETIMEDOUT`; the first timeout does not install a `spill-failed` tombstone. Required ACL failures +remain fail-closed after that bounded recovery. Optional config-directory hardening uses a separate +per-directory async single-flight, while required config mutation writers retain their existing +awaited or synchronous fail-closed boundary. + +Each ordinary async spill write attempt owns one 30-second ACL budget shared across directory, temp, +and exclusive-copy destination hardening; the single timeout retry receives one fresh whole-attempt +budget. No harden step may reopen an independent 30-second window inside either attempt. +Both icacls and effective-principal subprocess waits are settlement-bounded: at deadline the child is +killed, unref'd, and abandoned without awaiting `proc.exited`. The caller-level deadline also bounds +injected/shared runners, so a child that ignores termination cannot pin the serialized spill queue. + +Graceful shutdown drains that serialized publication queue to a stable fixed point before snapshot +serialization. The drain has a wall-clock cap with a reserved synchronous fallback budget; expiry +supersedes the async writer, claims and removes any temp or destination it still owns, and only then +starts fallback publication. The writer rechecks supersession before no-replace publication, while +the fallback splits its reserve across the directory and file ACL hardens. This ordering is +load-bearing because resident entries over 2 MiB are deliberately excluded from +`responses-state.json`: serializing first could omit the resident before its durable spill stub +exists, losing the continuation on restart. Cleanup is attempted for every abandoned writer; any +failure is retained while fallback and snapshot persistence continue, then returned through the +shutdown status so process exit is non-zero without sacrificing unrelated replay state. +If the fallback reserve expires, every remaining resident candidate is terminalized as a bounded +`spill-failed` tombstone before pruning, so no payload remains eligible for shutdown requeue and the +snapshot flush always regains control. +The terminalization pass itself is hard-capped at `MAX_STORED_RESPONSES + 1`; exceeding that +structural bound records a bounded failure, fail-closes every remaining resident, and returns control +to snapshot persistence instead of relying on the progress argument alone. + +> Decision record: [ADR-0015](decisions/ADR-0015-codex-home.md) + +## Codex-home diagnostics + +Some Codex-home conditions are reported rather than repaired, because repairing them would overwrite +a deliberate user choice: + +- Bundled-plugin marketplace state on Windows (`src/codex/plugins-doctor.ts`), surfaced by + `ocx status`. +- Project-level Codex config that bypasses managed routing + (`src/codex/project-config-warnings.ts`), surfaced by `ocx doctor` as a warning rather than an + override. diff --git a/structure/config.md b/structure/config.md new file mode 100644 index 0000000000..224c5a350d --- /dev/null +++ b/structure/config.md @@ -0,0 +1,194 @@ +# Config Surface + +## Config surface + +### OpenCodex home and live process state + +`initializePersistedConfigIfMissing` in `src/config.ts` is the create-only path consumed by +`src/cli/init.ts`. It rechecks absence under the existing config-mutation lock and publishes through +`src/config/initialize.ts`: a private descriptor is hardened before secret bytes are written, then +linked without replacing an occupied destination. Existing invalid or unsafe entries are preserved. +The initializer never truncates a staged inode or rolls back by unlinking the destination; cleanup +only removes its own temporary name. Unsupported/denied links and incomplete cleanup fail explicitly, +and publication followed by a later failure can leave a complete config or private residue. Ordinary +`saveConfig` replacement behavior remains unchanged. This protects init-time config bytes, not a +foreign winner's ownership under future uninstall; the existing ownership manifest and global CLI +shim preflight keep their separate contracts. + +Initial publication diagnostics distinguish required permission-hardening failures from denied +hard-link publication without exposing raw filesystem causes. Both identify `OPENCODEX_HOME` +as the supported-location recovery path; uncertain publication and cleanup warnings remain in +the CLI. The quickstart documents inspection before retry, private-permission requirements, +and fresh-location examples. Diagnostics do not introduce a fallback or alter file I/O ordering. + +`src/config/paths.ts` is the single owner of `OPENCODEX_HOME` expansion and resolution. It exposes +the config directory and `config.json` path and retains the existing cache rule: a relative home is +resolved once for each distinct raw environment value, so a later working-directory change cannot +silently move the active installation. + +`src/config/process-state.ts` derives `ocx.pid` and `runtime-port.json` from that resolved directory. +It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command +identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` remains optional, +owner-only state and is validated before a record is returned. `src/config.ts` re-exports the same +symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. + +Replacing config and process-state writes use `src/config/atomic-write.ts`. The leaf preserves the shared +process-wide temp sequence, symlink target resolution, real-home test guard, owner manifest, +Windows ACL hardening, scrub-before-unlink failure path, and explicit residual-temp errors. A caller +must not replace it with a local temp-and-rename shortcut. + +> Decision record: [ADR-0016](decisions/ADR-0016-config-surface.md) + +`src/types.ts` is the shape and `src/config.ts` is the loader; neither is reproduced here. What +matters for maintainers is which groups exist and who resolves them: + +| Group | Keys | Resolution rule | +| --- | --- | --- | +| Listener | `port`, `hostname` | The listener owns the port; `runtime-port.json` reports where it actually landed. | +| Routing | `defaultProvider`, `providers`, per-provider `selectedModels` | Explicit `provider/model` wins over `defaultProvider`. | +| Catalog | `disabledModels`, `customModels`, `modelCacheTtlMs`, `providerContextCaps`, `contextCapValue`, per-provider `modelDisplayNames`, `codexAccountNamespaces`, `codexAccountPickerEnabled` | Catalog state is derived; config only records intent. Exact provider model display names are durable display only overlays. The picker flag is an explicit visibility override, while selector mappings remain the durable exact-routing contract. | +| Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. | +| Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | +| Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | +| Lifecycle | `codexAutoStart`, shim/start behavior, resume-history sync, storage cleanup | Startup safety reads these; see [`gui-and-management-api.md`](gui-and-management-api.md). | + +Env values are resolved through `src/config.ts`, so a config value naming an env var never persists +the secret itself. + +## Config injection + +`src/codex/inject.ts` writes one of two forms. The choice is not cosmetic: it decides whether Codex +keeps its native provider id, which decides whether existing thread history still resolves. + +**Loopback (default).** A single marker-owned root override, no provider table: + +```toml +model_catalog_json = "/absolute/path/to/opencodex-catalog.json" +openai_base_url = "http://127.0.0.1:10100/v1" +``` + +Codex keeps the native `openai` provider id, so new threads stay under that identity instead of +being re-tagged. History restore is manifest-authoritative: only rows whose original provider, +source, and event marker were backed up for the same state database are restored exactly. A bare +`opencodex` row is never assumed to have originated at OpenAI; it stays unchanged unless the user +explicitly runs legacy OpenAI recovery. A user-owned root `openai_base_url` is preserved instead of +overwritten, and that case also blocks managed sub-agent defaults rather than fighting the user for +ownership. + +Client-compaction mode can retain that user-owned root URL alongside an injected provider table. +Its status must distinguish ownership from destination: an unmarked user-owned line may already +point to this proxy. Report that existing `openai` threads follow the configured root URL and new +threads use the injected table, without inferring a foreign endpoint or prescribing URL removal. +This diagnostic distinction does not change URL ownership, journal entries, or session history. + +**API auth header (non-loopback).** The built-in `openai` provider cannot carry the +`x-opencodex-api-key` env header, so this form re-tags the root provider and appends the table: + +```toml +model_provider = "opencodex" +model_catalog_json = "/absolute/path/to/opencodex-catalog.json" + +[model_providers.opencodex] +name = "OpenCodex Proxy" +base_url = "http://:/v1" +wire_api = "responses" +requires_openai_auth = true +env_key = "OPENCODEX_API_AUTH_TOKEN" +``` + +Root TOML keys must be written before the first `[table]`. Re-injection strips the stale form of +both shapes — opencodex blocks, injected root base-url overrides, stale root context-window +overrides, and stale catalog paths — before rewriting, so switching between forms leaves no residue. + +Read-only doctor and project-routing diagnostics use a lightweight root/table TOML reader rather +than mutating or normalizing the user's file. That reader must lexically skip both basic and literal +multiline string bodies: instruction prose can contain key-shaped examples and `[table]` snippets, +which are data rather than configuration. Diagnostic result objects may retain the real path for +local correlation, but every formatted doctor line must pass it through the shared user-path +redaction boundary before display. + +> Decision record: [ADR-0017](decisions/ADR-0017-config-injection.md) + +Native Codex sub-agent defaults are a separate, explicit opt-in. When +`syncCodexSubagentDefaults` is true and `injectionModel` is set, injection writes marker-owned +`agents.default_subagent_model` and, when configured, +`agents.default_subagent_reasoning_effort`. Unmarked values are user-owned and must never be +overwritten. Disabling the option and fallback restore remove only marker-owned values; journal +restore must preserve later user edits while stripping those managed values. + +### History backup manifest contract + +`src/codex/history-manifest.ts` is the pure schema-and-identity leaf for the versioned history +backup manifest. It owns the accepted provider/source provenance tuples, platform-aware database +path identity, backup filename id, and validation from unknown JSON to a typed manifest. It does +not read files, inspect rollouts, open SQLite, retry, fingerprint, write, or delete anything. + +`history-provider.ts` remains the strict mutation owner and maps shared validation failures to its +restore/no-op integrity states. `native-residue.ts` remains a read-only observer and maps the same +result to clean, residue, or indeterminate before inspecting referenced rollout files. + +> Decision record: [ADR-0018](decisions/ADR-0018-config-injection.md) + +If the root config selects a provider other than `openai` or `opencodex`, injection must leave the +config byte-for-byte unchanged and skip profile creation/updates and history metadata restoration. External +provider managers own that routing configuration, and replacing their provider id can hide +otherwise intact Codex sessions. This ownership check must run before catalog/cache refresh, +journal creation, and the background history restoration guardian. + +`ocx sync` and `ocx restore back` run the injector's non-writing preflight before provider +discovery or catalog/cache replacement. Deterministic config and ownership refusals therefore +leave the existing catalog and cache untouched, and their concrete messages are emitted on stderr. +The real injection still revalidates under its normal write boundary after catalog convergence; +the preflight is an early no-write guard, not an authorization token for a later write. + +> Decision record: [ADR-0019](decisions/ADR-0019-config-injection.md) + +`supports_websockets = true` is appended to the provider table only when `websocketsEnabled(config)` +returns true. + +## Profile and fast tier + +When opencodex owns routing, it also writes `$CODEX_HOME/opencodex.config.toml` as an explicit profile +target. Codex config uses `service_tier = "fast"` and `[features].fast_mode = true`; +catalog/request tier metadata may use `priority`. Do not collapse these spellings into one value. + +## Provider output defaults + +`OcxProviderConfig.defaultMaxOutputTokens` and `modelMaxOutputTokens` are OpenAI Chat wire defaults, +not context-window metadata. They are applied only when a Responses request omits +`max_output_tokens`; an explicit request value wins, then a model-specific configured value, then +the provider default, then the adapter omits `max_tokens`. + +Both fields must stay positive finite integers at disk-config and management validation boundaries. +Registry entries may seed them through `providerConfigSeed`, key-login derivation, OAuth reconcile, +and `routeModel`, but user config overrides registry defaults per field/key. + +## Provider validation ownership + +`src/config/provider-validation.ts` owns the pure provider payload checks shared by persisted config, +CLI writes, and management DTO validation. `src/config.ts` imports those checks for Zod refinement +and re-exports them as a compatibility facade; it must not grow a second copy. Validation error text, +ordering, and cross-field rules are part of the write/load contract because management requests and +hand-edited `config.json` must accept and reject the same provider shapes. + +> Decision record: [ADR-0020](decisions/ADR-0020-provider-validation-ownership.md) + +## Restore + +`ocx stop`, `ocx restore` / `ocx eject`, `ocx service stop`, and `ocx service uninstall` must strip +opencodex config and routed catalog entries without damaging native Codex state. + +Full `ocx uninstall` config cleanup is ownership-manifest based. A fresh config directory receives a +root-bound owner marker and an uninstall manifest before its first atomic config write. Uninstall +validates both bounded metadata files, rejects path traversal and a symlink/junction config root, +and removes only normalized manifest entries. Manifest-owned directory links are unlinked without +traversing their targets. Unknown files remain in place and make the command report a partial +uninstall with their exact paths. + +Legacy nonempty config directories are deliberately not retroactively claimed. If either ownership +file is missing, malformed, or bound to another root, uninstall refuses config deletion and reports +the residual directory for manual review; there is no recursive-delete fallback. + +## Remote client key files + +Client connection metadata stores a stable `apiKeyId` and a non-secret rotation `pendingOperation`. The current data secret remains only in `service-api-token`; a bounded rotation temporarily keeps the old secret in owner-only `service-api-token.prev`. Commit or recovery clears the marker before orphan cleanup. `ocx disconnect` is local-only and leaves remote revocation to the hub's **Integrations → API Keys** page. Hub and local usage stores are not mirrored. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md new file mode 100644 index 0000000000..4183571a2a --- /dev/null +++ b/structure/data-planes/images.md @@ -0,0 +1,68 @@ +# Images Data Plane + +## Standalone Images + +Codex's local `image_gen.imagegen` tool makes a second Images request after the model calls it: +`POST /v1/images/generations` for generation or `POST /v1/images/edits` for reference-image edits. +These are standalone Images API routes, not the hosted Responses `image_generation` tool. + +`src/server/images.ts` uses the existing ChatGPT/OpenAI fallback unless `images.provider` explicitly +selects a custom API-key `openai-responses` provider. Explicit selection fails closed when the +provider is missing, disabled, registry-managed, incompatible, or lacks a usable key; it never +falls through to another paid upstream. The relay accepts bounded JSON generation and edit requests, +then forwards the decoded JSON without rewriting Codex's edit schema. Each paid Images POST receives +one upstream attempt; client cancellation aborts the upstream and pool-only failures update the +existing account-health state. Unknown Images subpaths still reach the JSON `/v1/*` 404 guard. + +When the OpenAI credential path is unavailable or its authentication fails, `generations` (not +`edits`) may fall back to Google Antigravity if that provider is logged in. The fallback is +credential-driven: it exists so an image request reaches a real upstream answer rather than dying on a +local credential error, and it does not apply when the caller selected an explicit keyed custom +provider, because a configured pool owns its own authentication failure rather than hiding it behind +separately billed generation. + +On non-loopback binds, data-plane authentication and origin policy cover both Images routes. An +explicit keyed Images provider accepts the proxy admission secret as either an OpenAI-style bearer +or `x-opencodex-api-key` because the provider key replaces caller authorization before fetch. The +ChatGPT forward path still requires the dedicated header so its upstream bearer remains distinct. + +The API-key `openai-responses` path also adapts Codex's private standalone image tool to the public +Responses tool surface. A complete `image_gen` namespace is lowered to safe +`image_gen__` function aliases even when no hosted image tool is present, because public +Responses runtimes may reserve the namespace itself and reject dotted function names. Native and +legacy dotted calls replayed in `body.input` are encoded to the same aliases. When any client +image-gen declaration is replaced by a usable `image_gen__` alias, the adapter also drops +hosted `image_generation` and deduplicates aliases in stable container order. Empty or malformed +namespaces do not remove the hosted fallback. Discovery and normalization span both top-level +`body.tools` and Codex Desktop Responses Lite `input[].type = "additional_tools"` containers. + +For a model explicitly listed in `modelPreferHostedTools`, a non-forward Responses provider may opt +to remove colliding client `image_gen` declarations before this normalization and rewrite their +selectors to hosted `image_generation`, so a provider-reserved hosted tool takes precedence without +loosening a caller's tool-choice restriction. The opt-in is intentionally model-scoped: the default +alias path remains safest for ordinary public Responses endpoints. + +For OpenAI API virtual `-pro` models, preference lookup checks the selected public ID first and +uses the resolved base wire-model ID as a fallback. `modelAdapters` resolves the public ID first and +the base ID second; the second pass selects the final adapter, and configuration validation mirrors +both steps. + +Client-facing API-key responses perform the inverse mapping: JSON output and SSE function-call +items restore `{ namespace: "image_gen", name: "" }` so Codex can dispatch the local +extension. When item-id repair is also enabled, both transforms compose in one SSE parse/stringify +pass (`src/server/sse-payload-rewrite.ts`) rather than chaining separate JS pull wrappers. +Inspection and continuation-cache branches keep the raw upstream alias, allowing stored +replays to return upstream without leaking a client-only namespace shape. The image-gen layer itself +leaves malformed and empty image-gen namespaces untouched, but on a noncanonical route the general +namespace boundary above runs after it and lowers whatever remains, so no private group reaches the +wire. ChatGPT forward mode preserves the private namespace and hosted tool because that backend +understands their native semantics. + +Per-model `modelReasoningSummaryDelivery` is a narrow compatibility layer for +`openai-responses` gateways whose summary capability is real but whose accepted delivery enum +differs from Codex. Presence advertises reasoning summaries in the routed catalog and rewrites only +an already-present `stream_options.reasoning_summary_delivery` at the adapter boundary. It never +injects summary generation into a request, and config validation rejects a delivery map that +conflicts with `modelSupportsReasoningSummaries: false` for the same model. + +> Decision record: [ADR-0045](../decisions/ADR-0045-standalone-images.md) diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md new file mode 100644 index 0000000000..927a26aa0e --- /dev/null +++ b/structure/data-planes/inbound-compat.md @@ -0,0 +1,84 @@ +# Inbound Compatibility Surfaces + +## Chat Completions inbound native path + +`POST /v1/chat/completions` sends eligible `openai-chat` routes directly to the provider's Chat +Completions endpoint. Route selection reads the raw Chat body and the native request keeps that body +as its wire source; a Responses projection is constructed only after the native route is declined +and is never converted back into Chat. Request construction remains owned by `src/adapters/openai-chat.ts`, including model +normalization, credential and provider headers, capability-specific fields, and the canonical +`openaiChatCompletionsUrl()` path. The passthrough builder uses an explicit Chat-field whitelist so +messages (including `name` and separate `system`/`developer` entries), Chat token controls, +sampling/logprob fields, caller identity/metadata, and caller stream options retain their wire +shape. For streams, caller `stream_options` are merged with mandatory `include_usage: true`. On +the native passthrough there is no canonical Fast injection and no wire mapping: every caller +`service_tier` — canonical or foreign — is forwarded raw and only under `chatServiceTier: true`, +and `fastMode` injects nothing here. Resolved-Fast-policy injection applies only to routes that +take the Chat -> Responses -> Chat bridge below. `parallel_tool_calls` is emitted only for providers opted into +parallel tools (or pinned false by the existing provider opt-out contract). +Combo/policy routes and requests that need Responses-only hosted tools, continuation, background, +or storage semantics retain the existing Chat -> Responses -> Chat bridge. + +The direct SSE relay accepts CRLF and arbitrary transport chunk boundaries while retaining at most +one bounded event. EOF with an unterminated event and an event above the translator limit are typed +upstream failures, never successful partial completions. Provider-controlled structured error +messages are redacted before either JSON or SSE reaches the client. The native path uses the same +request-attempt logging, reset retry, same-key 429 replay, key rotation, usage extraction, and +request-signal cancellation contracts as routed Responses transport. + +## Chat streaming client with a JSON upstream result + +The translated inbound path in `src/server/chat-completions.ts` may receive a complete JSON +Responses result even when the Chat client requested SSE. Its synthetic stream reuses +`responsesJsonToChatCompletion` as the semantic authority: converted text, reasoning, available +refusal content, tool calls, finish reason, and usage must survive this final delivery conversion. +Tool calls gain their array-order stream `index`; the stream retains one assistant-role frame, +one terminal choice, and one `[DONE]`. Both native and translated JSON fallbacks share +`jsonCompletionSse`; its temporary frame strings and final body ownership are charged to the +existing translator budget. Known incomplete limits take precedence over tool finish reasons; +unmapped incomplete boundaries remain errors. The existing response-body lifecycle owns translation-budget +release on consumption or cancellation. Actual upstream SSE and native Chat bypass this fallback. + +> Decision record: [ADR-0062](../decisions/ADR-0062-chat-streaming-client-with-a-json-upstream-resul.md) + +### Chat refusal projection + +`src/chat/outbound.ts` keeps Responses refusal parts separate from ordinary content. JSON output +and the stream collector expose nullable `message.refusal`; `jsonCompletionSse` preserves it as +`delta.refusal`, while the native SSE relay remains opaque. The translated live stream keys refusal +state by raw `output_index` / `content_index`, validates present item IDs as correlation constraints, +and emits buffered parts in that order only at a valid completed/incomplete terminal. Deltas append; +equal, empty, absent, and shorter-prefix snapshots preserve existing text; extending snapshots add +only new text. Non-string or contradictory snapshots fail with a content-free typed error. + +The existing turn budget accounts for refusal text and map metadata, including empty entries, and +releases that state on terminal, failure, or cancellation. Pending role/tool/refusal/finish/`[DONE]` +frames form one terminal batch: all serialized strings and encoded frames must be admitted before +any batch frame is enqueued. Admission failure releases the batch and refusal state, cancels upstream, +and emits only the bounded overflow error. Collector processing failures cancel their reader before +releasing its lock, so upstream translation cannot continue after failed JSON collection. The outer +response finalizer continues to own retained response bytes. These are projection rules, not new +refusal policy or changes to ordinary content/tool semantics. + +## MiniMax Anthropic-compatible clients + +The MiniMax platform CLI's text resource posts Anthropic Messages to +`/anthropic/v1/messages`. `ocx mmx` adapts that hard-coded client path with a temporary +loopback bridge instead of adding another server route. The bridge accepts only POSTs to the +messages and count-tokens paths, rewrites them to the existing `/v1/messages` data plane, +preserves the query and streaming body, strips all incoming credential headers, and pins the +public loopback placeholder. It stops as soon as the MMX child exits, so the server's +`AUTH_MATRIX` and authentication surface remain unchanged. + +`ocx mmx` exposes only the text resource because the other MMX resources use MiniMax-specific +image, video, speech, music, vision, search, quota and file endpoints. The launcher isolates +`~/.mmx` credentials behind a temporary config, removes ambient proxy variables so loopback +traffic cannot be sent off-machine, owns the temporary bridge lifecycle, and refuses +destination, region and credential overrides. It is +loopback-only because MMX cannot carry the dedicated remote-admission header. MiniMax Code uses +the separate reversible `custom_provider.opencodex` file integration and is likewise +loopback-only; its generated block never changes `defaultModel`. Each generated MCode model +copies an authoritative catalog context window into `limit.context` and a nonempty canonical +reasoning ladder into `thinking.effortOptions`. Missing capabilities stay absent instead of +falling back to OpenCodex guesses, and the integration does not write the removed +`thinking.effort` / `defaultEffort` fields because MCode owns the active effort per session. diff --git a/structure/data-planes/search.md b/structure/data-planes/search.md new file mode 100644 index 0000000000..de35b28ea9 --- /dev/null +++ b/structure/data-planes/search.md @@ -0,0 +1,19 @@ +# Search Data Plane + +## Standalone Search and exact account selectors + +`POST /v1/alpha/search` retains the selected model in its request body. When that value is an +account-qualified native selector, the server resolves the public namespace, uses only the mapped +stored Codex credential, and sends the bare native model upstream. That exact path is fail-closed: +it does not consult Pool active state or affinity when selecting, and its outcomes cannot rotate +the active Pool account. An account-wide credential failure still quarantines that credential and +clears stale ordinary Pool affinities so they cannot reappear after reauthentication. Quota and +transient outcomes from an exact request leave Pool affinities untouched. Ordinary search requests +keep the normal Direct/Pool sidecar behavior. + +Standalone Images and Live requests currently carry neither the account-qualified model selector +nor a trustworthy thread correlation from the Codex client. They therefore retain normal provider +routing. Do not infer an exact account from caller-supplied account headers, process-global last +selection, connection identity, or other ambient state; concurrent threads could cross-route +credentials. Extending exact routing to those endpoints requires an opaque client correlation that +can be bound server-side to a previously validated selector. diff --git a/structure/decisions/ADR-0001-product-boundary.md b/structure/decisions/ADR-0001-product-boundary.md new file mode 100644 index 0000000000..04a1e18463 --- /dev/null +++ b/structure/decisions/ADR-0001-product-boundary.md @@ -0,0 +1,12 @@ +# ADR-0001 — Product boundary + +- Contract owner: [overview.md](../overview.md#product-boundary) + +## Decision record + +- 목적과 의도: Add two widely used API-key providers through the canonical registry so CLI, GUI, login, routing, and documentation remain in parity. +- 기존 구현 및 제약 조건: Tencent Coding Plan is OpenAI-compatible but contractually restricted to interactive coding tools and has a dynamic, text-only model set. SiliconFlow exposes a dynamic OpenAI-compatible catalog whose reasoning controls vary by model. +- 검토한 주요 대안: Treat both as custom providers only; freeze a large SiliconFlow model list and reasoning map; expose Tencent without a usage warning. +- 선택한 방식: Add registry-derived key presets, keep live discovery enabled, seed only Tencent's currently documented coding-plan models, and surface Tencent's usage restriction in both the preset note and public docs. +- 다른 대안 대신 이 방식을 선택한 이유: Registry presets remove setup friction while live discovery avoids claiming that mutable catalogs are permanent. Avoiding speculative SiliconFlow reasoning metadata prevents invalid vendor-specific parameters. +- 장점, 단점 및 영향: Both providers appear consistently across supported setup surfaces. Tencent users receive an explicit policy warning; SiliconFlow reasoning controls remain conservative until model-specific limits can be represented safely. diff --git a/structure/decisions/ADR-0002-lifecycle.md b/structure/decisions/ADR-0002-lifecycle.md new file mode 100644 index 0000000000..1e26642652 --- /dev/null +++ b/structure/decisions/ADR-0002-lifecycle.md @@ -0,0 +1,12 @@ +# ADR-0002 — Lifecycle + +- Contract owner: [runtime.md](../runtime.md#lifecycle) + +## Decision record + +- 목적과 의도: Give a headless hub a browser management ingress without widening its data plane or trusting spoofable forwarding headers on the public listener. +- 기존 구현 및 제약 조건: `startServer` is synchronous through Lab activation, already owns an optional-listener transaction, and the service installer already has an owner-only token-file flow. +- 검토한 주요 대안: Add management routes to the public listener; infer trusted ingress from `Host`/`Forwarded`/Tailscale headers; create a separate service manager; extend the existing composition root. +- 선택한 방식: Bind a third socket exactly to `127.0.0.1`, select trust by receiving `Bun.serve` instance, keep a fixed route allowlist, and reuse the current launchd/systemd definitions. +- 다른 대안 대신 이 방식을 선택한 이유: Headers do not prove which transport received a request, while a kernel loopback bind plus Tailscale Serve supplies a concrete ingress boundary without duplicating lifecycle or secret delivery. +- 장점, 단점 및 영향: Public/default behavior stays unchanged and management can use Tailscale identity; operators must provide a co-located HTTPS frontend and pairing remains necessary for generic TLS proxies. diff --git a/structure/decisions/ADR-0003-lifecycle.md b/structure/decisions/ADR-0003-lifecycle.md new file mode 100644 index 0000000000..e64cdef923 --- /dev/null +++ b/structure/decisions/ADR-0003-lifecycle.md @@ -0,0 +1,12 @@ +# ADR-0003 — Lifecycle + +- Contract owner: [runtime.md](../runtime.md#lifecycle) + +## Decision record + +- 목적과 의도: Separate proxy process ownership from persisted configuration without changing lifecycle behavior. +- 기존 구현 및 제약 조건: `src/config.ts` mixed config transactions with cross-platform PID identity, runtime-port attestation, and stale-state cleanup; process writes still require the same config-home and atomic-write protections. +- 검토한 주요 대안: Keep the mixed module; create a process-state module that imports `config.ts`; duplicate atomic writes inside the new module; split the minimal path and atomic-write foundations first. +- 선택한 방식: `paths.ts` and `atomic-write.ts` are dependency leaves, `process-state.ts` depends only on those leaves, and `config.ts` remains a compatibility facade. +- 다른 대안 대신 이 방식을 선택한 이유: Importing the facade would create a cycle, while duplicated writes could drift on ACL, symlink, residual-secret, and atomic-sequence behavior. +- 장점, 단점 및 영향: Lifecycle callers have a narrow owner and behavior remains characterized; the temporary facade and three small config modules add files but preserve downstream imports. diff --git a/structure/decisions/ADR-0004-lifecycle.md b/structure/decisions/ADR-0004-lifecycle.md new file mode 100644 index 0000000000..94598c8b68 --- /dev/null +++ b/structure/decisions/ADR-0004-lifecycle.md @@ -0,0 +1,12 @@ +# ADR-0004 — Lifecycle + +- Contract owner: [runtime.md](../runtime.md#lifecycle) + +## Decision record + +- 목적과 의도: Prevent repository dotenv data from becoming a durable executable or an OAuth-bearing Claude destination. +- 기존 구현 및 제약 조건: Bun auto-loads project dotenv before OpenCodex TypeScript evaluates, while provider interpolation still depends on that behavior and cannot be disabled globally. +- 검토한 주요 대안: Reject only relative Bun paths; disable Bun dotenv; trust a plain environment marker; capture provenance in the Node launcher and bind it to an argv proof. +- 선택한 방식: The Node launcher selects Bun and snapshots Anthropic credential/destination slots before Bun starts. Durable runtime selection uses only the stamped current executable, while Claude accepts the snapshot only when its random argv proof matches. +- 다른 대안 대신 이 방식을 선택한 이유: Absolute dotenv expansion bypasses a relative-path check, global dotenv removal breaks supported configuration, and an environment-only marker can itself come from dotenv. +- 장점, 단점 및 영향: Normal npm launches preserve genuine shell overrides. Direct Bun or legacy launches have no provenance signal and fail closed for all three ambient Anthropic slots — credentials included, because subscription mode leaves `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST` unset by design (#253) and a `settings.env` merge can still replace the destination after launch, so a preserved key would travel with it. The cost is that `bun src/cli/index.ts` loses ambient Anthropic values; the escape hatch is running through the published `ocx` bin, where genuine shell exports are preserved by proof. Durable artifacts use the running or bundled Bun. diff --git a/structure/decisions/ADR-0005-codex-home.md b/structure/decisions/ADR-0005-codex-home.md new file mode 100644 index 0000000000..24d3824b3f --- /dev/null +++ b/structure/decisions/ADR-0005-codex-home.md @@ -0,0 +1,12 @@ +# ADR-0005 — Codex home + +- Contract owner: [codex-home.md](../codex-home.md#codex-home) + +## Decision record + +- 목적과 의도: Make the container's catalog location persistent and writable without changing native home semantics. +- 기존 구현 및 제약 조건: Compose persisted only the OCX home, leaving Codex state on a read-only root; both products use incompatible auth.json formats. +- 검토한 주요 대안: Merge the homes, nest Codex under an existing volume with a new startup initializer, or persist the existing separate Codex home. +- 선택한 방식: Add a separate codex-state volume and create both owner-only directories in the image. +- 다른 대안 대신 이 방식을 선택한 이유: It preserves existing paths, avoids credential-file collisions, and works when an older ocx-state volume hides the image's seeded directory tree. +- 장점, 단점 및 영향: Two volumes must be backed up, but no automatic credential migration or runtime resolver change is needed. Catalog import/materialization remains an explicit prerequisite. diff --git a/structure/decisions/ADR-0006-codex-home.md b/structure/decisions/ADR-0006-codex-home.md new file mode 100644 index 0000000000..2230117cb4 --- /dev/null +++ b/structure/decisions/ADR-0006-codex-home.md @@ -0,0 +1,12 @@ +# ADR-0006 — Codex home + +- Contract owner: [codex-home.md](../codex-home.md#codex-home) + +## Decision record + +- 목적과 의도: Keep service ownership metadata aligned with the Codex home the proxy actually uses. +- 기존 구현 및 제약 조건: The runtime performed narrow WSL Windows-home discovery, while service state used `CODEX_HOME || ~/.codex`. +- 검토한 주요 대안: Bake `CODEX_HOME` into every service, migrate old state automatically, or reuse the runtime resolver. +- 선택한 방식: Resolve service install and comparison state through the existing runtime Codex-home resolver. +- 다른 대안 대신 이 방식을 선택한 이유: It preserves explicit overrides and the existing WSL ambiguity rules without rewriting user environment or foreign state. +- 장점, 단점 및 영향: New installs and same-environment repairs agree with runtime targeting; genuinely foreign or ambiguous state remains fail-closed. diff --git a/structure/decisions/ADR-0007-codex-home.md b/structure/decisions/ADR-0007-codex-home.md new file mode 100644 index 0000000000..7f4db176b5 --- /dev/null +++ b/structure/decisions/ADR-0007-codex-home.md @@ -0,0 +1,12 @@ +# ADR-0007 — Codex home + +- Contract owner: [codex-home.md](../codex-home.md#codex-home) + +## Decision record + +- 목적과 의도: Make every history safety check and mutation address the SQLite database Codex actually opened. +- 기존 구현 및 제약 조건: History code rebuilt `CODEX_HOME/state_5.sqlite`, while Codex supports a config or environment-selected SQLite root for split Windows/WSL layouts. +- 검토한 주요 대안: Copy the database into CODEX_HOME, teach only the writer about the override, or centralize the call-time target. +- 선택한 방식: Add one Codex-compatible SQLite resolver, fail closed when its authoritative config is unreadable or its present `sqlite_home` cannot be parsed as a non-empty string, and share it across history jobs, provider defaults, admission, and residue classification. +- 다른 대안 대신 이 방식을 선택한 이유: A writer-only override would let ownership checks authorize one database while the mutation touched another. +- 장점, 단점 및 영향: Split-home history remains correct and backup identities stay database-specific; storage cleanup of an external root remains out of scope. diff --git a/structure/decisions/ADR-0008-codex-home.md b/structure/decisions/ADR-0008-codex-home.md new file mode 100644 index 0000000000..f055de913d --- /dev/null +++ b/structure/decisions/ADR-0008-codex-home.md @@ -0,0 +1,12 @@ +# ADR-0008 — Codex home + +- Contract owner: [codex-home.md](../codex-home.md#codex-home) + +## Decision record + +- 목적과 의도: Keep zero-profile and zero-stage installations out of the native-profile transaction path without weakening staged-credential cleanup. +- 기존 구현 및 제약 조건: Every live server swept stages at startup and every minute, and a failed sweep closed the global native-main gate even when no stage artifact existed. +- 검토한 주요 대안: Disable native-main ownership entirely when the vault is empty, add a stale-lock deletion command, or skip only the stage sweep when both artifact paths are absent. +- 선택한 방식: Preserve owner and claim protection, but bypass `sweepStages()` only after proving the registry and staging tree are both absent. +- 다른 대안 대신 이 방식을 선택한 이유: Physical credential ownership remains cross-process safe, while an inert optional subsystem can no longer create the reported lock/recovery catch-22. +- 장점, 단점 및 영향: Fresh installs avoid the SQLite profile lock; any present or uncertain stage state retains the existing locked fail-closed cleanup and recovery behavior. diff --git a/structure/decisions/ADR-0009-codex-home.md b/structure/decisions/ADR-0009-codex-home.md new file mode 100644 index 0000000000..5640d39498 --- /dev/null +++ b/structure/decisions/ADR-0009-codex-home.md @@ -0,0 +1,12 @@ +# ADR-0009 — Codex home + +- Contract owner: [codex-home.md](../codex-home.md#codex-home) + +## Decision record + +- 목적과 의도: Recover a crashed zero-byte coordinator without mistaking SQLite's normal creation window for stale authority. +- 기존 구현 및 제약 조건: Eligibility treated every existing pathname as coordinated, while initialization correctly refused a missing row over routed residue; catalog sync could therefore succeed before config injection failed permanently. +- 검토한 주요 대안: Delete zero-byte files automatically, initialize a new row over residue, require a manual filesystem command, or add observe-only classification plus explicit guarded quarantine. +- 선택한 방식: Treat only a settled, identity-stable, immutably verified zero-byte database like the existing legacy-uncoordinated boundary; keep fresh creators coordinated, diagnose all other database states immutably, and expose an opt-in zero-byte-only same-directory backup move with identity, ownership, sidecar, liveness, and SQLite-lock checks. +- 다른 대안 대신 이 방식을 선택한 이유: Automatic deletion or adoption can race a live creator or erase transition evidence; a guarded backup preserves evidence and makes the operator action reproducible. +- 장점, 단점 및 영향: A stale zero-byte file no longer wedges sync, valid/unrecognized databases remain fail-closed, and recovery requires the proxy to be stopped before `ocx sync` retries injection. diff --git a/structure/decisions/ADR-0010-codex-home.md b/structure/decisions/ADR-0010-codex-home.md new file mode 100644 index 0000000000..02814e9e6e --- /dev/null +++ b/structure/decisions/ADR-0010-codex-home.md @@ -0,0 +1,12 @@ +# ADR-0010 — Codex home + +- Contract owner: [codex-home.md](../codex-home.md#codex-home) + +## Decision record + +- 목적과 의도: Make multi-home injection truthful without taking ownership of user environment variables. +- 기존 구현 및 제약 조건: CODEX_HOME is an intentional override, but Orca exports it for its own bundled runtime and the Windows app reads a different home. +- 검토한 주요 대안: Rewrite CODEX_HOME automatically, warn for every custom home, or detect only the Orca-owned signature and report the target path. +- 선택한 방식: Preserve the override, add a narrow Windows/Orca diagnostic, and qualify sync/restore success output with the effective home. +- 다른 대안 대신 이 방식을 선택한 이유: It fixes the silent failure while avoiding destructive or noisy behavior for intentional custom homes. +- 장점, 단점 및 영향: Orca users get an actionable warning; other multi-home products remain unchanged until they have an equally reliable signature. diff --git a/structure/decisions/ADR-0011-codex-home.md b/structure/decisions/ADR-0011-codex-home.md new file mode 100644 index 0000000000..5e5d8e01fd --- /dev/null +++ b/structure/decisions/ADR-0011-codex-home.md @@ -0,0 +1,12 @@ +# ADR-0011 — Codex home + +- Contract owner: [codex-home.md](../codex-home.md#codex-home) + +## Decision record + +- 목적과 의도: keep trusted Windows identity and process probes console-less without triggering Bun's direct PowerShell `-WindowStyle Hidden` failure. +- 기존 구현 및 제약 조건: the calls already used `windowsHide: true` or a hidden VBS host, but redundantly passed PowerShell's window-style CLI option; the same option remains valid inside `Start-Process` and must not be removed there. +- 검토한 주요 대안: decode the generic failure specially, retry after failure, remove all hidden-window controls, or remove only the redundant direct CLI pair. +- 선택한 방식: retain trusted executable resolution, non-interactive flags, timeouts, and process-level hiding; remove `-WindowStyle Hidden` only from direct PowerShell argv. +- 다른 대안 대신 이 방식을 선택한 이유: the command executes on affected Bun/Windows combinations, no console window is introduced, and working elevated/detached child-process behavior stays unchanged. +- 장점, 단점 및 영향: SID, process-owner, tray, update, and sync probes share the compatible launch contract; a future call must use launcher-level hiding rather than reintroducing the PowerShell CLI pair. diff --git a/structure/decisions/ADR-0012-codex-home.md b/structure/decisions/ADR-0012-codex-home.md new file mode 100644 index 0000000000..391b52fecf --- /dev/null +++ b/structure/decisions/ADR-0012-codex-home.md @@ -0,0 +1,12 @@ +# ADR-0012 — Codex home + +- Contract owner: [codex-home.md](../codex-home.md#codex-home) + +## Decision record + +- 목적과 의도: Preserve required Windows ACL hardening on the bundled Windows ARM64 runtime without weakening executable trust. +- 기존 구현 및 제약 조건: The effective-SID query depended on the shared `GetSystemDirectoryW` FFI resolver; Bun 1.3.14 Windows ARM64 has no working `bun:ffi`, so config mutation reached `EACLIDENTITY` before PowerShell could start. +- 검토한 주요 대안: Restore `USERDOMAIN\\USERNAME`; trust `SystemRoot`, `WINDIR`, or `PATH`; weaken required ACL writes; broaden the shared elevation resolver; or add a fixed-path fallback only for the non-elevated SID query. +- 선택한 방식: Keep FFI authoritative, then allow only Windows ARM64 to use the existing default `C:\Windows\System32` PowerShell binary for the SID query when that exact file exists. +- 다른 대안 대신 이 방식을 선택한 이유: Names and environment paths are caller-controlled, required secret writes must not silently skip ACLs, and elevation has a larger authority boundary that should remain FFI-only. +- 장점, 단점 및 영향: Default Windows ARM64 installations can start and harden secrets; non-default Windows roots continue to fail closed until Bun exposes a trustworthy native system-directory API without FFI. diff --git a/structure/decisions/ADR-0013-codex-home.md b/structure/decisions/ADR-0013-codex-home.md new file mode 100644 index 0000000000..339bf63835 --- /dev/null +++ b/structure/decisions/ADR-0013-codex-home.md @@ -0,0 +1,12 @@ +# ADR-0013 — Codex home + +- Contract owner: [codex-home.md](../codex-home.md#codex-home) + +## Decision record + +- 목적과 의도: Bound the durable spill directory in aggregate so demoted continuation state cannot consume the host disk. +- 기존 구현 및 제약 조건: The resident map has an unconditional byte cap and demotes past it, but the disk it demotes onto had only a per-file ceiling and the shared 1000-entry count cap. Retention itself worked — the hour-long TTL did evict — so the gap was a missing budget, not a leak. +- 검토한 주요 대안: Lower the per-file ceiling; shorten the TTL; sweep the directory on a timer; add a configurable budget key; carry a running byte counter. +- 선택한 방식: A constant aggregate ceiling checked at the end of the existing prune, evicting oldest-first, with the total recomputed per prune rather than carried as a counter. +- 다른 대안 대신 이 방식을 선택한 이유: Per-file or TTL changes alter retention semantics other bounds depend on; a timer adds a second owner for eviction; a config key would surface a knob the sibling bounds (count, TTL, per-file) do not have; and a running counter could silently disable the cap if any of the several insertion paths missed an increment, where a walk over at most 1000 entries cannot drift. +- 장점, 단점 및 영향: Disk use stops tracking client request rate. Ordinary traffic is unaffected because the count cap binds at a comparable point for median-sized payloads; a workload of unusually large continuations loses its oldest spills earlier than the TTL would, surfacing as the existing `previous_response_not_found` continuation miss. diff --git a/structure/decisions/ADR-0014-codex-home.md b/structure/decisions/ADR-0014-codex-home.md new file mode 100644 index 0000000000..616027eb9e --- /dev/null +++ b/structure/decisions/ADR-0014-codex-home.md @@ -0,0 +1,12 @@ +# ADR-0014 — Codex home + +- Contract owner: [codex-home.md](../codex-home.md#codex-home) + +## Decision record + +- 목적과 의도: Bound disk and conversation-state retention after abrupt process termination. +- 기존 구현 및 제약 조건: Ordinary write failures clean up immediately, but a killed process cannot run that path and Windows may temporarily lock files. +- 검토한 주요 대안: Delete every `.tmp`, rely on manual cleanup, or recover only exact response-state remnants with age and PID guards. +- 선택한 방식: Run a capped, best-effort, unlink-only sweep on lazy response-state startup. +- 다른 대안 대신 이 방식을 선택한 이유: It repairs known remnants without broad authority over unrelated temp files or active writers. +- 장점, 단점 및 영향: Old dead-PID files are reclaimed automatically; locked or conservatively classified files remain for a later retry. diff --git a/structure/decisions/ADR-0015-codex-home.md b/structure/decisions/ADR-0015-codex-home.md new file mode 100644 index 0000000000..d1bbd8f78d --- /dev/null +++ b/structure/decisions/ADR-0015-codex-home.md @@ -0,0 +1,12 @@ +# ADR-0015 — Codex home + +- Contract owner: [codex-home.md](../codex-home.md#codex-home) + +## Decision record + +- 목적과 의도: Keep `/healthz` and unrelated requests responsive during intermittent Windows ACL stalls without publishing an unhardened continuation. +- 기존 구현 및 제약 조건: Response demotion called the synchronous spill writer from request-time state mutations; `Bun.spawnSync(icacls)` could block the only Bun event loop for the full timeout and immediately replace replayable state with a tombstone. +- 검토한 주요 대안: Increase the ACL timeout, weaken required ACL checks, publish before hardening, move every platform to async state mutation, or isolate only the Windows ACL-dependent publication boundary. +- 선택한 방식: Preserve non-Windows behavior; serialize Windows publications through async ACL APIs, retain the exact resident generation until compare-before-swap succeeds, cap pending bytes, and retry one proven timeout. +- 다른 대안 대신 이 방식을 선택한 이유: Longer waits worsen liveness, early publication weakens secret-file ACLs, and a cross-platform async rewrite would disturb mature immediate memory and crash-ordering contracts that do not cause this incident. +- 장점, 단점 및 영향: Windows health stays schedulable and transient ACL stalls retain continuation replay; pending payloads can temporarily exceed the 64 MiB resident target but are pinned under a 256 MiB local ceiling and remain inside the documented 512 MiB process-owned worst case. diff --git a/structure/decisions/ADR-0016-config-surface.md b/structure/decisions/ADR-0016-config-surface.md new file mode 100644 index 0000000000..c2f6c91119 --- /dev/null +++ b/structure/decisions/ADR-0016-config-surface.md @@ -0,0 +1,12 @@ +# ADR-0016 — Config surface + +- Contract owner: [config.md](../config.md#config-surface) + +## Decision record + +- 목적과 의도: Make persisted config, path resolution, atomic file publication, and live process state distinct ownership boundaries. +- 기존 구현 및 제약 조건: All four concerns lived in `src/config.ts`; process-state extraction could not safely import the facade without a cycle and could not copy the atomic writer without creating two security/correctness contracts. +- 검토한 주요 대안: Keep one file, tolerate the cycle, duplicate only PID/runtime writes, or extract the minimal dependency leaves. +- 선택한 방식: Preserve one implementation per concern under `src/config/` and keep facade re-exports for downstream compatibility. +- 다른 대안 대신 이 방식을 선택한 이유: The dependency graph stays acyclic and every existing path, serialized shape, error, identity probe, and cleanup guard remains reusable from one owner. +- 장점, 단점 및 영향: Internal lifecycle imports become narrow and testable; review must still treat changes to `atomic-write.ts` and `process-state.ts` as shared cross-platform runtime changes. diff --git a/structure/decisions/ADR-0017-config-injection.md b/structure/decisions/ADR-0017-config-injection.md new file mode 100644 index 0000000000..fe97d8c16f --- /dev/null +++ b/structure/decisions/ADR-0017-config-injection.md @@ -0,0 +1,12 @@ +# ADR-0017 — Config injection + +- Contract owner: [config.md](../config.md#config-injection) + +## Decision record + +- 목적과 의도: Keep strict-config diagnostics useful without interpreting instruction prose as TOML or exposing OS account names in shareable output. +- 기존 구현 및 제약 조건: The diagnostic reader intentionally covers only Codex root keys and tables; a full TOML dependency is not otherwise required. +- 검토한 주요 대안: Add a full TOML parser, scan raw lines for one legacy key, or preserve the lightweight parser with multiline lexical state. +- 선택한 방식: Preserve the bounded reader, skip multiline string bodies before key/table matching, and redact paths only at the formatting boundary. +- 다른 대안 대신 이 방식을 선택한 이유: All consumers keep one root/table interpretation while internal diagnostics retain actionable local paths. +- 장점, 단점 및 영향: False positives and username disclosure are removed; unsupported exotic TOML syntax remains outside this diagnostic reader's contract. diff --git a/structure/decisions/ADR-0018-config-injection.md b/structure/decisions/ADR-0018-config-injection.md new file mode 100644 index 0000000000..33e1b41a28 --- /dev/null +++ b/structure/decisions/ADR-0018-config-injection.md @@ -0,0 +1,12 @@ +# ADR-0018 — Config injection + +- Contract owner: [config.md](../config.md#config-injection) + +## Decision record + +- 목적과 의도: Make restore and native-residue inspection accept and reject exactly the same versioned history provenance contract. +- 기존 구현 및 제약 조건: Both modules independently checked version, database identity, entry ids, absolute rollout paths, provider/source tuples, and event markers; drift could make one module restore a manifest that the other refused to classify. +- 검토한 주요 대안: Keep duplicate validators synchronized through review, import the mutation-heavy history provider into residue inspection, or extract a pure shared leaf. +- 선택한 방식: Extract only types, path identity, filename id, provenance, and unknown-data validation; keep all filesystem, rollout, SQLite, retry, and mutation policy in the existing callers. +- 다른 대안 대신 이 방식을 선택한 이유: A pure leaf removes schema drift without pulling write-side effects or database ownership into the read-only startup inspection graph. +- 장점, 단점 및 영향: Format changes now have one validator and shared invalid fixtures; callers still intentionally own different user-facing failure mappings, so contract changes require updating both mappings and this document. diff --git a/structure/decisions/ADR-0019-config-injection.md b/structure/decisions/ADR-0019-config-injection.md new file mode 100644 index 0000000000..5d8456124c --- /dev/null +++ b/structure/decisions/ADR-0019-config-injection.md @@ -0,0 +1,12 @@ +# ADR-0019 — Config injection + +- Contract owner: [config.md](../config.md#config-injection) + +## Decision record + +- 목적과 의도: Prevent a refused Codex config injection from degrading a previously usable model catalog and make the refusal actionable from the CLI. +- 기존 구현 및 제약 조건: Catalog discovery and replacement ran before injection, while the injector alone owned the authoritative TOML transforms and write-coordination eligibility checks. +- 검토한 주요 대안: Roll back catalog and cache bytes after a later refusal, duplicate a partial TOML validator in the CLI, or run the injector's existing planning path without committing before discovery. +- 선택한 방식: Add a non-writing mode to the injector and call it before catalog work; keep the normal injector call as the final under-lock authority check. +- 다른 대안 대신 이 방식을 선택한 이유: Post-hoc rollback can overwrite a concurrent catalog writer, and a second validator would drift from the real refusal rules. Reusing the injector keeps one policy path and avoids compensating writes. +- 장점, 단점 및 영향: Deterministic refusals preserve catalog/cache bytes and print their reason on stderr. A concurrent state change can still make the final injection refuse, but catalog and injection retain their existing independent revalidation and serialization boundaries. diff --git a/structure/decisions/ADR-0020-provider-validation-ownership.md b/structure/decisions/ADR-0020-provider-validation-ownership.md new file mode 100644 index 0000000000..07e011d29a --- /dev/null +++ b/structure/decisions/ADR-0020-provider-validation-ownership.md @@ -0,0 +1,12 @@ +# ADR-0020 — Provider validation ownership + +- Contract owner: [config.md](../config.md#provider-validation-ownership) + +## Decision record + +- 목적과 의도: Separate reusable provider payload validation from config file persistence without changing accepted configuration or error behavior. +- 기존 구현 및 제약 조건: The Zod schema, CLI, and management API shared helpers defined inside `src/config.ts`, so callers needing one pure check depended on the full persistence module. +- 검토한 주요 대안: Keep validation in the persistence module; duplicate checks per caller; extract one leaf and retain compatibility re-exports. +- 선택한 방식: Use one pure validation leaf, consume it from config refinement and direct DTO callers, and keep `src/config.ts` re-exports during migration. +- 다른 대안 대신 이 방식을 선택한 이유: One implementation preserves load/write parity while reducing dependency breadth and avoiding a flag-day import rewrite. +- 장점, 단점 및 영향: Validation can be characterized independently and config persistence becomes smaller; a temporary facade remains until all internal callers migrate. diff --git a/structure/decisions/ADR-0021-shared-catalog.md b/structure/decisions/ADR-0021-shared-catalog.md new file mode 100644 index 0000000000..7b764da7f4 --- /dev/null +++ b/structure/decisions/ADR-0021-shared-catalog.md @@ -0,0 +1,12 @@ +# ADR-0021 — Shared catalog + +- Contract owner: [catalog.md](../catalog.md#shared-catalog) + +## Decision record + +- 목적과 의도: Prevent Windows PowerShell/CIM process discovery from blocking Bun's event loop while v2 sub-agent guidance is assembled. +- 기존 구현 및 제약 조건: The stale-catalog check is advisory on the request path, but CLI/service lifecycle operations use the same process evidence before warning or terminating narrowly matched app-servers. +- 검토한 주요 대안: Remove stale-catalog guidance, move every platform collector into workers, or isolate only the Windows request path behind asynchronous child processes. +- 선택한 방식: Keep the synchronous fail-closed collector for explicit lifecycle operations; v2 requests use asynchronous trusted-System32 PowerShell, one identity-scoped in-flight refresh, and the existing short cache. Cache invalidation advances a generation so a pre-write CIM result cannot repopulate post-write state. +- 다른 대안 대신 이 방식을 선택한 이유: This preserves process ownership and matching invariants while preventing a slow CIM query from starving `/healthz` and unrelated proxy traffic. +- 장점, 단점 및 영향: Concurrent v2 turns do not multiply CIM walks and the event loop remains responsive. A cold request can still await the bounded advisory check, and collection failure suppresses OpenCodex-authored model guidance as `unknown`. diff --git a/structure/decisions/ADR-0022-routed-tool-discovery-and-hosted-search.md b/structure/decisions/ADR-0022-routed-tool-discovery-and-hosted-search.md new file mode 100644 index 0000000000..64360fbdfd --- /dev/null +++ b/structure/decisions/ADR-0022-routed-tool-discovery-and-hosted-search.md @@ -0,0 +1,12 @@ +# ADR-0022 — Routed tool discovery and hosted search + +- Contract owner: [catalog.md](../catalog.md#routed-tool-discovery-and-hosted-search) + +## Decision record + +- 목적과 의도: keep routed plugin/MCP tools reachable without paying the full-catalog turn-1 payload tax or starving Cursor's unified execution bridge. +- 기존 구현 및 제약 조건: #1596 restored deferred discovery only for non-Cursor rows because Cursor bypasses the hosted-search sidecar; codex-rs treats deferred exposure and hosted search as separate capabilities, and Cursor independently enforces a 120,000-byte serialized tool-catalog limit. +- 검토한 주요 대안: keep Cursor opted out, raise/disable Cursor's transport ceiling, synthesize another execution bridge, or enable Cursor-native local exec only when the bridge disappears. +- 선택한 방식: enable Codex deferred exposure for Cursor code-mode rows too, while continuing to omit Cursor's hosted `web_search_tool_type`. +- 다른 대안 대신 이 방식을 선택한 이유: it removes the known exec-description inflation before Cursor budgeting without weakening the measured transport limit, inventing caller tools, or turning bridge absence into local-execution authority. +- 장점, 단점 및 영향: Cursor keeps a compact Responses-owned `exec` path under rich tool catalogs and hosted-search behavior remains unchanged; the existing Cursor budget and native-local-exec fail-closed policy remain authoritative. diff --git a/structure/decisions/ADR-0023-ultra-reasoning-level.md b/structure/decisions/ADR-0023-ultra-reasoning-level.md new file mode 100644 index 0000000000..f23a613370 --- /dev/null +++ b/structure/decisions/ADR-0023-ultra-reasoning-level.md @@ -0,0 +1,19 @@ +# ADR-0023 — Ultra reasoning level + +- Contract owner: [catalog.md](../catalog.md#ultra-reasoning-level) + +## Decision record + +- 목적과 의도: Xiaomi MiMo의 공식 OpenAI Chat endpoint가 실제로 받지 않는 `max`/ + `ultra` reasoning tier를 catalog에 노출하지 않도록 한다. +- 기존 구현 및 제약 조건: `xiaomi`는 Anthropic endpoint, `mimo`는 token-plan endpoint를 + 소유하며, 공식 `https://api.xiaomimimo.com/v1`은 generic custom provider로 처리됐다. +- 검토한 주요 대안: 기존 `xiaomi`/`mimo` contract를 확장하기, 모든 custom provider의 ladder를 + 일괄 축소하기, 공식 public endpoint만을 별도 registry row로 소유하기. +- 선택한 방식: `xiaomi-mimo`를 고정 목적지의 `openai-chat` preset으로 등록하고 + `low`/`medium`/`high`만 노출하며 높은 direct request는 `high`로 clamp한다. +- 다른 대안 대신 이 방식을 선택한 이유: 서로 다른 auth/wire/host를 하나의 preset으로 + 합치지 않으면서 upstream error로 확인된 계약만 적용할 수 있다. +- 장점, 단점 및 영향: 공식 endpoint에서 안전한 picker/wire 계약을 제공하고, + `preserveCustomDestination`으로 같은 이름의 다른 host/key를 보호한다. 대신 새 preset 표면을 + 문서와 registry parity에서 함께 유지해야 한다. diff --git a/structure/decisions/ADR-0024-ultra-reasoning-level.md b/structure/decisions/ADR-0024-ultra-reasoning-level.md new file mode 100644 index 0000000000..88c75bcb2a --- /dev/null +++ b/structure/decisions/ADR-0024-ultra-reasoning-level.md @@ -0,0 +1,19 @@ +# ADR-0024 — Ultra reasoning level + +- Contract owner: [catalog.md](../catalog.md#ultra-reasoning-level) + +## Decision record + +- 목적과 의도: Xiaomi token-plan에서 image input을 거부하는 `mimo-v2.5-pro`만 vision + sidecar로 우회하고, 실제 image input을 받는 `mimo-v2.5`는 native vision 경로에 남긴다. +- 기존 구현 및 제약 조건: upstream `/v1/models`는 input modality를 제공하지 않으며, + `noVisionModels`는 text-only 모델을 sidecar로 보내면서 Codex catalog에는 image input을 + 광고하는 provider-scoped 계약이다. +- 검토한 주요 대안: MiMo 전체를 text-only로 분류하기, live discovery에서 modality를 + 추측하기, `mimo-v2.5-pro` 하나만 registry에 고정 분류하기. +- 선택한 방식: canonical `mimo` preset의 `noVisionModels`에 `mimo-v2.5-pro`만 추가한다. +- 다른 대안 대신 이 방식을 선택한 이유: live endpoint 검증으로 확인된 최소 범위만 + 적용하며, 정상 동작하는 `mimo-v2.5`의 native image 경로를 훼손하지 않는다. +- 장점, 단점 및 영향: Pro image 요청의 404를 sidecar 설명 경로로 바꾸고 base 모델은 + 그대로 유지한다. `preserveCustomDestination` guard 때문에 같은 provider id를 다른 host에 + 연결한 사용자 설정에는 이 capability 분류가 전파되지 않는다. diff --git a/structure/decisions/ADR-0025-ultra-reasoning-level.md b/structure/decisions/ADR-0025-ultra-reasoning-level.md new file mode 100644 index 0000000000..b66deffb01 --- /dev/null +++ b/structure/decisions/ADR-0025-ultra-reasoning-level.md @@ -0,0 +1,19 @@ +# ADR-0025 — Ultra reasoning level + +- Contract owner: [catalog.md](../catalog.md#ultra-reasoning-level) + +## Decision record + +- 목적과 의도: GitHub Copilot의 live model catalog가 명시하는 모델별 image-input 지원을 + Codex catalog에 정확히 보존한다. +- 기존 구현 및 제약 조건: 공용 discovery parser는 직접 `capabilities.vision`과 표준 modality + 필드는 읽었지만 Copilot의 `capabilities.supports.vision` 중첩 boolean은 읽지 않아 모든 + Copilot 모델이 text-only fallback으로 축소되었다. +- 검토한 주요 대안: 모든 Copilot 모델에 정적 vision seed를 추가하기, 모델 이름을 외부 + metadata alias에 연결하기, live 모델별 boolean을 공용 parser에서 해석하기. +- 선택한 방식: 직접 vision boolean이 없을 때만 중첩 `supports.vision`의 명시적 boolean을 + 사용하고, `false`도 보존하며 malformed 값은 추론하지 않는다. +- 다른 대안 대신 이 방식을 선택한 이유: live 응답이 모델별 capability의 가장 좁은 근거라서 + 새 모델에도 적용되며 text-only 모델을 image-capable로 과장하지 않는다. +- 장점, 단점 및 영향: Copilot vision 모델은 image attachment를 받을 수 있고 명시적 text-only + 모델은 계속 차단된다. Capability를 제공하지 않는 모델은 기존 fallback을 유지한다. diff --git a/structure/decisions/ADR-0026-ultra-reasoning-level.md b/structure/decisions/ADR-0026-ultra-reasoning-level.md new file mode 100644 index 0000000000..f416d26e01 --- /dev/null +++ b/structure/decisions/ADR-0026-ultra-reasoning-level.md @@ -0,0 +1,21 @@ +# ADR-0026 — Ultra reasoning level + +- Contract owner: [catalog.md](../catalog.md#ultra-reasoning-level) + +## Decision record + +- 목적과 의도: bare `defaultModel` selectors that route into third-party providers must keep their + adapter-owned effort ladder; only true ChatGPT-native requests should receive the mock-max repair. +- 기존 구현 및 제약 조건: `nativeEffortClamp` already needed the original request id because + routing strips `provider/`, but bare third-party selectors like `glm-5.2-fast-preview` still look + native after that strip. +- 검토한 주요 대안: (1) infer nativeness from the bare slug prefix alone, (2) gate clamping by the + resolved provider identity, (3) disable the clamp for all off-snapshot slugs. +- 선택한 방식: request-time clamp entry is allowed only when the resolved route is the canonical + built-in OpenAI/Codex forward provider and the original request id is still bare. +- 다른 대안 대신 이 방식을 선택한 이유: provider identity is the only durable signal that + distinguishes true native ChatGPT traffic from third-party `defaultModel` routes when both share a + bare model id shape. +- 장점, 단점 및 영향: preserves `gpt-5.5 max -> xhigh` repair for native traffic, removes false + clamps for bare routed models, and keeps adapter-specific effort mapping as the single source of + truth for third-party providers. diff --git a/structure/decisions/ADR-0027-subagents.md b/structure/decisions/ADR-0027-subagents.md new file mode 100644 index 0000000000..24ec1918cd --- /dev/null +++ b/structure/decisions/ADR-0027-subagents.md @@ -0,0 +1,24 @@ +# ADR-0027 — Subagents + +- Contract owner: [subagents.md](../subagents.md#subagents) + +## Decision record + +- 목적과 의도: keep generated Claude Code `ocx-*.md` roster files synchronized when the proxy is + started or ensured on Linux, Windows, and macOS, including background service restarts. +- 기존 구현 및 제약 조건: explicit `ocx claude` launches and Management API writes reconciled the + files, while the startup call inside `injectSystemEnv` ran only on macOS with system-env enabled. + `startServer` is also used as an in-process library/test primitive and cannot safely mutate the + real user home on every invocation. +- 검토한 주요 대안: write from `startServer`; duplicate hooks in each OS service manager; reconcile + once from the owning CLI lifecycle after the listener becomes available. +- 선택한 방식: the foreground/service start and live-proxy ensure paths call one best-effort helper + after bind, using the live Management API context-window map and the existing marker-verified + atomic roster writer. macOS system-env startup keeps its existing shared-window sync and skips the + duplicate call. +- 다른 대안 대신 이 방식을 선택한 이유: it covers every supported service entrypoint without + adding home-directory side effects to server-library consumers or creating a second roster format. +- 장점, 단점 및 영향: stale OpenCodex-owned definitions converge on every daemon start, disabled integration + prunes them without provider discovery, and catalog failure falls back to unmarked definitions so + startup remains available. A later dashboard save or `ocx claude` launch restores missing context + markers after a transient failure. diff --git a/structure/decisions/ADR-0028-background-service-command-selection.md b/structure/decisions/ADR-0028-background-service-command-selection.md new file mode 100644 index 0000000000..1ba0a2c196 --- /dev/null +++ b/structure/decisions/ADR-0028-background-service-command-selection.md @@ -0,0 +1,12 @@ +# ADR-0028 — Background service command selection + +- Contract owner: [ops/service-and-sidecars.md](../ops/service-and-sidecars.md#background-service-command-selection) + +## Decision record + +- 목적과 의도: Make a bare service refresh safe and idempotent without converting a localized or transient Windows status failure into an elevated re-registration. +- 기존 구현 및 제약 조건: The command defaulted to install and later used a boolean diagnostic whose scheduler query fallback could collapse unknown into absent; repair must preserve the existing Windows launcher and Bun stability workarounds. +- 검토한 주요 대안: Always repair; keep a boolean installed check; infer presence from saved state alone; use a tri-state live registration probe. +- 선택한 방식: Validate arguments first, then use a narrow tri-state platform probe only for a bare backend-neutral invocation; route installed to repair, absent to install, and unknown to a refusal. +- 다른 대안 대신 이 방식을 선택한 이유: Saved state can be stale and unconditional repair breaks first install, while a boolean cannot represent the exact uncertainty that must fail closed. +- 장점, 단점 및 영향: Healthy existing services avoid UAC and registration churn; stale Windows scheduler definitions may be refreshed and require elevation. Invalid input performs no status I/O, and uncertain Windows hosts require one explicit status/installation decision instead of risking a destructive guess. diff --git a/structure/decisions/ADR-0029-windows-startup-ownership-listing-reuse.md b/structure/decisions/ADR-0029-windows-startup-ownership-listing-reuse.md new file mode 100644 index 0000000000..d7a0bfb99a --- /dev/null +++ b/structure/decisions/ADR-0029-windows-startup-ownership-listing-reuse.md @@ -0,0 +1,12 @@ +# ADR-0029 — Windows startup ownership listing reuse + +- Contract owner: [ops/service-and-sidecars.md](../ops/service-and-sidecars.md#windows-startup-ownership-listing-reuse) + +## Decision record + +- 목적과 의도: Preserve the race-sensitive Windows ownership recheck while paying for an unchanged locale-neutral full task listing only once during synchronous startup. +- 기존 구현 및 제약 조건: Localized `schtasks /query /tn ... /xml` failures need a full listing to prove absence, the listing may legitimately take more than two seconds, and `unknown` must never become `absent` merely to reduce latency. +- 검토한 주요 대안: Delete the second ownership check; lower the listing timeout; keep a process-wide or TTL cache; reuse an earlier absence regardless of the fresh targeted result; or scope a memo to the two pre-listen checks and key it to the complete targeted result. +- 선택한 방식: Create one cache inside `startServer`, run every targeted query, and reuse its listing result only when status, timeout/spawn flags, stdout, and stderr are byte-identical. Runtime ownership retries do not receive the startup cache. +- 다른 대안 대신 이 방식을 선택한 이유: Removing or weakening revalidation widens the install race, while a global/TTL cache can outlive startup and stale absence can authorize the wrong home. Exact targeted-result identity lets the ordinary no-task locale fallback coalesce without hiding changed evidence. +- 장점, 단점 및 영향: The reported stable zh-CN absence path performs two cheap targeted queries and one full listing. A task that appears is detected by the second targeted query; changed or failed evidence triggers a fresh fail-closed decision, so unusual churn may still pay for two listings rather than guess. diff --git a/structure/decisions/ADR-0030-stable-service-launcher-launchd-and-systemd.md b/structure/decisions/ADR-0030-stable-service-launcher-launchd-and-systemd.md new file mode 100644 index 0000000000..681f5107ae --- /dev/null +++ b/structure/decisions/ADR-0030-stable-service-launcher-launchd-and-systemd.md @@ -0,0 +1,12 @@ +# ADR-0030 — Stable service launcher (launchd and systemd) + +- Contract owner: [ops/service-and-sidecars.md](../ops/service-and-sidecars.md#stable-service-launcher-launchd-and-systemd) + +## Decision record + +- 목적과 의도: Keep systemd services upgrade-stable without losing an explicitly trusted Bun override or accepting a non-executable PATH placeholder. +- 기존 구현 및 제약 조건: Version managers replace package trees but retain lexical shims; Bun dotenv makes ambient override values untrustworthy unless the Node launcher already stamped matching runtime provenance. +- 검토한 주요 대안: Bake the package Bun and CLI forever; resolve the shim target; accept the first existing PATH entry; drop every runtime override in launcher mode; or preserve only a proof-bound override. +- 선택한 방식: Require a regular executable lexical launcher, resolve it once during installation, preserve only `durableBunRuntime().source === "override"`, and keep token loading in the existing file-backed shell preamble. +- 다른 대안 대신 이 방식을 선택한 이유: Resolving or pinning package paths recreates upgrade restart loops, existence-only selection can name a directory or non-executable file, and dropping a trusted override silently changes an operator's runtime. +- 장점, 단점 및 영향: Mise/asdf-style upgrades keep working and explicit Bun selection survives; source installs still use the direct pair, while a removed or non-executable launcher requires `ocx service repair`. diff --git a/structure/decisions/ADR-0031-responses-http-sse.md b/structure/decisions/ADR-0031-responses-http-sse.md new file mode 100644 index 0000000000..13e01493fc --- /dev/null +++ b/structure/decisions/ADR-0031-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-0031 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Keep long but progressing client-driven tool continuations valid while locating repository-semantic loop detection at the layer that owns the workspace and continuation policy. +- 기존 구현 및 제약 조건: Issue #2600 recorded 18 persisted Cursor continuations whose transcript and tool counters grew while the worktree did not. Every proxy-local liveness and capacity bound was therefore satisfied, but the proxy had no workspace delta to compare. +- 검토한 주요 대안: Stop after a fixed continuation count; classify read-like tool names as no progress; compare assistant prose; emit a new proxy-only terminal code after a time budget; or leave semantic progress to the client while preserving transport cancellation for objective proxy failures. +- 선택한 방식: Do not add a proxy semantic cutoff without a client-supplied progress contract. Keep objective transport, byte, concurrency, and silence bounds typed and cancellable; require the workspace-owning client to bound repeated continuations using repository state plus its own side-effect ledger. +- 다른 대안 대신 이 방식을 선택한 이유: Calls and prose are not a repository oracle, and tool names do not prove side effects. A proxy cutoff would either miss the reported loop because items kept changing or terminate legitimate slow work. Retrying after the cutoff could also replay side-effecting work. +- 장점, 단점 및 영향: OpenCodex does not manufacture a root cause or silently terminate healthy long turns. The combined route still needs a client-side semantic boundary; if a future client sends an explicit privacy-safe progress marker, the proxy may enforce that contract without inferring workspace state. diff --git a/structure/decisions/ADR-0032-responses-http-sse.md b/structure/decisions/ADR-0032-responses-http-sse.md new file mode 100644 index 0000000000..d007310b35 --- /dev/null +++ b/structure/decisions/ADR-0032-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-0032 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Keep transport helpers reusable without making every consumer evaluate the full routed Responses and sidecar graph at module load. +- 기존 구현 및 제약 조건: The original `responses.ts` split copied the monolith import header into `fetch-helpers.ts`; seven helper exports therefore retained 39 distinct runtime import specifiers and reached 326 modules even though the implementations used only three runtime dependencies. +- 검토한 주요 대안: Leave the imports because current modules have limited top-level side effects; move the helpers again; prune the copied imports and lock the direct runtime boundary. +- 선택한 방식: Preserve the file and all public exports, remove unused runtime edges, and enforce an explicit three-specifier allowlist with a source-level regression that also proves type-only imports are ignored. +- 다른 대안 대신 이 방식을 선택한 이유: Relying on unrelated modules to remain side-effect-free makes startup ownership accidental, while another move adds churn without changing the responsibility boundary. +- 장점, 단점 및 영향: Ordinary native Chat and compact consumers no longer load unrelated routing, combo, OAuth, web-search, vision, and relay modules through this leaf. The allowlist is intentionally strict, so a future helper that needs a new runtime dependency must make that ownership decision explicit in code, tests, and this document. diff --git a/structure/decisions/ADR-0033-responses-http-sse.md b/structure/decisions/ADR-0033-responses-http-sse.md new file mode 100644 index 0000000000..ffe3b1909c --- /dev/null +++ b/structure/decisions/ADR-0033-responses-http-sse.md @@ -0,0 +1,13 @@ +# ADR-0033 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Prevent routed models from turning invented or neighboring-agent tool names into client-executable Responses calls. +- 기존 구현 및 제약 조건: The request catalog already controlled custom-tool restoration and the non-OpenAI prompt nudge, but an undeclared upstream name still fell through as an ordinary `function_call`; Codex then reduced the mismatch to a bare `aborted` result. +- 검토한 주요 대안: Rely only on prompt guidance; automatically translate undeclared `apply_patch` into Code Mode; validate returned names against the request-visible catalog at the final bridge. +- 선택한 방식: Retain the allowed wire-name set with the existing bridge maps and fail the turn with an explicit compatibility error before emitting any undeclared tool item. +- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` stays in its upstream function-call form unless the destination explicitly denies Responses custom tools; tools replaced by hosted-provider policy also stay in their upstream function-call form. +- 다른 대안 대신 이 방식을 선택한 이유: Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation. +- 장점, 단점 및 영향: Streaming and non-streaming routed responses now fail closed with an actionable provider-contract error; providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior. diff --git a/structure/decisions/ADR-0034-responses-http-sse.md b/structure/decisions/ADR-0034-responses-http-sse.md new file mode 100644 index 0000000000..40d947ceb7 --- /dev/null +++ b/structure/decisions/ADR-0034-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-0034 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Accept a routed model's decorated outer `apply_patch` delimiter lines without changing the executable meaning of any provider-returned program. +- 기존 구현 및 제약 조건: Routed custom tools arrive through a public function wrapper and are restored at the response boundary, but arbitrary `exec` JavaScript is caller-executable source whose strings, comments, templates, and helper arguments cannot be safely rewritten with text patterns. +- 검토한 주요 대안: Regex-rewrite nested helper calls in `exec`; wrap a raw `exec` patch body as a helper call; reject every decorated patch; or normalize only the outer lines of a complete top-level `apply_patch` custom-tool payload. +- 선택한 방식: After unwrapping the request-authorized custom-tool function shape, normalize only exact decorated Begin/End lines when the entire `apply_patch` input is one structurally recognizable patch with a file operation. Keep `exec` and all other freeform bodies byte-identical. +- 다른 대안 대신 이 방식을 선택한 이유: A top-level `apply_patch` call already carries explicit executable intent, so its unambiguous outer-line spelling can be repaired without inventing a call or parsing JavaScript. Every broader rewrite could reinterpret ordinary data as code. +- 장점, 단점 및 영향: Decorated top-level patches regain compatibility while strings, comments, generated source, raw `exec` text, incomplete envelopes, and patch-file content remain untouched. Nested malformed helper source must be corrected by the provider instead of being guessed at the response boundary. diff --git a/structure/decisions/ADR-0035-responses-http-sse.md b/structure/decisions/ADR-0035-responses-http-sse.md new file mode 100644 index 0000000000..549da89c5a --- /dev/null +++ b/structure/decisions/ADR-0035-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-0035 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Stop wasting a turn when a routed model submits one complete patch envelope as the entire code-mode `exec` body. +- 기존 구현 및 제약 조건: The decision above rejected "wrap a raw `exec` patch body as a helper call" because a text rewrite could reinterpret data as code. Rollout evidence then showed about 55 such bodies across four models, each a guaranteed isolate throw. Measurement added the missing fact: a complete envelope is never valid JavaScript, since `*** Begin Patch` fails to parse at the leading `**`. +- 검토한 주요 대안: Keep failing closed; rewrite decorated delimiters inside `exec` JavaScript; parse `exec` bodies as JavaScript; or retarget only a body that is itself one complete operation-bearing envelope. +- 선택한 방식: Retarget only that complete-envelope shape to the existing apply_patch helper, through one shared resolver used by all four restore paths. Delimiter-repair functions stay unchanged and every other `exec` body, including JavaScript that mentions an envelope, stays byte-identical. Streaming holds a buffer that could still become an envelope so the live preview is never rewound. +- 다른 대안 대신 이 방식을 선택한 이유: This narrows the earlier rejection rather than reversing it. The rejection protected bodies with a competing executable reading; a complete envelope has none, so it is the same one-faithful-reading rule the delimiter repair already follows. Rewriting inside JavaScript remains rejected: there the marker is a delimiter or a string or a comment, and no lexical or parse-based rule separates them safely. +- 장점, 단점 및 영향: A previously wasted turn now performs the edit the model intended. This does convert a hard failure into a real filesystem write, so the predicate stays anchored and operation-bearing; prefixed, suffixed, incomplete, namespaced, and JavaScript bodies still fail closed. The write itself is the same `apply_patch` capability code mode already grants, reached by payload shape instead of tool name. diff --git a/structure/decisions/ADR-0036-responses-http-sse.md b/structure/decisions/ADR-0036-responses-http-sse.md new file mode 100644 index 0000000000..ebdb1893cb --- /dev/null +++ b/structure/decisions/ADR-0036-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-0036 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Keep Codex client-side deferred tool discovery usable through third-party Responses-compatible gateways that implement public function tools but reject the private `tool_search` declaration. +- 기존 구현 및 제약 조건: The chat translation path already exposed search as a function and bridged its call back to `tool_search_call`; passthrough only promoted definitions returned by an earlier search, so it could not initiate discovery on a strict third-party Responses endpoint. +- 검토한 주요 대안: Require every gateway to implement Codex-private tool types; route affected models through `openai-chat`; lower the declaration only; lower the noncanonical request and restore both JSON and SSE response lifecycles. +- 선택한 방식: On noncanonical Responses passthrough only, lower an actually declared `tool_search` to a collision-free public function name, translate its replayed call/output history to public function pairs, record only caller-authorized request-local conversions, and restore matching JSON/SSE calls to client `tool_search_call` items. Canonical OpenAI forward remains byte-shape native. +- 다른 대안 대신 이 방식을 선택한 이유: Provider-specific workarounds fragment the contract, while unconditional restoration could turn an untrusted ordinary function call into a privileged client discovery action. +- 장점, 단점 및 영향: Strict third-party Responses gateways can start and continue deferred discovery without changing native ChatGPT behavior; ordinary same-named functions remain distinct, and the proxy performs a capped SSE lifecycle rewrite only when the request actually required compatibility translation. diff --git a/structure/decisions/ADR-0037-responses-http-sse.md b/structure/decisions/ADR-0037-responses-http-sse.md new file mode 100644 index 0000000000..dd88e7c4dd --- /dev/null +++ b/structure/decisions/ADR-0037-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-0037 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Keep Codex 0.147 namespace tool catalogs usable after a routed provider adopts native Responses but implements only the public flat tool variants. +- 기존 구현 및 제약 조건: Chat translation already flattened namespace children, while native Responses passthrough forwarded the private `namespace` variant unchanged. xAI therefore rejected Grok requests before inference after its OAuth Grok 4.5/4.6 route moved to Responses. +- 검토한 주요 대안: Move Grok back to Chat; special-case only xAI or the reserved `functions` group; flatten every complete namespace on noncanonical Responses and restore request-authorized aliases on return. +- 선택한 방식: Noncanonical Responses lowers `functions` children to their bare top-level names and every other complete namespace to collision-checked `__` aliases after custom/tool-search conversion. It rewrites matching replay calls and tool selectors, records the aliases on the built request, and restores only those aliases in JSON/SSE call items before custom/tool-search lifecycle repair. Canonical OpenAI forward preserves native namespace shapes. +- 다른 대안 대신 이 방식을 선택한 이유: A transport regression should not discard Responses streaming or create a provider-specific fork, and restoration without request-local authorization could reinterpret an unrelated upstream function as a client namespace call. +- 장점, 단점 및 영향: Grok and other public-schema Responses gateways accept current Codex catalogs while Codex still receives explicit namespace routing. No `type: "namespace"` value survives the boundary: a group the layer cannot express — empty, nested, or with an unusable child name — is dropped along with the children it cannot represent, because relaying the private shape costs the whole request rather than one tool. Genuinely ambiguous wire names still fail closed, now as a 400 rather than an unstructured 500. diff --git a/structure/decisions/ADR-0038-responses-http-sse.md b/structure/decisions/ADR-0038-responses-http-sse.md new file mode 100644 index 0000000000..adaa6d85d7 --- /dev/null +++ b/structure/decisions/ADR-0038-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-0038 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Complete Cursor turns at the protocol terminal instead of waiting for a separate HTTP-body EOF that may never arrive. +- 기존 구현 및 제약 조건: Cursor can send turnEnded followed by a clean Connect END_STREAM envelope while RunSSE remains open or later closes through an abort-shaped transport error. The adapter logged the clean envelope but did not settle its terminal owner, so a completed-looking turn could remain open until the Responses stall watchdog. +- 검토한 주요 대안: Shorten the global stall timeout; treat every later abort as success; settle only when the HTTP stream emits end; make the clean Connect envelope authoritative. +- 선택한 방식: Process preceding frames in order, preserve an already-emitted terminal, run any already-armed drained client-tool finalizer before protocol cleanup clears its grace timer only while the call set is still drained, otherwise finalize once through the existing fail-closed tool-call logic, and settle the transport successfully on a clean Connect END_STREAM. +- 다른 대안 대신 이 방식을 선택한 이유: The protocol envelope is upstream's explicit terminal signal. Timeout changes only hide the race, and globally swallowing aborts would mask genuine mid-turn cancellation. +- 장점, 단점 및 영향: Completed Cursor responses no longer wait for the 300-second watchdog when the HTTP body stays open; incomplete tool calls still emit their existing truncation error, and error-bearing Connect terminals remain failures. diff --git a/structure/decisions/ADR-0039-responses-http-sse.md b/structure/decisions/ADR-0039-responses-http-sse.md new file mode 100644 index 0000000000..2abea209e3 --- /dev/null +++ b/structure/decisions/ADR-0039-responses-http-sse.md @@ -0,0 +1,23 @@ +# ADR-0039 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Keep a session usable after its history crosses backends, instead of wedging it on a + compaction blob the current upstream cannot decode. +- 기존 구현 및 제약 조건: Compaction handling was binary — `ocx1:` envelopes were ours, everything + else was treated as a native blob and gated only by the destination, even though multiple backends + mint mutually incompatible blobs. Response-side field backfill exempted only `compaction`, so its + two sibling types received synthesized ids the client then replayed. +- 검토한 주요 대안: Tag every compaction item with its minting provider/credential/model identity; + drop compaction items on any route change; gate relay on the destination that would decode them. +- 선택한 방식: Reuse the thread's recorded serving identity to degrade native blobs after a known + route change; otherwise retain the destination capability gate, and treat the compact wire family + as one enumeration so id-bearing passes cannot diverge per type. +- 다른 대안 대신 이 방식을 선택한 이유: Full per-item provenance tagging is unnecessary when the + existing thread identity proves a route change, while dropping the item would silently discard + compacted context and widening unknown-identity behavior needs a separate decision. +- 장점, 단점 및 영향: A cross-backend session degrades one compaction summary to a note instead of + failing every later turn. A self-hosted OpenAI relay keeps its blobs only when explicitly opted in; + other routed gateways see a note because routed compaction produces an `ocx1:` envelope. diff --git a/structure/decisions/ADR-0040-responses-http-sse.md b/structure/decisions/ADR-0040-responses-http-sse.md new file mode 100644 index 0000000000..926149eaf2 --- /dev/null +++ b/structure/decisions/ADR-0040-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-0040 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Stop routed models from abandoning `apply_patch` after the Codex host rejects an object argument or a decorated marker, and from blocking a turn in a shell sleep loop when the host offers `session_id` polling. +- 기존 구현 및 제약 조건: The shared nudge, Cursor guidance and native Responses instructions already carry the result-emission rule from `exec-tool-result-normalize.ts`, but none stated the helper's argument type, the marker rule, the import ban, or the polling protocol; `260905_apply_patch_envelope_gap` refused to rewrite JavaScript bodies (MODE B), so payload repair is off the table. +- 검토한 주요 대안: Repair the argument shape inside the proxy (rejected: same body ambiguity as MODE B and it turns a rejected write into a performed one); Cursor-only guidance (rejected: the incident was native routed Responses on xAI); annotate every adapter's tool results (rejected: Anthropic/Google/OpenAI-chat/command-code have no exec-result seam and would need a new one). +- 선택한 방식: One pre-call sentence and one marker→recovery table in the module that already owns the echo pair; inject the sentence at the three existing code-mode sites; annotate at the three existing exec-result seams with an exec-gated, idempotent helper that never changes error status. +- 다른 대안 대신 이 방식을 선택한 이유: The safe repair for a host contract the model broke is to state it before the call and name it after the failure; keeping both halves in one file is what keeps them consistent. +- 장점, 단점 및 영향: Code-mode system prompts grow by roughly 600 characters on routed turns; OpenAI destinations, flat catalogs and compaction requests are untouched. An exec result that legitimately prints one of the four phrases gains a recovery line, which is additive text and never an error flip. On Cursor, a structured tool literally named `exec` whose output quotes one of those phrases would also gain that line. The effect on the live Grok defect rate is unmeasured until a re-probe. diff --git a/structure/decisions/ADR-0041-responses-http-sse.md b/structure/decisions/ADR-0041-responses-http-sse.md new file mode 100644 index 0000000000..451cd66f69 --- /dev/null +++ b/structure/decisions/ADR-0041-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-0041 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Keep Codex hosted web search usable on xAI's public Responses endpoint without forwarding private OpenAI-only fields that xAI rejects. +- 기존 구현 및 제약 조건: Codex emits `external_web_access`, `search_context_size`, `search_content_types`, and `user_location`; xAI documents a live-only `web_search` tool with domain filters and image flags, while Codex cached mode explicitly forbids external access. +- 검토한 주요 대안: Strip only the first rejected field; pass every hosted-search field unchanged; disable web search for all xAI turns; normalize only the exact official xAI API destination. +- 선택한 방식: On `https://api.x.ai` Responses traffic, lower live search to xAI's public shape, map image content requests to `enable_image_search`, remove unsupported OpenAI-private fields, and omit cached/index-only search plus stale selectors because xAI has no non-live equivalent. +- 다른 대안 대신 이 방식을 선택한 이유: One-field stripping exposes the next schema mismatch and turning `external_web_access:false` into xAI live search widens the caller's network policy; destination scoping leaves custom gateways and canonical OpenAI byte-shape native. +- 장점, 단점 및 영향: Grok 4.5/4.6 no longer fail every default Codex turn with an unsupported-argument 400; live search remains available when explicitly enabled, while cached search degrades to no hosted search on xAI rather than silently going live. diff --git a/structure/decisions/ADR-0042-responses-http-sse.md b/structure/decisions/ADR-0042-responses-http-sse.md new file mode 100644 index 0000000000..065b8e6268 --- /dev/null +++ b/structure/decisions/ADR-0042-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-0042 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Match OpenCode Go's model-specific Luna endpoint without changing sibling model behavior. +- 기존 구현 및 제약 조건: The preset had one Chat default even though the upstream publishes a mixed Chat, Responses, and Anthropic matrix; operators must retain explicit override precedence. +- 검토한 주요 대안: Move the whole preset to Responses; infer from the model name; declare one exact registry default; also force bounded JSON from an older conditional terminal report. +- 선택한 방식: Use one exact Luna wire default and leave upstream streaming unchanged. +- 다른 대안 대신 이 방식을 선택한 이유: The endpoint mismatch is reproducible from current code and upstream documentation, whereas a current-dev live canary has not established the separate terminal-delivery policy. +- 장점, 단점 및 영향: Luna reaches its documented endpoint across inbound surfaces and explicit opt-out still works; any future stream workaround remains a separately reviewed compatibility decision. diff --git a/structure/decisions/ADR-0043-responses-http-sse.md b/structure/decisions/ADR-0043-responses-http-sse.md new file mode 100644 index 0000000000..a387ffc126 --- /dev/null +++ b/structure/decisions/ADR-0043-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-0043 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Give OpenCode Go the stable per-conversation header it requires for prompt-cache routing without exposing raw Codex identifiers. +- 기존 구현 및 제약 조건: Codex already supplies task and subagent identity, but Go requests reached every adapter without `x-opencode-session`; one static provider header would collapse unrelated conversations. +- 검토한 주요 대안: Forward a raw thread header; reuse `prompt_cache_key`; configure one global value; inject separately in Chat and Responses adapters; enrich the canonical provider before wire selection. +- 선택한 방식: Hash the existing parent-qualified session lane with a provider-specific domain, attach it as runtime-only provider metadata before wire selection, and preserve an explicit operator override. +- 다른 대안 대신 이 방식을 선택한 이유: The lane already separates sibling subagents, while cache keys may represent shared cohorts and adapter-local changes would drift across Go's mixed wire matrix. +- 장점, 단점 및 영향: Go requests gain stable opaque affinity across normal retries and key rotation without persisted config changes; requests with no stable lane remain headerless rather than receiving a per-request value that defeats affinity. diff --git a/structure/decisions/ADR-0044-responses-http-sse.md b/structure/decisions/ADR-0044-responses-http-sse.md new file mode 100644 index 0000000000..fece102711 --- /dev/null +++ b/structure/decisions/ADR-0044-responses-http-sse.md @@ -0,0 +1,20 @@ +# ADR-0044 — Responses HTTP/SSE + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Turn upstream terminal variants and bare EOF into one deterministic Responses + outcome instead of a retryable disconnect or duplicate terminal. +- 기존 구현 및 제약 조건: Policy refusals can arrive in several SSE envelopes, while a clean EOF, + an unterminated final frame, and a read error exercise different pull/tee and eager cleanup paths. +- 검토한 주요 대안: Forward every byte unchanged; classify only request logs; synthesize a failure + after every EOF or read error; normalize the bounded terminal at the client output boundary. +- 선택한 방식: Rewrite only high-confidence policy terminal shapes, preserve their bounded metadata, + flush native terminal candidates before transport-error classification, keep repair-owned + delimiter-less candidates tainted, and synthesize `adapter_eof` only when no real terminal exists. +- 다른 대안 대신 이 방식을 선택한 이유: Log-only classification leaves Codex retry behavior + unchanged, while unconditional synthesis can create two contradictory outcomes for one turn. +- 장점, 단점 및 영향: Both native relay shapes expose exactly one terminal and one sentinel with + matching accounting. Ordinary upstream errors remain fail-closed, and policy refusals remain + refusals rather than becoming successful model output. diff --git a/structure/decisions/ADR-0045-standalone-images.md b/structure/decisions/ADR-0045-standalone-images.md new file mode 100644 index 0000000000..75490bcf41 --- /dev/null +++ b/structure/decisions/ADR-0045-standalone-images.md @@ -0,0 +1,12 @@ +# ADR-0045 — Standalone Images + +- Contract owner: [data-planes/images.md](../data-planes/images.md#standalone-images) + +## Decision record + +- 목적과 의도: Preserve Codex Desktop reasoning summaries while adapting only the delivery enum rejected by a specific Responses-compatible upstream. +- 기존 구현 및 제약 조건: The existing boolean capability either passed Codex's enum unchanged or disabled summaries entirely; stale running clients can keep sending the old enum after a catalog refresh. +- 검토한 주요 대안: Disable summaries; rewrite the enum globally; inject a delivery field when absent; configure a provider-wide value. +- 선택한 방식: Use a validated per-model allowlisted map, imply summary capability for that model, and rewrite only a caller-provided delivery field at the Responses adapter boundary. +- 다른 대안 대신 이 방식을 선택한 이유: Upstream enum support differs by model and provider, while global rewriting or injection would change unrelated requests and disabling summaries removes Desktop UX. +- 장점, 단점 및 영향: Configured models retain the native summary UI and stale clients self-heal; each incompatible model needs an explicit map entry and contradictory opt-out configuration now fails closed. diff --git a/structure/decisions/ADR-0046-claude-desktop-config-library-resolution.md b/structure/decisions/ADR-0046-claude-desktop-config-library-resolution.md new file mode 100644 index 0000000000..ef08c1afde --- /dev/null +++ b/structure/decisions/ADR-0046-claude-desktop-config-library-resolution.md @@ -0,0 +1,12 @@ +# ADR-0046 — Claude Desktop config-library resolution + +- Contract owner: [clients/claude-desktop.md](../clients/claude-desktop.md#claude-desktop-config-library-resolution) + +## Decision record + +- 목적과 의도: 생성된 Claude Desktop 프로필이 설치된 Desktop이 실제로 읽는 디렉터리에 떨어지고, 대시보드 상태가 그 쓰기 대상과 일치하게 한다. +- 기존 구현 및 제약 조건: 두 호출자가 경로 계산을 각자 복제했고, Desktop이 실제로 참조하는 `CLAUDE_USER_DATA_DIR`와 Windows `LOCALAPPDATA` 분기가 빠져 있었다(#539). 사용자가 프로필 루트를 직접 지정하는 경우도 있다. +- 검토한 주요 대안: `-3p` 접미사를 구버전 잔재로 보고 제거; 두 디렉터리를 모두 스캔; 레거시 파일을 자동 이전; 크로스플랫폼 해석기를 한 곳에 둔다. +- 선택한 방식: Desktop 번들의 해석 규칙을 그대로 이식한 override 인지 해석기를 한 곳에 두고, 쓰기 경로와 상태 조회가 같은 함수를 쓴다. +- 다른 대안 대신 이 방식을 선택한 이유: `-3p`는 Desktop의 정상 동작이므로 제거는 회귀였다. 해석기를 한 곳에 두면 두 호출자의 드리프트가 불가능해지고, 파괴적 이전 없이 상태와 쓰기 대상이 일치한다. +- 장점, 단점 및 영향: 지원 플랫폼 전부에서 apply 결과가 Desktop에 보인다. 비표준 레이아웃 사용자는 문서화된 override를 써야 하고, 해석기는 Desktop 번들의 규칙 변경을 따라가야 한다. diff --git a/structure/decisions/ADR-0047-cursor-native-exec.md b/structure/decisions/ADR-0047-cursor-native-exec.md new file mode 100644 index 0000000000..ca7b4ea990 --- /dev/null +++ b/structure/decisions/ADR-0047-cursor-native-exec.md @@ -0,0 +1,12 @@ +# ADR-0047 — Cursor Native Exec + +- Contract owner: [providers/cursor.md](../providers/cursor.md#cursor-native-exec) + +## Decision record + +- 목적과 의도: prevent caller-controlled Responses text from authorizing Cursor native local shell, filesystem, or fetch execution. +- 기존 구현 및 제약 조건: the adapter preserved top-level `instructions`, system messages, and developer messages, then treated a `sandbox_mode ... danger-full-access` prose marker as an exec allow signal in `codex-sandbox` mode. +- 검토한 주요 대안: keep marker-based authorization, require a future trustworthy attestation channel, or restrict authorization to server-local config. +- 선택한 방식: keep marker detection only as diagnostic/context and make `nativeLocalExec: "on"` the only non-legacy mode that enables built-in local exec; unset, `off`, and `codex-sandbox` all deny. +- 다른 대안 대신 이 방식을 선택한 이유: opencodex has no trustworthy per-request sandbox attestation in request text or headers, so any prompt-carried marker is spoofable by data-plane callers. +- 장점, 단점 및 영향: this closes prompt-to-native-exec escalation while preserving an explicit operator escape hatch; existing configs that relied on `codex-sandbox` must switch to `nativeLocalExec: "on"` for trusted local experiments. diff --git a/structure/decisions/ADR-0048-cursor-native-exec.md b/structure/decisions/ADR-0048-cursor-native-exec.md new file mode 100644 index 0000000000..9d777b62ee --- /dev/null +++ b/structure/decisions/ADR-0048-cursor-native-exec.md @@ -0,0 +1,12 @@ +# ADR-0048 — Cursor Native Exec + +- Contract owner: [providers/cursor.md](../providers/cursor.md#cursor-native-exec) + +## Decision record + +- 목적과 의도: keep fresh Cursor-routed Codex Desktop subagents able to invoke the actual unified `exec` tool exposed by their client catalog. +- 기존 구현 및 제약 조건: catalog truncation already pinned `exec`, but the later generic-tool filter recognized only bare `exec_command`/`shell_command` and could erase the sole executable client tool while also naming aliases that were absent. +- 검토한 주요 대안: synthesize a legacy alias, execute `exec` through Cursor native-local-exec, disable generic filtering, or treat every Responses-owned execution-path tool as eligible. +- 선택한 방식: preserve the existing client tool and schema by filtering with `isCursorExecutionPathTool`; keep alias-specific prompt guidance gated on an alias actually being present. +- 다른 대안 대신 이 방식을 선택한 이유: Codex Desktop remains the execution and approval authority, no unavailable tool name is invented, and the existing Responses MCP suspension path can relay the call without widening native execution privileges. +- 장점, 단점 및 영향: unified `exec` survives the filter and returns to Desktop for execution; legacy aliases behave as before; `wait` and unrelated tools remain excluded from generic tool-count prompts. diff --git a/structure/decisions/ADR-0049-heartbeat-and-stall-deadline.md b/structure/decisions/ADR-0049-heartbeat-and-stall-deadline.md new file mode 100644 index 0000000000..0bd427e7f5 --- /dev/null +++ b/structure/decisions/ADR-0049-heartbeat-and-stall-deadline.md @@ -0,0 +1,21 @@ +# ADR-0049 — Heartbeat and stall deadline + +- Contract owner: [transports/streaming-health.md](../transports/streaming-health.md#heartbeat-and-stall-deadline) + +## Decision record + +- 목적과 의도: Stop Codex from replaying a provider-rejected oversized turn and hand the failure + to the client's existing context-compaction semantics. +- 기존 구현 및 제약 조건: Providers can reject before SSE starts; Codex retries raw HTTP 413, + while it recognizes terminal `response.failed` `context_length_exceeded`; the proxy cannot edit + Codex's persisted transcript safely. +- 검토한 주요 대안: Relay 413 unchanged; return HTTP 400 JSON; silently remove media or old turns; + synthesize a successful assistant warning. +- 선택한 방식: Preserve HTTP 413 with typed JSON for non-streaming clients, and map the final + streaming 413 to one redacted non-retryable Responses failure at the outer request boundary. +- 다른 대안 대신 이 방식을 선택한 이유: Raw 413 causes a retry loop, HTTP JSON does not enter + Codex's context-window path, and silent deletion or fake success loses user intent without fixing + transcript ownership. +- 장점, 단점 및 영향: Codex stops reconnecting and can compact on the next turn; no input is + silently lost. The failed turn itself is not auto-replayed, and callers must retry after Codex + compacts or reduce the current input. diff --git a/structure/decisions/ADR-0050-heartbeat-and-stall-deadline.md b/structure/decisions/ADR-0050-heartbeat-and-stall-deadline.md new file mode 100644 index 0000000000..2e54be1db7 --- /dev/null +++ b/structure/decisions/ADR-0050-heartbeat-and-stall-deadline.md @@ -0,0 +1,12 @@ +# ADR-0050 — Heartbeat and stall deadline + +- Contract owner: [transports/streaming-health.md](../transports/streaming-health.md#heartbeat-and-stall-deadline) + +## Decision record + +- 목적과 의도: Prevent Kiro progress from becoming a false final answer, reject invalid empty completion retries, and stop concurrent transient 429s from consuming independent retry budgets. +- 기존 구현 및 제약 조건: Kiro text has no trustworthy phase; stop metadata arrives only at stream end; the private completion tool is adapter-owned; normal parallel tool traffic must remain parallel; client cancellation must interrupt all waits. +- 검토한 주요 대안: Trust native `END_TURN`; infer completion from wording; serialize every Kiro request; leave throttling entirely to the client; manufacture empty assistant turns to preserve alternation. +- 선택한 방식: Require the private completion tool on tool-enabled turns, rebuild only valid replayable wire turns, validate the final conversation, and activate a shared cooldown plus single probe only after a transient throttle. +- 다른 대안 대신 이 방식을 선택한 이유: Native stop metadata has mislabeled progress, wording is language-dependent, global serialization harms healthy concurrency, client-only retries amplify bursts, and empty structural turns are rejected upstream. +- 장점, 단점 및 영향: Completion phase is deterministic and throttled concurrency recovers without a request storm; some clean Kiro stops pay one bounded validation call and an exactly repeated completion answer may be shown twice to preserve `final_answer` semantics. diff --git a/structure/decisions/ADR-0051-reasoning-and-tool-result-compatibility.md b/structure/decisions/ADR-0051-reasoning-and-tool-result-compatibility.md new file mode 100644 index 0000000000..5b31252467 --- /dev/null +++ b/structure/decisions/ADR-0051-reasoning-and-tool-result-compatibility.md @@ -0,0 +1,12 @@ +# ADR-0051 — Reasoning and tool-result compatibility + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#reasoning-and-tool-result-compatibility) + +## Decision record + +- 목적과 의도: Keep same-backend opaque reasoning replay while preventing backend-private blobs and output-only fields from breaking the first turn after a route change. +- 기존 구현 및 제약 조건: Reasoning-input sanitation already handled raw content and `ocxr1:` envelopes; the replay cache already supplied a bounded, thread-scoped physical-route identity, but no record connected that identity to native `encrypted_content` provenance. +- 검토한 주요 대안: Strip every opaque blob, persist provenance across restarts, trust generic 4xx prose, rely only on a retry, or combine deterministic route comparison with a narrowly identified recovery. +- 선택한 방식: Remove output-only `status` from every reasoning input item without changing the pre-existing raw-`content` blanking rule; compare a 64-entry/256 KiB/one-hour in-process serving-identity record using durable destination and credential dimensions before sending, commit it only after the selected destination succeeds, pass a proven change into the Responses adapter before the first send, and use one self-identified opaque-blob recovery only when provenance was unknown. +- 다른 대안 대신 이 방식을 선택한 이유: The former blob/status coupling was defensive rather than observed, and live backends showed that removing `status` preserves same-backend Grok replay while allowing cold cross-backend requests to reach blob validation. Unknown provenance can still be valid after restart, durable storage is unnecessary for this bounded compatibility hint, and deterministic pre-flight avoids the extra paid or stateful upstream attempt whenever the process has evidence. The upstream's narrow error identity supplies authoritative evidence only for histories the process could not observe. +- 장점, 단점 및 영향: Same-route and unknown replay retain cached reasoning on the first send without replaying an output-only field, known cross-route replay keeps the reasoning item without its undecodable blob, and a cold cross-route replay can reach opaque-blob recovery instead of failing early on `status`. A repeated blob rejection is surfaced unchanged after exactly one recovery attempt. diff --git a/structure/decisions/ADR-0052-reasoning-and-tool-result-compatibility.md b/structure/decisions/ADR-0052-reasoning-and-tool-result-compatibility.md new file mode 100644 index 0000000000..421d973d09 --- /dev/null +++ b/structure/decisions/ADR-0052-reasoning-and-tool-result-compatibility.md @@ -0,0 +1,12 @@ +# ADR-0052 — Reasoning and tool-result compatibility + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#reasoning-and-tool-result-compatibility) + +## Decision record + +- 목적과 의도: Preserve DeepSeek reasoning replay for parallel tool calls while retaining the provider-scoped repair for hook-interleaved results. +- 기존 구현 및 제약 조건: Pair-by-pair adjacency fixed one call but split parallel calls into separate assistant turns; DeepSeek always enables parallel tool calling and merges adjacent reasoning and calls into one assistant message. +- 검토한 주요 대안: Disable parallel calls, duplicate reasoning, remove the #1292 repair, or normalize one unambiguous call/output batch. +- 선택한 방식: Group calls that occur before the first matched output, emit the call batch followed by outputs in call order, and retain intervening non-tool items after the batch. +- 다른 대안 대신 이 방식을 선택한 이유: The batch shape matches the documented Responses contract without inventing reasoning or reintroducing hook-interleaving failures. +- 장점, 단점 및 영향: Sequential and parallel tool continuations both retain their reasoning contract; only the declared strict provider changes order, and ambiguous histories still fail closed upstream. diff --git a/structure/decisions/ADR-0053-cursor-active-context-usage.md b/structure/decisions/ADR-0053-cursor-active-context-usage.md new file mode 100644 index 0000000000..5ca7cb286d --- /dev/null +++ b/structure/decisions/ADR-0053-cursor-active-context-usage.md @@ -0,0 +1,12 @@ +# ADR-0053 — Cursor active-context usage + +- Contract owner: [providers/cursor.md](../providers/cursor.md#cursor-active-context-usage) + +## Decision record + +- 목적과 의도: Keep Codex's visible "context left" indicator aligned with Cursor's active-context usage on client-tool turns that finalize before a checkpoint arrives. +- 기존 구현 및 제약 조건: Checkpoint turns reported totalTokens correctly, but no-checkpoint client-tool finalize fell back to output-only usage and could overwrite a meaningful prior total with values like 109 tokens. +- 검토한 주요 대안: Add a longer wait for late checkpoints; infer prior+output totals; store full prompt/history state; carry forward only the last numeric checkpoint per Cursor conversation. +- 선택한 방식: Carry forward the last numeric absolute checkpoint per Cursor conversation with bounded LRU/TTL storage, update it only from live checkpoint frames, and clear/suppress it once when a newly appended compaction boundary starts an epoch; previous_response replay provenance acknowledges historical markers without serializing private metadata upstream. +- 다른 대안 대신 이 방식을 선택한 이유: It fixes the UI regression without delaying tool turns, fabricating token growth, storing prompt/tool content, or repeatedly clearing valid post-compaction usage when historical markers replay; one-time compaction resets still prevent stale over-report when history is replaced. +- 장점, 단점 및 영향: Active-context reporting stays monotonic within an uncompacted Cursor conversation; no-checkpoint turns remain estimated; a process restart loses the numeric cache, and when neither a checkpoint nor a carry-forward is available the turn reports a request-local estimate derived from the same pruned payload sent to Cursor (#373 — reporting output-only usage made Codex read the context as nearly empty). Estimates are never persisted or promoted into checkpoint carry-forward; only live checkpoint frames update the cache. diff --git a/structure/decisions/ADR-0054-cursor-conversation-checkpoint-reuse.md b/structure/decisions/ADR-0054-cursor-conversation-checkpoint-reuse.md new file mode 100644 index 0000000000..ad108c9813 --- /dev/null +++ b/structure/decisions/ADR-0054-cursor-conversation-checkpoint-reuse.md @@ -0,0 +1,12 @@ +# ADR-0054 — Cursor conversation checkpoint reuse + +- Contract owner: [providers/cursor.md](../providers/cursor.md#cursor-conversation-checkpoint-reuse) + +## Decision record + +- 목적과 의도: Reuse Cursor's returned ConversationStateStructure on validated linear continuations so OpenCodex does not rebuild the full root history every turn. +- 기존 구현 및 제약 조건: Stable conversation ids already exist (#366), but every turn still reconstructed rootPromptMessagesJson and conversationTurns. Cursor Connect still reports only usedTokens/maxTokens, so cache_read_tokens cannot be treated as authoritative (#275). +- 검토한 주요 대안: Keep full replay; copy Pi's live MCP bridge immediately; store raw protobuf in Responses JSON; key checkpoints only by conversation id. +- 선택한 방식: Keep an opaque process-local checkpointRef on OcxProviderContinuationState.cursor, bind the snapshot to conversation/account/model affinity, and require a remembered provider conversation or stable client thread before a ref-less prefix lookup. Reuse the bounded process-local Desktop session/thread HMAC when the canonical parent-thread header is absent. Pin referenced blobs for the checkpoint lifetime, and fall back to the existing full-replay path for unowned headerless requests, isolation, compaction, restart, missing refs, and invalid_argument recovery. Tool-result turns reuse the last completed checkpoint plus an uncovered suffix. previous_response_id is a branch anchor, never a Cursor conversation ownership key. +- 다른 대안 대신 이 방식을 선택한 이유: It removes avoidable replay cost without claiming cache-hit rates, without changing OAuth, and without collapsing helper/compaction isolation or tool-call replay safety. +- 장점, 단점 및 영향: Validated no-tool follow-ups stop growing local rootBytes with history; a process restart or missing blob lease falls back to full replay; large-context 429 / premature-completion acceptance for #1527 is still unproven; a stateful live MCP bridge remains out of scope. diff --git a/structure/decisions/ADR-0055-google-thought-text-visibility-boundary.md b/structure/decisions/ADR-0055-google-thought-text-visibility-boundary.md new file mode 100644 index 0000000000..02398a04e5 --- /dev/null +++ b/structure/decisions/ADR-0055-google-thought-text-visibility-boundary.md @@ -0,0 +1,12 @@ +# ADR-0055 — Google thought-text visibility boundary + +- Contract owner: [providers/google.md](../providers/google.md#google-thought-text-visibility-boundary) + +## Decision record + +- 목적과 의도: Prevent provider-marked internal reasoning from appearing as ordinary assistant text while preserving reasoning and tool-call continuation. +- 기존 구현 및 제약 조건: Both Google response paths emitted every non-empty `Part.text` as visible text; function calls, inline images, and Antigravity/Vertex thought-signature replay already depended on the original part ordering. +- 검토한 주요 대안: Drop thought text; classify it separately in each parser; remove the marker and keep visible text; use one shared classifier without mutating the provider parts. +- 선택한 방식: Map `thought: true` text to `reasoning_raw_delta` through one helper used by streaming and buffered parsing, leaving part order and signature observation unchanged. +- 다른 대안 대신 이 방식을 선택한 이유: Dropping the text loses reasoning replay/display policy input, while duplicated parser rules can drift and exposing marked thoughts violates the provider's visibility boundary. +- 장점, 단점 및 영향: Internal reasoning no longer leaks into normal answers and both transports stay consistent; downstream reasoning policy still decides whether raw reasoning is rendered or only preserved, and malformed non-boolean markers remain ordinary text rather than broadening hidden-content inference. diff --git a/structure/decisions/ADR-0056-google-response-part-field-boundary.md b/structure/decisions/ADR-0056-google-response-part-field-boundary.md new file mode 100644 index 0000000000..4a74ed3aa0 --- /dev/null +++ b/structure/decisions/ADR-0056-google-response-part-field-boundary.md @@ -0,0 +1,12 @@ +# ADR-0056 — Google response-part field boundary + +- Contract owner: [providers/google.md](../providers/google.md#google-response-part-field-boundary) + +## Decision record + +- 목적과 의도: Keep malformed Google-compatible response fields from violating the internal string-only text and tool-name contract or dispatching an unidentified tool. +- 기존 구현 및 제약 조건: Container validation guaranteed object parts, but truthy string/number/array functionCall values emitted a nameless tool call and truthy non-string text values crossed as text or reasoning events. Gemini supplies a complete call in one part, so there is no later name fragment to await. +- 검토한 주요 대안: Pass malformed values through; coerce them to strings; silently drop every malformed field; terminate the turn for every malformed field; distinguish dispatch identity from optional text. +- 선택한 방식: Prevalidate function calls and terminate on a non-object, non-string, empty, or whitespace name; drop only non-string text; leave arguments untouched. +- 다른 대안 대신 이 방식을 선택한 이유: Passing or coercing can execute the wrong tool or fabricate transcript text, while terminating for optional malformed text discards an otherwise usable response. An invalid call name cannot be recovered or safely ignored once the model selected a tool. +- 장점, 단점 및 영향: Streaming and buffered paths enforce the same AdapterEvent contract and invalid calls cannot enter thought-signature replay. Nonconforming third-party Google-compatible text fields are ignored rather than surfaced, and operators receive a structured terminal error for call identity failures. diff --git a/structure/decisions/ADR-0057-google-tool-call-thought-signature-replay.md b/structure/decisions/ADR-0057-google-tool-call-thought-signature-replay.md new file mode 100644 index 0000000000..e3b83b16a6 --- /dev/null +++ b/structure/decisions/ADR-0057-google-tool-call-thought-signature-replay.md @@ -0,0 +1,12 @@ +# ADR-0057 — Google tool-call thought-signature replay + +- Contract owner: [providers/google.md](../providers/google.md#google-tool-call-thought-signature-replay) + +## Decision record + +- 목적과 의도: Preserve Vertex Gemini tool-call continuation without exposing opaque signatures to Codex or another Google backend. +- 기존 구현 및 제약 조건: Responses history does not carry a safe Gemini signature field; Antigravity already used a bounded in-process replay cache, while Vertex bypassed it and received HTTP 400 after the first tool call. +- 검토한 주요 대안: Serialize the signature into Responses item ids or reasoning content; create an unbounded Vertex map; reuse the bounded cache with or without a transport namespace. +- 선택한 방식: Reuse the bounded cache for Vertex, observe both response shapes, apply after wire-name compilation, and scope Vertex by transport/project/location plus the opaque client session key when available. +- 다른 대안 대신 이 방식을 선택한 이유: Responses ids are not Gemini signatures and previously caused Base64/TYPE_BYTES failures; a second cache duplicates limits; an unscoped cache could send provider-private state across destinations. +- 장점, 단점 및 영향: Tool loops continue with exact opaque state and bounded memory while cross-transport reuse fails closed. Replay remains process-local, matching the existing Antigravity contract. diff --git a/structure/decisions/ADR-0058-google-tool-result-adjacency-repair.md b/structure/decisions/ADR-0058-google-tool-result-adjacency-repair.md new file mode 100644 index 0000000000..c7c7f9bdcd --- /dev/null +++ b/structure/decisions/ADR-0058-google-tool-result-adjacency-repair.md @@ -0,0 +1,23 @@ +# ADR-0058 — Google tool-result adjacency repair + +- Contract owner: [providers/google.md](../providers/google.md#google-tool-result-adjacency-repair) + +## Decision record + +- 목적과 의도: prevent interrupted or replayed Claude-on-Antigravity histories from reaching the + Google wire with unanswered `functionCall` or unpaired `functionResponse` parts. +- 기존 구현 및 제약 조건: `messagesToGeminiFormat` emitted every internal message independently; + Antigravity translates the resulting Gemini shape back into strict Anthropic tool-use blocks, and + rejects malformed adjacency with HTTP 400. Tool-result images cannot live inside a + `functionResponse` and already rely on sibling `inline_data` parts. +- 검토한 주요 대안: repair the shared internal history; synthesize fake calls for orphan results; + repair only the Google adapter serialization boundary. +- 선택한 방식: group only consecutive results after a model call batch, match by the normalized + request-scoped call id, emit responses in call order, synthesize an explicit missing result, and + degrade remaining results to marked text while retaining image siblings. +- 다른 대안 대신 이 방식을 선택한 이유: shared-history mutation could change other adapters, + while fabricating a successful call would invent model behavior. The adapter boundary owns the + strict upstream wire contract and can repair it without changing client-visible history. +- 장점, 단점 및 영향: normal histories remain byte-shape equivalent, parallel and interrupted + histories become provider-valid, and orphan data is not lost. A result separated by a non-tool + barrier is intentionally not reattached across that boundary. diff --git a/structure/decisions/ADR-0059-xai-grok-hardening-official-grok-build-contract.md b/structure/decisions/ADR-0059-xai-grok-hardening-official-grok-build-contract.md new file mode 100644 index 0000000000..ec93ac6e8f --- /dev/null +++ b/structure/decisions/ADR-0059-xai-grok-hardening-official-grok-build-contract.md @@ -0,0 +1,23 @@ +# ADR-0059 — xAI Grok hardening (official Grok Build contract parity) + +- Contract owner: [providers/xai-grok.md](../providers/xai-grok.md#xai-grok-hardening-official-grok-build-contract-parity) + +## Decision record + +- 목적과 의도: Prevent Grok Build from classifying a visibly streamed answer as empty and replaying + the same billable turn when the terminal snapshot is sparse. +- 기존 구현 및 제약 조건: OpenCodex already reconstructed missing terminal output for provider + opt-ins, but preserved explicit empty arrays; Grok Build discarded ordinary completed-item events + when constructing its final conversation response. +- 검토한 주요 대안: Change every caller's empty-array semantics; accept a turn merely because a + text delta was visible; reuse the provider's broader lifecycle synthesis; add a strict repair at + the generated Grok client boundary. +- 선택한 방식: Use the existing generated client marker to opt Grok into a terminal-only repair and + backfill only from unique, contiguous, bounded real done items whose raw semantics are valid. +- 다른 대안 대신 이 방식을 선택한 이유: A global rewrite would alter valid provider semantics, + while accepting deltas without durable items would leave persistence and continuation empty. The + marker is already the client-specific compatibility boundary; keeping the provider repair separate + also prevents synthesized or permissively normalized items from overriding an explicit empty terminal. +- 장점, 단점 및 영향: Grok receives one durable completed answer without a paid retry; ordinary + clients remain byte-semantics compatible. The proxy retains bounded item state for marked streams + and intentionally refuses ambiguous reconstruction. diff --git a/structure/decisions/ADR-0060-kiro-client-parallel-tool-hint.md b/structure/decisions/ADR-0060-kiro-client-parallel-tool-hint.md new file mode 100644 index 0000000000..c163324e79 --- /dev/null +++ b/structure/decisions/ADR-0060-kiro-client-parallel-tool-hint.md @@ -0,0 +1,12 @@ +# ADR-0060 — Kiro client parallel-tool hint + +- Contract owner: [providers/kiro.md](../providers/kiro.md#kiro-client-parallel-tool-hint) + +## Decision record + +- 목적과 의도: Keep current Codex clients usable with Kiro without claiming or inventing parallel execution on the CodeWhisperer wire. +- 기존 구현 및 제약 조건: Codex can send `parallel_tool_calls: true` even for catalog rows that advertise false; Kiro has no verified parallel-control request field and serializes tool execution. +- 검토한 주요 대안: Reject the client hint, rewrite it to false before routing, or accept it as permission while leaving the Kiro wire unchanged. +- 선택한 방식: Accept either request value, preserve the parsed client intent internally, and omit all parallel-control fields from the Kiro payload. +- 다른 대안 대신 이 방식을 선택한 이유: Rejection interprets permission as a requirement and blocks valid turns, while rewriting shared request state hides caller intent and can affect later policy or diagnostics. +- 장점, 단점 및 영향: Codex tool turns reach Kiro again and the adapter contract stays honest; Kiro still cannot produce true parallel tool batches through this transport. diff --git a/structure/decisions/ADR-0061-kiro-responses-text-controls.md b/structure/decisions/ADR-0061-kiro-responses-text-controls.md new file mode 100644 index 0000000000..3306da454d --- /dev/null +++ b/structure/decisions/ADR-0061-kiro-responses-text-controls.md @@ -0,0 +1,12 @@ +# ADR-0061 — Kiro Responses text controls + +- Contract owner: [providers/kiro.md](../providers/kiro.md#kiro-responses-text-controls) + +## Decision record + +- 목적과 의도: Stop rejecting valid Kiro turns whose only offence is carrying a Responses text control the wire ignores. +- 기존 구현 및 제약 조건: The guard tested `_rawBody.text !== undefined`, so `text.verbosity`, `text.format:{"type":"text"}`, and even `text:{}` produced HTTP 400 with sendCount 0 while identical turns without `text` succeeded; `_structuredOutput` already distinguishes real structured output, and the catalog's `support_verbosity: false` helps neither a client holding a cached catalog nor the default text format, which no capability flag governs. +- 검토한 주요 대안: Keep the presence check, add an openai-responses-style stripper before serialization, or narrow the guard to `_structuredOutput` alone. +- 선택한 방식: Narrow the condition to `_structuredOutput`; no stripper is needed because the Kiro payload never spreads the raw body. +- 다른 대안 대신 이 방식을 선택한 이유: The presence check reads a preference as a requirement — the same error `db040e70f` removed for parallel-tool hints — and a stripper would add a serialization stage to defend against a body Kiro already ignores by construction. +- 장점, 단점 및 영향: Kiro-routed Codex turns stop failing intermittently and structured output stays honestly refused; a future `text` member Kiro genuinely cannot ignore would need its own condition. diff --git a/structure/decisions/ADR-0062-chat-streaming-client-with-a-json-upstream-resul.md b/structure/decisions/ADR-0062-chat-streaming-client-with-a-json-upstream-resul.md new file mode 100644 index 0000000000..becb6bf6b7 --- /dev/null +++ b/structure/decisions/ADR-0062-chat-streaming-client-with-a-json-upstream-resul.md @@ -0,0 +1,12 @@ +# ADR-0062 — Chat streaming client with a JSON upstream result + +- Contract owner: [data-planes/inbound-compat.md](../data-planes/inbound-compat.md#chat-streaming-client-with-a-json-upstream-result) + +## Decision record + +- 목적과 의도: Keep tool execution and incomplete-response detection working when a streaming client receives a JSON upstream result. +- 기존 구현 및 제약 조건: The existing fallback copied only text and forced `stop`, despite the JSON converter already retaining tool calls, reasoning, and incomplete status. +- 검토한 주요 대안: Duplicate Responses parsing in the emitter; perform another inference request; preserve the already-converted Chat completion. +- 선택한 방식: Copy supported converted message fields into one delta, assign tool-call stream indexes, and retain the converted finish reason. +- 다른 대안 대신 이 방식을 선택한 이유: One conversion authority prevents the streaming fallback from drifting from non-streaming semantics without changing routing or retry behavior. +- 장점, 단점 및 영향: No additional upstream request or dependency; this remains buffered delivery, not token-by-token upstream streaming. Handler regressions cover tools, reasoning, length, ordinary and empty completions, and budget release. diff --git a/structure/decisions/ADR-0063-volcengine-ark-assistant-continuation-shapes.md b/structure/decisions/ADR-0063-volcengine-ark-assistant-continuation-shapes.md new file mode 100644 index 0000000000..043b37d24b --- /dev/null +++ b/structure/decisions/ADR-0063-volcengine-ark-assistant-continuation-shapes.md @@ -0,0 +1,12 @@ +# ADR-0063 — Volcengine Ark assistant continuation shapes + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#volcengine-ark-assistant-continuation-shapes) + +## Decision record + +- 목적과 의도: Preserve multi-turn tool-call continuations across both Ark Chat endpoint families. +- 기존 구현 및 제약 조건: The #796 workaround was host-wide and unverified; live Coding Plan evidence shows its structured placeholder returns HTTP 400 while an empty string succeeds. +- 검토한 주요 대안: Remove the workaround globally, select by model ID, or scope it by endpoint path. +- 선택한 방식: Apply the structured placeholder only to recognized Ark hosts whose normalized base path is exactly `/api/v3`. +- 다른 대안 대신 이 방식을 선택한 이유: Global removal would reopen #796, while model IDs can appear behind multiple Ark products and therefore do not identify the wire contract. +- 장점, 단점 및 영향: Coding Plan regains its accepted continuation shape without changing generic providers; any future Ark endpoint family must provide evidence before inheriting the pay-as-you-go quirk. diff --git a/structure/decisions/ADR-0064-chat-structured-output-compatibility.md b/structure/decisions/ADR-0064-chat-structured-output-compatibility.md new file mode 100644 index 0000000000..64c11744b4 --- /dev/null +++ b/structure/decisions/ADR-0064-chat-structured-output-compatibility.md @@ -0,0 +1,12 @@ +# ADR-0064 — Chat structured-output compatibility + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#chat-structured-output-compatibility) + +## Decision record + +- 목적과 의도: Make Moonshot's compatibility rewrite remove rejected sibling `$ref` shapes without silently weakening a tool schema. +- 기존 구현 및 제약 조건: The target and sibling both apply under JSON Schema 2020-12, but a shallow shared-property merge let sibling bounds replace stricter target bounds; Moonshot still requires the local bounded rewrite. +- 검토한 주요 대안: Keep shallow sibling precedence; emit `allOf`; intersect only top-level bounds; recursively compose the supported set-valued and ordered assertions. +- 선택한 방식: Reuse the existing bound and required intersection rules recursively for overlapping object properties inside the first-party destination gate. +- 다른 대안 대신 이 방식을 선택한 이유: Shallow precedence weakens constraints, while a new `allOf` wire shape needs separate provider evidence; recursive composition fixes the demonstrated loss without broadening normalization to custom providers. +- 장점, 단점 및 영향: Looser siblings cannot relax nested constraints and tighter siblings still narrow them; non-ordered conflicting keywords retain the existing sibling precedence and are not treated as a complete JSON Schema algebra. diff --git a/structure/decisions/ADR-0065-chat-structured-output-compatibility.md b/structure/decisions/ADR-0065-chat-structured-output-compatibility.md new file mode 100644 index 0000000000..22de798b6c --- /dev/null +++ b/structure/decisions/ADR-0065-chat-structured-output-compatibility.md @@ -0,0 +1,12 @@ +# ADR-0065 — Chat structured-output compatibility + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#chat-structured-output-compatibility) + +## Decision record + +- 목적과 의도: Recover chat models that reject `response_format` without removing structured output from models that support it. +- 기존 구현 및 제약 조건: The adapter forwarded the field to every routed chat model after #1137, while the same model id may sit behind gateways with different capabilities. +- 검토한 주요 대안: Revert translation globally; blacklist a model id globally; detect a proxy by name or URL; add an explicit provider/model opt-out. +- 선택한 방식: Preserve default translation and omit it only for exact ids in `noStructuredOutputModels`. +- 다른 대안 대신 이 방식을 선택한 이유: Global or heuristic rules regress supported providers and make custom gateway names part of the wire contract. +- 장점, 단점 및 영향: Compatible siblings retain schema enforcement and explicitly incompatible models avoid the upstream 400; operators must classify each unsupported model they route. diff --git a/structure/decisions/ADR-0066-anthropic-structured-output-compatibility.md b/structure/decisions/ADR-0066-anthropic-structured-output-compatibility.md new file mode 100644 index 0000000000..f170c9c337 --- /dev/null +++ b/structure/decisions/ADR-0066-anthropic-structured-output-compatibility.md @@ -0,0 +1,12 @@ +# ADR-0066 — Anthropic structured-output compatibility + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#anthropic-structured-output-compatibility) + +## Decision record + +- 목적과 의도: Preserve schema-constrained output when OpenAI-shaped Responses or Chat Completions requests route to Anthropic Messages. +- 기존 구현 및 제약 조건: The parser retained the requested schema, but the Anthropic adapter dropped it; forwarding the OpenAI schema unchanged fails when it includes constraints outside Anthropic's supported subset. +- 검토한 주요 대안: Keep tool-call emulation; forward the raw schema; depend on the full Anthropic SDK; maintain a local compatibility transform based on the SDK. +- 선택한 방식: Merge Anthropic `output_config.format` into compatible adaptive-thinking configuration, mirror the SDK transform locally with strict `unknown` narrowing, move unsupported constraints into descriptions, and preserve root `$defs` before returning a root `$ref`. +- 다른 대안 대신 이 방식을 선택한 이유: Native structured output avoids synthetic tools, raw forwarding produces upstream 400s, and importing the full SDK only for a small wire transform would duplicate the adapter's direct HTTP ownership. +- 장점, 단점 및 영향: Both OpenAI-shaped input surfaces gain native Anthropic schema enforcement and unsupported intent remains visible to the model; the copied subset must track upstream SDK changes, description-carried constraints are guidance rather than hard validation, and the root-reference fix is an intentional divergence to keep definitions reachable. diff --git a/structure/decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md b/structure/decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md new file mode 100644 index 0000000000..7d7fad33be --- /dev/null +++ b/structure/decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md @@ -0,0 +1,12 @@ +# ADR-0067 — Reasoning display parity (hideThinkingSummary) + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#reasoning-display-parity-hidethinkingsummary) + +## Decision record + +- 목적과 의도: Keep reasoning replay bounded while preserving opaque values exactly. +- 기존 구현 및 제약 조건: Reasoning continuity needs JSON/base64 envelopes, and existing callers already own retained accounting and typed overflow handling. +- 검토한 주요 대안: Per-field truncation, an independent fixed field limit, or shared transient admission plus cumulative inbound ownership. +- 선택한 방식: Reserve conservative copy projections in the envelope helpers and use the existing request budget across inbound blocks. +- 다른 대안 대신 이 방식을 선택한 이유: Truncation changes signed values; one field limit does not describe aggregate ownership. Existing budget errors retain the established HTTP and stream error contracts. +- 장점, 단점 및 영향: Normal replay is unchanged; envelope admission includes copy overhead and is stricter than a raw-string length ceiling. These are translator accounting limits, not a process-wide RSS guarantee. diff --git a/structure/decisions/ADR-0068-reasoning-display-parity-hidethinkingsummary.md b/structure/decisions/ADR-0068-reasoning-display-parity-hidethinkingsummary.md new file mode 100644 index 0000000000..b2a7120777 --- /dev/null +++ b/structure/decisions/ADR-0068-reasoning-display-parity-hidethinkingsummary.md @@ -0,0 +1,12 @@ +# ADR-0068 — Reasoning display parity (hideThinkingSummary) + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#reasoning-display-parity-hidethinkingsummary) + +## Decision record + +- 목적과 의도: Preserve tool-call continuation compatibility without forwarding one provider or physical account's private reasoning to another fallback target. +- 기존 구현 및 제약 조건: Conversation-only scoping stopped process-global call-id collisions, but combo and 429 failover can reuse the same thread and provider-generated call id across destinations or credentials. +- 검토한 주요 대안: Disable replay on every failover-capable provider; key only by provider name; use persisted or truncated secret-derived ids; bind the in-memory cache to an exact process-local route and credential tuple. +- 선택한 방식: Keep a shared mutable scope holder and key entries by thread, provider name, an opaque destination HMAC, adapter, final model, and an opaque HMAC/account identity; incomplete identities read and write nothing. +- 다른 대안 대신 이 방식을 선택한 이유: Exact binding preserves same-generation same-target retries while making account switches and OAuth token refreshes fail closed, without logging, persisting, or exposing credential material. +- 장점, 단점 및 영향: Cross-provider/account replay is blocked and rotations are visible to live bridges; providers without a stable credential identity lose cache replay and use the existing minimal placeholder path. diff --git a/structure/decisions/ADR-0069-chat-to-responses-message-phase-inference.md b/structure/decisions/ADR-0069-chat-to-responses-message-phase-inference.md new file mode 100644 index 0000000000..29bf5c9ee3 --- /dev/null +++ b/structure/decisions/ADR-0069-chat-to-responses-message-phase-inference.md @@ -0,0 +1,12 @@ +# ADR-0069 — Chat-to-Responses message phase inference + +- Contract owner: [transports/responses.md](../transports/responses.md#chat-to-responses-message-phase-inference) + +## Decision record + +- 목적과 의도: Prevent Codex App from rendering one bridged Chat Completions answer as both live commentary and a second persisted final answer. +- 기존 구현 및 제약 조건: openai-chat emits text deltas without phase, the bridge streamed them immediately, and whether text is pre-tool commentary or the terminal answer is unknowable until a later boundary arrives. +- 검토한 주요 대안: Mark every delta final_answer; mark every delta commentary; buffer the entire answer before emitting; infer phase only when the message is finalized. +- 선택한 방식: Keep the live added item provisional and infer commentary or final_answer at the authoritative close boundary, preserving explicit phases and item identity in done/completed output. +- 다른 대안 대신 이 방식을 선택한 이유: Eager defaults misclassify either tool preambles or final answers, while full buffering removes live streaming; close-time inference provides correct persisted semantics without adding latency. +- 장점, 단점 및 영향: Codex App receives a definitive phase for persisted bridged messages and avoids the duplicate-final rendering path; the provisional output_item.added event intentionally has no phase because its classification is not yet knowable. diff --git a/structure/decisions/ADR-0070-same-provider-combo-quota-fallback.md b/structure/decisions/ADR-0070-same-provider-combo-quota-fallback.md new file mode 100644 index 0000000000..6c118f6c9c --- /dev/null +++ b/structure/decisions/ADR-0070-same-provider-combo-quota-fallback.md @@ -0,0 +1,12 @@ +# ADR-0070 — Same-provider combo quota fallback + +- Contract owner: [transports/responses.md](../transports/responses.md#same-provider-combo-quota-fallback) + +## Decision record + +- 목적과 의도: Let an ordered combo recover when one model-specific Codex quota window is exhausted but another model on the same account remains usable. +- 기존 구현 및 제약 조건: Account health is shared across models, and recording a reset-derived 429 before combo advancement rejected the later model locally. +- 검토한 주요 대안: Make every quota cooldown model-scoped; ignore all combo 429 cooldowns; or defer only reset-derived cooldown recording for an eligible later same-provider failover target. +- 선택한 방식: Use the narrow request-scoped deferral while retaining target cooldown and all explicit Retry-After/default account cooldown behavior. +- 다른 대안 대신 이 방식을 선택한 이유: Reset timestamps identify quota windows rather than a literal account-wide retry instruction, but widening the exception would risk hot retries and provider abuse. +- 장점, 단점 및 영향: Same-account model fallback works without weakening explicit upstream backoff; the account health map intentionally does not remember that one deferred reset-derived failure, while the combo target map does. diff --git a/structure/decisions/ADR-0071-combo-streaming-commit-boundary.md b/structure/decisions/ADR-0071-combo-streaming-commit-boundary.md new file mode 100644 index 0000000000..ba6bbc306b --- /dev/null +++ b/structure/decisions/ADR-0071-combo-streaming-commit-boundary.md @@ -0,0 +1,12 @@ +# ADR-0071 — Combo streaming commit boundary + +- Contract owner: [transports/responses.md](../transports/responses.md#combo-streaming-commit-boundary) + +## Decision record + +- 목적과 의도: Recover a failover combo from a provider-local SSE or model-lifecycle failure only while replay is provably free of duplicate client output and tool calls. +- 기존 구현 및 제약 조건: The parent committed every HTTP-200 child before reading its SSE body, while terminal stream errors were classified only later by logging; generic 410 responses stopped the chain. +- 검토한 주요 대안: Retry every failed stream, buffer the complete turn, inspect only HTTP status, or preflight a bounded prefix until an explicit output/terminal boundary. +- 선택한 방식: Put the one-reader bounded preflight in a dedicated module, commit on any non-control event, and treat only explicit model-lifecycle 410 evidence as target-local. +- 다른 대안 대신 이 방식을 선택한 이유: Replaying after output can duplicate text or tools, full-turn buffering destroys streaming and grows memory, and making every 410 retryable hides caller/application errors. +- 장점, 단점 및 영향: Zero-output provider failures can reach a healthy target with ordered receipts and cooldown; ambiguous or oversized pre-output streams keep the current fail-closed behavior instead of consuming unbounded memory. diff --git a/structure/decisions/ADR-0072-transport-inventory.md b/structure/decisions/ADR-0072-transport-inventory.md new file mode 100644 index 0000000000..07ac6af65f --- /dev/null +++ b/structure/decisions/ADR-0072-transport-inventory.md @@ -0,0 +1,12 @@ +# ADR-0072 — Transport inventory + +- Contract owner: [transports/inventory.md](../transports/inventory.md#transport-inventory) + +## Decision record + +- 목적과 의도: Keep reactive OAuth 429 recovery available without silently enabling proactive account-routing policy the operator switched off. +- 기존 구현 및 제약 조건: #3495 made reactive recovery presence-driven, but a disabled Anthropic pool still consulted its dormant strategy on the reactive path, and a per-provider `oauthAccountFailover.enabled: true` could no longer beat a global `false`. +- 검토한 주요 대안: Restore the old all-or-nothing enable flag; leave the merged behavior and document the gaps; or keep the reactive/proactive split and repair the exact policy boundaries. +- 선택한 방식: Keep presence-driven reactive recovery, apply proactive precedence only before dispatch, and use quota ordering for disabled-pool Anthropic recovery. +- 다른 대안 대신 이 방식을 선택한 이유: This preserves the merged product decision without letting disabled proactive settings influence a retry, and it restores the published narrow-over-broad precedence in both directions. +- 장점, 단점 및 영향: 429 recovery stays automatic for operators with multiple eligible accounts; operators who require no automatic account switch must keep one eligible account, which the GUI and public docs state explicitly. diff --git a/structure/decisions/ADR-0073-authentication-boundaries.md b/structure/decisions/ADR-0073-authentication-boundaries.md new file mode 100644 index 0000000000..dbdaba4163 --- /dev/null +++ b/structure/decisions/ADR-0073-authentication-boundaries.md @@ -0,0 +1,12 @@ +# ADR-0073 — Authentication boundaries + +- Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#authentication-boundaries) + +## Decision record + +- 목적과 의도: Keep a lower-privileged local process from collecting the management bearer by impersonating `/healthz` on an unused port. +- 기존 구현 및 제약 조건: Liveness must remain public and backward-compatible, but its service string and reported PID are assertions made by the listener itself. +- 검토한 주요 대안: Require only a runtime source and non-null PID; stop showing account health; authenticate the listener with a protected per-process challenge secret. +- 선택한 방식: Store a random secret in the mode-protected runtime record and use method/path/PID/port-bound HMAC capabilities for the two CLI health reads, so the CLI sends no reusable Authorization value. +- 다른 대안 대신 이 방식을 선택한 이유: PID and command-line checks are not cryptographic listener identity, while removing live account health would regress diagnostics unnecessarily. +- 장점, 단점 및 영향: The long-lived token never reaches a listener without the runtime secret; an old running proxy remains visible but cannot provide detailed CLI account health until restarted on the new version. diff --git a/structure/decisions/ADR-0074-api-ownership.md b/structure/decisions/ADR-0074-api-ownership.md new file mode 100644 index 0000000000..53aedd297f --- /dev/null +++ b/structure/decisions/ADR-0074-api-ownership.md @@ -0,0 +1,12 @@ +# ADR-0074 — API ownership + +- Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#api-ownership) + +## Decision record + +- 목적과 의도: Distinguish a healthy process from a continuation spill writer that is repeatedly failing, especially on Windows where the ACL publication lane is asynchronous. +- 기존 구현 및 제약 조건: `/healthz` intentionally reports liveness only, while `spillWriteFailures` was cumulative and discarded the failure class, event time, and recovery boundary. +- 검토한 주요 대안: Make `/healthz` fail on a spill error; publish raw error messages; expose a fixed classified health projection only on the authenticated memory route. +- 선택한 방식: Keep liveness unchanged and add a consecutive streak, fixed error class, and last failure/success timestamps to the existing authenticated response-state metrics. +- 다른 대안 대신 이 방식을 선택한 이유: One failed cache demotion must not restart or remove an otherwise serving proxy, and raw filesystem errors can disclose user paths while still failing to show whether the next write recovered. +- 장점, 단점 및 영향: Operators can identify accumulating failures and same-process recovery without sensitive text. The status is process-local and resets to `initial` on restart, so historical diagnosis still requires external metric collection. diff --git a/structure/decisions/ADR-0075-startup-safety.md b/structure/decisions/ADR-0075-startup-safety.md new file mode 100644 index 0000000000..332295ed21 --- /dev/null +++ b/structure/decisions/ADR-0075-startup-safety.md @@ -0,0 +1,12 @@ +# ADR-0075 — Startup safety + +- Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#startup-safety) + +## Decision record + +- 목적과 의도: Keep a refused fresh Windows service install from stopping a working proxy and removing managed Codex routing. +- 기존 구현 및 제약 조건: The generic installer stopped service managers and the standalone proxy before the first scheduler create attempt; the Dashboard UAC path depended on assets produced by that already-destructive failure. +- 검토한 주요 대안: Reject every non-elevated caller up front, restart and re-inject after failure, snapshot every runtime/config artifact for rollback, or separate registration approval from the destructive commit. +- 선택한 방식: When scheduler absence is proven, create but do not run the owned registration from a temporary XML first; cleanup and canonical asset publication begin only after registration succeeds. +- 다른 대안 대신 이 방식을 선택한 이유: An early rejection breaks Dashboard UAC, while a best-effort restart cannot prove that manager, proxy, and routing state were restored. The two-phase boundary makes denial/cancellation a real pre-commit failure. +- 장점, 단점 및 영향: Fresh-install UAC failure preserves the live proxy and routing. Failures after registration remain explicit partial-install cases, and existing/conflicting scheduler recovery remains conservative until exact prior-state restoration is available. diff --git a/structure/decisions/ADR-0076-startup-safety.md b/structure/decisions/ADR-0076-startup-safety.md new file mode 100644 index 0000000000..bde538d907 --- /dev/null +++ b/structure/decisions/ADR-0076-startup-safety.md @@ -0,0 +1,12 @@ +# ADR-0076 — Startup safety + +- Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#startup-safety) + +## Decision record + +- 목적과 의도: Make Windows scheduler installation recovery work on non-English systems without broadening the commands that may request UAC. +- 기존 구현 및 제약 조건: Access-denied classification parsed English and German stderr. Chinese OEM output decoded as UTF-8 became mojibake, so the fixed scheduler-create failure lost its machine marker and the dashboard could not select its existing elevation transaction. +- 검토한 주요 대안: Add translations and code-page decoders; elevate every scheduler failure; always launch installation elevated; or combine a native effective-token probe with the already fixed command shape and exit status. +- 선택한 방식: Preserve text detection, then use the native token probe only for status-1 creation of the owned `opencodex-proxy` XML task. Unknown probe results fail closed. +- 다른 대안 대신 이 방식을 선택한 이유: Windows localization and OEM code pages are open-ended, while the token state and owned command shape are stable security signals already bounded by the elevated transaction protocol. +- 장점, 단점 및 영향: Non-English users receive stable guidance and dashboard UAC recovery. A non-permission status-1 failure from the exact owned command may be retried once elevated, but foreign operations cannot cross the elevation boundary and the elevated transaction still fails closed. diff --git a/structure/decisions/ADR-0077-startup-safety.md b/structure/decisions/ADR-0077-startup-safety.md new file mode 100644 index 0000000000..4bf4b36d65 --- /dev/null +++ b/structure/decisions/ADR-0077-startup-safety.md @@ -0,0 +1,12 @@ +# ADR-0077 — Startup safety + +- Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#startup-safety) + +## Decision record + +- 목적과 의도: Prevent a crashed dashboard update worker from permanently blocking every later update. +- 기존 구현 및 제약 조건: The job file was written before spawn, the returned PID was not persisted, and active status had no liveness or freshness check. +- 검토한 주요 대안: Require manual deletion; expire all jobs by age; or persist PID and use age only for legacy no-PID records. +- 선택한 방식: Persist and verify PID liveness, with a ten-minute fallback only for legacy records. +- 다른 대안 대신 이 방식을 선택한 이유: It recovers known-dead workers promptly without allowing a second installer beside a long-running live worker. +- 장점, 단점 및 영향: New jobs self-recover after worker death and spawn failures become visible; legacy crashes may remain blocked for up to ten minutes. diff --git a/structure/decisions/ADR-0078-usage-accounting.md b/structure/decisions/ADR-0078-usage-accounting.md new file mode 100644 index 0000000000..0b0910719b --- /dev/null +++ b/structure/decisions/ADR-0078-usage-accounting.md @@ -0,0 +1,12 @@ +# ADR-0078 — Usage accounting + +- Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#usage-accounting) + +## Decision record + +- 목적과 의도: Explain missing main-account quota without confusing a working login with a successful WHAM read. +- 기존 구현 및 제약 조건: HTTP failures and body/transport exceptions returned identical null metadata; existing authentication and freshness policy must remain unchanged. +- 검토한 주요 대안: Copy raw errors, infer plan/quota, reuse stale evidence, or add a bounded diagnostic outcome. +- 선택한 방식: Carry a non-persisted fixed category and optional numeric HTTP status through the existing management and CLI read paths. +- 다른 대안 대신 이 방식을 선택한 이유: It gives reporters actionable evidence without disclosing payloads, changing permissions, or introducing another cache. +- 장점, 단점 및 영향: Main-account failures become distinguishable; root-cause repair and pool diagnostics remain separate work, and clients must tolerate an absent field. diff --git a/structure/decisions/ADR-0079-usage-accounting.md b/structure/decisions/ADR-0079-usage-accounting.md new file mode 100644 index 0000000000..9592887287 --- /dev/null +++ b/structure/decisions/ADR-0079-usage-accounting.md @@ -0,0 +1,12 @@ +# ADR-0079 — Usage accounting + +- Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#usage-accounting) + +## Decision record + +- 목적과 의도: Keep dashboard and management requests responsive as `usage.jsonl` grows. +- 기존 구현 및 제약 조건: The append-only JSONL file remains the durable source of truth and may be truncated or replaced. A tail-only byte/row bound kept memory finite but made historical totals incomplete on busy installations; arbitrary in-place historical edits cannot be detected without rereading the prefix. +- 검토한 주요 대안: Raise the byte/row caps, retain normalized rows, maintain a second database, or stream the complete ledger into compact accumulators and cache only revision-keyed summaries. +- 선택한 방식: Stream the complete ledger in fixed 1 MiB chunks for a cold rebuild, retain only compact aggregate state plus an LF/digest checkpoint, fold verified append suffixes atomically, share concurrent work, yield during parsing, and poll usage separately at a slower cadence. +- 다른 대안 대신 이 방식을 선택한 이유: It restores complete historical aggregation without making correctness depend on an operator-sized read limit, retaining every parsed row, or introducing a second persistence format. +- 장점, 단점 및 영향: Unchanged queries are cheap, normal refreshes read only appended bytes, and memory stays bounded. Cold starts and explicit invalidations still consume file-size-proportional IO/CPU. A same-inode historical rewrite outside the trailing checkpoint requires replacement, truncation, or restart to force that cold rebuild. diff --git a/structure/decisions/ADR-0080-github-pages.md b/structure/decisions/ADR-0080-github-pages.md new file mode 100644 index 0000000000..31b08df6ed --- /dev/null +++ b/structure/decisions/ADR-0080-github-pages.md @@ -0,0 +1,12 @@ +# ADR-0080 — GitHub Pages + +- Contract owner: [ops/docs-and-release.md](../ops/docs-and-release.md#github-pages) + +## Decision record + +- 목적과 의도: Serve the public documentation from the memorable first-party `opencodex.me` domain. +- 기존 구현 및 제약 조건: The project Pages site was built for `lidge-jun.github.io/opencodex`, so Astro emitted a `/opencodex` base path that returns 404 under a root custom domain. +- 검토한 주요 대안: Keep the GitHub project URL as canonical; redirect the custom domain through Cloudflare; configure the custom domain directly on GitHub Pages and build for the domain root. +- 선택한 방식: Keep GitHub Actions Pages hosting, configure `opencodex.me` as the repository custom domain, publish root-relative assets and routes, and retain the default GitHub URL only as GitHub's automatic redirect. +- 다른 대안 대신 이 방식을 선택한 이유: Direct Pages hosting preserves the existing deployment and HTTPS lifecycle without adding a second proxy or redirect service. +- 장점, 단점 및 영향: Public links and canonical metadata become stable and branded. DNS and the Pages custom-domain setting are now deployment dependencies, and old hardcoded `/opencodex` links must not be reintroduced. diff --git a/structure/decisions/ADR-0081-container-deployment-recipe.md b/structure/decisions/ADR-0081-container-deployment-recipe.md new file mode 100644 index 0000000000..6c0d8b8c3a --- /dev/null +++ b/structure/decisions/ADR-0081-container-deployment-recipe.md @@ -0,0 +1,12 @@ +# ADR-0081 — Container deployment recipe + +- Contract owner: [ops/docs-and-release.md](../ops/docs-and-release.md#container-deployment-recipe) + +## Decision record + +- 목적과 의도: Document a reproducible container topology without silently creating an official image channel. +- 기존 구현 및 제약 조건: The documentation recipe was not executable from the repository root, file-backed Compose secret ownership varies by implementation, and no registry workflow, scanner, SBOM/signing chain, or image rollback policy exists. +- 검토한 주요 대안: Publish an official image; keep only copied documentation snippets; ship a maintained source recipe with a volume-backed stdin bootstrap. +- 선택한 방식: Maintain the root source-build recipe, persist the owner-only token in the state volume, publish only `10100`, and leave registry publication out of scope. +- 다른 대안 대신 이 방식을 선택한 이유: A runnable source recipe can be tested and reviewed without claiming provenance and operational controls the project does not provide. +- 장점, 단점 및 영향: Compose users get a reproducible non-root deployment and safe first-run secret path; operators still own image builds, upgrades, external TLS/tailnet management, and rollout policy. diff --git a/structure/decisions/ADR-0082-windows-service-wrapper-and-incomplete-updates.md b/structure/decisions/ADR-0082-windows-service-wrapper-and-incomplete-updates.md new file mode 100644 index 0000000000..75c6ff2105 --- /dev/null +++ b/structure/decisions/ADR-0082-windows-service-wrapper-and-incomplete-updates.md @@ -0,0 +1,12 @@ +# ADR-0082 — Windows service wrapper and incomplete updates + +- Contract owner: [ops/docs-and-release.md](../ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates) + +## Decision record + +- 목적과 의도: Prevent a failed npm replacement from making the Task Scheduler wrapper retry missing package files forever. +- 기존 구현 및 제약 조건: The wrapper deliberately restarts a proxy after runtime crashes, but an absent baked Bun or CLI path cannot recover inside that process. Current updater preflight and stop-first behavior reduce replacement risk but do not provide a transactional restore of npm's package tree and global launchers. +- 검토한 주요 대안: Keep unconditional five-second retries, add a generic crash ceiling, restore npm directories in-place, or classify only proven missing executable paths as terminal. +- 선택한 방식: Check the baked Bun and CLI paths before every spawn; log one actionable incomplete-install message and exit with code 3 when either is absent. Preserve the existing retry loop for a child that actually launched and then failed. +- 다른 대안 대신 이 방식을 선택한 이유: A generic retry ceiling can stop a service after unrelated intermittent crashes, while copying a package directory without matching npm shims, ownership, and lock guarantees is not a safe rollback. +- 장점, 단점 및 영향: File-less package skeletons no longer produce unbounded service logs or restart churn. The wrapper still recovers ordinary proxy crashes, but repairing an incomplete npm install remains an explicit reinstall plus `ocx service repair` operation until a verified staged-update design exists. diff --git a/structure/decisions/ADR-0083-maintenance-governance.md b/structure/decisions/ADR-0083-maintenance-governance.md new file mode 100644 index 0000000000..6591b3c167 --- /dev/null +++ b/structure/decisions/ADR-0083-maintenance-governance.md @@ -0,0 +1,12 @@ +# ADR-0083 — Maintenance governance + +- Contract owner: [ops/docs-and-release.md](../ops/docs-and-release.md#maintenance-governance) + +## Decision record + +- 목적과 의도: Make project ownership and review authority discoverable without exposing credentials or treating a documentation file as an access-control mechanism. +- 기존 구현 및 제약 조건: Contribution and security docs referred to maintainers generically, while the repository had no maintainer roster or CODEOWNERS policy. GitHub permissions can change independently of the source tree. +- 검토한 주요 대안: Keep the roster only in GitHub settings; introduce a larger standalone governance charter; list raw GitHub permission levels in the repository. +- 선택한 방식: Add a concise maintainer roster and merge policy, use CODEOWNERS for review routing, and keep actual permission state authoritative in GitHub settings. +- 다른 대안 대신 이 방식을 선택한 이유: A two-maintainer project needs clear ownership and sensitive-path review rules but does not yet need a separate governance framework. +- 장점, 단점 및 영향: Contributors can identify reviewers and merge expectations directly from the repository. The roster must be updated when responsibilities change, and CODEOWNERS still requires branch-protection configuration to enforce approvals. diff --git a/structure/decisions/ADR-0084-public-provider-contract.md b/structure/decisions/ADR-0084-public-provider-contract.md new file mode 100644 index 0000000000..fea0542785 --- /dev/null +++ b/structure/decisions/ADR-0084-public-provider-contract.md @@ -0,0 +1,12 @@ +# ADR-0084 — Public provider contract + +- Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#public-provider-contract) + +## Decision record + +- 목적과 의도: Let public Responses clients use the Codex-login route without one unsupported prompt-cache extension failing the whole turn. +- 기존 구현 및 제약 조건: Parsing already preserves unknown top-level fields in `_rawBody`, and the canonical backend rejects `prompt_cache_options`; API-key and custom providers may accept the same field. +- 검토한 주요 대안: Add the field to the Zod schema; strip it for every Responses provider; translate it to a legacy retention hint; remove it only at the canonical destination boundary. +- 선택한 방식: Keep parser passthrough unchanged and strip the caller field only after `isCanonicalOpenAiForwardProvider` succeeds. +- 다른 대안 대신 이 방식을 선택한 이유: Schema admission does not change `_rawBody`, global stripping would remove supported public API behavior, and translation would invent cache policy. +- 장점, 단점 및 영향: VS Code and other public-shape clients avoid the canonical backend rejection while API-key/custom routes retain their wire options; canonical callers cannot request this cache option through OpenCodex. diff --git a/structure/decisions/ADR-0085-public-provider-contract.md b/structure/decisions/ADR-0085-public-provider-contract.md new file mode 100644 index 0000000000..bf06876a16 --- /dev/null +++ b/structure/decisions/ADR-0085-public-provider-contract.md @@ -0,0 +1,22 @@ +# ADR-0085 — Public provider contract + +- Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#public-provider-contract) + +## Decision record + +- 목적과 의도: Keep Desktop reconnects on the account selected for the App task without persisting + or exposing its session and thread identifiers. +- 기존 구현 및 제약 조건: Pool affinity used only `x-codex-parent-thread-id`; Desktop requests can + omit it while stable `session-id` and `thread-id` headers remain available. Exact account + selectors must stay outside automatic Pool affinity. +- 검토한 주요 대안: Leave reconnects unbound, persist a plain hash, bind from either header alone, + delete App turn metadata, or derive one process-local key from the complete pair. +- 선택한 방식: Preserve the parent-thread key when present; otherwise HMAC the two bounded headers + under a random per-process key and carry that opaque value through selection, subagent preview, + and outcome handling. +- 다른 대안 대신 이 방식을 선택한 이유: A complete pair avoids weak partial identities, a + process-local HMAC prevents durable correlation or dictionary recovery, and no upstream metadata + needs to be mutated before the first-403 cause is proven. +- 장점, 단점 및 영향: Reconnects stop rotating among Pool accounts and failure accounting clears + the correct binding. Affinity intentionally resets on process restart, and requests missing either + component retain the prior unbound behavior. diff --git a/structure/decisions/ADR-0086-public-provider-contract.md b/structure/decisions/ADR-0086-public-provider-contract.md new file mode 100644 index 0000000000..78da4d1b6f --- /dev/null +++ b/structure/decisions/ADR-0086-public-provider-contract.md @@ -0,0 +1,18 @@ +# ADR-0086 — Public provider contract + +- Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#public-provider-contract) + +## Decision record + +- 목적과 의도: Keep an explicit healthy main selection from being replaced by an exhausted stored + account merely because the client supplied main through a request-owned keyring bearer. +- 기존 구현 및 제약 조건: Request-owned credentials are deliberately excluded from stored-account + entitlement discovery, but shared-state preservation interpreted that exclusion as a dead main login. +- 검토한 주요 대안: Persist the caller credential, read the physical main token for identity, ignore + the manual pin, or validate the caller independently before stored-Pool selection. +- 선택한 방식: Use only the effective pin, pause state, cached quota, and the caller credential's own + gated-model check; synthesize shared-state liveness only while main stays request-ineligible. +- 다른 대안 대신 이 방식을 선택한 이유: It preserves credential isolation and explicit operator + intent without admitting an unentitled model or binding an ephemeral bearer into durable Pool state. +- 장점, 단점 및 영향: Healthy main pins survive keyring requests and model-only detours; cached quota + remains the only proactive drain evidence available without crossing the physical credential boundary. diff --git a/structure/decisions/ADR-0087-model-and-wire-identity.md b/structure/decisions/ADR-0087-model-and-wire-identity.md new file mode 100644 index 0000000000..6e09ebda41 --- /dev/null +++ b/structure/decisions/ADR-0087-model-and-wire-identity.md @@ -0,0 +1,19 @@ +# ADR-0087 — Model and wire identity + +- Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#model-and-wire-identity) + +## Decision record + +- 목적과 의도: Preserve the account-gated Daybreak UX while avoiding shard-dependent selector + rejection and the unsupported prompt-cache retention parameter. +- 기존 구현 및 제약 조건: The authenticated roster grants Daybreak, but live successful + responses report `gpt-5.6-sol`; the selector can still fail eight consecutive times. +- 검토한 주요 대안: Increase retries indefinitely, hide Daybreak entirely, or canonicalize only + the credential-bearing wire model after entitlement selection. +- 선택한 방식: Keep Daybreak for visibility and account authorization, then send the stable + serving id and remove only the unsupported optional retention hint. +- 다른 대안 대신 이 방식을 선택한 이유: It keeps fail-closed entitlement checks and avoids + unbounded duplicate requests while preserving the user-facing model choice. +- 장점, 단점 및 영향: Requests become deterministic and cheaper; this relies on the serving id + observed from successful upstream responses and must be revisited if the roster exposes a + first-class wire id later. diff --git a/structure/decisions/ADR-0088-model-and-wire-identity.md b/structure/decisions/ADR-0088-model-and-wire-identity.md new file mode 100644 index 0000000000..01a8ca2719 --- /dev/null +++ b/structure/decisions/ADR-0088-model-and-wire-identity.md @@ -0,0 +1,21 @@ +# ADR-0088 — Model and wire identity + +- Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#model-and-wire-identity) + +## Decision record + +- 목적과 의도: Prevent account-gated native models from being shown or dispatched through a + ChatGPT account that upstream does not authorize. +- 기존 구현 및 제약 조건: A static global Daybreak row solved clean-install discovery for + entitled accounts, but Pool accounts can hold different grants and Codex's injected catalog does + not refresh itself. +- 검토한 주요 대안: Infer grants from plan labels, learn only from prompt failures, bind Daybreak + permanently to main, or rewrite the wire id to `gpt-5.6-sol`. +- 선택한 방식: Share bounded authenticated per-account model-roster evidence between catalog sync, + `/v1/models`, and Pool auth selection. +- 다른 대안 대신 이 방식을 선택한 이유: Plan labels and account position do not prove a grant; + failure-only learning wastes a turn; permanent main binding rejects valid secondary grants; wire + rewriting changes the requested product identity. +- 장점, 단점 및 영향: Entitled accounts retain clean-install discovery while unentitled accounts + never receive the gated dispatch. A cold gated request may pay one bounded roster fetch per + account, and an unavailable discovery temporarily hides the model rather than guessing. diff --git a/structure/decisions/ADR-0089-process-local-affinity-diagnostics.md b/structure/decisions/ADR-0089-process-local-affinity-diagnostics.md new file mode 100644 index 0000000000..7b11661001 --- /dev/null +++ b/structure/decisions/ADR-0089-process-local-affinity-diagnostics.md @@ -0,0 +1,12 @@ +# ADR-0089 — Process-local affinity diagnostics + +- Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#process-local-affinity-diagnostics) + +## Decision record + +- 목적과 의도: Identify which combined Codex affinity values survive a Plus-to-K12 credential substitution without collecting private thread or account data. +- 기존 구현 및 제약 조건: Pool auth intentionally copies the curated caller metadata and replaces only authorization plus chatgpt-account-id. Individual header probes did not reproduce the workspace denial, while raw captures would expose account-bound identifiers. +- 검토한 주요 대안: Delete all affinity metadata; log raw values; persist ordinary hashes; perform automatic header-ablation retries; or emit process-local keyed equality evidence only when provider debug is enabled. +- 선택한 방식: Emit bounded pre-stream diagnostics with a random per-process HMAC key, a fixed non-credential header allowlist, known turn-field summaries, and no request mutation. +- 다른 대안 대신 이 방식을 선택한 이유: Equality across two requests in one run is enough to narrow the incompatible combination; process-local HMACs prevent durable correlation and make offline guessing useless, while observation-only capture cannot change production semantics. +- 장점, 단점 및 영향: Maintainers can compare a Plus success and exact-K12 denial safely. Tags cannot be compared across restarts, and the diagnostic does not itself identify an upstream policy rule or fix the rejection. diff --git a/structure/decisions/ADR-0090-hermes-model-capabilities.md b/structure/decisions/ADR-0090-hermes-model-capabilities.md new file mode 100644 index 0000000000..c9287ab171 --- /dev/null +++ b/structure/decisions/ADR-0090-hermes-model-capabilities.md @@ -0,0 +1,12 @@ +# ADR-0090 — Hermes Model Capabilities + +- Contract owner: [clients/integrations.md](../clients/integrations.md#hermes-model-capabilities) + +## Decision record + +- 목적과 의도: Preserve catalog-backed image routing when Hermes uses OpenCodex as a custom provider. +- 기존 구현 및 제약 조건: A string array preserved model selection but normalized to empty metadata in Hermes, while OpenCodex has authoritative text/image/audio facts but no video fact. +- 검토한 주요 대안: Keep the array; mark every model vision-capable; infer video from model names; emit a per-model metadata map from declared modalities. +- 선택한 방식: Emit a stable per-model map and include only the `supports_vision` boolean that the catalog can prove. +- 다른 대안 대신 이 방식을 선택한 이유: The map is the Hermes-supported capability boundary, while guesses would misroute attachments or advertise unsupported video. +- 장점, 단점 및 영향: Vision-capable custom models route correctly and text-only rows stay explicit; unknown rows remain unknown, and video routing waits for authoritative source metadata. diff --git a/structure/decisions/ADR-0091-ownership-axes.md b/structure/decisions/ADR-0091-ownership-axes.md new file mode 100644 index 0000000000..0b0cd24985 --- /dev/null +++ b/structure/decisions/ADR-0091-ownership-axes.md @@ -0,0 +1,12 @@ +# ADR-0091 — Ownership Axes + +- Contract owner: [clients/integrations.md](../clients/integrations.md#ownership-axes) + +## Decision record + +- 목적과 의도: Treat JSON object-key order as formatting while retaining safe ownership proof across upgrades. +- 기존 구현 및 제약 조건: Existing records contain order-sensitive hashes, and replacing their hash format in place would make every installed integration look foreign-edited. +- 검토한 주요 대안: Replace the hash format globally; ignore key order only for ZCode; store a semantic companion beside the existing exact hash. +- 선택한 방식: Preserve the exact hashes for compatibility and add object-key-independent semantic companions to new records, with a bounded desired-contribution fallback for old records. +- 다른 대안 대신 이 방식을 선택한 이유: A global replacement cannot validate old records, while a ZCode-only exception would leave the shared JSON ownership rule inconsistent. +- 장점, 단점 및 영향: New records tolerate key normalization even across catalog refreshes; old records recover when the recorded catalog is still reconstructible, and ambiguous old-record drift remains fail-closed. diff --git a/structure/decisions/ADR-0092-zcode-runtime-metadata.md b/structure/decisions/ADR-0092-zcode-runtime-metadata.md new file mode 100644 index 0000000000..69df34425d --- /dev/null +++ b/structure/decisions/ADR-0092-zcode-runtime-metadata.md @@ -0,0 +1,12 @@ +# ADR-0092 — ZCode Runtime Metadata + +- Contract owner: [clients/integrations.md](../clients/integrations.md#zcode-runtime-metadata) + +## Decision record + +- 목적과 의도: Allow ZCode's documented runtime normalization without turning genuine provider or connection edits into refreshable drift. +- 기존 구현 및 제약 조건: The classifier hashed the whole `provider.opencodex` fragment. That was safe for ordinary JSON clients but made every ZCode save a permanent foreign edit. Refresh and disable both depend on the same ownership proof. +- 검토한 주요 대안: Ignore all model metadata; compare only the provider connection envelope; hard-code a ZCode branch directly in `state.ts`; store explicit operation-scoped mutable paths and a protected fingerprint. +- 선택한 방식: Keep the strict generated contribution hash, add a separate protected fingerprint, and persist the exact ZCode-derived paths with each ownership record through a client-scoped policy module. +- 다른 대안 대신 이 방식을 선택한 이유: Ignoring all model metadata would allow user model edits to be overwritten. Comparing only the connection envelope would stop protecting model membership and capabilities. A state-only special case would disagree with writer behavior. Operation-scoped paths preserve the original grant across later catalog changes. +- 장점, 단점 및 영향: Normal ZCode saves become refreshable, connection edits still fail closed, and later catalog refreshes remain possible. Legacy records with simultaneous catalog drift still require a conservative manual recovery because the old schema did not store enough evidence. diff --git a/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md b/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md new file mode 100644 index 0000000000..7123efe652 --- /dev/null +++ b/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md @@ -0,0 +1,24 @@ +# ADR-0093 — Moonshot `$ref`-with-siblings normalization + +- Contract owner: [adapters/registry.md](../adapters/registry.md#moonshot-ref-with-siblings-normalization) + +## Decision record + +- 목적과 의도: Codex가 내보내는 `$ref` + 형제 키워드 스키마를 Moonshot이 받아들이는 형태로 + 바꾸되, 도구가 실제로 요구하는 제약을 잃지 않는다. +- 기존 구현 및 제약 조건: JSON Schema 2020-12에서 `$ref`는 in-place applicator라 형제 + 키워드와 함께 적용된다. Moonshot은 이를 거부하므로 참조 대상을 노드 아래로 인라인해야 하고, + 재귀 스키마는 유한해야 하며, 어댑터는 요청 경로에 있으므로 지연이 그대로 사용자에게 간다. +- 검토한 주요 대안: (1) 형제 키워드를 버리고 순수 `$ref`만 남긴다. (2) 참조를 인라인하되 + 형제 키워드가 대상을 덮어쓴다. (3) 인라인하되 집합형 어서션은 합집합으로 합치고 나머지는 + 좁히는 쪽이 이긴다. (4) `allOf`로 감싼다. +- 선택한 방식: (3). `required`는 합집합, `properties`는 병합, 나머지 키워드는 노드가 이긴다. + 해석 불가능한 참조는 순수 `$ref`로 남기고, 깊이·노드·확장 예산을 각각 둔다. +- 다른 대안 대신 이 방식을 선택한 이유: (1)은 노드가 좁힌 제약을 통째로 버린다. (2)는 대상이 + 요구하던 `a`를 형제의 `b`가 덮어써서, 양쪽 어느 쪽도 요청하지 않은 더 약한 계약을 조용히 + 내보냈다 — 리뷰가 지적한 정확한 결함이다. (4)는 Moonshot이 `allOf`를 어떻게 다루는지 + 확인된 근거가 없어 검증되지 않은 가정을 계약으로 만든다. +- 장점, 단점 및 영향: 도구 계약이 보존된 채 Moonshot을 통과한다. 인라인은 대상을 복제하므로 + 큰 정의를 여러 노드가 참조하면 출력이 커질 수 있고, 예산이 소진되면 해당 노드는 빈 객체나 + 순수 `$ref`로 닫힌다 — 약해진 스키마를 절반만 내보내는 것보다 낫다. Moonshot 계열 + `openai-chat` baseUrl에만 적용되고 다른 provider는 손대지 않는다. diff --git a/structure/decisions/ADR-0094-canonical-forward-continuation-extensions.md b/structure/decisions/ADR-0094-canonical-forward-continuation-extensions.md new file mode 100644 index 0000000000..dc5af93122 --- /dev/null +++ b/structure/decisions/ADR-0094-canonical-forward-continuation-extensions.md @@ -0,0 +1,12 @@ +# ADR-0094 — Canonical forward continuation extensions + +- Contract owner: [adapters/compatibility-contracts.md](../adapters/compatibility-contracts.md#canonical-forward-continuation-extensions) + +## Decision record + +- 목적과 의도: Preserve Posit Assistant tool continuation semantics while preventing canonical ChatGPT Codex forwarding from sending client-only cache markers or unresolvable stored-item references. +- 기존 구현 및 제약 조건: The existing `store: false` sanitizer removed item ids but left `item_reference` shells, and no bounded pass recognized markers nested inside content; tool `call_id` pairing and reasoning effort are continuation-critical. +- 검토한 주요 대안: Strip the extensions for every Responses destination; delete only reference ids; expand references from local state; or normalize only the canonical forward destination with bounded recursive marker removal. +- 선택한 방식: Apply the bounded marker pass only to canonical forward `input`, and omit `item_reference` rows only when `store` is exactly `false`. +- 다른 대안 대신 이 방식을 선택한 이유: Public and custom gateways may implement these extensions, while id-only deletion creates an invalid reference shell and local expansion would invent unavailable persistence authority. +- 장점, 단점 및 영향: Posit continuations retain tool pairing and reasoning controls without widening public-provider behavior; hostile nesting fails closed to the original input, so an over-limit request may still be rejected upstream rather than partially rewritten. diff --git a/structure/decisions/ADR-0095-canonical-forward-continuation-extensions.md b/structure/decisions/ADR-0095-canonical-forward-continuation-extensions.md new file mode 100644 index 0000000000..abeac4d4a9 --- /dev/null +++ b/structure/decisions/ADR-0095-canonical-forward-continuation-extensions.md @@ -0,0 +1,12 @@ +# ADR-0095 — Canonical forward continuation extensions + +- Contract owner: [adapters/compatibility-contracts.md](../adapters/compatibility-contracts.md#canonical-forward-continuation-extensions) + +## Decision record + +- 목적과 의도: Make provider compatibility explicit and machine-readable before larger routing or Responses refactors. +- 기존 구현 및 제약 조건: Adapter-wide conformance tests already protect tool translation, and Compatibility Lab owns broader protocol evidence, but neither publishes an exact provider/destination/auth/model claim table. Lab must remain outside the ordinary request import graph. +- 검토한 주요 대안: Infer capabilities directly from registry flags; publish prose only; add a broad all-provider matrix immediately; introduce the schema with one exact fixture-backed subject. +- 선택한 방식: Add a passive versioned schema and one exact `openai`/canonical Codex URL/forward/`gpt-5.6-sol` manifest whose claims reference assertion-level fixtures executed against the production adapter. +- 다른 대안 대신 이 방식을 선택한 이유: Registry flags do not capture transformations such as local continuation expansion or orphan-output degradation. A broad first matrix would turn unverified assumptions into public promises. +- 장점, 단점 및 영향: The first contract is small but trustworthy and can feed future CLI/GUI surfaces. Coverage expands only as fixtures are added; no request behavior changes in this slice. diff --git a/structure/decisions/ADR-0096-z-ai-quota-destination-ownership.md b/structure/decisions/ADR-0096-z-ai-quota-destination-ownership.md new file mode 100644 index 0000000000..2084395269 --- /dev/null +++ b/structure/decisions/ADR-0096-z-ai-quota-destination-ownership.md @@ -0,0 +1,12 @@ +# ADR-0096 — Z.ai quota destination ownership + +- Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#zai-quota-destination-ownership) + +## Decision record + +- 목적과 의도: Restore quota reads for documented international Anthropic and Responses bases without changing their inference configuration. +- 기존 구현 및 제약 조건: Admission omitted both bases; a separate monitor ternary treated all other admitted bases as CN. +- 검토한 주요 대안: Add the same paths to two lists, accept any path on either host, or share one exact mapping. +- 선택한 방식: Share one base-to-monitor mapping and preserve the existing CN allowlist. +- 다른 대안 대신 이 방식을 선택한 이유: A single mapping prevents new international admission from silently selecting the CN authentication scheme, without admitting unrelated pay-as-you-go paths. +- 장점, 단점 및 영향: No config migration or inference change; new documented endpoints still require an explicit reviewed mapping entry. Quota-consumption differences are not inferred from adapter choice. diff --git a/structure/07_design-methodology.md b/structure/design-methodology.md similarity index 94% rename from structure/07_design-methodology.md rename to structure/design-methodology.md index 66f736c216..bcaf940d72 100644 --- a/structure/07_design-methodology.md +++ b/structure/design-methodology.md @@ -1,4 +1,4 @@ -# 07 — Design Methodology for New Surfaces +# Design Methodology For New Surfaces When adding or redesigning a GUI page, CLI wizard, or user-facing flow in opencodex, follow the PABCD Catalog Discovery stage ordering (CATALOG-DESIGN-FIRST-01): @@ -18,7 +18,7 @@ interview engine. The rule stands on its own; it does not depend on an external The surfaces below are examples chosen to show the design direction, not an inventory; the current surface list lives in `gui/src/app-routing.ts` and -[`05_gui-and-management-api.md`](05_gui-and-management-api.md). +[`gui-and-management-api.md`](gui-and-management-api.md). | Surface | Current design | Notes | |---|---|---| diff --git a/structure/05_gui-and-management-api.md b/structure/gui-and-management-api.md similarity index 81% rename from structure/05_gui-and-management-api.md rename to structure/gui-and-management-api.md index f9d4fb79ec..c6b35fbad8 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,4 +1,4 @@ -# GUI And Management API SOT +# GUI And Management API ## Dashboard serving @@ -47,13 +47,7 @@ contains no provider object, API key, OAuth value, custom header, reusable manag credential, or config digest. Both the proof and reload request use the direct local transport so environment HTTP proxies cannot observe or fabricate the exchange. -[Decision Log] -- 목적과 의도: Keep a lower-privileged local process from collecting the management bearer by impersonating `/healthz` on an unused port. -- 기존 구현 및 제약 조건: Liveness must remain public and backward-compatible, but its service string and reported PID are assertions made by the listener itself. -- 검토한 주요 대안: Require only a runtime source and non-null PID; stop showing account health; authenticate the listener with a protected per-process challenge secret. -- 선택한 방식: Store a random secret in the mode-protected runtime record and use method/path/PID/port-bound HMAC capabilities for the two CLI health reads, so the CLI sends no reusable Authorization value. -- 다른 대안 대신 이 방식을 선택한 이유: PID and command-line checks are not cryptographic listener identity, while removing live account health would regress diagnostics unnecessarily. -- 장점, 단점 및 영향: The long-lived token never reaches a listener without the runtime secret; an old running proxy remains visible but cannot provide detailed CLI account health until restarted on the new version. +> Decision record: [ADR-0073](decisions/ADR-0073-authentication-boundaries.md) Management authentication never has a loopback bypass. If no management credential is available, or management token creation, validation, or permission hardening fails, every `/api/*` request returns @@ -124,7 +118,7 @@ this document owns is which module holds which area and what invariant that area | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | -| OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | +| OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | @@ -137,19 +131,13 @@ this document owns is which module holds which area and what invariant that area | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | -| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply`, `GET /api/claude-desktop/status`, `GET/PUT /api/claude-code`. Apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`04_transports-and-sidecars.md`](04_transports-and-sidecars.md)). | +| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply`, `GET /api/claude-desktop/status`, `GET/PUT /api/claude-code`. Apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. | | Codex accounts | `src/codex/auth-api.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Per-account quota activation uses the existing `GET/PUT /api/settings` surface and `src/codex/quota-auto-refresh.ts`, keeping scheduled spending separate from credential/authentication mutation. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. New-account config commits add UI-managed selector bindings in the same config save; deletion deliberately retains existing bindings for fail-closed exact routing and re-add stability. Account mutations request catalog convergence only after config durability and expose only the boolean `catalogRefreshPending` completion projection. | | Sidebar | `src/server/management/sidebar-routes.ts` — `GET/POST /api/github/star` and `GET /api/update/badge`. Sidebar state is cosmetic; a failed fetch degrades silently. | | Logs | `src/server/management/logs-usage-routes.ts` — `GET /api/logs`, `GET /api/claude/inbound-debug`, and `GET /api/debug/injection-logs` join the debug streams described above. | -[Decision Log] -- 목적과 의도: Distinguish a healthy process from a continuation spill writer that is repeatedly failing, especially on Windows where the ACL publication lane is asynchronous. -- 기존 구현 및 제약 조건: `/healthz` intentionally reports liveness only, while `spillWriteFailures` was cumulative and discarded the failure class, event time, and recovery boundary. -- 검토한 주요 대안: Make `/healthz` fail on a spill error; publish raw error messages; expose a fixed classified health projection only on the authenticated memory route. -- 선택한 방식: Keep liveness unchanged and add a consecutive streak, fixed error class, and last failure/success timestamps to the existing authenticated response-state metrics. -- 다른 대안 대신 이 방식을 선택한 이유: One failed cache demotion must not restart or remove an otherwise serving proxy, and raw filesystem errors can disclose user paths while still failing to show whether the next write recovered. -- 장점, 단점 및 영향: Operators can identify accumulating failures and same-process recovery without sensitive text. The status is process-local and resets to `initial` on restart, so historical diagnosis still requires external metric collection. +> Decision record: [ADR-0074](decisions/ADR-0074-api-ownership.md) Provider writes must not round-trip masked API keys as real secrets. Dashboard actions that change model visibility or subagent selection should trigger catalog/cache sync behavior through the server @@ -158,7 +146,7 @@ path that owns it. The UI must show one provider card and one Models group for Codex-login OpenAI, describe Pool and Direct accurately, and keep the main account inside Pool. Public model state keeps virtual Pro ids even though transport logs may additionally report the resolved base model. Detailed rules live in -[`08_openai-provider-tiers.md`](08_openai-provider-tiers.md). +[`openai-tiers.md`](providers/openai-tiers.md). User aliases are display metadata only. Codex pool aliases live on `CodexAccount`, OAuth aliases on `ProviderAccount`, and API-key aliases reuse the existing key `label`; account ids, credential @@ -186,7 +174,7 @@ is routing metadata that Pool selection consults, it lives in config rather than `__main__` Desktop login can carry one, and the alias route's rejection of `__main__` would be wrong for it. The matching CLI is `ocx account priority []`, reading the current order when the value is omitted. Ordering invariants live in -[`08_openai-provider-tiers.md`](08_openai-provider-tiers.md). +[`openai-tiers.md`](providers/openai-tiers.md). ## Sidebar stop button @@ -277,40 +265,16 @@ until Windows returns approval or cancellation; other proxy requests keep runnin Existing or conflicting registrations stay on the older fail-closed path because deleting or replacing them cannot be called a rollback without an exact prior-registration snapshot. -```text -[Decision Log] -- 목적과 의도: Keep a refused fresh Windows service install from stopping a working proxy and removing managed Codex routing. -- 기존 구현 및 제약 조건: The generic installer stopped service managers and the standalone proxy before the first scheduler create attempt; the Dashboard UAC path depended on assets produced by that already-destructive failure. -- 검토한 주요 대안: Reject every non-elevated caller up front, restart and re-inject after failure, snapshot every runtime/config artifact for rollback, or separate registration approval from the destructive commit. -- 선택한 방식: When scheduler absence is proven, create but do not run the owned registration from a temporary XML first; cleanup and canonical asset publication begin only after registration succeeds. -- 다른 대안 대신 이 방식을 선택한 이유: An early rejection breaks Dashboard UAC, while a best-effort restart cannot prove that manager, proxy, and routing state were restored. The two-phase boundary makes denial/cancellation a real pre-commit failure. -- 장점, 단점 및 영향: Fresh-install UAC failure preserves the live proxy and routing. Failures after registration remain explicit partial-install cases, and existing/conflicting scheduler recovery remains conservative until exact prior-state restoration is available. -``` - -```text -[Decision Log] -- 목적과 의도: Make Windows scheduler installation recovery work on non-English systems without broadening the commands that may request UAC. -- 기존 구현 및 제약 조건: Access-denied classification parsed English and German stderr. Chinese OEM output decoded as UTF-8 became mojibake, so the fixed scheduler-create failure lost its machine marker and the dashboard could not select its existing elevation transaction. -- 검토한 주요 대안: Add translations and code-page decoders; elevate every scheduler failure; always launch installation elevated; or combine a native effective-token probe with the already fixed command shape and exit status. -- 선택한 방식: Preserve text detection, then use the native token probe only for status-1 creation of the owned `opencodex-proxy` XML task. Unknown probe results fail closed. -- 다른 대안 대신 이 방식을 선택한 이유: Windows localization and OEM code pages are open-ended, while the token state and owned command shape are stable security signals already bounded by the elevated transaction protocol. -- 장점, 단점 및 영향: Non-English users receive stable guidance and dashboard UAC recovery. A non-permission status-1 failure from the exact owned command may be retried once elevated, but foreign operations cannot cross the elevation boundary and the elevated transaction still fails closed. -``` +> Decision record: [ADR-0075](decisions/ADR-0075-startup-safety.md) + +> Decision record: [ADR-0076](decisions/ADR-0076-startup-safety.md) Dashboard updates persist their detached worker PID before returning success. This lets a later run distinguish a live installer from a worker that crashed. Records created by older versions do not have a PID, so they remain exclusive for a conservative ten-minute window before automatic recovery; operators no longer need to delete `update-job.json` after a dead worker. -```text -[Decision Log] -- 목적과 의도: Prevent a crashed dashboard update worker from permanently blocking every later update. -- 기존 구현 및 제약 조건: The job file was written before spawn, the returned PID was not persisted, and active status had no liveness or freshness check. -- 검토한 주요 대안: Require manual deletion; expire all jobs by age; or persist PID and use age only for legacy no-PID records. -- 선택한 방식: Persist and verify PID liveness, with a ten-minute fallback only for legacy records. -- 다른 대안 대신 이 방식을 선택한 이유: It recovers known-dead workers promptly without allowing a second installer beside a long-running live worker. -- 장점, 단점 및 영향: New jobs self-recover after worker death and spawn failures become visible; legacy crashes may remain blocked for up to ten minutes. -``` +> Decision record: [ADR-0077](decisions/ADR-0077-startup-safety.md) ## UX boundary @@ -338,7 +302,7 @@ single forms, and the shell pattern is the part worth keeping stable: | Subagents | Featured-roster selection workspace (`gui/src/components/subagents-workspace/`). | | Combos | Rail, detail panel, and an add flow (`gui/src/components/ComboWorkspace.tsx`). | | Add provider | Catalog browser plus form and OAuth panes (`gui/src/components/provider-catalog/`, `gui/src/components/AddProviderModal.tsx`). | -| Codex accounts | Account pool cards, add-account flow, switch and reset modals (`gui/src/components/CodexAccountPool.tsx`, `gui/src/components/AddCodexAccountModal.tsx`), plus the generic account-targeting picker opt-in on `gui/src/pages/CodexAuth.tsx`. Add/delete/login completion is projected to one boolean before presentation; pending catalog work is a warning, not a failed account mutation. | +| Codex accounts | Account pool cards, add-account flow, switch and reset modals (`gui/src/components/CodexAccountPool.tsx`, `gui/src/components/AddCodexAccountModal.tsx`), plus the generic account-targeting picker opt-in on `gui/src/pages/codex-set-multiauth.tsx`. Add/delete/login completion is projected to one boolean before presentation; pending catalog work is a warning, not a failed account mutation. | | Dashboard overview | Overview, Providers, and Models tabs at the page level (`gui/src/pages/Dashboard.tsx`), the 30-day token and coverage stats in the overview head (`gui/src/pages/dashboard-overview-head.tsx`), and the effort-cap, injection, maintenance, sidecar, and memory panels below it (`gui/src/pages/dashboard-overview-panels.tsx`). | Rail selection is component-local state today, so a reload returns to the workspace's default @@ -429,13 +393,7 @@ attempts; the generation itself is never serialized or stored in the quota cache The CLI reconstructs the object using a fixed vocabulary and bounded numeric HTTP status, so an unexpected management response cannot add raw upstream material. -[Decision Log] -- 목적과 의도: Explain missing main-account quota without confusing a working login with a successful WHAM read. -- 기존 구현 및 제약 조건: HTTP failures and body/transport exceptions returned identical null metadata; existing authentication and freshness policy must remain unchanged. -- 검토한 주요 대안: Copy raw errors, infer plan/quota, reuse stale evidence, or add a bounded diagnostic outcome. -- 선택한 방식: Carry a non-persisted fixed category and optional numeric HTTP status through the existing management and CLI read paths. -- 다른 대안 대신 이 방식을 선택한 이유: It gives reporters actionable evidence without disclosing payloads, changing permissions, or introducing another cache. -- 장점, 단점 및 영향: Main-account failures become distinguishable; root-cause repair and pool diagnostics remain separate work, and clients must tolerate an absent field. +> Decision record: [ADR-0078](decisions/ADR-0078-usage-accounting.md) `src/usage/log.ts` writes append-only JSONL to `~/.opencodex/usage.jsonl` with file mode `0o600`. An opt-in shadow-call rewrite persists the bounded, redacted original helper model as @@ -491,13 +449,7 @@ remain in the response for compatibility with older GUI and CLI clients. A succe scan reports `false`, `0`, `false`, and `0`; clients must not interpret those fields as evidence that `managementUsageMaxReadBytes` was raised or that a bounded tail was selected. -[Decision Log] -- 목적과 의도: Keep dashboard and management requests responsive as `usage.jsonl` grows. -- 기존 구현 및 제약 조건: The append-only JSONL file remains the durable source of truth and may be truncated or replaced. A tail-only byte/row bound kept memory finite but made historical totals incomplete on busy installations; arbitrary in-place historical edits cannot be detected without rereading the prefix. -- 검토한 주요 대안: Raise the byte/row caps, retain normalized rows, maintain a second database, or stream the complete ledger into compact accumulators and cache only revision-keyed summaries. -- 선택한 방식: Stream the complete ledger in fixed 1 MiB chunks for a cold rebuild, retain only compact aggregate state plus an LF/digest checkpoint, fold verified append suffixes atomically, share concurrent work, yield during parsing, and poll usage separately at a slower cadence. -- 다른 대안 대신 이 방식을 선택한 이유: It restores complete historical aggregation without making correctness depend on an operator-sized read limit, retaining every parsed row, or introducing a second persistence format. -- 장점, 단점 및 영향: Unchanged queries are cheap, normal refreshes read only appended bytes, and memory stays bounded. Cold starts and explicit invalidations still consume file-size-proportional IO/CPU. A same-inode historical rewrite outside the trailing checkpoint requires replacement, truncation, or restart to force that cold rebuild. +> Decision record: [ADR-0079](decisions/ADR-0079-usage-accounting.md) For diagnosing upstream-shape / usage-extraction issues run `ocx debug usage on` (or set `OPENCODEX_USAGE_DEBUG=1` before start). The proxy then writes a rolling debug record per finalized @@ -514,13 +466,7 @@ Responses bases use `api.z.ai` with Bearer authentication. Existing BigModel CN coding Chat and Responses bases use `open.bigmodel.cn` with the raw key. Unsupported bases produce no probe; redirect refusal and quota parsing/cache semantics are unchanged. -[Decision Log] -- 목적과 의도: Restore quota reads for documented international Anthropic and Responses bases without changing their inference configuration. -- 기존 구현 및 제약 조건: Admission omitted both bases; a separate monitor ternary treated all other admitted bases as CN. -- 검토한 주요 대안: Add the same paths to two lists, accept any path on either host, or share one exact mapping. -- 선택한 방식: Share one base-to-monitor mapping and preserve the existing CN allowlist. -- 다른 대안 대신 이 방식을 선택한 이유: A single mapping prevents new international admission from silently selecting the CN authentication scheme, without admitting unrelated pay-as-you-go paths. -- 장점, 단점 및 영향: No config migration or inference change; new documented endpoints still require an explicit reviewed mapping entry. Quota-consumption differences are not inferred from adapter choice. +> Decision record: [ADR-0096](decisions/ADR-0096-z-ai-quota-destination-ownership.md) ## Provider debug logging diff --git a/structure/manifest.json b/structure/manifest.json new file mode 100644 index 0000000000..df35edc7b8 --- /dev/null +++ b/structure/manifest.json @@ -0,0 +1,402 @@ +{ + "version": 2, + "sizeBudgetLines": 600, + "generatedPaths": [ + "gui/dist" + ], + "absentPaths": [ + { + "path": "go/", + "reason": "the Go native-runtime experiment is retired; ops/docs-and-release.md documents that no go/ tree is tracked" + } + ], + "tiers": [ + { + "id": 1, + "name": "Foundation", + "purpose": "What opencodex is, what it owns on disk, and the invariants nothing may break." + }, + { + "id": 2, + "name": "Configuration and catalog", + "purpose": "Persisted config, the Codex home it writes into, and the model catalog it publishes." + }, + { + "id": 3, + "name": "Data planes and transports", + "purpose": "The wire surfaces a client actually talks to." + }, + { + "id": 4, + "name": "Providers and adapters", + "purpose": "Per-vendor contracts and the adapter authority that constructs them." + }, + { + "id": 5, + "name": "Surfaces and clients", + "purpose": "The dashboard, the management API, and third-party client config ownership." + }, + { + "id": 6, + "name": "Operations and process", + "purpose": "Background service, docs, release, and design discipline." + } + ], + "docs": [ + { + "path": "overview.md", + "tier": 1, + "title": "Overview", + "scope": "Product boundary, local state ownership, and the non-negotiable invariants index.", + "documents": [ + "gui/", + "scripts/", + "src/config.ts", + "src/lib/" + ] + }, + { + "path": "runtime.md", + "tier": 1, + "title": "Runtime", + "scope": "Entrypoints, process lifecycle, CLI surface, and provider/adapter selection.", + "documents": [ + "bin/", + "src/adapters/", + "src/chat/", + "src/claude/", + "src/cli.ts", + "src/cli/", + "src/client/", + "src/codex/", + "src/combos/", + "src/compatibility/", + "src/config.ts", + "src/config/", + "src/generated/", + "src/github/", + "src/grok/", + "src/images/", + "src/index.ts", + "src/lab/", + "src/lib/", + "src/oauth/", + "src/providers/", + "src/reasoning-effort.ts", + "src/remote/", + "src/responses/", + "src/router.ts", + "src/server/", + "src/service.ts", + "src/stall-timeout.ts", + "src/storage/", + "src/tray/", + "src/types.ts", + "src/update/", + "src/usage/", + "src/vision/", + "src/web-search/" + ] + }, + { + "path": "config.md", + "tier": 2, + "title": "Config Surface", + "scope": "Persisted config schema, both injection forms, provider validation, and restore.", + "documents": [ + "src/cli/", + "src/codex/", + "src/config.ts", + "src/config/", + "src/types.ts" + ] + }, + { + "path": "codex-home.md", + "tier": 2, + "title": "Codex Home", + "scope": "CODEX_HOME resolution, the files opencodex manages there, and Codex-home diagnostics.", + "documents": [ + "src/codex/" + ] + }, + { + "path": "catalog.md", + "tier": 2, + "title": "Model Catalog", + "scope": "Shared Codex catalog assembly, account namespaces, pool rotation, and effort ladders.", + "documents": [ + "src/codex/", + "src/routing/", + "src/server/" + ] + }, + { + "path": "subagents.md", + "tier": 2, + "title": "Subagents And Multi-Agent Surface", + "scope": "Multi-agent surface mode and subagent roster ordering.", + "documents": [ + "src/codex/", + "src/providers/", + "src/server/" + ] + }, + { + "path": "transports/responses.md", + "tier": 3, + "title": "Responses Transport", + "scope": "The Responses HTTP/SSE data plane, combo failover, and streaming commit boundaries.", + "documents": [ + "src/adapters/", + "src/lib/", + "src/responses/", + "src/server/" + ] + }, + { + "path": "transports/streaming-health.md", + "tier": 3, + "title": "Streaming Health And WebSocket", + "scope": "Heartbeat and stall deadlines, plus the opt-in WebSocket transport.", + "documents": [ + "src/server/" + ] + }, + { + "path": "transports/inventory.md", + "tier": 3, + "title": "Transport Inventory", + "scope": "The per-provider transport table and diagnostic outbound safety.", + "documents": [ + "src/adapters/", + "src/chat/", + "src/images/", + "src/lib/", + "src/oauth/", + "src/providers/", + "src/server/" + ] + }, + { + "path": "data-planes/images.md", + "tier": 3, + "title": "Images Data Plane", + "scope": "Standalone image generation and edit relay.", + "documents": [ + "src/server/" + ] + }, + { + "path": "data-planes/search.md", + "tier": 3, + "title": "Search Data Plane", + "scope": "Hosted search relay and exact account selectors.", + "documents": [] + }, + { + "path": "data-planes/inbound-compat.md", + "tier": 3, + "title": "Inbound Compatibility Surfaces", + "scope": "Chat Completions inbound, Anthropic-shaped clients, and JSON-upstream streaming clients.", + "documents": [ + "src/adapters/", + "src/chat/", + "src/server/" + ] + }, + { + "path": "providers/openai-tiers.md", + "tier": 4, + "title": "OpenAI Provider Account Modes", + "scope": "Pool/Direct account modes, API-key separation, wire identity, and quota evidence.", + "documents": [ + "src/codex/", + "src/config.ts" + ] + }, + { + "path": "providers/cursor.md", + "tier": 4, + "title": "Cursor Provider", + "scope": "Cursor native exec, parameterized models, checkpoints, and active-context usage.", + "documents": [ + "src/adapters/" + ] + }, + { + "path": "providers/google.md", + "tier": 4, + "title": "Google Provider", + "scope": "Gemini thought-text, response parts, thought-signature replay, and adjacency repair.", + "documents": [] + }, + { + "path": "providers/kiro.md", + "tier": 4, + "title": "Kiro Provider", + "scope": "Kiro parallel-tool hints, Responses text controls, and reasoning round-trip.", + "documents": [ + "src/responses/" + ] + }, + { + "path": "providers/xai-grok.md", + "tier": 4, + "title": "xAI Grok Provider", + "scope": "Grok Build contract parity and hardening.", + "documents": [ + "src/oauth/", + "src/providers/", + "src/responses/", + "src/server/" + ] + }, + { + "path": "providers/chat-compat.md", + "tier": 4, + "title": "Chat Provider Compatibility", + "scope": "Cross-vendor Chat Completions behavior: reasoning, tool results, structured output, parallel tools.", + "documents": [ + "src/adapters/", + "src/responses/" + ] + }, + { + "path": "adapters/registry.md", + "tier": 4, + "title": "Adapter Registry Authority", + "scope": "The single adapter construction authority and contract inheritance.", + "documents": [ + "src/adapters/", + "src/server/" + ] + }, + { + "path": "adapters/compatibility-contracts.md", + "tier": 4, + "title": "Compatibility Contracts", + "scope": "Versioned provider compatibility claims and fixture-evidence boundaries.", + "documents": [ + "src/compatibility/" + ] + }, + { + "path": "adapters/compatibility-lab.md", + "tier": 4, + "title": "Compatibility Lab", + "scope": "Optional Lab evidence, automation, and its core-runtime isolation boundary.", + "documents": [ + "src/lab/" + ] + }, + { + "path": "gui-and-management-api.md", + "tier": 5, + "title": "GUI And Management API", + "scope": "Dashboard serving, authentication boundaries, /api/* ownership, and usage accounting.", + "documents": [ + "gui/", + "src/codex/", + "src/lib/", + "src/server/", + "src/usage/", + "src/vision/" + ] + }, + { + "path": "clients/integrations.md", + "tier": 5, + "title": "Client Integrations", + "scope": "Third-party client config ownership, snapshots, refresh, disable, and restore.", + "documents": [ + "src/clients/", + "src/integrations/", + "src/lib/" + ] + }, + { + "path": "clients/claude-desktop.md", + "tier": 5, + "title": "Claude Desktop Integration", + "scope": "Claude Desktop profile ownership and config-library resolution.", + "documents": [ + "src/claude/", + "src/cli/", + "src/client/", + "src/server/" + ] + }, + { + "path": "ops/service-and-sidecars.md", + "tier": 6, + "title": "Background Service And Sidecars", + "scope": "Service install/repair, platform launchers, tray, and sidecar processes.", + "documents": [ + "src/server/" + ] + }, + { + "path": "ops/docs-and-release.md", + "tier": 6, + "title": "Docs And Release", + "scope": "Docs site, workflow map, branch policy, release flow, and cross-platform CI.", + "documents": [ + ".github/", + "bin/", + "docs-site/", + "scripts/", + "src/cli.ts", + "src/cli/", + "src/codex/", + "src/lib/", + "src/service.ts" + ] + }, + { + "path": "design-methodology.md", + "tier": 6, + "title": "Design Methodology For New Surfaces", + "scope": "Stage ordering for new GUI, CLI, and user-facing surfaces.", + "documents": [ + "gui/" + ] + } + ], + "grace": { + "undocumentedSourceAreas": [ + { + "path": "src/bridge.ts", + "reason": "no doc names this file; it is the legacy adapter bridge entry and its behavior is described under the adapter registry without a path reference" + }, + { + "path": "src/quota/", + "reason": "no doc names a path here; quota evidence is described in providers/openai-tiers.md in prose only" + }, + { + "path": "src/service-manager-probe.ts", + "reason": "no doc names this file; service probing is described in ops/service-and-sidecars.md without a path reference" + }, + { + "path": "src/sidecar/", + "reason": "no doc names a path here; ops/service-and-sidecars.md describes sidecar behavior in prose only" + }, + { + "path": "src/types/", + "reason": "shared declarations plus the tool-name and wire-pin resolvers, which no doc currently describes" + } + ], + "unboundInvariants": [ + { + "id": "INV-HOME-01", + "reason": "no test asserts CODEX_HOME precedence over ~/.codex directly; the closest coverage is WSL home discovery" + }, + { + "id": "INV-SLUG-01", + "reason": "no test constrains routed slug shape; the ten files that mention provider/model consume slugs rather than enforcing the form" + } + ], + "oversizeDocs": [], + "staleRefs": [] + } +} diff --git a/structure/06_docs-and-release.md b/structure/ops/docs-and-release.md similarity index 80% rename from structure/06_docs-and-release.md rename to structure/ops/docs-and-release.md index 9475dba43e..6d7988af78 100644 --- a/structure/06_docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -1,4 +1,4 @@ -# Docs And Release SOT +# Docs And Release ## Public docs @@ -19,13 +19,7 @@ https://opencodex.me/ The workflow runs on `main` pushes touching `docs-site/**` or the workflow itself, builds `docs-site`, uploads the artifact, and deploys with GitHub Pages. -[Decision Log] -- 목적과 의도: Serve the public documentation from the memorable first-party `opencodex.me` domain. -- 기존 구현 및 제약 조건: The project Pages site was built for `lidge-jun.github.io/opencodex`, so Astro emitted a `/opencodex` base path that returns 404 under a root custom domain. -- 검토한 주요 대안: Keep the GitHub project URL as canonical; redirect the custom domain through Cloudflare; configure the custom domain directly on GitHub Pages and build for the domain root. -- 선택한 방식: Keep GitHub Actions Pages hosting, configure `opencodex.me` as the repository custom domain, publish root-relative assets and routes, and retain the default GitHub URL only as GitHub's automatic redirect. -- 다른 대안 대신 이 방식을 선택한 이유: Direct Pages hosting preserves the existing deployment and HTTPS lifecycle without adding a second proxy or redirect service. -- 장점, 단점 및 영향: Public links and canonical metadata become stable and branded. DNS and the Pages custom-domain setting are now deployment dependencies, and old hardcoded `/opencodex` links must not be reintroduced. +> Decision record: [ADR-0080](../decisions/ADR-0080-github-pages.md) Local validation: @@ -57,23 +51,11 @@ An official image would create a larger release surface requiring maintained bas updates, vulnerability scanning, SBOM, signing, registry provenance, rollback, and support policy. Those controls still have no owner, so there is no image-publish workflow or official registry tag. -[Decision Log] -- 목적과 의도: Document a reproducible container topology without silently creating an official image channel. -- 기존 구현 및 제약 조건: The documentation recipe was not executable from the repository root, file-backed Compose secret ownership varies by implementation, and no registry workflow, scanner, SBOM/signing chain, or image rollback policy exists. -- 검토한 주요 대안: Publish an official image; keep only copied documentation snippets; ship a maintained source recipe with a volume-backed stdin bootstrap. -- 선택한 방식: Maintain the root source-build recipe, persist the owner-only token in the state volume, publish only `10100`, and leave registry publication out of scope. -- 다른 대안 대신 이 방식을 선택한 이유: A runnable source recipe can be tested and reviewed without claiming provenance and operational controls the project does not provide. -- 장점, 단점 및 영향: Compose users get a reproducible non-root deployment and safe first-run secret path; operators still own image builds, upgrades, external TLS/tailnet management, and rollout policy. +> Decision record: [ADR-0081](../decisions/ADR-0081-container-deployment-recipe.md) ## Windows service wrapper and incomplete updates -[Decision Log] -- 목적과 의도: Prevent a failed npm replacement from making the Task Scheduler wrapper retry missing package files forever. -- 기존 구현 및 제약 조건: The wrapper deliberately restarts a proxy after runtime crashes, but an absent baked Bun or CLI path cannot recover inside that process. Current updater preflight and stop-first behavior reduce replacement risk but do not provide a transactional restore of npm's package tree and global launchers. -- 검토한 주요 대안: Keep unconditional five-second retries, add a generic crash ceiling, restore npm directories in-place, or classify only proven missing executable paths as terminal. -- 선택한 방식: Check the baked Bun and CLI paths before every spawn; log one actionable incomplete-install message and exit with code 3 when either is absent. Preserve the existing retry loop for a child that actually launched and then failed. -- 다른 대안 대신 이 방식을 선택한 이유: A generic retry ceiling can stop a service after unrelated intermittent crashes, while copying a package directory without matching npm shims, ownership, and lock guarantees is not a safe rollback. -- 장점, 단점 및 영향: File-less package skeletons no longer produce unbounded service logs or restart churn. The wrapper still recovers ordinary proxy crashes, but repairing an incomplete npm install remains an explicit reinstall plus `ocx service repair` operation until a verified staged-update design exists. +> Decision record: [ADR-0082](../decisions/ADR-0082-windows-service-wrapper-and-incomplete-updates.md) ## GitHub workflow map @@ -125,7 +107,7 @@ manual. When an investigation graduates into a maintained invariant, summarize i ## Branch and devlog policy -[`AGENTS.md`](../AGENTS.md) and [`MAINTAINERS.md`](../MAINTAINERS.md) are authoritative; this section +[`AGENTS.md`](../../AGENTS.md) and [`MAINTAINERS.md`](../../MAINTAINERS.md) are authoritative; this section exists so the repository-shape source of truth does not omit the shape of its own history. - `dev` is the single integration branch and the target for ordinary pull requests. `main` moves only @@ -161,13 +143,7 @@ helper validates this actor/base exception separately from its default contribut path; it does not certify CI or security review. The PR-only bypass leaves direct pushes, force-pushes and deletion blocked. `main` and `preview` retain their existing review rules. -[Decision Log] -- 목적과 의도: Make project ownership and review authority discoverable without exposing credentials or treating a documentation file as an access-control mechanism. -- 기존 구현 및 제약 조건: Contribution and security docs referred to maintainers generically, while the repository had no maintainer roster or CODEOWNERS policy. GitHub permissions can change independently of the source tree. -- 검토한 주요 대안: Keep the roster only in GitHub settings; introduce a larger standalone governance charter; list raw GitHub permission levels in the repository. -- 선택한 방식: Add a concise maintainer roster and merge policy, use CODEOWNERS for review routing, and keep actual permission state authoritative in GitHub settings. -- 다른 대안 대신 이 방식을 선택한 이유: A two-maintainer project needs clear ownership and sensitive-path review rules but does not yet need a separate governance framework. -- 장점, 단점 및 영향: Contributors can identify reviewers and merge expectations directly from the repository. The roster must be updated when responsibilities change, and CODEOWNERS still requires branch-protection configuration to enforce approvals. +> Decision record: [ADR-0083](../decisions/ADR-0083-maintenance-governance.md) ## Package runtime (bundled Bun) @@ -204,7 +180,7 @@ Opening a release starts with the `dev` pre-move. Dispatch `.github/workflows/dev-version-bump.yml` with the intended version, merge the pull request it opens, then promote and release. A no-op is valid when `dev` already outranks the target. `release.yml` independently enforces that readiness condition and refuses publication if the pre-move is missing. -The design and repair history live in `devlog/_plan/260904_release_version_line/`. +The design and repair history live in `devlog/_fin/260904_release_version_line/`. Opening a preview for the next core ends the current patch line. After `vX.Y.0-preview.*` is tagged, a fix ships as part of `X.Y.0`, not as diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md new file mode 100644 index 0000000000..31319b28de --- /dev/null +++ b/structure/ops/service-and-sidecars.md @@ -0,0 +1,134 @@ +# Background Service And Sidecars + +## Background service command selection + +A bare `ocx service` is an idempotent install-or-repair command. Argument validation happens before +any platform status probe. macOS and Linux choose from the registration file's proven presence; +Windows combines the Task Scheduler and WinSW probes into `installed`, `absent`, or `unknown`. +Only proven absence enters registration. A query failure refuses the bare command with status +guidance, because treating `unknown` as absent can rerun elevated `schtasks /create` against an +existing task. Explicit `ocx service install` remains the operator-owned registration request. + +> Decision record: [ADR-0028](../decisions/ADR-0028-background-service-command-selection.md) + +## Windows startup ownership listing reuse + +One proxy startup asks service-home ownership twice before listen: once before cache invalidation and +again immediately before native-main lifecycle preparation. The second targeted Task Scheduler query +is a deliberate race check and remains mandatory. On a localized host, however, the same nonzero +targeted answer can require a full task listing with a 20-second ceiling; running that identical +enumeration twice made a measured 12.3-second fallback cost roughly 25 seconds before listen. + +> Decision record: [ADR-0029](../decisions/ADR-0029-windows-startup-ownership-listing-reuse.md) + +## Stable service launcher (launchd and systemd) + +Launchd and systemd installation resolve the first absolute `ocx` PATH candidate that is both a regular file +and executable, keeps that path lexical so a version-manager shim remains an indirection, and +records the same single resolution in the service definition and service state. Definition +construction (`buildPlist`, `buildUnit`) never performs PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI +fallback, keeping diagnostics and tests independent of the host PATH. + +Launcher mode omits the package-local Bun provenance pair because an upgrade may delete that +versioned tree. The only runtime path carried through the launcher is a pre-Bun, proof-bound +`OPENCODEX_BUN_PATH` whose durable runtime source is `override`; bundled and process fallbacks are +rediscovered by the current launcher. The API-auth token remains file-backed and is loaded only by +the service shell at start. On macOS, `start` and detailed `status` compare the live launchd job +against `expectedLaunchdCommand`, which follows the recorded `launcherPath` rather than re-walking +PATH, so a launcher-backed job is never misreported as an older plist (#3464). + +> Decision record: [ADR-0030](../decisions/ADR-0030-stable-service-launcher-launchd-and-systemd.md) + +## Sidecars + +Web search and vision sidecars run only when the main request needs that capability and a usable +sidecar authority exists. Vision has two possible backends; web search's config union additionally +admits `xai`, `gemini`, and `exa`. xAI is a live explicit-only backend through stored Grok OAuth; +Gemini and Exa remain inert until their executors ship. Selection differs per sidecar: + +| Sidecar | Backend selection | Default model | Activation | +| --- | --- | --- | --- | +| `web-search/` | Explicit configuration only: unset always resolves to the OpenAI forward path. No backend — Anthropic or otherwise — is auto-selected from credential availability (doing so once sent OpenAI model ids to the Anthropic API). Explicit xAI requires usable stored Grok OAuth and may add hosted `x_search`; explicit Gemini/Exa remain fail-closed until their executors land. | `gpt-5.6-luna` (OpenAI), `claude-sonnet-5` (Anthropic), `grok-4.6` (xAI) | Hosted `web_search` requested by a non-passthrough routed model. | +| `vision/` | Explicit configuration wins for both backends. Only an unset backend auto-selects: Anthropic when a usable Anthropic OAuth provider exists, otherwise the OpenAI forward authority. An explicitly selected backend whose authority is unavailable produces no plan rather than falling back. | `claude-sonnet-5` (Anthropic), `gpt-5.4-mini` (OpenAI) | Input contains images for a model listed in `noVisionModels`. | + +The asymmetry is in the unset case only: vision may describe an image with whichever model can see +it, while a hosted search tool is tied to a provider-specific tool contract, so search never infers +Anthropic from credentials alone. + +On the OpenAI path there is one deterministic `openai` sidecar candidate and its current account mode +owns credential selection; API-key OpenAI is not a ChatGPT forward sidecar candidate. + +Sidecar failures must degrade to text markers or skipped capability, not abort the main request. + +### Grok snapshot module ownership + +The client-specific tracker lives in `grok-responses-snapshot-repair.ts`; the +provider-opt-in tracker remains in `responses-snapshot-repair.ts`. Their unchanged +object guard, JSON block encoder and retained-item shape live in the dependency- +free `responses-snapshot-codec.ts`. Core imports each tracker directly. No existing +snapshot export moves, and neither tracker imports the core dispatcher. The Grok +marker selects compatibility behavior and conveys no authenticated client identity. + +Manual and automatic OAuth/API-key selection commit through their shared selection owners before +dispatch. Selection revisions fence stale retries and reselection; request identity includes the +actual committed account/key. Generic proactive selection is opt-in and preserves a healthy active +account, while reactive429 recovery remains enabled even with the pool off. Post-commit selection +events immediately invalidate dashboard roster state; see`structure/gui-and-management-api.md`. + + +### Incomplete quota terminals + +A native forward response that ends with quota or rate-limit evidence in an +`incomplete` terminal records account quota failure and spawn-fallback health. +Structured `incomplete_details.reason` and error codes are accepted without a +message; ordinary output-limit, filtering, steering and stall incompletes do not +cool an account. Cyber-policy classification retains precedence. The terminal is +not replayed after output, and fixed-account request selection remains fixed. + +Remote compact requests release the server request-idle timeout only after a complete +JSON object with a valid model has been read. Partial or invalid uploads retain +the listener guard; admitted compaction then uses the upstream operation's own +deadlines and client cancellation. + +Buffered routed compaction treats nonempty text and reasoning deltas as progress +without exposing partial summary text. Comments, empty deltas and gateway +keepalives do not reset the adapter-event stall watchdog. The default stall +timeout stays 300 seconds; encrypted compaction content is preserved unchanged. + +Native compact response buffering also enforces a body-byte inactivity deadline +using `stallTimeoutSec` (300 seconds by default). Nonempty chunks reset that +deadline; a stalled body returns HTTP 504, client cancellation retains HTTP 499, +and cleanup does not wait for a stuck upstream cancellation promise. The 32 MiB +response ceiling and the original body bytes are preserved. + +A canonical upstream WebSocket refused-create error can become an HTTP 4xx only +before the response is committed and after stream correlation checks. Permitted +quota headers are bounded and rebuilt without upstream framing headers; the JSON +response is not cacheable. Post-commit and 5xx errors keep the no-resend path. + +When encrypted agent-task recovery refuses a routed task, its existing 400 error +can include a bounded `recovery_reason`: `unsupported_envelope`, +`admission_denied`, `recovery_unavailable`, `caller_cancelled`, `input_changed`, +`recovery_http_rejected`, `recovery_timeout`, `recovery_aborted`, +`recovery_transport_error`, or `recovery_invalid_output`. +HTTP rejection requires an observed non-success response. Invalid output includes +invalid UTF-8, oversized bodies, malformed or incomplete recovery streams, and +invalid or conflicting assignments. A caller's cancellation takes precedence over +an owned deadline, which takes precedence over decode/transport failures. +`recovery_aborted` describes a shared recovery cancelled independently of that caller. +Shared-flight waiters receive the same underlying failure unless individually cancelled; +only successful plaintext is cached. Diagnostics contain no upstream error or payload text. +The field is omitted when no classified recovery result exists, and existing combo +branches that return the original target failure keep that response. +`recovery_unavailable` includes cache/singleflight capacity and does not prove an +upstream request was attempted. No retry or broader envelope acceptance is enabled. + +## Voice diagnostic metadata + +`src/server/live.ts` owns optional `OCX_LIVE_FRAME_LOG` diagnostics for both sideband directions. +The JSONL schema contains only `ts`, `dir`, `kind`, `bytes`, and `fffd`. It never stores frame +content or transcript excerpts, and logging failures do not affect transparent frame delivery. +Binary detection decodes only the supplied buffer view; malformed UTF-8 can itself produce U+FFFD, +so the flag does not identify the peer responsible for corruption. Existing diagnostic files are +not rewritten. Audio devices, WebRTC media negotiation, captions and spoken handoff delivery remain +client responsibilities. diff --git a/structure/00_overview.md b/structure/overview.md similarity index 55% rename from structure/00_overview.md rename to structure/overview.md index 4ca10c0e16..1802d31b72 100644 --- a/structure/00_overview.md +++ b/structure/overview.md @@ -1,33 +1,10 @@ -# opencodex Structure - -This folder is the maintainer source of truth for the current system shape. Public user workflows -belong in `docs-site/`. Development work is recorded in `devlog/` units — `_plan/` while open, -`_fin/` once closed — while `docs/` keeps investigations and diagnostic notes worth retaining for -archaeology, debugging, or source research. - -## Reading order - -| File | Purpose | -| --- | --- | -| [`00_overview.md`](00_overview.md) | Product boundary, local state, and non-negotiable invariants. | -| [`01_runtime.md`](01_runtime.md) | Process lifecycle, CLI, server endpoints, config, providers, adapters. | -| [`02_config-and-codex-home.md`](02_config-and-codex-home.md) | `CODEX_HOME`, the config surface, both injection forms, profile files, restore rules, and Codex-home diagnostics. | -| [`03_catalog-and-subagents.md`](03_catalog-and-subagents.md) | Shared Codex catalog and per-catalog backups, account namespaces and pool rotation, model cache, effort ceilings, multi-agent surface mode, and subagent ordering. | -| [`04_transports-and-sidecars.md`](04_transports-and-sidecars.md) | Responses HTTP/SSE, WebSocket opt-in, per-provider transport hardening, the transport inventory, sidecars, and compatibility guards. | -| [`05_gui-and-management-api.md`](05_gui-and-management-api.md) | Dashboard serving and surfaces, plus the `/api/*` management surface and which module owns each route area. | -| [`06_docs-and-release.md`](06_docs-and-release.md) | Public docs site, GitHub Pages, the workflow map, branch and devlog policy, README ownership, release flow. | -| [`07_design-methodology.md`](07_design-methodology.md) | Design process discipline for new GUI, CLI, and user-facing surfaces. | -| [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md) | OpenAI Pool/Direct account-mode and API credential/routing invariants. | -| [`09_client-integrations.md`](09_client-integrations.md) | Third-party client config ownership, state classification, snapshots, refresh, disable, and restore. | -| [`09_compatibility-lab.md`](09_compatibility-lab.md) | Optional Compatibility Lab evidence, automation, and core-runtime isolation. | -| [`10_adapter-registry.md`](10_adapter-registry.md) | Adapter construction authority and registry-derived contract inheritance. | -| [`11_compatibility-contracts.md`](11_compatibility-contracts.md) | Versioned provider compatibility claims and fixture-evidence boundaries. | +# Overview ## Product boundary opencodex is a local proxy for Codex. It does not patch Codex binaries. It changes local Codex state by writing root routing keys and a model catalog — a provider table only in the -API-auth-header form described in [`02_config-and-codex-home.md`](02_config-and-codex-home.md) — +API-auth-header form described in [`config.md`](config.md) — then serves the Responses data plane: ```text @@ -53,13 +30,7 @@ pay-as-you-go, Coding Plan, and Agent Plan endpoints. Additional providers are routed by explicit `provider/model`, provider model lists, or the configured `defaultProvider`. -[Decision Log] -- 목적과 의도: Add two widely used API-key providers through the canonical registry so CLI, GUI, login, routing, and documentation remain in parity. -- 기존 구현 및 제약 조건: Tencent Coding Plan is OpenAI-compatible but contractually restricted to interactive coding tools and has a dynamic, text-only model set. SiliconFlow exposes a dynamic OpenAI-compatible catalog whose reasoning controls vary by model. -- 검토한 주요 대안: Treat both as custom providers only; freeze a large SiliconFlow model list and reasoning map; expose Tencent without a usage warning. -- 선택한 방식: Add registry-derived key presets, keep live discovery enabled, seed only Tencent's currently documented coding-plan models, and surface Tencent's usage restriction in both the preset note and public docs. -- 다른 대안 대신 이 방식을 선택한 이유: Registry presets remove setup friction while live discovery avoids claiming that mutable catalogs are permanent. Avoiding speculative SiliconFlow reasoning metadata prevents invalid vendor-specific parameters. -- 장점, 단점 및 영향: Both providers appear consistently across supported setup surfaces. Tencent users receive an explicit policy warning; SiliconFlow reasoning controls remain conservative until model-specific limits can be represented safely. +> Decision record: [ADR-0001](decisions/ADR-0001-product-boundary.md) ## Local state @@ -79,7 +50,7 @@ opencodex state root does not undo those writes. Putting native Codex back is th | `~/.opencodex/config.json` | opencodex | Init creates via private temp plus no-replace hard link; dashboard and explicit updates use atomic replacement. | | `~/.opencodex/auth.json` | opencodex | OAuth tokens; not committed. Multiauth shape: `provider -> { activeAccountId, accounts[] }` (legacy single-credential values normalize on load; a one-time `auth.json.pre-multiauth` backup guards downgrades). ChatGPT scratch OAuth stays separate from the Codex account store. For multi-slot providers, credentials without `accountId`/email replace the active slot on a normal login; an explicit add-account login preserves the prior slot and appends a distinct one. Single-slot providers such as ChatGPT remain replacement-only. | | `~/.opencodex/codex-accounts.json` | opencodex | Hardened main-plus-added credential store used by `openai` in Pool mode. | -| `~/.opencodex/catalog-backup.json` | opencodex | One-time pristine Codex catalog backup for restore; per-catalog copies are hashed variants (see [`03_catalog-and-subagents.md`](03_catalog-and-subagents.md)). | +| `~/.opencodex/catalog-backup.json` | opencodex | One-time pristine Codex catalog backup for restore; per-catalog copies are hashed variants (see [`catalog.md`](catalog.md)). | | `~/.opencodex/usage.jsonl` | opencodex | Append-only request usage log (0o600); request metadata + token counts only, never prompts or auth. | | `~/.opencodex/ocx.pid`, `runtime-port.json`, `system-env-port` | opencodex runtime | Live process identity and the port a client should reach; rewritten on start. `runtime-port.json` also carries the protected per-process listener-attestation key used before CLI diagnostics attach a management bearer. | | `~/.opencodex/codex-runtime.json`, `codex-runtime-clamp.json` | opencodex Codex runtime | Selected Codex executable/version state and effort-clamp diagnostics. Not process identity: these persist a resolved choice and a diagnostic, so losing them changes behavior until re-resolved. | @@ -96,21 +67,39 @@ opencodex state root does not undo those writes. Putting native Codex back is th ## Non-negotiable invariants -- `websockets` defaults to `false`; only `true` advertises `supports_websockets`. -- `CODEX_HOME` wins over `~/.codex` when present and valid. -- Root TOML keys such as `model_provider` and `model_catalog_json` must stay before any table. -- Routed model slugs use `provider/model`. -- OpenAI has one `openai` Codex-login provider with Pool(default)/Direct modes and a separate `openai-apikey`; see [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md). -- Codex `spawn_agent` visibility depends on the first five featured catalog entries. -- The management plane (`/api/*`) and the data plane (`/v1/*`) never share an admission credential. -- `ocx stop`, `ocx restore`, and service stop/uninstall must leave native Codex usable. -- `tests/` is organised by domain (`tests//`, mirroring `src/`); the map is - `scripts/test-layout/layout.json` and `tests/test-layout.test.ts` rejects a test outside its +Each invariant carries a stable id. A bound invariant names one test, and that test names the id +back, so deleting or renaming the test fails `bun run structure:check` instead of quietly unbinding the +rule. The binding proves the test EXISTS and is claimed; it does not prove the assertions inside it +still cover the rule, which is a judgement only review makes. + +- **INV-WS-01** — `websockets` defaults to `false`; only `true` advertises `supports_websockets`. + Enforced by `tests/codex-integration/codex-catalog.test.ts`. +- **INV-TOML-01** — Root TOML keys such as `model_provider` and `model_catalog_json` must stay + before any table. + Enforced by `tests/codex-integration/codex-inject.test.ts`. +- **INV-OPENAI-01** — OpenAI has one `openai` Codex-login provider with Pool(default)/Direct modes + and a separate `openai-apikey`; see [`openai-tiers.md`](providers/openai-tiers.md). + Enforced by `tests/adapters/openai/openai-provider-option.test.ts`. +- **INV-AGENT-01** — Codex `spawn_agent` visibility depends on the first five featured catalog + entries. + Enforced by `tests/codex-integration/catalog-full-picker-order.test.ts`. +- **INV-AUTH-01** — The management plane (`/api/*`) and the data plane (`/v1/*`) never share an + admission credential. + Enforced by `tests/server/server-management-auth.test.ts`. +- **INV-RESTORE-01** — `ocx restore` returns the pristine Codex catalog, so a restored install is a + usable native Codex. The service-stop and uninstall paths of the same promise are covered + separately in `tests/cli/restore-completes-shared-teardown.test.ts` and are not bound to this id. + Enforced by `tests/codex-integration/codex-catalog-restore.test.ts`. +- **INV-TESTS-01** — `tests/` is organised by domain (`tests//`, mirroring `src/`); the map + is `scripts/test-layout/layout.json` and `tests/test-layout.test.ts` rejects a test outside its domain. Only the two layout guards sit at the root. Source-oracle tests reach the repository through `tests/helpers/repo-root.ts`, never `import.meta.dir + "/.."`. + Enforced by `tests/test-layout.test.ts`. -## Writing rule +Two invariants are stated here without a binding, and `grace.unboundInvariants` in +[`manifest.json`](manifest.json) carries the reason for each. They are true statements about the system; +no test in this repository currently pins them, and saying so is more useful than naming a test that +would pass while the rule was violated. -Keep this directory flat. Add or extend lexicographically ordered `NN_topic.md` files; do not add -subdirectories. If one file grows too broad, split the next stable topic into the next unused number -instead of creating nested folders. +- **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. +- **INV-SLUG-01** — Routed model slugs use `provider/model`. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md new file mode 100644 index 0000000000..e0b87add2d --- /dev/null +++ b/structure/providers/chat-compat.md @@ -0,0 +1,266 @@ +# Chat Provider Compatibility + +## Reasoning and tool-result compatibility + +Kiro groups only consecutive original-message tool results whose raw call ID exactly matches +the originating call. Its wire-ID map retains the original ID privately so replacement or +truncation collisions cannot join unrelated results. Every non-tool message ends the group, +including a reasoning-only assistant omitted from the Kiro turns. Group finalization preserves +single-result normalization, ordered meaningful raw text and whitespace in multi-result output, +failure text, image order and sticky error status. Empty hints are applied once for an entirely +text-empty group, not once per chunk; local grouping state never enters the wire payload. + +`src/responses/task-input.ts` recognizes complete external Codex task-input envelopes +before translated Responses adapters: `function_call_output`, no `call_id` property, +nonblank `id`/`name`/`namespace`, and fully representable nonempty text/image output. +`parser.ts` emits a user turn, clears pending reasoning and includes that turn in the +existing continuation conversation-boundary calculation. The metadata is structural, +not authentication. Unknown/opaque/malformed parts reject the entire conversion; +ordinary missing/empty tool call ids retain the existing translated-route 400 guard. +Native passthrough and compaction retain raw-body handling. The leaf reuses the input +content converter after validation and imports no optional subsystem. +Stateful developer-guidance injection reuses that validator for its raw insertion +boundary, so parsed messages and stored raw history retain the same task/guidance order. + +Native OpenAI passthrough sanitizes routed reasoning history so `reasoning` input items do not send +non-empty `content` arrays to upstream models that reject them. Chat Completions bridging repairs +orphan `toolResult` messages by inserting a synthetic assistant `tool_call` before tool messages. +It also repairs the opposite direction (260718): an assistant `tool_calls` round left dangling — +by an intervening user/developer barrier or an interrupted turn — is closed by deferring barrier +messages until the round completes, reattaching real results to their original call occurrence, +and synthesizing explicit "no tool result was recorded" answers only when no real result exists +(Kimi/Moonshot 400 `ocx-mrqaiw05-269`; unit `devlog/_fin/260718_dangling_toolcall_hardening`). + +Forward-mode OpenAI passthrough also repairs replayed `call_id` values longer than the Responses +API's 64-character limit. Sidechat/fork replay can namespace routed-provider ids beyond that limit, +so each oversized id and all matching call/output items receive the same deterministic, +request-local alias. Raw API-key continuations deliberately preserve ids because an output-only +continuation may reference a call stored upstream under its original id; proxy-expanded API-key +replays are explicit and receive the same repair. + +These compatibility guards are covered by focused tests and should stay close to the adapters that +need them. + +Responses passthrough always removes output-only `status` from `reasoning` input items, including +items that retain opaque `encrypted_content`. The prior retains-blob-keeps-status invariant was +defensive rather than observed: measured OpenAI reasoning items never contain `status`, and Grok +accepts its own blob with `status` removed. Keeping it on a cold cross-backend replay instead made +OpenAI reject the unknown field before validating the blob, starving opaque-blob recovery of the +provenance error it needs. The established raw-`content` rule remains separate: ChatGPT accepts +reasoning input only with empty `content`, so a native blob plus raw content keeps the blob but still +blanks `content`. The blob is kept unless the in-process thread record proves that the current +provider, destination, adapter, model, or credential differs from the route recorded for the prior +request on that client thread. On a proven change the blob is removed while the reasoning item and +its summary survive; `status` has already been removed on every path. Missing, expired, or evicted +identity state is unknown. The comparison uses the durable destination and credential identities +with the provider, adapter, and model, so OAuth token-generation refreshes do not look like backend +changes; when either durable dimension is unavailable it refuses to record rather than falling back +to a volatile identity. Route binding only compares: it does not replace the recorded identity until +the destination successfully serves the turn. Bridged streams commit on a completed or incomplete +terminal; native passthrough streams use the non-error upstream status before relay as their success +boundary so the proxy does not retain request state across the whole stream. This deterministic +pre-flight is the primary path and covers threads the process has served while their record remains +inside the TTL/LRU bounds. Missing, expired, evicted, and +pre-process history stays fail-soft on the first send. If a Responses upstream then returns its own +self-identifying opaque-blob 4xx (`invalid_encrypted_content`, or xAI's two `invalid-argument` +decoder errors), the proxy rebuilds once through the same sanitation path: reasoning +`encrypted_content` is removed and compaction blobs use the existing text degradation. A one-shot +guard makes a second rejection terminal, and a successful recovery records the current serving +identity so later route changes return to deterministic pre-flight. A cold-record cross-backend +switch therefore costs one extra upstream round trip and one turn of degraded reasoning, rather than +wedging the thread; unrelated 4xx responses and requests whose outbound body carries no blob never +enter this recovery. + +After a self-identified opaque-blob rejection, the proxy also keeps a five-minute rejection memo. +The memo key is the resolved conversation identity plus the durable serving identity: provider, +destination, adapter, model, and credential. It is recorded only when the blobless recovery resend +succeeds. A missing durable destination or credential prevents memo creation and lookup. On a later +request with the same key, pre-flight sanitation removes opaque reasoning `encrypted_content` and +degrades compaction blobs before the first upstream send. This skips the rejected first send and +the recovery round trip. A different serving identity does not match the memo. Route changes still +follow the normal pre-flight stripping rule. Memo expiry returns to the fail-soft recovery path. + +A combo target rotation between turns legitimately changes that serving identity, so the following +turn drops blobs minted by the prior target. This is correct because the new target cannot decode +them, but it is intentionally unobvious to the client: `pickComboTarget` keys selection state only by +combo id, without a conversation dimension, and the SSE model-name rewrite preserves the requested +combo name instead of exposing the concrete target switch. A user can therefore observe a reasoning +cache drop with no visible model change. + +The image and web-search auxiliary loops consume `_reasoningReplayScope` for bridge-level replay but +never call `bindRouteReasoningReplayScope`, so their internal small-model requests do not update the +serving-identity record. That omission is intentional: binding those routes would poison the main +conversation's last-serving identity and cause a later main-model turn to strip valid blobs. + +> Decision record: [ADR-0051](../decisions/ADR-0051-reasoning-and-tool-result-compatibility.md) + +DeepSeek's stateless Responses compatibility pass normalizes only unambiguous tool-call batches. +Calls emitted before the first matched output stay together as one assistant batch, followed by +their outputs in call order; hook-injected messages that split the batch move after it without being +dropped. This preserves #1292's single-call adjacency repair without splitting a same-turn parallel +batch away from its preceding plaintext reasoning (#1477). Tolerant providers never enter this pass, +and duplicate, missing, or backwards call/result pairs are left for the upstream to reject rather than guessed. + +> Decision record: [ADR-0052](../decisions/ADR-0052-reasoning-and-tool-result-compatibility.md) + +## OpenRouter provider routing + +The canonical OpenRouter `openai-chat` transport may carry optional provider-routing preferences +from `OcxProviderConfig.openRouterRouting`, with exact model-id replacements in +`modelOpenRouterRouting`. The adapter maps camel-case config to OpenRouter's request wire +(`order`, `only`, `allow_fallbacks`) after the Codex-facing routed slug has been decoded to the +native model id. + +Preferences are accepted only for `https://openrouter.ai/api/v1` (an optional trailing slash is +equivalent) and the `openai-chat` adapter. Alternate ports, credentials, query strings, fragments, +lookalike hosts, and custom proxy paths fail validation. A model override replaces rather than +merges the provider-wide default, keeping precedence deterministic. With no preference configured, +the request body is byte-for-byte unchanged in this area and OpenRouter retains its default routing. + +## Kimi Coding Plan prompt-cache affinity + +The canonical `kimi` OAuth and `kimi-code` API-key presets opt into forwarding the internal +request's `prompt_cache_key` to Kimi's Chat Completions body. Kimi Code Plan documents a stable +session/task key as required to improve cache hit rates. The chat adapter never invents a key of +its own: it forwards what the request already carries — Codex's session key on +`/v1/responses`, or the session-scoped key the Claude `/v1/messages` inbound derives +(metadata.user_id hash, else the system+tools cohort hash) — and a request with no key stays +keyless. An explicit provider-level `promptCacheKey: false` continues to opt out, and the flag is +persisted through `providerConfigSeed`/`enrichProviderFromRegistry` for new configs; key-pool 429 +rotation keeps it — along with every other registry backfill — because the retry starts from the +fresh committed provider row and routes it again (`rotateProviderTransportOn429` in +src/providers/key-failover.ts). Stale request-time config fields are deliberately discarded so a +concurrent deletion stays authoritative; only runtime `fetch` state and generated OpenCode session +affinity survive the rebuild. If an opted-in upstream rejects the field, OpenCodex does not strip it and retry or mutate the +saved configuration. Other OpenAI-compatible providers remain deny-by-default because strict +backends may reject the OpenAI-specific field. + +## Parallel tool calls (default-on for chat providers) + +The openai-chat adapter buffers ALL streamed `tool_calls` deltas (keyed by `index`, falling back to +`id`, then last-seen) and flushes them as atomic start/delta/end sequences at the terminal signal. +This is required by the bridge's sequential tool-call contract and makes interleaved parallel +deltas, id-only-first-chunk continuations, and whole-chunk multi-call frames all safe. + +Parallel tool calls are DEFAULT-ON for openai-chat providers: the adapter follows Codex's +request-level `parallel_tool_calls` bit (default true) and routed catalog entries advertise +`supports_parallel_tool_calls`. `OcxProviderConfig.parallelToolCalls: false` is the per-provider +opt-out (registry-seeded, router-backfilled; an explicit user value always wins). Non-chat +adapters advertise the catalog bit only on explicit `true`; cursor keeps its own special-casing. +Providers with flaky parallel streaming can be opted out individually. Evidence and provider +ledger: `devlog/_fin/260709_parallel_tool_calls/`. + +## Volcengine Ark assistant continuation shapes + +The `openai-chat` adapter keeps Volcengine's pay-as-you-go Chat endpoint and Coding Plan endpoint +on separate empty-assistant contracts. The pay-as-you-go `/api/v3` route retains the structured +`[{ "type": "text", "text": "" }]` placeholder inferred for #796, while `/api/coding/v3` uses the +ordinary empty string accepted by its live tool-call continuation contract (#1571). Matching only +the shared Ark hostname is too broad because the two endpoint families reject opposite shapes. + +> Decision record: [ADR-0063](../decisions/ADR-0063-volcengine-ark-assistant-continuation-shapes.md) + +## Chat structured-output compatibility + +First-party Kimi and Moonshot Chat destinations normalize a `$ref` with sibling keywords because +their wire rejects that valid JSON Schema 2020-12 shape. Inlining preserves conjunction semantics: +`required` members are unioned, lower numeric bounds take the maximum, upper numeric bounds take the +minimum, and overlapping `properties` recurse with the same rules. The walk remains depth-, node-, +and expansion-bounded. Unresolvable or cyclic references keep the existing bare-`$ref` fallback, +and unrelated OpenAI-compatible providers retain the caller's schema unchanged. + +> Decision record: [ADR-0064](../decisions/ADR-0064-chat-structured-output-compatibility.md) + +The `openai-chat` adapter translates Responses `text.format` and Chat Completions +`response_format` through one internal format, then emits `response_format` on the upstream chat +wire. That remains the default because silently returning prose breaks clients that requested a +JSON object or schema. A mixed-capability gateway may list exact native model ids in +`noStructuredOutputModels`; only those models omit the wire field, while siblings keep the normal +translation. The proxy does not infer this from provider names, localhost destinations, or a model +family shared by unrelated upstreams. + +> Decision record: [ADR-0065](../decisions/ADR-0065-chat-structured-output-compatibility.md) + +## Anthropic structured-output compatibility + +The Anthropic adapter lowers Responses `text.format` and Chat Completions `response_format` JSON +Schema requests to `output_config.format`. The local transform follows Anthropic's TypeScript SDK +subset so upstream rejects neither OpenAI-only envelope fields nor unsupported schema constraints. +The adapter merges `format` into an existing adaptive-thinking `output_config` rather than replacing +it, so a compatible `output_config.effort` remains alongside the structured-output format. +Routed Anthropic Messages input carries `output_config.format` through internal `text.format`, so +stored-OAuth requests regain the same native format when the Anthropic adapter rebuilds the wire body. +Unsupported constraints remain in `description` as model guidance instead of disappearing. Root +`$defs` stay beside a root `$ref`, intentionally differing from the current SDK transform's early +`$ref` return so local references remain resolvable. + +> Decision record: [ADR-0066](../decisions/ADR-0066-anthropic-structured-output-compatibility.md) + +## Reasoning display parity (hideThinkingSummary) + +Reasoning-envelope serialization uses preflight byte sizing and transient reservations before +creating JSON, UTF-8, or base64 copies. Encoding also admits the matching decode projection, so +a successfully encoded standalone envelope fits the standalone decoder's limit. Callers retain +ownership of returned values; the helper releases only its temporary reservation. Inbound +Anthropic translation carries one budget across all assistant blocks and accounts for retained +envelopes until the response lifecycle disposes it. Standalone translation owns a temporary +budget and disposes it on success or failure. Final translated-request sizing uses plain-JSON +measurement rather than allocating a serialized copy just to measure it. + +> Decision record: [ADR-0067](../decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md) + +`hideThinkingSummary` (request reasoning summary absent/"none" — the routed catalog default) is +honored by BOTH reasoning paths: anthropic `thinking_delta` AND raw `reasoning_raw_delta` +(openai-chat `reasoning_content`, kiro tags). Hidden reasoning emits an envelope-only reasoning +item (`summary: []`, txt-only `ocxr1:` `encrypted_content`, no text deltas) — invisible in the +Codex app, so tool cells group like native models — while the text still round-trips for +`preserveReasoningContentModels` replay. Visible mode (summary "auto") keeps the raw +`content[reasoning_text]` shape. Diagnosis and codex-rs grouping evidence: +`devlog/_fin/260709_native_response_pattern/`. + +The content-to-summary channel rewrite skips any reasoning item that carries a native +`encrypted_content` blob. The blob is opaque, state-bearing provider data, so the item must +round-trip unchanged unless that backend has an explicit replay contract permitting a rewrite. +This defensively protects providers that issue blobs and later join the route through +`preserveReasoningContentModels`. The rewrite's round trip was verified against DeepSeek, which is +`statelessResponses` and issues no blob. Grok is unaffected in practice because it natively emits +summary-channel reasoning and no `reasoning_text` events, so this content-to-summary item rewrite +does not engage on its route. Only the stored item is exempt — `reasoning_text` delta events carry +no blob and still route to the summary channel, so the live expandable trace is unchanged. + +The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client +thread plus an exact provider destination, wire adapter, final model, and physical credential +identity. API-key material is represented only by a process-keyed HMAC; OAuth replay is bound to the +existing credential slot and exact credential generation, and an authentication-header override is +folded into that identity without retaining the raw value. A token refresh intentionally starts a +new fail-closed replay namespace. The destination is likewise process-HMACed because a configured +base-URL path may itself be a credential. Header-only/keyless routes cannot establish a physical +credential identity and therefore fail closed. Parsed-request copies and already-created bridges +share one scope holder, and key/account rotation replaces its current identity before rebuilding +the request. A retry may therefore reuse reasoning on the same physical target, but a provider, model, or +credential failover receives the provider's configured placeholder instead of another target's raw +reasoning. + +> Decision record: [ADR-0068](../decisions/ADR-0068-reasoning-display-parity-hidethinkingsummary.md) + +## Chat streamed tool-call identity + +`src/adapters/openai-chat.ts` retains a call's first observed non-negative safe integer +index as an alias when the call started by ID. Every present, non-null index must +be a number in that range: strings (including numeric and empty strings), booleans, +objects, arrays, negative numbers, fractions and unsafe integers terminate the stream +before any key, alias, ID or last-call matching. `Number.MAX_SAFE_INTEGER` is accepted; +larger integers are rejected because distinct wire literals can parse to the same number. +The invalid-index error releases all pending call reservations without emitting +those calls or a successful completion; invalid indexes are never treated as absent. +Only missing and null indexes are absent-index placeholders. Repeated ID, name and +argument string-field tolerance retains its existing rules. + +For valid indexes, lookup preserves direct-key precedence, then index alias, then +ID fallback. The initial key continues to own all translator budget reservations +and release; learning an alias creates no additional owner. Unassociated index-only +fragments are not guessed onto pending ID-only calls. +`tests/adapters/openai/openai-chat-parallel-stream.test.ts` covers late aliases, +parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum +safe-integer boundary, invalid index types, missing/null continuations and UTF-8 +byte-limit boundaries. diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md new file mode 100644 index 0000000000..be42793e0b --- /dev/null +++ b/structure/providers/cursor.md @@ -0,0 +1,84 @@ +# Cursor Provider + +## Cursor Native Exec + +Cursor's experimental live transport can receive server-driven local read/write/delete/ls/grep, +shell, and fetch exec frames. These frames are denied by default because they bypass Codex's normal +approval and sandbox path. `nativeLocalExec: "on"` is the explicit config-owner opt-in for trusted +local experiments; `off` and the backwards-compatible `codex-sandbox` spelling both fail closed. +MCP, screen recording, and computer-use stay on their separate explicit executor/MCP config paths. + +> Decision record: [ADR-0047](../decisions/ADR-0047-cursor-native-exec.md) + +Cursor's generic tool-use prompt filter must preserve every Responses-owned execution-path tool +that survives the transport budget: unified Desktop `exec` as well as the legacy +`exec_command`/`shell_command` aliases. The legacy aliases receive Cursor-specific shell guidance; +unified `exec` keeps its own schema and is surfaced back to Codex as a client tool. It must never +fall through to the separate native-local-exec dispatcher. + +> Decision record: [ADR-0048](../decisions/ADR-0048-cursor-native-exec.md) + +## Cursor parameterized models + +Cursor Router's parameterized `default` model is represented in Codex by four catalog rows: +`cursor/auto` preserves Cursor's team/account default, while `cursor/auto-cost`, +`cursor/auto-balance`, and `cursor/auto-intelligence` make each optimization level explicit. +All four route to the `default` Cursor wire model. Explicit variants additionally populate +`AgentRunRequest.requested_model.parameters` with the `optimization` parameter; this is the same +parameterized-model channel used by current Cursor clients. Router rows are static capabilities and +must survive a live `GetUsableModels` response that omits `default`. + +`cursor/grok-4.5-fast` and `cursor/grok-4.6-fast` are stable Codex-facing rows, but current Cursor +clients do not request them as flat model slugs. OpenCodex sends the matching Grok base id through +`requested_model` with separate `effort` and `fast=true` parameters, leaving legacy `model_details` +unset for that parameterized external selection. Grok 4.5 stops at `high`; Grok 4.6 additionally +advertises and sends `xhigh`. Live discovery recognizes Cursor's flattened +`cursor-grok-{version}-{effort}-fast` variants, plus the older +`grok-{version}-fast-{effort}` ordering, as availability evidence only. + +## Cursor active-context usage + +Cursor's `conversationCheckpointUpdate.tokenDetails.usedTokens` is treated as the authoritative +absolute active-context size for a Cursor conversation. Some client-tool suspension turns must end +before Cursor emits a new checkpoint; those turns carry forward the last observed total for the same +Cursor conversation instead of reporting only the tiny current-turn output delta. The carry-forward +cache is process-local, numeric-only, bounded, and keyed by Cursor conversation id. Compaction +boundaries clear the carry so pre-compaction totals are not reused after Codex replaces history. +Historical compaction markers restored by `previous_response_id` expansion are acknowledged as a +replayed prefix and do not clear a fresh post-compaction checkpoint again on every later turn. +Compaction summarizer turns may still report their own checkpoint for that response, but their +pre-compaction checkpoint is not persisted for later carry-forward. + +> Decision record: [ADR-0053](../decisions/ADR-0053-cursor-active-context-usage.md) + +## Cursor conversation checkpoint reuse + +After a successful no-tool turn, the Cursor adapter keeps the returned ConversationStateStructure in +a process-local store and reuses that snapshot on the next validated linear continuation instead of +rebuilding rootPromptMessagesJson and conversationTurns. Tool-result turns reuse the last completed +checkpoint plus only the uncovered suffix. A request without checkpointRef may use the prefix index +only when a remembered Cursor conversation or stable client thread owns the resolved conversation id. +The stable owner may be the Codex parent-thread header or the existing bounded process-local HMAC of +the complete Desktop session-id/thread-id pair. The request must also have a covered message prefix +and system/developer digest that match exactly one snapshot for that same +conversation. Headerless requests without a stable owner full-replay. Isolated helper/shadow turns +never join the parent or sibling conversation. An explicit missing checkpointRef full-replays. Compaction, account or model mismatch, missing refs, decode failures, and +invalid_argument recovery keep the existing full-replay path. previous_response_id may select a +branch's opaque checkpointRef; it is never a Cursor conversation ownership key. Cursor Connect still +does not expose authoritative cache_read_tokens. + +> Decision record: [ADR-0054](../decisions/ADR-0054-cursor-conversation-checkpoint-reuse.md) + +## Cursor executable tool schema ownership + +`src/adapters/cursor/tool-schemas.ts` owns advertised and argument-normalization +schemas; `tool-definitions.ts` remains the public facade and protobuf encoder. +Advertisement and normalization intentionally differ for shell bridges: Cursor may +emit `cmd`, while the declared Responses contract decides whether it becomes +`command`. Both paths preserve execution-control fields. Freeform tools use one +required string `input` in a closed object, retaining that tool's string-valued +input description from the parser (including patch-envelope guidance). Other input +constraints cannot widen the canonical shape. Bare shell bridge names are rejected +on the freeform path. +Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in +`tests/providers/cursor/cursor-tool-definitions.test.ts`. diff --git a/structure/providers/google.md b/structure/providers/google.md new file mode 100644 index 0000000000..26187aeebf --- /dev/null +++ b/structure/providers/google.md @@ -0,0 +1,47 @@ +# Google Provider + +## Google thought-text visibility boundary + +Google-family responses may represent model-internal reasoning as a text-bearing part with +`thought: true`. The Google adapter maps that text to the internal `reasoning_raw_delta` event; +only text without the marker becomes visible `text_delta`. Streaming SSE and buffered JSON share +one classifier so transport selection cannot change whether provider-declared reasoning is shown +as assistant output. Thought-signature observation still runs on the original parts before text +classification, preserving the opaque continuation state independently of display semantics. + +> Decision record: [ADR-0055](../decisions/ADR-0055-google-thought-text-visibility-boundary.md) + +## Google response-part field boundary + +Google-family adapters validate the values inside an otherwise well-formed response part before +they become `AdapterEvent`s. A present `functionCall` must be an object with a nonblank string +`name`; because Gemini delivers that call atomically rather than across deltas, an invalid name is a +terminal protocol error and is never dispatched. A non-string optional `text` value is dropped +without coercion, while the rest of the part and turn continue. Structured `functionCall.args` +remain provider-native and are serialized as before. + +> Decision record: [ADR-0056](../decisions/ADR-0056-google-response-part-field-boundary.md) + +## Google tool-call thought-signature replay + +Gemini may attach an opaque `thoughtSignature` to a `functionCall` and requires that exact value on +the matching model turn when its tool result is submitted. Antigravity and Vertex share the existing +bounded TTL/LRU replay store, keyed by compiled function-call name plus canonical arguments. Vertex +prefixes its cache model key with the transport, project, and location identity, so a signature +minted by Vertex cannot be sent to Antigravity even when both routes expose the same public model id. +Vertex prefers Codex's opaque `prompt_cache_key` for session identity and falls back to the existing +first-user-message derivation for clients that omit it; only the fixed hash is retained. +Both streaming and non-streaming responses feed the store; request compilation happens before replay +so matching uses the provider-visible tool name. + +> Decision record: [ADR-0057](../decisions/ADR-0057-google-tool-call-thought-signature-replay.md) + +## Google tool-result adjacency repair + +Google-family requests serialize a model tool-call turn and its results as one adjacent +`model -> user` pair. The user turn contains exactly one `functionResponse` for every representable +call in original call order. Missing results use an explicit unknown-history marker; duplicate, +mismatched, and standalone results become marked text instead of unpaired function responses. +Representable data-URL images remain sibling `inline_data` parts in either case. + +> Decision record: [ADR-0058](../decisions/ADR-0058-google-tool-result-adjacency-repair.md) diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md new file mode 100644 index 0000000000..4650e912dc --- /dev/null +++ b/structure/providers/kiro.md @@ -0,0 +1,61 @@ +# Kiro Provider + +## Kiro client parallel-tool hint + +Kiro's wire remains serialized even when an OpenAI Responses client sends +`parallel_tool_calls: true`. That request field is permissive: it allows parallel calls but does not +require the routed transport to expose a matching flag. The Kiro catalog therefore continues to +advertise `supports_parallel_tool_calls: false`, and the adapter emits no parallel-control field, +while accepting the client hint and translating the ordinary tool catalog normally. + +> Decision record: [ADR-0060](../decisions/ADR-0060-kiro-client-parallel-tool-hint.md) + +## Kiro Responses text controls + +Kiro refuses structured output and tolerates every other Responses `text` member. `text.format` +of type `json_schema` or `json_object` is a contract the CodeWhisperer wire cannot honour, so the +adapter rejects it rather than returning prose to a caller expecting JSON. `text.verbosity` and +`text.format: {"type":"text"}` are preferences, not contracts; they are accepted and dropped, +because `buildKiroPayload` composes `conversationState` from parsed fields and never forwards the +raw body. + +> Decision record: [ADR-0061](../decisions/ADR-0061-kiro-responses-text-controls.md) + +## Kiro reasoning round-trip (`redactedContent`) + +Kiro never returns plaintext reasoning for its **GPT-5.6 family** (`gpt-5.6-sol`, `-terra`, +`-luna`): `reasoningContentEvent` carries a KMS-encrypted `redactedContent` blob, never `text`. +Their `additionalModelRequestFieldsSchema` (`ListAvailableModels`) accepts only `reasoning.effort` +with `additionalProperties: false` — there is no display/summary opt-in, so this is the only +reasoning these models can return. Kiro's own CLI replays the blob on the matching +`assistantResponseMessage.reasoningContent` to preserve model reasoning across turns; dropping it +makes every turn restart without the previous turn's reasoning. Verified on kiro-cli 2.14.1 and +2.16.0, all three models. + +The Claude 4.6+/5 entries advertise a different, richer contract (`thinking.type` adaptive/disabled, +`thinking.display` summarized/omitted, `output_config.effort`, `max_tokens`) and are not covered by +that measurement; older Claude, deepseek, minimax, glm, and qwen entries advertise no additional +fields at all. The handling below keys off the wire field, not the model id, so any model that +sends `redactedContent` round-trips. + +- The blob rides the existing `ocxr1:` envelope as `krc` (`src/responses/reasoning-envelope.ts`) on + an envelope-only reasoning item — `summary: []`, no text deltas — so it stays invisible in the + Codex app while round-tripping, exactly like the hidden-thinking path. +- **Pairing is backwards.** Kiro emits `reasoningContentEvent` at the END of an assistant turn, + after content AND tool calls. A `krc`-only item therefore belongs to the turn that already + closed, so the parser attaches it to the PRECEDING assistant message rather than folding it into + the following turn like ordinary reasoning (`src/responses/parser.ts`). With no assistant turn to + own it, the blob is dropped rather than mis-paired. +- The blob lives on `OcxAssistantMessage.kiroRedactedReasoning`, not on a thinking content part, so + no other adapter replays provider-private state if the conversation switches providers. + +Kiro reports context pressure in its own `contextUsageEvent`, which is the authoritative source. On +every capture taken (2.14.1 and 2.16.0) `metadataEvent` carried only `stopReason` — which is why +reading the percentage from `metadataEvent` alone never saw a value — but the parser still accepts a +finite `contextUsagePercentage` (and a `tokenUsage` block) there as a fallback, so a value parsed +from `metadataEvent` is legitimate rather than impossible. Both feed the same field, and any +positive value overwrites an earlier one. + +Spend arrives in `meteringEvent` as **credits, not tokens**. No captured response carried +`tokenUsage` on any event, which is why Kiro usage stays estimated; `meteringEvent` is currently +ignored because a credit is not a token count. diff --git a/structure/08_openai-provider-tiers.md b/structure/providers/openai-tiers.md similarity index 78% rename from structure/08_openai-provider-tiers.md rename to structure/providers/openai-tiers.md index f53cdb9b0c..9f86870bae 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/providers/openai-tiers.md @@ -1,4 +1,4 @@ -# OpenAI Provider Account-Mode SOT +# OpenAI Provider Account Modes This current contract supersedes the provider-identity and account-selection sections of `devlog/_fin/260717_openai_hardening`; that archived unit remains historical evidence for the @@ -57,13 +57,7 @@ before inference; `prompt_cache_key` remains supported. `openai-apikey` and nonc Responses destinations preserve caller-provided options because their upstream contracts may support them. -[Decision Log] -- 목적과 의도: Let public Responses clients use the Codex-login route without one unsupported prompt-cache extension failing the whole turn. -- 기존 구현 및 제약 조건: Parsing already preserves unknown top-level fields in `_rawBody`, and the canonical backend rejects `prompt_cache_options`; API-key and custom providers may accept the same field. -- 검토한 주요 대안: Add the field to the Zod schema; strip it for every Responses provider; translate it to a legacy retention hint; remove it only at the canonical destination boundary. -- 선택한 방식: Keep parser passthrough unchanged and strip the caller field only after `isCanonicalOpenAiForwardProvider` succeeds. -- 다른 대안 대신 이 방식을 선택한 이유: Schema admission does not change `_rawBody`, global stripping would remove supported public API behavior, and translation would invent cache policy. -- 장점, 단점 및 영향: VS Code and other public-shape clients avoid the canonical backend rejection while API-key/custom routes retain their wire options; canonical callers cannot request this cache option through OpenCodex. +> Decision record: [ADR-0084](../decisions/ADR-0084-public-provider-contract.md) Pool affinity preserves the existing `x-codex-parent-thread-id` supplied by ordinary Codex clients. The parent id is trimmed and bounded under the same 512-byte component limit as the Desktop @@ -75,23 +69,7 @@ and terminal outcome accounting carry the same key so route planning cannot prev and authenticate another, and a transient failure clears the binding that actually selected the account. -[Decision Log] -- 목적과 의도: Keep Desktop reconnects on the account selected for the App task without persisting - or exposing its session and thread identifiers. -- 기존 구현 및 제약 조건: Pool affinity used only `x-codex-parent-thread-id`; Desktop requests can - omit it while stable `session-id` and `thread-id` headers remain available. Exact account - selectors must stay outside automatic Pool affinity. -- 검토한 주요 대안: Leave reconnects unbound, persist a plain hash, bind from either header alone, - delete App turn metadata, or derive one process-local key from the complete pair. -- 선택한 방식: Preserve the parent-thread key when present; otherwise HMAC the two bounded headers - under a random per-process key and carry that opaque value through selection, subagent preview, - and outcome handling. -- 다른 대안 대신 이 방식을 선택한 이유: A complete pair avoids weak partial identities, a - process-local HMAC prevents durable correlation or dictionary recovery, and no upstream metadata - needs to be mutated before the first-403 cause is proven. -- 장점, 단점 및 영향: Reconnects stop rotating among Pool accounts and failure accounting clears - the correct binding. Affinity intentionally resets on process restart, and requests missing either - component retain the prior unbound behavior. +> Decision record: [ADR-0085](../decisions/ADR-0085-public-provider-contract.md) An explicit `Retry-After` or an unclassified quota 429 is account-wide. A reset-derived native-model 429 is advisory and remains within its confirmed quota group: `gpt-5.3-codex-spark` is separate from @@ -255,19 +233,7 @@ If the caller lacks the requested model, a stored-account model detour may serve clearing the healthy shared main pin. A paused or quota-drained main skips this exception and follows the ordinary Pool promotion path. -[Decision Log] -- 목적과 의도: Keep an explicit healthy main selection from being replaced by an exhausted stored - account merely because the client supplied main through a request-owned keyring bearer. -- 기존 구현 및 제약 조건: Request-owned credentials are deliberately excluded from stored-account - entitlement discovery, but shared-state preservation interpreted that exclusion as a dead main login. -- 검토한 주요 대안: Persist the caller credential, read the physical main token for identity, ignore - the manual pin, or validate the caller independently before stored-Pool selection. -- 선택한 방식: Use only the effective pin, pause state, cached quota, and the caller credential's own - gated-model check; synthesize shared-state liveness only while main stays request-ineligible. -- 다른 대안 대신 이 방식을 선택한 이유: It preserves credential isolation and explicit operator - intent without admitting an unentitled model or binding an ephemeral bearer into durable Pool state. -- 장점, 단점 및 영향: Healthy main pins survive keyring requests and model-only detours; cached quota - remains the only proactive drain evidence available without crossing the physical credential boundary. +> Decision record: [ADR-0086](../decisions/ADR-0086-public-provider-contract.md) ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option @@ -338,37 +304,9 @@ preserving a stale one would block every later migration. The optional `prompt_cache_retention` hint is removed on this route because Daybreak's authenticated catalog does not advertise it and upstream rejects it before execution. -[Decision Log] -- 목적과 의도: Preserve the account-gated Daybreak UX while avoiding shard-dependent selector - rejection and the unsupported prompt-cache retention parameter. -- 기존 구현 및 제약 조건: The authenticated roster grants Daybreak, but live successful - responses report `gpt-5.6-sol`; the selector can still fail eight consecutive times. -- 검토한 주요 대안: Increase retries indefinitely, hide Daybreak entirely, or canonicalize only - the credential-bearing wire model after entitlement selection. -- 선택한 방식: Keep Daybreak for visibility and account authorization, then send the stable - serving id and remove only the unsupported optional retention hint. -- 다른 대안 대신 이 방식을 선택한 이유: It keeps fail-closed entitlement checks and avoids - unbounded duplicate requests while preserving the user-facing model choice. -- 장점, 단점 및 영향: Requests become deterministic and cheaper; this relies on the serving id - observed from successful upstream responses and must be revisited if the roster exposes a - first-class wire id later. - -[Decision Log] -- 목적과 의도: Prevent account-gated native models from being shown or dispatched through a - ChatGPT account that upstream does not authorize. -- 기존 구현 및 제약 조건: A static global Daybreak row solved clean-install discovery for - entitled accounts, but Pool accounts can hold different grants and Codex's injected catalog does - not refresh itself. -- 검토한 주요 대안: Infer grants from plan labels, learn only from prompt failures, bind Daybreak - permanently to main, or rewrite the wire id to `gpt-5.6-sol`. -- 선택한 방식: Share bounded authenticated per-account model-roster evidence between catalog sync, - `/v1/models`, and Pool auth selection. -- 다른 대안 대신 이 방식을 선택한 이유: Plan labels and account position do not prove a grant; - failure-only learning wastes a turn; permanent main binding rejects valid secondary grants; wire - rewriting changes the requested product identity. -- 장점, 단점 및 영향: Entitled accounts retain clean-install discovery while unentitled accounts - never receive the gated dispatch. A cold gated request may pay one bounded roster fetch per - account, and an unavailable discovery temporarily hides the model rather than guessing. +> Decision record: [ADR-0087](../decisions/ADR-0087-model-and-wire-identity.md) + +> Decision record: [ADR-0088](../decisions/ADR-0088-model-and-wire-identity.md) - The two GPT-5.6 surfaces advertise different windows on purpose. API rows use 1,050,000 context with 922,000 max input. Codex-login rows default to the live catalog 272,000 (auto-compact 244,800) and only rise to 922,000 / 829,800 when the user turns the 1M @@ -380,7 +318,7 @@ preserving a stale one would block every later migration. spending budget, not a label: Codex fills `context_window * effective_context_window_percent` (95% by default, codex-rs `turn_context.rs`). Advertising 1,050,000 there spent 997,500 and blew past the ceiling. The 922,000 opt-in yields a 875,900-token budget and keeps ~46k of - headroom. Evidence: `devlog/_plan/260817_native_gpt56_1m_context/001_measurement_evidence.md` + headroom. Evidence: `devlog/_fin/260817_native_gpt56_1m_context/001_measurement_evidence.md` and `014_final_922k_with_margin.md`. - `*-pro` selected ids rewrite to the base wire id with `reasoning.mode: "pro"`; request logs, usage, model visibility, subagent state, and injection state retain the selected virtual id. @@ -397,15 +335,7 @@ use the same process-local tags, while unknown fields contribute only a count. O classified without hashing. The diagnostic is observational: it cannot strip headers, retry, switch accounts, reset threads, or mutate affinity. -```text -[Decision Log] -- 목적과 의도: Identify which combined Codex affinity values survive a Plus-to-K12 credential substitution without collecting private thread or account data. -- 기존 구현 및 제약 조건: Pool auth intentionally copies the curated caller metadata and replaces only authorization plus chatgpt-account-id. Individual header probes did not reproduce the workspace denial, while raw captures would expose account-bound identifiers. -- 검토한 주요 대안: Delete all affinity metadata; log raw values; persist ordinary hashes; perform automatic header-ablation retries; or emit process-local keyed equality evidence only when provider debug is enabled. -- 선택한 방식: Emit bounded pre-stream diagnostics with a random per-process HMAC key, a fixed non-credential header allowlist, known turn-field summaries, and no request mutation. -- 다른 대안 대신 이 방식을 선택한 이유: Equality across two requests in one run is enough to narrow the incompatible combination; process-local HMACs prevent durable correlation and make offline guessing useless, while observation-only capture cannot change production semantics. -- 장점, 단점 및 영향: Maintainers can compare a Plus success and exact-K12 denial safely. Tags cannot be compared across restarts, and the diagnostic does not itself identify an upstream policy rule or fix the rejection. -``` +> Decision record: [ADR-0089](../decisions/ADR-0089-process-local-affinity-diagnostics.md) ## Account identity and store concurrency diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md new file mode 100644 index 0000000000..765b85a763 --- /dev/null +++ b/structure/providers/xai-grok.md @@ -0,0 +1,43 @@ +# xAI Grok Provider + +## xAI Grok hardening (official Grok Build contract parity) + +Grounded in the open-sourced official client (xai-org/grok-build); unit + evidence: +`devlog/_fin/260716_grok_build_hardening/`. + +- **Reasoning folding:** the Responses parser folds `reasoning` items into the FOLLOWING + assistant turn (`pendingReasoning` in `src/responses/parser.ts`) so the Grok chat wire carries + ONE assistant message with `reasoning_content` — exact-prefix cache stability. Unsigned + siblings newline-join; `ocxr1`-signed siblings stay separate parts (Anthropic replay keeps + each signature on its own text); boundaries (user/tool-result/agent) clear pending state; + call items fold pending reasoning into the same turn. +- **Grok CLI credential ownership:** `source:"local-cli"` xAI credentials re-read + `~/.grok/auth.json` (read-only) before any refresh and adopt a newer usable generation with + zero IdP calls (`shouldAdoptGrokGeneration`, later-expiresAt authority); an IdP refresh + detaches the credential to `source:"oauth"`. +- **Two-lock refresh transaction:** per-provider+account intent lock held across the IdP + exchange plus a short global store-write lock + async mutation funnel around every + `auth.json` load-merge-persist (`src/oauth/store.ts`); generation-guarded persist + (`expectedGeneration` → superseded adoption), conditional `needsReauth`, bounded jittered + retry for transient token-endpoint failures. +- **Reactive 401 replay:** both the adapter recovery loop and native Responses passthrough branch + force-refresh once (singleflight, generation-checked) and replay OAuth-backed xAI requests + exactly once with a re-resolved transport; API-key/BYOK paths are excluded + (`src/server/responses/core.ts`). +- **Header parity:** per-attempt `x-grok-req-id` (fresh UUID inside the transport fetch + wrapper), stable session/conv affinity headers, always-set User-Agent, and a single + compatibility profile const for the Grok client version (`src/providers/xai-transport.ts`); + `fetchWithHeaderTimeout` takes an executor so provider fetch wrappers stay inside the + timeout race. + +The generated Grok client marker also enables a client-facing sparse-terminal repair for native +Responses streams. Grok Build renders text deltas immediately but derives its durable assistant +turn from `response.completed.response.output`; an OpenAI-compatible stream may instead place the +complete items in `response.output_item.done` and finish with an explicit empty output array. For +that marked client only, OpenCodex uses a terminal-only tracker: it retains bounded, contiguous, +unique and semantically valid raw completed items, then backfills a missing or empty terminal +snapshot. It never promotes locally synthesized or merely repaired items. Unmarked callers continue +to treat an explicit empty array as authoritative. Within this marked client-facing repair, +malformed, gapped, oversized, contradictory, failed, or incomplete streams stay fail-closed. + +> Decision record: [ADR-0059](../decisions/ADR-0059-xai-grok-hardening-official-grok-build-contract.md) diff --git a/structure/01_runtime.md b/structure/runtime.md similarity index 80% rename from structure/01_runtime.md rename to structure/runtime.md index 36c9a282be..3099c13bfd 100644 --- a/structure/01_runtime.md +++ b/structure/runtime.md @@ -1,4 +1,4 @@ -# Runtime SOT +# Runtime ## Entrypoints @@ -76,13 +76,7 @@ sockets before lifecycle release. The existing launchd/systemd installer remains and continues loading the data token from `service-api-token`; hub mode adds no service-manager fork and no token-bearing unit/plist field. -[Decision Log] -- 목적과 의도: Give a headless hub a browser management ingress without widening its data plane or trusting spoofable forwarding headers on the public listener. -- 기존 구현 및 제약 조건: `startServer` is synchronous through Lab activation, already owns an optional-listener transaction, and the service installer already has an owner-only token-file flow. -- 검토한 주요 대안: Add management routes to the public listener; infer trusted ingress from `Host`/`Forwarded`/Tailscale headers; create a separate service manager; extend the existing composition root. -- 선택한 방식: Bind a third socket exactly to `127.0.0.1`, select trust by receiving `Bun.serve` instance, keep a fixed route allowlist, and reuse the current launchd/systemd definitions. -- 다른 대안 대신 이 방식을 선택한 이유: Headers do not prove which transport received a request, while a kernel loopback bind plus Tailscale Serve supplies a concrete ingress boundary without duplicating lifecycle or secret delivery. -- 장점, 단점 및 영향: Public/default behavior stays unchanged and management can use Tailscale identity; operators must provide a co-located HTTPS frontend and pairing remains necessary for generic TLS proxies. +> Decision record: [ADR-0002](decisions/ADR-0002-lifecycle.md) The process-state boundary deliberately exposes two PID checks. `readAlivePid()` is the cheap non-destructive probe used by liveness polling. `readPid()` and `verifyPidIdentity()` include the @@ -97,13 +91,7 @@ scans may proceed if verification succeeds or the holder exits. The allowlist na eligibility and supplies no identity evidence by itself. This contract uses the existing verifier; it does not add process-instance proof or change the classification cache. -[Decision Log] -- 목적과 의도: Separate proxy process ownership from persisted configuration without changing lifecycle behavior. -- 기존 구현 및 제약 조건: `src/config.ts` mixed config transactions with cross-platform PID identity, runtime-port attestation, and stale-state cleanup; process writes still require the same config-home and atomic-write protections. -- 검토한 주요 대안: Keep the mixed module; create a process-state module that imports `config.ts`; duplicate atomic writes inside the new module; split the minimal path and atomic-write foundations first. -- 선택한 방식: `paths.ts` and `atomic-write.ts` are dependency leaves, `process-state.ts` depends only on those leaves, and `config.ts` remains a compatibility facade. -- 다른 대안 대신 이 방식을 선택한 이유: Importing the facade would create a cycle, while duplicated writes could drift on ACL, symlink, residual-secret, and atomic-sequence behavior. -- 장점, 단점 및 영향: Lifecycle callers have a narrow owner and behavior remains characterized; the temporary facade and three small config modules add files but preserve downstream imports. +> Decision record: [ADR-0003](decisions/ADR-0003-lifecycle.md) An installed Codex shim is checked on ordinary CLI startup with a regular-file/1 MiB state bound plus bounded metadata and prefix reads. A complete replacement must produce identical fingerprints and @@ -143,13 +131,7 @@ the client never sees a completed call ahead of `response.failed` / `response.in The server exposes `POST /api/stop` which restores native Codex config, stops any installed service (to prevent respawn), and exits the process. The GUI sidebar stop button calls this endpoint. -[Decision Log] -- 목적과 의도: Prevent repository dotenv data from becoming a durable executable or an OAuth-bearing Claude destination. -- 기존 구현 및 제약 조건: Bun auto-loads project dotenv before OpenCodex TypeScript evaluates, while provider interpolation still depends on that behavior and cannot be disabled globally. -- 검토한 주요 대안: Reject only relative Bun paths; disable Bun dotenv; trust a plain environment marker; capture provenance in the Node launcher and bind it to an argv proof. -- 선택한 방식: The Node launcher selects Bun and snapshots Anthropic credential/destination slots before Bun starts. Durable runtime selection uses only the stamped current executable, while Claude accepts the snapshot only when its random argv proof matches. -- 다른 대안 대신 이 방식을 선택한 이유: Absolute dotenv expansion bypasses a relative-path check, global dotenv removal breaks supported configuration, and an environment-only marker can itself come from dotenv. -- 장점, 단점 및 영향: Normal npm launches preserve genuine shell overrides. Direct Bun or legacy launches have no provenance signal and fail closed for all three ambient Anthropic slots — credentials included, because subscription mode leaves `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST` unset by design (#253) and a `settings.env` merge can still replace the destination after launch, so a preserved key would travel with it. The cost is that `bun src/cli/index.ts` loses ambient Anthropic values; the escape hatch is running through the published `ocx` bin, where genuine shell exports are preserved by proof. Durable artifacts use the running or bundled Bun. +> Decision record: [ADR-0004](decisions/ADR-0004-lifecycle.md) ## Providers and adapters diff --git a/structure/subagents.md b/structure/subagents.md new file mode 100644 index 0000000000..e87fa4e34a --- /dev/null +++ b/structure/subagents.md @@ -0,0 +1,197 @@ +# Subagents And Multi-Agent Surface + +## Multi-agent surface mode (3-state) + +`OcxConfig.multiAgentMode` controls the `multi_agent_version` field stamped on catalog entries: + +| Mode | Behavior | +| --- | --- | +| `"v1"` | Force ALL entries to `multi_agent_version = "v1"` — overrides upstream pins (sol/terra included). | +| `"default"` (install default) | Respect upstream model pins (sol/terra=v2, luna=v1, others=null → codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. | +| `"v2"` | Force ALL entries to `multi_agent_version = "v2"` — overrides upstream pins (luna included). | + +The override is applied as a final pass in both `buildCatalogEntries` (live `/v1/models` path) and +`mergeCatalogEntriesForSync` (on-disk sync), AFTER all normalization and visibility processing. This +ensures `normalizeRoutedCatalogEntry` (which deletes `multi_agent_version` from routed entries) does +not clobber the forced value. + +CLI: `ocx v2 mode v1|default|v2`. GUI: segmented control on the Models page. API: `GET/PUT /api/v2` +with `multiAgentMode` field. + +The `multi_agent_v2` feature flag and the logical maximum thread count are separate from +`multiAgentMode` (`src/codex/features.ts`): the mode decides which surface Codex advertises, while +the flag and thread count decide what the native runtime allows. + +`keepNativeChatGptOnV1` makes mode `v2` a catalog-driven hybrid: OpenCodex disables the global +`multi_agent_v2` override because codex-rs resolves that override before a model row's explicit +`multi_agent_version`. Native ChatGPT rows then select v1 from the catalog and routed rows select +v2. An explicit attempt to enable the global flag while the hybrid pin is active is rejected. + +### What the five-model `spawn_agent` window is, and how V1 differs from V2 + +`MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5` (mirrored in `src/codex/catalog/sync.ts`) is **not** a +subagent concurrency limit and **not** an eligibility limit. Upstream uses it in exactly two +places: the model list rendered into the `spawn_agent` tool description +(`multi_agents_spec.rs:789`) and the "Available models:" suggestions in an unknown-model error +(`multi_agents_common.rs:448`, inside the `ok_or_else` closure that runs only *after* the lookup +already failed). The success path `find_spawn_agent_model_name` (`:431-442`) scans the whole +catalog with neither the cap nor a `show_in_picker` filter, so a model outside the advertised +five is still accepted when named exactly. + +Three different numbers, often conflated: + +| Quantity | Value | Source | +| --- | --- | --- | +| Models **advertised** as overrides | `min(5, picker-visible eligible rows)` | `multi_agents_spec.rs:785-790` | +| Models **eligible** as targets | no numeric cap (only `"disabled"` is excluded, and only on V2) | `multi_agents_common.rs:36-42` | +| **Concurrent** subagents | V1 6 children (root excluded); V2 total 4 including root → 3 children | `config/mod.rs:211-212`, `:1497-1506` | + +**The cap is the same 5 on both surfaces, but the window's contents are not.** The eligibility +filter runs *before* `.take(5)`, and it behaves differently per surface: on a V1 call +`model_supports_multi_agent_backend` short-circuits true for every row (including `disabled` +ones), while a V2 call drops `Some(Disabled)` first — which lets a later row move into the five. +Same catalog, different advertised list: + +| # | Model | pin | V1 advertises | V2 advertises | +| ---: | --- | --- | :---: | :---: | +| 1 | `v2-a` | `v2` | ✅ | ✅ | +| 2 | `disabled-a` | `disabled` | ✅ | — | +| 3 | `v1-a` | `v1` | ✅ | ✅ | +| 4 | `null-a` | absent | ✅ | ✅ | +| 5 | `v2-b` | `v2` | ✅ | ✅ | +| 6 | `disabled-b` | `disabled` | — | — | +| 7 | `null-b` | absent | — | ✅ | + +opencodex already matches this: `effectiveSubagentRoster` filters with +`surface !== "v2" || isEligibleV2SubagentEntry(entry)`, so the V1 path skips the eligibility +filter exactly as upstream does. opencodex also injects no roster on V1 +(`src/server/responses/collaboration.ts` emits only proactive text at the top effort tier), so +the upstream tool description remains the authority there. + +Two further V1/V2 differences worth knowing: the list gate is +`hide_agent_type_model_reasoning` on V1 (hard-coded `false` at registration, so V1 always +advertises) but `expose_spawn_agent_model_overrides` on V2 (default `true`; when false the list +is omitted *and* the `model`/`reasoning_effort` schema fields are removed). And V2's +`hide_spawn_agent_metadata` defaults true, which removes `service_tier`. + +`modelPickerOrder` (#1649) separates **OpenCodex guidance** from native advertisement. +`SPAWN_PRIORITY_FIELD` preserves the natural priority used by `effectiveSubagentRoster`, so +OpenCodex's preferred/guidance candidate calculation stays independent of display order. +Native Codex ignores that private field: its advertised five on V1 and exposed V2 follow the +native `priority` and may change when the picker is reordered. Exact-name override lookup is +not restricted to those five advertised rows. V1 receives no OpenCodex preferred-roster +injection; V2 can additionally receive natural-priority guidance when its catalog state permits. +The helper tests pin guidance behavior, not native tool-description equivalence. + +A nonblank bare id in `modelPickerOrder` opts into complete-picker display ordering. Exact +ids take precedence over raw/encoded equivalents; routed-only and empty lists keep the legacy +ordering behavior. This does not change the separate `opencodex_spawn_priority` contract. +Retained rows recompute their natural ranks from the current featured roster and account-selector +stride before display order is applied, so a discovery outage cannot preserve an obsolete +featured or picker rank. Canonical `opencode-go` rows retain their configured reasoning ladder +both when generated and when merged from retained catalog state; synthetic max/ultra choices +are not added to that provider's declared ladder. + +Full derivation with per-line citations: `devlog/_plan/260816_codexrs_multiagent_v2_and_history_perf/013_five_cap_v1_vs_v2.md`. + +## Subagents + +New non-OAuth provider registrations carry `initialModelSelection` with a unique +registration identity. Until reliable live/static discovery completes, public +catalogs and model candidates withhold those providers' models; the provider itself +stays active. At 20 or more canonical Models switch rows, initialization appends +all corresponding disabled selectors once. Existing registrations and later manual +choices are not reinitialized. OAuth/ChatGPT forwarding is exempt using the same +usable-key override predicate as routing. Display aliases do not add switch rows. + +`src/providers/initial-model-selection-runtime.ts` commits the decision against a +matching registration/inventory snapshot before catalog authority is captured. +Ordinary management discovery also completes it with Codex integration OFF. The +final catalog merge fences pending retained rows, including delete/re-add recovery. +Raw management rows remain visible as pending/OFF. Config listener bindings are +excluded from inventory identity because live and persisted bindings may differ. + +Codex `spawn_agent` advertises only the highest-priority first five picker-visible catalog rows. +Use at most five configured `subagentModels` ids; they may contain bare catalog ids, routed +`provider/model` ids, or exact account-qualified `/` ids. The +dashboard offers bare native and routed choices; exact account-qualified choices are configured +through `ocx agent subagents set` or the opencodex configuration. + +When account selectors are active, one featured bare native id expands into a complete selector row +group. Catalog priorities use the selector count as a stride so each group stays together without +widening Codex's five-row advertisement window. Fresh defaults are Astra, Sol, Terra, Luna, 5.5. +Startup upgrades unmarked rosters once: prepend `gpt-6-astra`, retain the first four unique +non-Astra choices, then move retained bare `gpt-5.5` last. The old fifth choice is dropped; +an unmarked empty list becomes Astra only, and an unset list receives the fresh defaults. +`subagentModelsVersion: 1` records completion, so later user edits (including an empty list or +removing Astra) persist. The migration rebases on the latest disk config under the existing +mutation lock; failed persistence degrades to an in-memory roster for that run without a stale +whole-config overwrite. Existing disabled-model visibility rules remain unchanged. + +Quota-aware fallback walks a configured chain when the featured model is exhausted, probing +availability on a bounded interval (default 60 s, `src/codex/subagent-model-fallback.ts`). It rewrites +the requested model id only; effort remains owned by the caps described under +[Ultra reasoning level](#ultra-reasoning-level). + +`injectionModel` and `injectionEffort` are shared selections with two independent consumers. +`multiAgentGuidanceEnabled` controls only OpenCodex-authored delegation guidance. +`syncCodexSubagentDefaults` is a separate, default-off opt-in that applies the selected values to +Codex's native `[agents]` defaults on sync/restart for newly created Codex tasks when OpenCodex owns +the active Codex routing; external user-managed provider configs remain untouched. It does not itself +cause delegation. The TOML edit owns only marker-tagged values, preserves existing unmarked +user-owned `[agents]` defaults rather than overwriting them, and rejects ambiguous table shapes +without changing the file. + +V2 proxy guidance uses `` for both built-in metadata and +custom `injectionPrompt` bodies. The built-in text reports the resolved preferred model, +effort, roster and fallback chain without prescribing delegation, spawn overrides or +`fork_turns`. Custom bodies retain their placeholder behavior. The guidance switch and +catalog-state gates still apply; stale or unknown catalog state suppresses proxy guidance. +V1 uses the shared `MULTI_AGENT_MODE_HINT_RECOMMENDATION.text` inside `` +at `max` or `ultra`. Only the separate explicit delegation-request trigger changes; user, +authority, task-scope and collaboration-tool rules remain applicable. This is guidance, +not an enforcement mechanism or a change to native settings or tool access. + +Replay deduplication compares the latest exact generated developer text separately for +each tag family, preserving built-in → custom → built-in transitions without duplicating +unchanged proxy metadata after a native policy change. Native and legacy-tagged history +remain intact: tags do not establish historical authorship or revoke old instructions, +and mixed-version transition detection is not guaranteed. + +The native mode hint is separate from proxy guidance and native `[agents]` defaults. +`src/codex/multi-agent-mode-policy.ts` owns the proactive recommendation; the dashboard +obtains it from `/api/v2` rather than maintaining its own preset. An explicit dashboard, +API or CLI hint write passes through `setMultiAgentModeHintText`, which replaces only +the two byte-exact released OpenCodex presets with the current recommendation. Other +valid custom text, including whitespace variants, is preserved. Reads, unrelated writes +and upgrades do not migrate stored hints. The writer retains its native capability check +and stores only `features.multi_agent_v2.multi_agent_mode_hint_text` in Codex TOML; +`null` removes that key. The hint affects new native Codex sessions when their v2 surface +is active, without changing reasoning effort or the proxy guidance switch. + +Claude Code `ocx-*` agent definitions consume the same effective `claudeCode.blockedSkills` policy +as inbound bundle elision. When the list is non-empty (default: `claude-api`), generated definitions +whose marker-stripped model resolves to a routed id receive a preventive instruction not to invoke +those skills. Direct `provider/model` selectors are routed even when their inbound resolution is +identity. The only unguarded `ocx-self` case is an identity-resolved `claude|anthropic` model while +native passthrough is enabled; `modelMap` claims and `nativePassthrough:false` restore the guard. The +guard avoids creating oversized skill messages before the proxy can intervene; inbound elision remains +the fallback if a client still sends a blocked bundle. An explicit empty list disables both routed-model +behaviors. + +> Decision record: [ADR-0027](decisions/ADR-0027-subagents.md) + + +### Saved picker presets + +The Models page saves routed snapshots in `modelPickerOrder` and records their origin in +`modelPickerOrderMode` (`alphabetical`, `provider`, `most-used`). Mode is UI provenance, not a +catalog sorting policy: catalog writers consume the saved array. Routed-only featured/native +bands and complete-picker natural-rank preservation remain as described above. Public +`buildCatalogEntries` accepts the order as its final argument and applies the complete-order +pass after building. On-disk convergence retains its existing post-merge final pass. + +Claude ModelInfo ordering receives optional `{ modelPickerOrder, featured }` after `fastRows`. +It orders routed output groups after alias deduplication, preserving the collision winner and +base/1M/Fast siblings. Native groups and explicit Desktop profile ownership are unchanged. +Native Codex advertisements still follow display priority; private guidance ranks do not freeze them. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md new file mode 100644 index 0000000000..dc9af564d6 --- /dev/null +++ b/structure/transports/inventory.md @@ -0,0 +1,59 @@ +# Transport Inventory + +## Transport inventory + +The sections above cover the transports with load-bearing invariants. The rest of the transport +surface is listed here so a maintainer can find the owner without grepping: + +| Transport | Owner | Invariant worth knowing | +| --- | --- | --- | +| Azure OpenAI Responses | `src/adapters/azure.ts` | Deployment-shaped URLs on top of the Responses contract. | +| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses the shared abort/deadline helpers (`src/lib/upstream-retry.ts`), wire-body repair, and upstream error normalization. | +| Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | +| Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. | +| Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | +| Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/http1-bidi.ts`, `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts`, `src/adapters/cursor/checkpoint-store.ts` | Thread continuity is the point: a retry must not start a new Cursor thread, and a validated checkpoint must not rebuild the full root history. HTTP/2 remains the default; an explicit `http1.1`/`h1` pin maps the bidi run onto Cursor's `RunSSE` receive stream plus sequenced `BidiAppend` sends, and applies to live discovery too. | +| Claude Messages | `src/server/claude-messages.ts` | Routed translation, a native Anthropic passthrough branch, and `count_tokens`. | +| Chat Completions inbound | `src/server/chat-completions.ts`, `src/chat/` | Inbound translation onto the same routing pipeline. The content mapper preserves image URLs and supported detail, including screenshot-bearing tool results; target adapters own image placement on their wire. Image-free tool results stay strings. | +| Hosted search relay | `src/server/search.ts` | Direct relay; distinct from the web-search sidecar loop below. | +| Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. | +| GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | +| API-key pools | `src/providers/api-key-selection.ts`, `src/providers/key-failover.ts` | A 429 rotates the active key and records a cooldown; `provider.apiKey` keeps mirroring the active entry so routing stays single-key. | +| OAuth account failover | `src/oauth/generic-account-failover.ts`, `src/oauth/anthropic-routing.ts` | Reactive pre-output 429 recovery is presence-driven with 2+ eligible accounts. Pool and `oauthAccountFailover` flags govern proactive routing, not the reactive retry: a disabled Anthropic pool recovers through quota ordering rather than its dormant strategy, and a per-provider `enabled` beats the global default in either direction. | +| Alibaba regions | `src/providers/alibaba-region-backup.ts`, `src/providers/alibaba-region-migration.ts`, `src/providers/alibaba-region-startup.ts` | Region migration backs up before rewriting and is idempotent across restarts. | +| Discovery and quota | `src/providers/model-discovery.ts`, `src/providers/quota.ts` | Discovery rejects a response over 4 MiB or past 2,000 raw rows before caching it. | + +> Decision record: [ADR-0072](../decisions/ADR-0072-transport-inventory.md) + +Cursor external-model continuations attach data-URL screenshots from the contiguous active +tool-result batch through the existing image preparation and selected-context owners. The batch +shares the 12-image active cap. Bounded source labels are emitted in active user-action text so +root pruning cannot erase attachment provenance; the same text participates in token estimation. +Native Composer/MCP behavior and text-only historical replay remain unchanged. + +## Provider diagnostic outbound safety + +Provider connection tests and live model discovery share the GET-only provider outbound wrapper. +Direct HTTP(S) resolves once and pins the validated address; HTTPS preserves the original Host/SNI +and always verifies certificates. Proxy-configured requests stay on Bun fetch so HTTP(S)_PROXY, +ALL_PROXY, and NO_PROXY semantics remain authoritative. The wrapper classifies successful local DNS answers, but +only a typed DNS-resolution failure degrades to proxy resolution; every literal, metadata, and +resolved-address policy error still rejects. Proxy mode logs once that the proxy-selected peer +cannot be pinned. Private destinations additionally require allowPrivateNetwork plus NO_PROXY. + +Two fake-IP DNS accommodations exist, both for resolved answers only (a literal address in the URL +still rejects). The IANA benchmark range (198.18/15 and its IPv4-mapped IPv6 spellings) is admitted +whenever any outbound proxy applies to the host, because the range itself marks the answer synthetic. +Mihomo's default IPv6 fake-IP range (fdfe:dcba:9876::/48) is ULA and carries no such mark, so it is +admitted only when the proxy variable that matches the URL scheme is set (HTTPS_PROXY for https:, +HTTP_PROXY for http:; ALL_PROXY is not consulted because Bun fetch does not honour it), the host is +not in NO_PROXY, and the request is then bound to that proxy through Bun's explicit `proxy` option +rather than environment inference. Both gates live in the outbound wrapper, not in classification: +`classifyIpv6` and config-time validation (`providerDestinationResolvedError`) never admit the +ULA, so provider save-time checks are unaffected (#3462). + +Both paths reject redirects and expose only credential-stripped final-address guidance. This phase +does not cover ordinary requests, streaming, retries, or per-hop redirect review on those paths. +Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and +redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer +executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. diff --git a/structure/transports/responses.md b/structure/transports/responses.md new file mode 100644 index 0000000000..b667d10b53 --- /dev/null +++ b/structure/transports/responses.md @@ -0,0 +1,468 @@ +# Responses Transport + +## Responses HTTP/SSE + +`/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a +provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to +Responses-compatible streaming output. + +### Credential-bearing HTTP redirects + +Credential/body-bearing HTTP sends use `redirect: "manual"` at the final executor boundary, +including dispatch overrides and adapter/sidecar retries. `fetchWithHeaderTimeout` retains its +legacy final argument for callers but no longer permits default-follow sends. Both same-origin +and cross-origin redirects remain observable responses: retry helpers must not synthesize a 502 +before the owning route can apply its existing response and health policy. Native Responses and +compact retain their 3xx/Location relay contract; image and search sidecar owners consume 3xx +through their existing upstream-error path without relaying Location. This server policy does not govern client-side +redirect following; providers requiring a redirect must be configured with their final API URL. + +### Fetch-helper import boundary + +`src/server/responses/fetch-helpers.ts` is a transport leaf shared by Responses, compact, and native +Chat. Its runtime imports are limited to the Codex WebSocket transport, provider request pacing, and +the upstream HTTP-version helper. Server, provider, and WebSocket data types remain type-only edges. +It must not import routing, combos, OAuth, adapters, sidecars, response parsing, logging, or relay +modules merely because those imports existed in the pre-split `responses.ts` monolith. + +### Semantic progress ownership + +The Responses proxy does not treat transcript growth as repository progress. It can observe request +boundaries, response items, tool names and payloads, adapter events, retained bytes, and elapsed +silence. It cannot observe the client's workspace or prove whether a successful tool result changed +repository state. Consequently, the active-turn and session-lane gates are concurrency admission +limits, the translator budget is a live retained-byte limit, the response-state caps are cache +retention limits, and the stall watchdog is a silence limit. None is a cumulative continuation or +semantic no-progress budget. + +> Decision record: [ADR-0031](../decisions/ADR-0031-responses-http-sse.md) + +> Decision record: [ADR-0032](../decisions/ADR-0032-responses-http-sse.md) + +> Decision record: [ADR-0033](../decisions/ADR-0033-responses-http-sse.md) + +> Decision record: [ADR-0034](../decisions/ADR-0034-responses-http-sse.md) + +> Decision record: [ADR-0035](../decisions/ADR-0035-responses-http-sse.md) + +> Decision record: [ADR-0036](../decisions/ADR-0036-responses-http-sse.md) + +> Decision record: [ADR-0037](../decisions/ADR-0037-responses-http-sse.md) + +Two coordinates that lower to the same wire name are treated as one tool when they denote one: +`buildTools` flattens the reserved `functions` group without a namespace, so a bare declaration and +a `functions` child of the same name are the duplicate the parser already tolerates — and the one +`promoteClientLoadedTools` produces. The declaration is emitted once instead of failing the request. + +Replayed call items are lowered whether or not this turn declares the group they name. A catalog can +be absent or change mid-session, but the client is still replaying items this layer's own response +restoration stamped with a private `namespace`. Routed compaction runs this boundary before removing +the tool surface so request-local aliases remain available for response restoration. Only +`tool_choice` resolves a bare name through the catalog: a history +item records which tool actually ran, so re-pointing it at a same-named namespace child would +rewrite that record on a coincidence rather than translate it. + +Codex-private tool fields are removed at the same boundary from one table +(`CANONICAL_ONLY_TOOL_FIELDS`) rather than one bespoke pass each: `external_web_access` on either +web-search variant, and `defer_loading` on any declaration, which `activateDeferredTool` clears only +for tools a `tool_search_output` already loaded. A new private bit is a row there. + +After that namespace boundary has produced public function tools, the Grok CLI Responses transport +applies the same root-schema policy as its Chat transport. A root `oneOf`/`anyOf` is flattened only +when the shared xAI normalizer can preserve its meaning; an unsafe function is omitted instead of +letting one incompatible declaration reject the entire request before inference. This is scoped to +`cli-chat-proxy.grok.com`: public `api.x.ai` keeps native root unions, as do unrelated Responses +gateways. Both top-level `tools` and Responses Lite `additional_tools` pass through this policy. + +Only the ROOT rejects a union, so exclusivity is preserved by moving it down rather than widening +it: a root `oneOf` whose branches differ in one property becomes that property's `oneOf`, or its +`anyOf` when the branches are provably disjoint and the two keywords describe the same set. That +property is also promoted into `required`, because absent it matched every branch — which the root +`oneOf` rejects. Branches that are wholly identical validate nothing and have no faithful +flattening, so they omit the tool. The walk carries depth, node, and variant budgets, since nested +unions are combinatorial and a `$ref` diamond amplifies the same way without ever cycling; +exceeding a budget omits that one function rather than expanding until memory is gone. + +Omitting a function makes `tool_choice` the loose end. A selector naming a dropped tool would reach +Grok as a dangling reference, and relaxing it to `auto` is worse — the turn would quietly run +without the tool the caller required. So an `allowed_tools` list drops the omitted entries while any +remain, and a selection with nothing left to point at fails locally with the same 400 a tool catalog +this proxy cannot lower already returns. + +The same noncanonical boundary strips ChatGPT's private `external_web_access` bit from routed +`web_search` declarations. The public tool remains enabled and all other options remain intact; +canonical OpenAI forwarding preserves the bit. xAI's public Responses schema enables browsing by +the presence of `web_search` and rejects the private argument, so forwarding it made the first +post-namespace request fail with HTTP 400. + +The option-aware `openai` provider uses `openai-responses` with `authMode: "forward"`. Pool mode +resolves main plus added accounts through affinity/quota/cooldown ownership; Direct forwards only +the allowed Codex/OpenAI auth/session headers from the current request and short-circuits pool +state. `openai-apikey` uses its configured key and canonical API base URL. Missing credentials fail +within their route; neither route falls through to the other. See +[`openai-tiers.md`](../providers/openai-tiers.md). + +### Routed service-tier capability + +OpenAI-compatible service-tier support is resolved only after the final provider/model wire is +known. `supportsServiceTier` remains the provider fallback, while the exact +`modelSupportsServiceTier` map can override it per upstream model, including an explicit `false`. +The catalog and request path share this decision: a routed row publishes `service_tiers` only when +the resolved policy is eligible, and the final-route normalizer applies the same gate to +`service_tier`. Both `openai-responses` and `openai-chat` use the resolved provider/model capability +for catalog publication, routing evidence, and fingerprints. Canonical Fast injection additionally +requires a compatible FastWire mapping on the final adapter and an eligible policy. Setting +`fastMode: false` drops it. On classified Chat routes, `chatServiceTier` separately authorizes +foreign caller values; an exact-model `true` does not grant that forwarding permission. On +unclassified Chat routes it gates every caller tier because no canonical Fast capability has been +validated. An object-form registry wire default may also set `forwardCallerServiceTier: false` to +close a known subscription gateway while leaving generic unclassified Responses passthrough +unchanged. Exact `false` +narrows provider defaults, and provider-level `supportsServiceTier: false` cannot be reopened. +Capability is namespaced by the selected provider and model; model-name similarity and adapter type +alone never opt a gateway in. + +`POST /v1/responses/compact` handles remote compaction v1 before the generic `/v1/responses` branch +and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through +to GUI static serving. + +Combo compaction recall uses accepted completed-response callbacks to record the final client-visible +model and originating combo target. The existing child callback gate defers publication until an +attempt is accepted and drops discarded/failed attempts. Both compaction entry points preserve +explicit configured selectors before consulting bounded lane state. The existing state-store +reconciliation owns removal of obsolete targets and generation fencing; core imports no registration +composition root or Lab code. Recall retains routing identity only, never account credentials. + +> Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) + +A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, +and the client replays it on every later turn. The proxy's own `ocx1:` envelopes are transparent +base64, so they always lower to plain user messages. A native blob is relayed only when there is no +known serving-identity mismatch and the destination is known to decode native blobs — the canonical +ChatGPT forward surface, the official OpenAI API, or a provider with the explicit +`decodesNativeCompactionBlobs` capability. The destination gate alone is insufficient because more +than one backend, including OpenAI and xAI, mints native blobs: a destination can decode its own blob +without being able to decode the previous backend's. The same serving-identity mismatch signal +therefore strips reasoning `encrypted_content` and degrades native compaction blobs through the +existing opaque-note path. When the thread has no recorded identity, the destination-only behavior +is deliberately unchanged. Forward auth alone is not evidence: noncanonical forward providers +receive no caller credentials and may point at any backend. On any other routed destination the blob +also degrades to the same opaque note the bridged parser uses, because forwarding it there fails the +turn and the item outlives the failure in the client transcript, repeating on every later turn +including the compaction turn the proxy itself drives. With `store: false`, request sanitization +strips ids from every input item, including compact-wire items, matching codex-rs +(`core/src/client.rs:918-925`). Compact-wire items remain exempt from response-side field backfill. + +> Decision record: [ADR-0039](../decisions/ADR-0039-responses-http-sse.md) + +### Mixed-wire provider defaults + +Registry `modelWireDefaults` select an evidence-backed upstream protocol for an exact model without +changing the provider-wide adapter. Explicit, allowed `modelAdapters` configuration always wins, +including an entry that opts the model back into the provider-wide wire. Defaults are applied only +while the configured provider still matches the registry transport, so reusing a preset name for a +different custom destination does not inherit its upstream assumptions. Object-form defaults may +also narrow the decision by inbound protocol and authentication mode; an auth-scoped default must +not leak from a subscription transport into an API-key or forwarded-credential route. + +xAI keeps `openai-chat` as its provider-wide compatibility wire, but Grok 4.5/4.6 subscription +Responses requests default to native `openai-responses`. Existing namespace, hosted-search and +reasoning-replay normalization remains in force. The reserved `xai` OAuth transport is name-pinned +to the Grok CLI gateway even if its saved base URL differs; custom provider IDs do not inherit this +default. API-key requests, translated Chat/Anthropic defaults and other Grok models retain their +existing wire and tier policy. OAuth still drops caller-owned `service_tier` on either wire. + +Native Responses participates in the same pre-stream OAuth HTTP-429 account rotation as the Chat +bridge. It uses the existing account quorum, cooldown and three-rotation request cap, refreshes +the complete credential/transport/replay identity, and attributes usage to the serving account. +Single-account installs do not retry; a missing alternate credential preserves the original error. + +Startup removes legacy Grok 4.5/4.6 Chat overrides once and persists the provider-owned +`xaiResponsesDefaultVersion` marker. Later explicit Chat choices survive restarts. The migration +rebases under the config mutation lock; unavailable persistence warns and uses an isolated in-memory +projection without overwriting invalid disk state. Read-only config loading does not migrate. + +The dashboard's Chat Completions switch and `ocx provider edit xai --xai-chat on|off` share the +existing `modelAdapters` lane. On writes Chat for both models; off writes Responses. Unrelated +overrides remain intact. The legacy PATCH field `xaiResponsesOptIn` retains its direction: +true selects Responses, false now writes explicit Chat rather than deleting entries. Its derived +`xaiResponsesOptInState` reflects effective Responses-inbound routing, including registry defaults; +only genuinely different effective wires report mixed. A switch write also records the migration +version (without lowering a future version), and provider-form overwrites retain omitted choices. + +Native routed Responses code-mode turns also receive the shared result-emission contract in both +instructions and the lowered exec input description: a bare awaited helper return is discarded by +the host, so visible results need `text(...)` or `notify(...)` in that first call. Paired exec outputs +containing only an empty completion/failure wrapper use the shared explanatory annotation. The +whole result is examined; populated text, image/file parts, unpaired results, shell-only catalogs, +compaction and OpenAI-operated destinations are untouched. This does not rewrite valid JavaScript +or reconstruct output that the code-mode host never emitted. + +Routed code-mode turns also carry the host contract for the nested helpers, stated in the same three +injection sites as the result-emission rule (shared catalog nudge, Cursor code-mode guidance, native +routed Responses instructions): `tools.apply_patch` takes one string that opens and closes with the +bare patch marker lines (blank lines or indentation around them are tolerated; a decorated or missing +marker is rejected), the isolate has no `import`/`require`, and a command that outlives +`yield_time_ms` is polled through `write_stdin` with empty `chars` rather than a shell sleep loop. +When a code-mode exec result still carries one of the host's failure strings ("expects a string +input", "The first line of the patch must be", "The last line of the patch must be", "Unsupported +import in exec"), the native routed Responses, Kiro, and Cursor result paths append a one-line +recovery hint naming the broken rule; flat shell bridges and foreign MCP namespaces are never +annotated, Responses and Kiro additionally require the request's verified code-mode catalog, Cursor +matches the exact `exec` name under its `opencodex-responses` provider without catalog context, and +Cursor's error classification and Kiro's whitespace and failed-wrapper grouping are unchanged. Both +halves live in `src/adapters/exec-tool-result-normalize.ts` +so the pre-call and post-hoc wording cannot drift. This guidance and annotation change rewrites +neither the model's JavaScript nor its patch payload; the existing name-alias delimiter +normalization in `src/responses/code-mode-helper-compat.ts` is unchanged, and the host still rejects a +malformed call exactly as before. Anthropic, Google, OpenAI-chat and command-code result paths +have no exec-result seam today and are not annotated. + +> Decision record: [ADR-0040](../decisions/ADR-0040-responses-http-sse.md) + +> Decision record: [ADR-0041](../decisions/ADR-0041-responses-http-sse.md) + +### xAI string agent-message continuation + +`normalizeRoutedAgentMessages` owns raw Responses `agent_message` lowering. Its existing +nonempty all-readable array behavior remains shared by non-forward destinations. The optional +`allowStringContent` argument defaults to false and is enabled only by the non-forward adapter +call when `isXaiResponsesDestination` recognizes HTTPS `api.x.ai` or `cli-chat-proxy.grok.com` +on the standard port. A nonblank string becomes one `input_text` part with the original text; +the same author/recipient attribution is retained and the private transport item id is removed. + +This addresses readable child-result delivery (#3907), not scheduling or decryption. Blank, +malformed, ciphertext-only and mixed unknown/encrypted content retains the existing fail-closed +path. Forward destinations never enable the option. The parser and encrypted-task recovery +owners are unchanged, and no broad content-schema validation or adapter-wide string conversion +is introduced. Mocked server fixtures cover parent, child, and parent-result continuation over +SSE and JSON while preserving actual tool-call/result pairs. + +OpenCode Go documents `gpt-5.6-luna` on `/zen/go/v1/responses` while sibling models use its Chat or +Anthropic endpoints. The built-in preset therefore selects `openai-responses` only for Luna and +keeps the provider-wide `openai-chat` default for other non-pinned models. This endpoint correction +does not set `modelResponsesUpstreamStreaming`: client `stream: true` remains real upstream +streaming until a current-runtime reproduction justifies a separate bounded-JSON compatibility +policy. + +Go's non-forward Responses request path moves valid `additional_tools` wrappers into top-level +`tools` through `src/adapters/opencode-go-additional-tools.ts`. Placement runs after existing +custom/search/namespace lowering and before code-mode, compaction and final hosted-tool pruning. +It does not recalculate wire identities or response aliases. The matcher reads the constructed +send URL, resolving it with URL semantics, and requires HTTPS `opencode.ai`, the standard port +and exact `/zen/go/v1/responses`. Normal and endpoint-inclusive bases or split `responsesPath` +configurations agree; a custom path resolving to Zen or elsewhere does not acquire Go placement. +Credentials, query, fragment, foreign hosts and other resource paths are excluded. The existing +URL constructor canonicalizes trailing base slashes before this check. Malformed wrappers remain unchanged and +the shared mixed-ciphertext agent-message gate remains fail-closed. + +The canonical `opencode-go` registry entry defaults to `statelessResponses: true` because Go +rejects reasoning ciphertext combined with `previous_response_id` (#3838). Existing derive +logic fills absent values and preserves explicit false; renamed custom configurations receive +no new destination-based migration. The existing stateless pass sets `store: false`, removes +stored continuation parameters, and repairs orphan calls/results without claiming execution +success. A local replay-cache hit supplies history; a miss cannot reconstruct it, so callers +must resend complete history without `previous_response_id`. This flag also enables the existing +visible content-to-summary rewrite for SSE and JSON; summary-channel items and opaque reasoning +blobs keep their existing response handling. The shared recording callback applies the same +reasoning rewrite under the exact client-visible predicate before caching output, after tool +restoration and function normalization. This keeps full-content replay fingerprints comparable +for both full-history-plus-ID and delta continuations without weakening identity checks. Hidden +summaries and opaque blobs keep their existing cache representation. It does not change streaming selection or Chat +model routes. Go fixtures cover Luna, Grok and Muse against both response formats. + +The canonical OpenCode Go transport also derives `x-opencode-session` from the existing hashed +session lane before per-model wire selection. One conversation keeps one opaque affinity value +across Responses, Chat, retries, and key rotation, while sibling subagents remain distinct. An +operator-supplied header wins case-insensitively. Renamed providers are covered only when their +fixed key-auth destination still matches the registry; custom and lookalike URLs receive nothing. +Muse Spark's Responses sanitizer also drops the provider-rejected `search_content_types` and +`indexed_web_access` fields from plain `web_search` tools while preserving preview tools and +unrelated models. + +> Decision record: [ADR-0042](../decisions/ADR-0042-responses-http-sse.md) + +> Decision record: [ADR-0043](../decisions/ADR-0043-responses-http-sse.md) + +### Passthrough SSE stream shapes (#314) + +Native passthrough SSE has TWO shapes, selected per request in +`src/server/responses/core.ts`: + +- **Default outside Windows: tee + background inspection.** `upstreamResponse.body.tee()` sends + branch[0] through a terminal-aware client relay while branch[1] is + drained eagerly by `consumeForInspection`/`consumeForResponseLogMetadata` + for terminal-outcome recording, quota, the passthrough continuation cache, + and request logs. This remains the default shape on bundled Bun 1.3.14. +- **Terminal-aware eager bounded relay** (`src/server/relay-eager.ts`). Windows + uses this single-reader shape for rewrite traffic and for no-rewrite traffic + selected by `selectEagerPath` in `src/lib/bun-stream-caps.ts`; the latter keeps + `legacy-tee` and known-bad-runtime `auto` on tee as documented. When selected, + `response.completed` closes the client stream even if upstream keeps HTTP/SSE + alive. Darwin uses it for no-client-rewrite traffic only (neither image-gen + aliases nor item-id repair) and is explicit-only: `auto` stays tee even after + a future threshold bump. One eager reader + byte-bounded + client queue + post-cancel bounded discard-drain replaces the tee and goes + directly to the response without a JS rewrite wrapper, preserving the full + inspection side-effect set (shared `createSseInspector` factory in `relay.ts`) + including the #44 late-terminal semantics. + +Both client readers also retain a bounded, redacted message from a bare upstream +`error` event. If EOF arrives without a real Responses terminal, they synthesize +one `response.failed` with that message instead of replacing it with `adapter_eof`. +The delivering reader owns this evidence; an asynchronous tee inspection branch +cannot reliably supply it before EOF. Inspection independently applies the same +bare-error rule when EOF arrives, so account health records failure instead of +clearing avoidance as if the turn had succeeded. Existing real terminals and +caller cancellation retain precedence on both branches. Native recovery preflight +also preserves a rejected body reader and its bounded prefix for the normal +mid-stream failure path; it does not turn that rejection into a decrypt retry. + +Native Responses may rebuild once when encrypted function/custom-tool output or +agent-message content receives the exact known decrypt rejection before output +commits. Recovery replaces only encrypted parts with an omission marker, preserves +the raw request object used by continuation persistence guards, and uses the same +adapter and cancellation path. A missing Content-Type is allowed only under the +existing successful streaming condition. Default combo preflight classification +is unchanged; only the native recovery caller supplies the exact error predicate. + +Both shapes carry the inbound caller-abort signal separately from the turn/shutdown +controller. A caller-driven read rejection is 499/client_cancel without pool penalty; +a genuine upstream reset remains synthetic 502. An already received terminal, including +one completed by the error-path parser flush, retains its real outcome. Eager relays +remove the caller listener when done and close signal-cancelled downstream streams even +when the response-body cancel hook has not run. + +The two-shape contract is mirror-commented in `src/server/index.ts`; the real +`core.ts` gate is source-invariant-tested by `tests/responses/passthrough-abort.test.ts`, +and the platform matrix lives in `tests/lib/bun-stream-caps.test.ts`. Keep all three +in lockstep with any passthrough-policy change. + +Canonical ChatGPT forward streaming has one transport-specific exception. A +stable Bun runtime at or above 1.4.0 may use Codex's upstream +`responses_websockets` transport; bundled Bun 1.3.14, prereleases, and +unverifiable runtime identities stay on HTTP/SSE. A successful upstream WS +response is re-encoded to the same SSE surface and forced through the bounded +eager single-reader relay instead of `tee()`: raw and enveloped frames are capped +at 4 MiB and the WS producer queue at 8 MiB. Overflow closes the upstream and +the downstream relay emits its terminal `response.failed` event plus `[DONE]`. +Pre-open HTTP fallback remains unmarked and follows the ordinary configured +stream path. + +At the canonical ChatGPT destination, HTTP Responses Lite intent is copied into +the native per-frame WS metadata key, and the routing hint is derived from the +final outgoing model/tier. No caller identity is synthesized. Noncanonical +opt-in gateways keep their own metadata policy. Oversized/unsupported-runtime +HTTP fallback preserves the original HTTP body and Lite header. + +Canonical WS quota and response metadata preceding the first Responses event +are projected into bounded, allowlisted HTTP headers before the response is +committed. Later quota observations update only the captured serving account; +they cannot retroactively change HTTP headers already sent to the client. +Control frames remain bounded, and provider credential/cookie headers are not +forwarded. Once a WS create may have been sent, a missing prelude, overflow or +disconnect settles as an errored SSE body rather than a retryable fetch failure, +so HTTP fallback cannot duplicate that inference. A standalone no-response +exchange has a 90-second prelude deadline in addition to the upgrade deadline. +That prelude deadline is a ceiling, not a floor: the exchange runs under the +caller's abort signal, so a `connectTimeoutMs` shorter than 90 seconds cancels +an already-sent create before the prelude timer fires. +These are transport-fidelity guarantees, not a provider-billing guarantee. + +Eligible complete-input creates can retain a canonical upstream socket within +one selected account, credential, thread and turn. Model/tier and immutable +handshake headers and the selected outbound proxy must also match. Turn-state and turn-metadata headers are +projected into their same-name per-frame metadata slots; explicit body values win. +The pool retains at most 32 sockets, expires idle sockets after 30 seconds, and +retires a socket after five minutes or 32 successful exchanges (after active work +finishes). Cancellation, errors, idle unsolicited frames and shutdown dispose it. +A busy key uses a separate one-shot connection rather than interleaving requests. + +This is connection reuse, not native incremental-input synthesis: complete HTTP +inputs are never trimmed and no previous response id is invented. Explicit +continuation IDs, named lanes, warmup and background requests remain outside this +pool. A fresh credential-dispatch guard runs before every warm send. Per-exchange +listeners, response/item correlation and metadata ownership detach before release. +No pool timer or shutdown registration exists before eligible traffic activates it. + +Translated response request-log tracking and the heartbeat relay also reuse +`createSseInspector`. This keeps every client-facing SSE observation path on +the same byte-bounded, discard-and-resynchronize frame policy and ensures the +request-log, first-output, and terminal observers share one payload parse. +The inspector records a structured `response.failed` status before invoking the +terminal observer. Native Responses, Chat Completions, Claude Messages, and WebSocket +request logs must therefore finalize through the context-aware terminal mapper; recognized +`cyber_policy` terminals stay `400 / cyber_policy` rather than collapsing to a generic 502. + +The client-facing boundary treats the first Responses terminal as authoritative in both relay +shapes. High-confidence policy errors carried as `response.incomplete`, `response.failed`, or a +top-level `error` are normalized to one `response.failed / cyber_policy` event without changing the +refusal outcome; later bytes cannot create a second terminal. A clean HTTP 200 EOF with no terminal +instead emits one `response.incomplete` with `adapter_eof`, followed by one `[DONE]`. Delimiter-less +EOF candidates follow the owning repair policy: the native boundary accepts a structurally valid +terminal tail, while an opted-in terminal repair keeps its unframed suffix tainted and emits +`missing_terminal_event`. Pull/tee and eager relays therefore agree on terminal, sentinel, and +request-log accounting without promoting a truncated repair candidate. + +> Decision record: [ADR-0044](../decisions/ADR-0044-responses-http-sse.md) + +## Chat-to-Responses message phase inference + +Chat Completions streams do not carry the Responses `message.phase` field. The bridge keeps an +unphased live message provisional while its deltas arrive, then assigns `commentary` when a later +tool, search, reasoning, or assistant boundary proves that more work follows, and assigns +`final_answer` only when a clean terminal `done` closes the current message. Explicit adapter +phases always win. Streaming `output_item.added` remains unphased until that future boundary is +known; `output_item.done` and the terminal response snapshot carry the authoritative inferred phase +with the same item id. The batch/non-streaming bridge follows the same rule. + +> Decision record: [ADR-0069](../decisions/ADR-0069-chat-to-responses-message-phase-inference.md) + +## Upstream reset retry + +`src/lib/upstream-retry.ts` guards upstream fetches against stale pooled keep-alive sockets +(Cloudflare closes idle connections; Bun's fetch reuses the dead socket and rejects with +`ECONNRESET` before any response bytes). `fetchWithResetRetry` retries only +connection-reset-shaped rejections (up to 3 total attempts, jittered backoff, warn-logged); +timeouts, aborts, `ECONNREFUSED`, HTTP error statuses, and mid-stream SSE failures are never +retried. Guarded paths: the ChatGPT passthrough and generic adapter fetch in +`src/server/responses.ts`, the vision/web-search sidecars, and the web-search loop's direct-fetch +fallback. Adapters with their own `fetchResponse` (kiro, cursor, google) keep their own retry +policies; kiro imports the shared abort/sleep helpers from this module. + +## Same-provider combo quota fallback + +For a failover combo with multiple models on the same Codex-login OpenAI provider, a pre-stream +429/402 carrying only `x-codex-*-reset-at` may advance to the later model on the same account. The +failed physical combo target still enters its normal target cooldown. An explicit `Retry-After` +remains an account-wide instruction and blocks the later target; a quota response with neither an +explicit retry delay nor a usable reset timestamp keeps the conservative default account cooldown. +This exception is request-scoped and is not applied to direct requests, round-robin combos, or a +combo whose remaining eligible targets use other providers. + +> Decision record: [ADR-0070](../decisions/ADR-0070-same-provider-combo-quota-fallback.md) + +## Combo streaming commit boundary + +An HTTP 200 does not by itself commit a streaming combo child. The combo parent runs the child's +downstream Responses SSE through `src/server/responses/combo-stream-preflight.ts`, which owns one +reader and buffers only until one of these boundaries: + +- a non-control Responses event begins client-visible output or a tool/action item, after which the + target is committed and cross-target replay is forbidden; +- a `response.failed` terminal arrives first, in which case the terminal is converted back through + the ordinary bounded combo-failure classifier and may advance to the next declared target; +- a completed/incomplete terminal or the aggregate preflight byte or retained-chunk cap is reached, + in which case the current target is committed conservatively. + +The buffered bytes are replayed unchanged before the reader continues. Native passthrough and eager +relay identity markers are restored on the wrapped response so Windows/Bun stream paths and deferred +logging retain their existing owners. A failed child keeps its physical attempt receipt and usage, +while the successful child remains the logical request result. + +HTTP 410 remains terminal by default. It advances and cools only the exact combo target when the +structured code or message explicitly identifies a model lifecycle event (end-of-life, retired, +deprecated, sunset, decommissioned, or no longer available). An unrelated application-level 410 is +not retried. + +> Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md new file mode 100644 index 0000000000..42d3442e99 --- /dev/null +++ b/structure/transports/streaming-health.md @@ -0,0 +1,191 @@ +# Streaming Health And WebSocket + +## Heartbeat and stall deadline + +The HTTP/SSE bridge emits an SSE comment-line keep-alive (`: opencodex heartbeat`) during upstream +silence to re-arm Codex's idle timer (Codex's default `stream_idle_timeout` is 300 s and ANY SSE +bytes re-arm it). A comment line is discarded by every eventsource parser without producing an event, +so strict Responses decoders never see an unknown variant. Those bridge-enqueued keepalive frames do +NOT count as activity for the bridge's own watchdog: a bounded stall deadline (default 300 s, +configurable via `stallTimeoutSec`, checked on the 2 s heartbeat tick) closes the stream with +`response.incomplete` / `upstream_stall_timeout` and cancels the upstream request if no real +adapter events arrive. Adapter-yielded `{ type: "heartbeat" }` events DO reset the watchdog. + +Top-level `emptyCompletionRetry: true` opts Responses turns into one identical replay when an +upstream turn produces neither output text nor a tool call, including a stream that ends before a +terminal event. A terminal-less stream is replayed only before actionable output; post-output EOF +remains incomplete so text or tool calls cannot be duplicated. The default is off because the replay +may be billable; `OCX_EMPTY_COMPLETION_RETRY=0` is a disable-only emergency override. Streaming and +buffered HTTP adapters plus `runTurn` transports share the same guard, while combo attempts and +routed compaction stay excluded. Pre-content reasoning is retained under named event-count and byte +caps and emits liveness heartbeats while held. A second empty result or retry failure becomes typed +502 `empty_completion_retry_failed`; usage is merged across sends, and the Logs attempt records +recovery kind `empty-completion`. + +The web-search loop requests `stream: true` for every routed-model iteration, but buffers the events +needed to decide whether to intercept a synthetic search call. Text explicitly phased as +`commentary` is safe to forward live because it cannot terminate the turn; this keeps Kiro's +progress visible. A Kiro stream EOF after user-facing text or reasoning gets one bounded completion +retry, because neither the upstream text event nor `END_TURN` / `STOP_SEQUENCE` reliably distinguishes +progress from a final answer. Those two clean-stop reasons prove only that the inference ended; on a +tool-enabled turn, only the private completion tool authorizes `final_answer`. Any other explicit +reason already terminated the inference upstream and is reported as a terminal state rather +than converted into another model request: output-token limits become continuable incomplete output, +context-window exhaustion becomes a non-retryable `context_length_exceeded` error, filtering becomes +filtered incomplete output, and a `TOOL_USE` without an actual tool call is a contradiction. Since +the stop reason arrives only at the end of the stream, `required`-mode assistant text is held inside +the adapter until a real tool call starts or the stream ends, then released as `commentary` unless a +private completion call supplied the final answer. Each held event yields a `heartbeat` in its place +so the stall watchdog stays armed. Synthetic search calls, real tool calls, +and terminal events remain buffered until the iteration validates. Only the first iteration's final +response headers/status and any 429 key rotations are handled eagerly. A failure before downstream +SSE starts returns non-2xx JSON; once headers have started the final response, a generation failure +is emitted as `response.failed` SSE. + +### Pre-stream provider input overflow + +A provider HTTP 413 received before streaming starts is unambiguous request-size refusal, but raw +relay is not compatible with Codex: Codex classifies the unknown status as retryable and resends the +same oversized turn through its reconnect budget. For a streaming Responses caller, OpenCodex +therefore converts the final 413 (after any adapter-owned bounded image retry) into one HTTP-200 SSE +`response.failed` event with `error.code = context_length_exceeded` and `retryable = false`. Codex +recognizes that terminal contract, marks the context as full, and can run its own compaction policy +on the next turn. Combo routing treats 413 as a stop condition and performs the conversion only at +the outer client boundary, so the failed target is never recorded as a successful combo attempt. + +Non-streaming Responses callers retain HTTP 413 and receive a JSON `error` with +`type: invalid_request_error` and `code: context_length_exceeded`, including routed synthetic +compaction. The upstream body is replaced with the same bounded, proxy-owned message used by SSE. +Combo attempts retain their existing internal failure accounting; classification happens only at +the outer client boundary. Local admission and configured outbound-byte refusals keep their own +distinct codes. Classification does not shrink input or automatically retry compaction. +The proxy never silently drops +prompts or images: it does not own the client's transcript, and deleting input would hide data that +was never analyzed. The streaming error message is proxy-owned and bounded instead of relaying the +upstream 413 body, which may echo request content. + +> Decision record: [ADR-0049](../decisions/ADR-0049-heartbeat-and-stall-deadline.md) + +Kiro transient HTTP 429 recovery is coordinated process-wide after the first throttle: healthy +traffic remains parallel, but throttled followers wait behind one abort-aware probe and share a +deadline that is re-checked after every sleep. Event-stream `ThrottlingException` records the same +deadline for the next client replay. Retries are bounded to three attempts; hard quota responses and +ordinary 5xx errors are not replayed. Completion fallback rebuilds only replayable text, preserves +the original user/tool-result turn for reasoning-only attempts, supplies neutral non-empty carriers +for empty tool output, and validates role alternation plus tool-use/result pairing before transport. + +Provider-level `retryOn429` (devlog 260802_429_same_target_retry) is the generic, opt-in +same-target 429 retry for API-key providers (`authMode: "key"`), primarily single-key pools +that cannot use multi-key failover. In the pre-stream recovery loop, a 429 waits (`Retry-After` +or the fixed interval, capped at `maxIntervalMs`) and replays the identical request on the same +key before any failover, up to `attempts` extra times per request (the budget lives outside the +recovery loop, so a 413/401 replay cannot re-arm it). The same wait-and-replay applies to every +other key-auth surface that bypasses that loop: the Responses passthrough wire (e.g. the +built-in DeepSeek preset), the image/video bridge and web-search sidecar loops (before their +`on429` key rotation), and Anthropic terminal-guard continuations (before key/account +failover). The policy covers HTTP-capable adapters only: custom `runTurn` transports in the +image loop run through an event queue and never receive an HTTP status, so they are outside +the HTTP retry scope and cannot replay a 429. Codex never retries 429 client-side (openai/codex#30471), so this is the only +defense for those providers; the final 429 still carries `Retry-After` for clients that honor +it. Concurrent requests each honor their own policy — there is no process-wide shared cooldown +(unlike the Kiro pattern), so a rate-limit storm multiplies upstream volume by at most +`attempts + poolKeys` per request (same-key replays, then failover keys; the pool size is the +operator-configured `apiKeyPool` length, fixed for the duration of the request). Every surface +releases (and awaits the cancellation of) the unread 429 body before the backoff, records the +`rate-limit-429` recovery kind on replay sends, and the bridge loops clear the old +response-header deadline before the wait and start a fresh one afterward — client cancellation +is re-checked after the wait, so 499 always wins over a stale-deadline edge, and backoffs never +consume the connect budget or surface as a 504. The wait is abort-aware: +once the server observes the client disconnect (Bun propagates it asynchronously, observed +1–10 s), the sleep is interrupted, the unread 429 body is released, and the request is +cancelled with 499 before any replay; because the propagation is async, a replay may precede +the cancel if the interval elapses first (bounded by the same `attempts` budget). + +Provider-level `requestPacing` is the proactive companion to `retryOn429`. It reserves outbound +request-start slots before transport work begins, so a known RPM ceiling does not have to fail once +before the proxy reacts. One provider-wide lane enforces the aggregate ceiling. Exact model lanes +may add a slower interval without lowering the provider-wide interval or blocking an otherwise +eligible sibling model. Queue wait is abort-aware and happens before the response-header timeout is +armed. The shared fetch boundary covers HTTP and Responses WebSocket sends; explicit adapter +`fetchResponse` and `runTurn` dispatches reserve the same lane at their call sites. Image-bridge +iterations reserve before arming their per-attempt response-header deadline. + +> Decision record: [ADR-0050](../decisions/ADR-0050-heartbeat-and-stall-deadline.md) + +Historical `web_search_call` output items from previous Responses turns are not converted into +assistant text. They are UI/search-cell evidence, not a replayable search result payload; turning +them into strings risks routed models echoing an internal marker or implying a current search ran +when the sidecar is unavailable. The active sidecar path is the only place that emits new +`web_search_call_begin` / `web_search_call_end` events. + +Four independent clocks bound this path. `stallTimeoutSec` is the base bridge event-stall budget. +`connectTimeoutMs` (default 200 s) covers only DNS/TCP/TLS and the wait for final response headers, +not response-body generation. Config-file-only +`webSearchSidecar.routedModelStallTimeoutMs` (default 200 s, integer 1..2147483647) bounds continuous +raw response-byte inactivity for a routed-model iteration and resets on every non-empty byte. +`webSearchSidecar.timeoutMs` (default 60 s) separately bounds one hosted search request (lowered +from 200 s so an unavailable/limit-exhausted search backend degrades within ~1 min instead of +hanging the whole turn, #398). The +effective web-search bridge watchdog is +`max(base stall, connect timeout, routed-model stall, sidecar timeout) + 30 s` (230 s at defaults, +dominated by the routed-model stall clock), +with seam heartbeats between bounded units. None of these clocks is a total generation deadline. + +## WebSocket + +The WebSocket endpoint exists at `/v1/responses`, but discovery is opt-in: + +```json +{ + "websockets": false +} +``` + +`websocketsEnabled(config)` is true only for an explicit `true`. When false, opencodex removes +`supports_websockets` from injected provider tables and routed catalog entries, keeping Codex on +HTTP/SSE. When true, Codex may use Responses WebSocket frames handled by `src/server/ws-bridge.ts`. +If Codex still attempts a WebSocket upgrade while the feature is disabled, `/v1/responses` rejects +the upgrade with 426 so Codex falls back to HTTP cleanly. + +That setting controls the client-facing upgrade only. The transparent upstream +ChatGPT WS optimization described above is selected independently and still +returns the same downstream SSE contract. Its WSS route checks NO_PROXY first, then selects the +first non-empty HTTPS_PROXY, https_proxy, ALL_PROXY, or all_proxy value. HTTP_PROXY alone does not +route WSS. Unsupported or malformed selected proxy values skip the WebSocket attempt and use the +existing SSE path immediately; they never fall through to a lower-priority proxy or direct WebSocket +egress. HTTP/SSE fallback retains Bun fetch's own proxy rules, which do not consult ALL_PROXY. + +The endpoint handles `response.create`, ignores `response.processed`, supports warmup +`generate: false`, and feeds the same request pipeline as HTTP/SSE. + +Registry-declared per-model compatibility hints (`modelResponsesUpstreamStreaming`) may ask the +upstream Responses endpoint for bounded JSON on ANY client transport — WebSocket or ordinary +HTTP/SSE. The bridge reframes that JSON into the same Responses event sequence +(`src/server/responses-json-events.ts`): WS turns send the frames as WebSocket messages, while +HTTP clients that requested streaming receive a synthesized terminal SSE body (created → +output_item.done → terminal → `[DONE]`). No production registry entry currently opts in: +DeepSeek V4 Flash used this path while its public-beta Responses stream was suspected of not +closing on the terminal event, but the official guide documents a +`response.completed`/`response.incomplete`/`response.failed` terminal with no `data: [DONE]` +sentinel, and live probes (2026-08-07) confirm the stream closes on the terminal. The relay's +terminal-output boundary (`src/server/relay.ts`) cuts the stream at that event and synthesizes +`[DONE]` itself, so DeepSeek streams live again; the registry knob remains as a one-line +rollback for upstreams that regress, kept suite-reachable by a synthetic-registry fixture in +`tests/providers/deepseek-inbound-wire.test.ts`. +Synthesized output is capped at 10,000 items across HTTP and WebSocket reframing. HTTP frames are +encoded incrementally, so bounded upstream JSON cannot expand into an unbounded event array or SSE string. + +DeepSeek V4 Flash keeps native Responses streaming for progressive reasoning, text, and tool-call +delivery. Its registry entry enables a model-scoped terminal repair before the existing +inspection/client split. A real `response.completed`, `response.failed`, or `response.incomplete` +event always passes through unchanged. If every opened output item has a structurally complete +`output_item.done` and no real terminal arrives for five seconds, the repair emits exactly one +`response.completed` snapshot and closes the upstream reader. EOF or `[DONE]` uses the same strict +completion check; open, malformed, duplicate, contradictory, or unknown output graphs fail closed +as `response.incomplete`, never synthetic success. The repair shares the per-turn translator byte +budget, preserves backpressure, and composes ahead of item-id/snapshot rewrites so HTTP/SSE and +WebSocket clients observe the same canonical lifecycle. + +`ws-bridge.ts` preserves upstream `failed` and `incomplete` status values in the final WebSocket +frame rather than always emitting `response.completed`. If the response status is `failed`, a +`response.failed` frame is sent; otherwise `response.completed` carries through the original status. diff --git a/tests/adapters/openai/openai-provider-option.test.ts b/tests/adapters/openai/openai-provider-option.test.ts index 5e1943ceb0..d40ef2a6b3 100644 --- a/tests/adapters/openai/openai-provider-option.test.ts +++ b/tests/adapters/openai/openai-provider-option.test.ts @@ -1,3 +1,4 @@ +// Holds INV-OPENAI-01 from structure/overview.md; keep the id here if this file is split or renamed. import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { repoPath } from "../../helpers/repo-root"; diff --git a/tests/ci-workflows/structure-ssot.test.ts b/tests/ci-workflows/structure-ssot.test.ts new file mode 100644 index 0000000000..ef516425b4 --- /dev/null +++ b/tests/ci-workflows/structure-ssot.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { renderIndex, runStructureChecks, type Manifest } from "../../scripts/structure-ssot"; +import { repoRoot } from "../helpers/repo-root"; + +/** + * structure/ is a second description of this tree, and a second description drifts. Before this gate + * existed nothing read the folder: four paths it named had already been moved or deleted, two docs + * shared the number 09, and one file had grown to 1,860 lines. + * + * The negative cases below exist because a guard nobody has driven red is a guard nobody has tested. + * The first revision of this file proved exactly one rule and shipped ten unproven ones; a review + * pass found several of those could not fail at all. + */ + +const BT = "\u0060"; +const scratch: string[] = []; + +afterEach(() => { + while (scratch.length) rmSync(scratch.pop()!, { recursive: true, force: true }); +}); + +function write(root: string, rel: string, body: string): void { + const p = join(root, rel); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, body, "utf8"); +} + +function manifestOf(root: string): Manifest { + return JSON.parse(readFileSync(join(root, "structure/manifest.json"), "utf8")) as Manifest; +} + +function saveManifest(root: string, manifest: Manifest): void { + write(root, "structure/manifest.json", JSON.stringify(manifest, null, 2) + "\n"); + write(root, "structure/INDEX.md", renderIndex(manifest)); +} + +/** A synthetic tree that passes every check, so each negative case isolates one rule. */ +function scaffold(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-structure-ssot-")); + scratch.push(root); + write(root, "src/alpha/keep.ts", "export const keep = 1;\n"); + write(root, "tests/alpha/alpha.test.ts", "// Holds INV-A-01\nexport {};\n"); + // The generated index names the sibling documentation roots and links the rules file, so a + // scaffold without them fails on the index rather than on the rule under test. + for (const root_dir of ["docs", "docs-site", "devlog"]) write(root, root_dir + "/.gitkeep", ""); + write(root, "structure/AGENTS.md", "# Rules\n"); + const manifest: Manifest = { + version: 2, + sizeBudgetLines: 600, + generatedPaths: [], + absentPaths: [], + tiers: [{ id: 1, name: "Foundation", purpose: "only tier" }], + docs: [{ path: "overview.md", tier: 1, title: "Overview", scope: "scope", documents: ["src/alpha/"] }], + grace: { undocumentedSourceAreas: [], unboundInvariants: [], oversizeDocs: [], staleRefs: [] }, + }; + write( + root, + "structure/overview.md", + [ + "# Overview", + "", + "## Non-negotiable invariants", + "", + "- **INV-A-01** — alpha keeps working.", + " Enforced by " + BT + "tests/alpha/alpha.test.ts" + BT + ".", + "", + "> Decision record: [ADR-0001](decisions/ADR-0001-alpha.md)", + "", + ].join("\n"), + ); + write( + root, + "structure/decisions/ADR-0001-alpha.md", + "# ADR-0001 — alpha\n\n- Contract owner: [overview.md](../overview.md#non-negotiable-invariants)\n", + ); + saveManifest(root, manifest); + return root; +} + +const fires = (root: string, needle: string): void => { + expect(runStructureChecks(root).join("\n")).toContain(needle); +}; + +describe("structure/ SSOT", () => { + test("the maintainer docs still describe this tree", () => { + expect(runStructureChecks(repoRoot())).toEqual([]); + }); + + test("the scaffold used by the negative cases is itself clean", () => { + expect(runStructureChecks(scaffold())).toEqual([]); + }); + + test("a doc on disk that the manifest does not list", () => { + const root = scaffold(); + write(root, "structure/orphan.md", "# Orphan\n"); + fires(root, "structure/orphan.md is not listed in manifest.json"); + }); + + test("a manifest doc that is not on disk", () => { + const root = scaffold(); + const manifest = manifestOf(root); + manifest.docs.push({ path: "ghost.md", tier: 1, title: "Ghost", scope: "s", documents: [] }); + saveManifest(root, manifest); + fires(root, "manifest.json lists structure/ghost.md but the file is missing"); + }); + + test("a filename that smuggles ordering back in", () => { + for (const name of ["01_overview.md", "01-overview.md", "deep/nested/doc.md"]) { + const root = scaffold(); + const manifest = manifestOf(root); + manifest.docs[0]!.path = name; + write(root, "structure/" + name, "# Moved\n"); + rmSync(join(root, "structure/overview.md")); + saveManifest(root, manifest); + fires(root, "must be kebab-case"); + } + }); + + test("a doc over the line budget", () => { + const root = scaffold(); + const manifest = manifestOf(root); + manifest.sizeBudgetLines = 5; + saveManifest(root, manifest); + write(root, "structure/overview.md", readFileSync(join(root, "structure/overview.md"), "utf8") + "filler\n".repeat(20)); + fires(root, "over the 5-line budget"); + }); + + test("a grace entry that outlived the split it promised", () => { + const root = scaffold(); + const manifest = manifestOf(root); + manifest.grace.oversizeDocs = ["overview.md"]; + saveManifest(root, manifest); + fires(root, "drop the grace entry"); + }); + + test("a link that does not resolve", () => { + const root = scaffold(); + write(root, "structure/overview.md", "# Overview\n\n[gone](missing.md)\n"); + fires(root, "links missing.md, which does not exist"); + }); + + test("a link whose anchor does not exist", () => { + const root = scaffold(); + write(root, "structure/decisions/ADR-0001-alpha.md", "# ADR-0001\n\n- Owner: [overview.md](../overview.md#no-such-heading)\n"); + fires(root, "that heading anchor does not exist"); + }); + + test("a repository path the tree does not have", () => { + const root = scaffold(); + const body = readFileSync(join(root, "structure/overview.md"), "utf8"); + write(root, "structure/overview.md", body + "\nSee " + BT + "src/nowhere/thing.ts" + BT + ".\n"); + fires(root, "which this tree does not have"); + }); + + test("a decision record is not held against the present tree", () => { + const root = scaffold(); + const adr = readFileSync(join(root, "structure/decisions/ADR-0001-alpha.md"), "utf8"); + write(root, "structure/decisions/ADR-0001-alpha.md", adr + "\nIt named " + BT + "src/nowhere/thing.ts" + BT + " at the time.\n"); + expect(runStructureChecks(root)).toEqual([]); + }); + + test("a path named only inside a fenced example is not held against the tree", () => { + const root = scaffold(); + const body = readFileSync(join(root, "structure/overview.md"), "utf8"); + const fence = BT.repeat(3); + write(root, "structure/overview.md", body + "\n" + fence + "text\n" + BT + "src/nowhere/thing.ts" + BT + "\n" + fence + "\n"); + expect(runStructureChecks(root)).toEqual([]); + }); + + test("inline decision-log reasoning that crept back into a doc body", () => { + for (const line of ["[Decision Log]", "**[decision log]**", "- 목적과 의도: 무언가"]) { + const root = scaffold(); + const body = readFileSync(join(root, "structure/overview.md"), "utf8"); + write(root, "structure/overview.md", body + "\n" + line + "\n"); + fires(root, "carries inline decision-log reasoning"); + } + }); + + test("a decision record with no owner", () => { + const root = scaffold(); + write(root, "structure/decisions/ADR-0002-lonely.md", "# ADR-0002\n"); + fires(root, "ADR-0002-lonely.md is not linked from any doc"); + }); + + test("a decision record claimed by two docs", () => { + const root = scaffold(); + const manifest = manifestOf(root); + manifest.docs.push({ path: "second.md", tier: 1, title: "Second", scope: "s", documents: [] }); + write(root, "structure/second.md", "# Second\n\n> Decision record: [ADR-0001](decisions/ADR-0001-alpha.md)\n"); + saveManifest(root, manifest); + fires(root, "a record has one owner"); + }); + + test("a reused decision-record number", () => { + const root = scaffold(); + write(root, "structure/decisions/ADR-0001-twin.md", "# ADR-0001 twin\n"); + fires(root, "decision record number 0001 is used twice"); + }); + + test("a doc naming the same record twice still has one owner", () => { + const root = scaffold(); + const body = readFileSync(join(root, "structure/overview.md"), "utf8"); + write(root, "structure/overview.md", body + "\nAlso see [ADR-0001](decisions/ADR-0001-alpha.md).\n"); + expect(runStructureChecks(root)).toEqual([]); + }); + + test("an invariant with no binding and no recorded reason", () => { + const root = scaffold(); + write( + root, + "structure/overview.md", + "# Overview\n\n## Non-negotiable invariants\n\n- **INV-A-01** — alpha keeps working.\n\n> Decision record: [ADR-0001](decisions/ADR-0001-alpha.md)\n", + ); + fires(root, "INV-A-01 has no Enforced by binding"); + }); + + test("an invariant that is both bound and graced", () => { + const root = scaffold(); + const manifest = manifestOf(root); + manifest.grace.unboundInvariants = [{ id: "INV-A-01", reason: "no test pins it yet" }]; + saveManifest(root, manifest); + fires(root, "also listed in grace.unboundInvariants"); + }); + + test("an invariant whose test is gone", () => { + const root = scaffold(); + rmSync(join(root, "tests/alpha/alpha.test.ts")); + fires(root, "INV-A-01 names tests/alpha/alpha.test.ts, which this tree does not have"); + }); + + test("a test that no longer names its invariant, including a longer id that merely starts the same", () => { + const silent = scaffold(); + write(silent, "tests/alpha/alpha.test.ts", "export {};\n"); + fires(silent, "does not name INV-A-01"); + + const prefixed = scaffold(); + write(prefixed, "tests/alpha/alpha.test.ts", "// Holds INV-A-011\nexport {};\n"); + fires(prefixed, "does not name INV-A-01"); + }); + + test("a src area nobody describes", () => { + const root = scaffold(); + write(root, "src/beta/new.ts", "export const beta = 1;\n"); + fires(root, "src/beta/ is described by no doc"); + }); + + test("a top-level src module nobody describes", () => { + const root = scaffold(); + write(root, "src/orphan-module.ts", "export const x = 1;\n"); + fires(root, "src/orphan-module.ts is described by no doc"); + }); + + test("an area that is both described and graced", () => { + const root = scaffold(); + const manifest = manifestOf(root); + manifest.grace.undocumentedSourceAreas = [{ path: "src/alpha/", reason: "conflicting" }]; + saveManifest(root, manifest); + fires(root, "is both described and listed as undescribed"); + }); + + test("a described area that does not exist", () => { + const root = scaffold(); + const manifest = manifestOf(root); + manifest.docs[0]!.documents.push("src/imaginary/"); + saveManifest(root, manifest); + fires(root, "claims src/imaginary/, which this tree does not have"); + }); + + test("INDEX.md that drifted from the manifest", () => { + const root = scaffold(); + write(root, "structure/INDEX.md", "# hand-edited\n"); + fires(root, "drifted from manifest.json"); + }); + + test("INDEX.md in this repository is the generated file", () => { + const root = repoRoot(); + expect(readFileSync(join(root, "structure/INDEX.md"), "utf8").replace(/\r\n/g, "\n")).toBe(renderIndex(manifestOf(root))); + }); +}); diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts index e0fd0e1fb6..272db59408 100644 --- a/tests/codex-integration/catalog-full-picker-order.test.ts +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -1,3 +1,4 @@ +// Holds INV-AGENT-01 from structure/overview.md; keep the id here if this file is split or renamed. import { routedSlug } from "../../src/providers/slug-codec"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; diff --git a/tests/codex-integration/codex-catalog-restore.test.ts b/tests/codex-integration/codex-catalog-restore.test.ts index 1b148daad1..c507b232c4 100644 --- a/tests/codex-integration/codex-catalog-restore.test.ts +++ b/tests/codex-integration/codex-catalog-restore.test.ts @@ -1,3 +1,4 @@ +// Holds INV-RESTORE-01 from structure/overview.md; keep the id here if this file is split or renamed. import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index b3d263a488..2a8b52543e 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -1,3 +1,4 @@ +// Holds INV-WS-01 from structure/overview.md; keep the id here if this file is split or renamed. import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/tests/codex-integration/codex-inject.test.ts b/tests/codex-integration/codex-inject.test.ts index 5208e93653..efdac8d15b 100644 --- a/tests/codex-integration/codex-inject.test.ts +++ b/tests/codex-integration/codex-inject.test.ts @@ -1,3 +1,4 @@ +// Holds INV-TOML-01 from structure/overview.md; keep the id here if this file is split or renamed. import { describe, expect, test } from "bun:test"; import { applyEol, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a6fef10432..36b7a2e1ae 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1015,6 +1015,7 @@ "sidecar-settings-web-search-stream.test.ts": "vision", "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", + "structure-ssot.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", "sponsor-presets.test.ts": "providers", "sse-client-frame-bounds.test.ts": "responses", diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index 4b9995ab90..a0c977202d 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -1,3 +1,4 @@ +// Holds INV-AUTH-01 from structure/overview.md; keep the id here if this file is split or renamed. import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { SERVER_BUDGET_MS } from "../helpers/test-budget"; import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; diff --git a/tests/test-layout.test.ts b/tests/test-layout.test.ts index dff198c579..ee3ef4f05d 100644 --- a/tests/test-layout.test.ts +++ b/tests/test-layout.test.ts @@ -1,3 +1,4 @@ +// Holds INV-TESTS-01 from structure/overview.md; keep the id here if this file is split or renamed. import { describe, expect, test } from "bun:test"; import { readdirSync, statSync } from "node:fs"; import { join, relative, sep } from "node:path"; From 9db29712a4c82a70a71cc48cf145f0871add2c0d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 21:34:23 +0900 Subject: [PATCH 022/231] docs(devlog): open the cursor checkpoint capture unit for #4245 Records the live-probe evidence that disproves the native/external diagnosis, maps the two real causes, and locks the slice order. wp2 replaces an instrumented build with a request-shape lever that reaches the existing expanded finalize grace, so the experiment runs on the shipped binary. --- .../000_plan.md | 105 ++++++++++++++++++ .../010_phase1_grace_experiment.md | 58 ++++++++++ .../020_phase2_responses_identity.md | 38 +++++++ .../030_phase3_landing.md | 91 +++++++++++++++ 4 files changed, 292 insertions(+) create mode 100644 devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md create mode 100644 devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md create mode 100644 devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md create mode 100644 devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md b/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md new file mode 100644 index 0000000000..e07fdb9018 --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md @@ -0,0 +1,105 @@ +# Cursor checkpoint capture — why #4245 full-replays + +Unit opened 2026-09-11. Tracks issue #4245 (Cursor adapter always full-replays, +`cached_tokens=0`, while direct `cursor-agent` cache-hits on the same account). + +## Why this unit exists + +A first triage pass concluded the cause was `isCursorExternalWireModel` excluding +native router models from the tool-suspended checkpoint commit, and proposed +relaxing that gate. A live probe disproved it. The gate is not reached: every +model class dies one condition later, on `capturedBytes === 0`. + +That matters beyond this issue. The proposed patch would have shipped a behaviour +change to a replay path, passed review on plausibility, and fixed nothing — the +refusal it removed is not the refusal that fires. + +## Evidence already captured + +macOS, opencodex 2.50.0, real Cursor OAuth account, `ocx debug provider on`, +requests to the local proxy. Nothing patched. + +Same forced-tool-call request, three model classes: + +``` +cursor/auto-intelligence (native router) +[ocx:cursor:checkpoint-commit-refused] {"replayUnsafe":false,"emittedClientTool":true,"capturedAfterClientTool":false,"externalModel":false,"storeCheckpoints":true,"capturedBytes":0} + +cursor/claude-4.5-sonnet (external) +[ocx:cursor:checkpoint-commit-refused] {"replayUnsafe":false,"emittedClientTool":true,"capturedAfterClientTool":false,"externalModel":true,"storeCheckpoints":true,"capturedBytes":0} + +cursor/composer-2.5-fast (native composer) +[ocx:cursor:checkpoint-commit-refused] {"replayUnsafe":false,"emittedClientTool":true,"capturedAfterClientTool":false,"externalModel":false,"storeCheckpoints":true,"capturedBytes":0} +``` + +A turn with no client tool commits normally: + +``` +[ocx:cursor:checkpoint-continuation] {"mode":"full-replay","checkpointRefHash":"c5609327a9ac1ec4","checkpointBytes":492,"wireModel":"default"} +[ocx:cursor:checkpoint-continuation] {"mode":"full-replay","checkpointRefHash":"bdd0d48f85f7ebee","checkpointBytes":553,"wireModel":"default"} +``` + +Two sequential chat-completions turns, same content: + +``` +[ocx:cursor:run-request] {"conversationId":"cursor_f3e3e375188f41b9af0669d7090eb962","continuationMode":"full-replay","checkpointPresent":false,"checkpointInvalidationReason":"missing_ref"} +[ocx:cursor:run-request] {"conversationId":"cursor_24bbb91416874b53b9a87f97530cfa14","continuationMode":"full-replay","checkpointPresent":false,"checkpointInvalidationReason":"missing_ref"} +``` + +And the suspend/cancel sequence on a tool turn, with no +`conversationCheckpointUpdate` among the 33 frames: + +``` +[ocx:cursor:client-tool-suspend] {"reason":"Responses bridge owns client tools; ending turn without fake mcpResult","framesReceived":33,"elapsedMs":2886} +[ocx:cursor:stream-cancel-expected] {"code":"ERR_HTTP2_STREAM_ERROR","message":"Cursor stream suspended: Stream closed with error code NGHTTP2_CANCEL"} +``` + +## Cause map + +**C1 — no capture on a client-tool turn.** `capturedCheckpointBytes` is set only by +the `conversationCheckpointUpdate` frame in `CursorLiveTransport.handleServerMessage` +(`src/adapters/cursor/live-transport.ts`). On a client-tool turn the finalize-grace +timer fires, logs `client-tool-suspend`, and calls `cancelCursorRun()`. The frame has +not arrived by then. Affects every model class equally. + +**C2 — unstable conversation identity.** Each chat-completions turn derives a new +`conversationId`, so a checkpoint committed on turn N is unreachable on turn N+1 +(`checkpointInvalidationReason: missing_ref`). Observed only on the stateless path so +far; the `/v1/responses` path is untested and is what Codex users actually take. + +**Not a cause:** the native/external model split. Recorded so the next reader does +not retry it. + +## Constraints + +- No change to `src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts`. +- No change to the checkpoint design or its safety contract: a checkpoint that claims + coverage it does not have would send wrong context upstream. Slower and correct + beats faster and wrong. +- Every behavioural claim needs a diagnostic captured in the same session it is + claimed in. + +## Work-phase map (dependency ordered) + +| Phase | Doc | Decides | +|---|---|---| +| wp1 | this file + 010/020/030 | roadmap locked, docs only | +| wp2 | `010_phase1_grace_experiment.md` | C1: does the frame arrive late, or never | +| wp3 | `020_phase2_responses_identity.md` | C2: is it chat-completions-specific | +| wp4 | `030_phase3_landing.md` | land the proven fix, or record the verdict | + +wp2 and wp3 are independent of each other and both depend only on wp1. wp4 depends on +wp2; if wp3 finishes first its outcome folds into wp4 as an additional branch. + +## Decision tree + +- **wp2 = LATE** (frame arrives when the grace is extended): C1 is a grace-computation + bug. Land branch A in `030`. +- **wp2 = NEVER**: upstream does not serialize state for a suspended turn. C1 is not + fixable inside this adapter; record the verdict and the evidence. +- **wp3 = STABLE on /v1/responses**: C2 is an artifact of the stateless path and is not + a user-facing defect for Codex. Record and close that half. +- **wp3 = UNSTABLE on /v1/responses**: C2 is real and general. Land branch B in `030`. + +Either NEVER or STABLE is a legitimate terminal outcome for its half. A recorded +negative with captured evidence is the deliverable when no safe change exists. diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md b/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md new file mode 100644 index 0000000000..a829ad613c --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md @@ -0,0 +1,58 @@ +# wp2 — does `conversationCheckpointUpdate` arrive late, or never + +Decides C1. Written at wp1; re-verify against the tree before executing. + +## The lever, and why no build is needed + +`src/adapters/cursor/live-transport.ts:114`: + +```ts +const CLIENT_TOOL_FINALIZE_GRACE_MS = 50; +``` + +Fifty milliseconds. The probe that produced `capturedBytes: 0` sent one tool and no +`parallel_tool_calls`, so it took the base path and the stream was cancelled 50 ms +after the turn drained. + +`clientToolFinalizeGraceMsForRequest` (same file, line 416) already raises that window +from the request alone: + +```ts +if (request.parallelToolCalls === true && (request.tools?.length ?? 0) > 1) { + const advertised = request.tools?.length ?? 0; + return Math.max(baseGraceMs, Math.min(1_800, Math.max(750, advertised * 125))); +} +``` + +A request with `parallel_tool_calls: true` and 12 advertised tools therefore gets +`min(1800, max(750, 1500)) = 1500 ms` instead of 50 ms — on the shipped binary, with +no patch, no second proxy and no credential copy. That is the experiment. + +This deliberately replaces the instrumented build the roadmap first imagined. It is +strictly better: it exercises production code rather than a local mutant, and it +touches nothing on the operator machine. + +## Procedure + +1. `ocx debug provider on` on macbookpro-2; record the log line count as a baseline. +2. Request A (control): 1 tool, no `parallel_tool_calls`, `tool_choice: required`, + model `cursor/auto-intelligence`. Expect the 50 ms path. +3. Request B (treatment): 12 tools, `parallel_tool_calls: true`, `tool_choice: required`, + same model. Expect the 1500 ms path. +4. Capture per request: `client-tool-suspend.elapsedMs`, whether any + `conversationCheckpointUpdate` frame appears, and + `checkpoint-commit-refused.capturedBytes`. +5. `ocx debug provider off`. + +## Decision rule + +- **LATE** — B shows `capturedBytes > 0`, or a `conversationCheckpointUpdate` frame + that A lacked. The 50 ms base grace is the defect. Go to `030` branch A. +- **NEVER** — B still shows `capturedBytes: 0` and no such frame, *and* B's + `elapsedMs` is clearly larger than A's, proving the longer window was actually + taken. Upstream does not serialize state for a suspended turn; no adapter-local fix. +- **INCONCLUSIVE** — B's `elapsedMs` is not larger than A's, so the branch was not + taken. Fix the request shape and rerun; do not read the result. + +That third case is the one worth guarding. Without comparing `elapsedMs` the +experiment can measure the same 50 ms twice and look like a clean NEVER. diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md b/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md new file mode 100644 index 0000000000..68c6608e06 --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md @@ -0,0 +1,38 @@ +# wp3 — is the fresh `conversationId` chat-completions-specific + +Decides C2. Independent of wp2. + +## What was seen, and what it does not yet prove + +Two sequential `/v1/chat/completions` turns produced two different `conversationId` +values and `checkpointInvalidationReason: missing_ref` on both. That endpoint carries +no Responses state, so a fresh identity per turn may be correct there rather than a +defect. + +`src/adapters/cursor.ts` reads the prior identity from +`_parsed._providerContinuation?.cursor?.checkpointRef` and `_parsed._cursorConversationId`, +and the builder comment says it "may derive a stable provider id from the client thread +when Responses state is unavailable". Whether that derivation actually holds across +turns is the open question. + +Codex uses `/v1/responses`. If identity is stable there, C2 is not user-facing and the +honest outcome is to record that and close the half. + +## Procedure + +1. `ocx debug provider on`; record the baseline line count. +2. Turn 1: `POST /v1/responses`, `store: true`, model `cursor/auto-intelligence`, + trivial prompt. Capture the response `id`. +3. Turn 2: `POST /v1/responses` with `previous_response_id` set to that `id`. +4. Compare the two `[ocx:cursor:run-request]` lines on `conversationId`, + `checkpointPresent`, `checkpointInvalidationReason`, `continuationMode`. +5. `ocx debug provider off`. + +## Decision rule + +- **STABLE** — same `conversationId` on both turns and `checkpointPresent: true` on + turn 2. C2 is an artifact of the stateless endpoint. Record and close. +- **UNSTABLE** — identity changes, or `checkpointPresent` stays false with + `missing_ref`. C2 is real on the path users take. Go to `030` branch B. +- **BLOCKED** — the proxy rejects the Responses shape for this provider. Record what it + rejected; do not infer the answer from the chat-completions result. diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md new file mode 100644 index 0000000000..7e60dbd8bc --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md @@ -0,0 +1,91 @@ +# wp4 — land what the probes proved, or record the verdict + +One branch per wp2/wp3 outcome. Only the branch the evidence selects gets built. + +## Branch A — wp2 = LATE + +The 50 ms base grace cancels the stream before upstream serializes conversation +state. Two edits, and the second is only safe *because* of the first. + +**A1. MODIFY `src/adapters/cursor/live-transport.ts`**, the client-tool finalize +timer (currently lines 1006-1018): + +```diff + this.pendingFinalize = setTimeout(() => { + this.pendingFinalize = undefined; + if (this.expectedClose) return; + const terminal = finalizeAfterDrain(state); + if (terminal.length === 0) return; + for (const event of terminal) push(event); ++ // A suspended tool turn is exactly the turn whose state we most want to ++ // resume from, and it is the one turn we used to cancel before upstream ++ // could send it. Give the checkpoint frame one bounded extension rather ++ // than a larger blanket grace: the common case stays fast, and a stream ++ // that never sends one is still cancelled at a known deadline (#4245). ++ if (this.wantsCheckpointCapture && !this.capturedCheckpointBytes && !this.checkpointGraceExtended) { ++ this.checkpointGraceExtended = true; ++ this.scheduleClientToolFinalize(state, push, CHECKPOINT_CAPTURE_GRACE_MS); ++ return; ++ } + debugProviderDiagnostic("cursor", "client-tool-suspend", { ... }); + this.cancelCursorRun(); + }, this.activeClientToolFinalizeGraceMs); +``` + +New constant beside the others at line 114-117, sized from the measured B-arm +latency, not guessed. New fields `checkpointGraceExtended` and +`wantsCheckpointCapture` (set from `contextUsageStoreCheckpoints !== false`). + +**A2. MODIFY `src/adapters/cursor.ts`** `commitCapturedCheckpoint`: + +```diff + const toolSuspendedCommit = + emittedClientTool + && capturedAfterClientTool +- && isCursorExternalWireModel(activeRequest.modelId); ++ // Once A1 makes the frame actually arrive, capturedAfterClientTool is a ++ // real ordering proof for every model, so the wire-model test stops being ++ // the thing standing in for it. Keep the proof; drop the proxy for it. ++ ; +``` + +A2 without A1 is the patch this unit exists to reject: with `capturedBytes: 0` it +changes nothing, and with a checkpoint captured *before* the tool call it would claim +coverage the bytes do not have. A1 is what makes `capturedAfterClientTool` mean +something. + +`checkpointUsable` stays `!toolSuspendedCommit`, so a tool-suspended checkpoint is +still only usable by the immediate trailing-toolResult continuation. This branch does +not widen what a checkpoint claims. + +**Tests.** `tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts`: a fake +transport that emits `conversationCheckpointUpdate` after `tool_call_end` but later +than the base grace must yield `checkpointRef` defined and `checkpointUsable: false`; +one that never emits must still refuse with `capturedBytes: 0`; and composer-2.5 must +keep whatever `cursorNeedsExternalToolContinuation` already guarantees. + +**Risk.** Every suspended tool turn gets up to one extra bounded wait before the +stream closes. That is added latency on the tool path, so the constant must come from +the measurement, and the no-frame case must still terminate. + +## Branch B — wp3 = UNSTABLE + +Identity, not capture. The checkpoint exists and is simply unreachable because turn +N+1 derives a different `conversationId`. The fix is in how +`_cursorConversationId` / `_providerContinuation` are threaded on the Responses path, +which is request-assembly territory rather than adapter transport. + +Do not start this as a patch. Write the observed identity chain into a `021` doc +first, then decide whether the correct owner is the Cursor adapter or the Responses +state layer. If it turns out to need `src/server/responses/core.ts`, it is out of this +unit's scope and becomes NEEDS_HUMAN with the evidence attached. + +## Branch C — wp2 = NEVER and wp3 = STABLE + +Nothing is safely fixable here. Deliverable is the recorded verdict: this file gains a +closing section, `000_plan.md` gets the outcome, issue #4245 gets a comment naming +what was measured and what would change the answer, and the unit moves to `_fin/`. + +A recorded negative with captured evidence is a real outcome. The failure mode this +unit was opened against was a plausible patch that fixed nothing, so shipping nothing +beats shipping that. From bedbfb48086e1d73a3b0d612eb7d8c1b74dccdb0 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 21:45:29 +0900 Subject: [PATCH 023/231] docs(structure): close the review findings on the SSOT gate Follow-up to #4276. CodeRabbit posted twelve findings on that PR; these are the ones that were still true against the merged code. The gate was claiming more than it checked, again, in four places: - A fragment-only link was skipped entirely, and one was already broken: structure/subagents.md pointed at #ultra-reasoning-level, a heading that moved to catalog.md during the split. Fragment targets now resolve against their own document, which found it immediately. - Link targets were resolved with existsSync while backticked paths went through the index. That is the per-machine split verdict this module exists to remove, on the reference class the split churned hardest. - A documents entry was accepted because the path existed, not because the doc said anything about it, so the map could claim coverage the prose did not have. A claim now has to be backed by a path the doc actually names. - Decision-record ownership was inferred from any occurrence of the filename in raw text. A record path inside a fenced example counted as a second owner, and an orphaned record whose owner link was deleted still looked owned. Ownership is now the > Decision record: link, read fence-stripped. Also: the filesystem fallback no longer applies when the git index is readable, so untracked local leftovers cannot satisfy a check that CI will fail; the manifest goes through a validating loader so a malformed file is a failure line instead of an uncaught stack trace; a missing overview.md is a failure rather than silence across every invariant binding; and backticked paths rooted at any tracked top-level entry are checked, not just the ten directories that were hardcoded. One finding is answered rather than implemented. Validating every filename-shaped token would reject the runtime files these docs legitimately name - config.toml, models_cache.json, ocx.pid - which live in a user's home, not in this repository. The boundary is now stated in structure/AGENTS.md, and root documents stay covered because a reference like MAINTAINERS.md is written as a link, and links are checked. Docs: the client-integration rationale left inline in adapters/registry.md moves into ADR-0093 with its evidence intact. All 96 records are retitled to say what they are - the heading names the section a record was extracted from, not the decision it contains, and a title that reads like a decision name while being a section name sends maintainers to the wrong record. src/AGENTS.md now says every applicable doc is updated, not one. Six new negative cases, including the two that must NOT fire: a bare filename is not a repository path, and a record named in prose is not an owner. --- scripts/structure-ssot.ts | 119 +++++++++++++++--- src/AGENTS.md | 2 +- structure/AGENTS.md | 25 +++- structure/adapters/registry.md | 5 - .../decisions/ADR-0001-product-boundary.md | 2 +- structure/decisions/ADR-0002-lifecycle.md | 2 +- structure/decisions/ADR-0003-lifecycle.md | 2 +- structure/decisions/ADR-0004-lifecycle.md | 2 +- structure/decisions/ADR-0005-codex-home.md | 2 +- structure/decisions/ADR-0006-codex-home.md | 2 +- structure/decisions/ADR-0007-codex-home.md | 2 +- structure/decisions/ADR-0008-codex-home.md | 2 +- structure/decisions/ADR-0009-codex-home.md | 2 +- structure/decisions/ADR-0010-codex-home.md | 2 +- structure/decisions/ADR-0011-codex-home.md | 2 +- structure/decisions/ADR-0012-codex-home.md | 2 +- structure/decisions/ADR-0013-codex-home.md | 2 +- structure/decisions/ADR-0014-codex-home.md | 2 +- structure/decisions/ADR-0015-codex-home.md | 2 +- .../decisions/ADR-0016-config-surface.md | 2 +- .../decisions/ADR-0017-config-injection.md | 2 +- .../decisions/ADR-0018-config-injection.md | 2 +- .../decisions/ADR-0019-config-injection.md | 2 +- .../ADR-0020-provider-validation-ownership.md | 2 +- .../decisions/ADR-0021-shared-catalog.md | 2 +- ...routed-tool-discovery-and-hosted-search.md | 2 +- .../ADR-0023-ultra-reasoning-level.md | 2 +- .../ADR-0024-ultra-reasoning-level.md | 2 +- .../ADR-0025-ultra-reasoning-level.md | 2 +- .../ADR-0026-ultra-reasoning-level.md | 2 +- structure/decisions/ADR-0027-subagents.md | 2 +- ...28-background-service-command-selection.md | 2 +- ...windows-startup-ownership-listing-reuse.md | 2 +- ...le-service-launcher-launchd-and-systemd.md | 2 +- .../decisions/ADR-0031-responses-http-sse.md | 2 +- .../decisions/ADR-0032-responses-http-sse.md | 2 +- .../decisions/ADR-0033-responses-http-sse.md | 2 +- .../decisions/ADR-0034-responses-http-sse.md | 2 +- .../decisions/ADR-0035-responses-http-sse.md | 2 +- .../decisions/ADR-0036-responses-http-sse.md | 2 +- .../decisions/ADR-0037-responses-http-sse.md | 2 +- .../decisions/ADR-0038-responses-http-sse.md | 2 +- .../decisions/ADR-0039-responses-http-sse.md | 2 +- .../decisions/ADR-0040-responses-http-sse.md | 2 +- .../decisions/ADR-0041-responses-http-sse.md | 2 +- .../decisions/ADR-0042-responses-http-sse.md | 2 +- .../decisions/ADR-0043-responses-http-sse.md | 2 +- .../decisions/ADR-0044-responses-http-sse.md | 2 +- .../decisions/ADR-0045-standalone-images.md | 2 +- ...laude-desktop-config-library-resolution.md | 2 +- .../decisions/ADR-0047-cursor-native-exec.md | 2 +- .../decisions/ADR-0048-cursor-native-exec.md | 2 +- .../ADR-0049-heartbeat-and-stall-deadline.md | 2 +- .../ADR-0050-heartbeat-and-stall-deadline.md | 2 +- ...reasoning-and-tool-result-compatibility.md | 2 +- ...reasoning-and-tool-result-compatibility.md | 2 +- .../ADR-0053-cursor-active-context-usage.md | 2 +- ...54-cursor-conversation-checkpoint-reuse.md | 2 +- ...google-thought-text-visibility-boundary.md | 2 +- ...056-google-response-part-field-boundary.md | 2 +- ...ogle-tool-call-thought-signature-replay.md | 2 +- ...058-google-tool-result-adjacency-repair.md | 2 +- ...-hardening-official-grok-build-contract.md | 2 +- ...ADR-0060-kiro-client-parallel-tool-hint.md | 2 +- .../ADR-0061-kiro-responses-text-controls.md | 2 +- ...aming-client-with-a-json-upstream-resul.md | 2 +- ...ngine-ark-assistant-continuation-shapes.md | 2 +- ...64-chat-structured-output-compatibility.md | 2 +- ...65-chat-structured-output-compatibility.md | 2 +- ...thropic-structured-output-compatibility.md | 2 +- ...ning-display-parity-hidethinkingsummary.md | 2 +- ...ning-display-parity-hidethinkingsummary.md | 2 +- ...at-to-responses-message-phase-inference.md | 2 +- ...0070-same-provider-combo-quota-fallback.md | 2 +- ...DR-0071-combo-streaming-commit-boundary.md | 2 +- .../decisions/ADR-0072-transport-inventory.md | 2 +- .../ADR-0073-authentication-boundaries.md | 2 +- structure/decisions/ADR-0074-api-ownership.md | 2 +- .../decisions/ADR-0075-startup-safety.md | 2 +- .../decisions/ADR-0076-startup-safety.md | 2 +- .../decisions/ADR-0077-startup-safety.md | 2 +- .../decisions/ADR-0078-usage-accounting.md | 2 +- .../decisions/ADR-0079-usage-accounting.md | 2 +- structure/decisions/ADR-0080-github-pages.md | 2 +- .../ADR-0081-container-deployment-recipe.md | 2 +- ...-service-wrapper-and-incomplete-updates.md | 2 +- .../ADR-0083-maintenance-governance.md | 2 +- .../ADR-0084-public-provider-contract.md | 2 +- .../ADR-0085-public-provider-contract.md | 2 +- .../ADR-0086-public-provider-contract.md | 2 +- .../ADR-0087-model-and-wire-identity.md | 2 +- .../ADR-0088-model-and-wire-identity.md | 2 +- ...0089-process-local-affinity-diagnostics.md | 2 +- .../ADR-0090-hermes-model-capabilities.md | 2 +- .../decisions/ADR-0091-ownership-axes.md | 2 +- .../ADR-0092-zcode-runtime-metadata.md | 2 +- ...oonshot-ref-with-siblings-normalization.md | 9 +- ...nonical-forward-continuation-extensions.md | 2 +- ...nonical-forward-continuation-extensions.md | 2 +- ...R-0096-z-ai-quota-destination-ownership.md | 2 +- structure/subagents.md | 2 +- tests/ci-workflows/structure-ssot.test.ts | 57 ++++++++- 102 files changed, 284 insertions(+), 125 deletions(-) diff --git a/scripts/structure-ssot.ts b/scripts/structure-ssot.ts index 81a4e416cc..b1d832ee7d 100644 --- a/scripts/structure-ssot.ts +++ b/scripts/structure-ssot.ts @@ -43,13 +43,48 @@ export type Manifest = { }; }; -const REPO_ROOTS = ["src", "tests", "gui", "scripts", "docs", "docs-site", "bin", "go", "devlog", ".github", "structure"]; const GENERATED_DOCS = ["INDEX.md"]; const RULE_DOCS = ["AGENTS.md"]; const toPosix = (p: string) => p.split("\\").join("/"); const trimSlash = (p: string) => p.replace(/\/+$/, ""); +/** Parse and validate the manifest, so a malformed file is an actionable failure, not a stack trace. */ +export function loadManifest(raw: string): { manifest: Manifest } | { error: string } { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + return { error: "structure/manifest.json is not valid JSON: " + (cause as Error).message }; + } + const m = parsed as Partial; + const problems: string[] = []; + const isArray = (v: unknown) => Array.isArray(v); + if (typeof m.sizeBudgetLines !== "number") problems.push("sizeBudgetLines must be a number"); + if (!isArray(m.generatedPaths)) problems.push("generatedPaths must be an array"); + if (!isArray(m.absentPaths)) problems.push("absentPaths must be an array"); + if (!isArray(m.tiers)) problems.push("tiers must be an array"); + if (!isArray(m.docs)) problems.push("docs must be an array"); + else { + m.docs.forEach((doc, i) => { + if (typeof doc?.path !== "string") problems.push("docs[" + i + "].path must be a string"); + if (typeof doc?.tier !== "number") problems.push("docs[" + i + "].tier must be a number"); + if (typeof doc?.title !== "string") problems.push("docs[" + i + "].title must be a string"); + if (typeof doc?.scope !== "string") problems.push("docs[" + i + "].scope must be a string"); + if (!isArray(doc?.documents)) problems.push("docs[" + i + "].documents must be an array"); + }); + } + const grace = m.grace as Partial | undefined; + if (!grace) problems.push("grace must be an object"); + else { + for (const key of ["undocumentedSourceAreas", "unboundInvariants", "oversizeDocs", "staleRefs"] as const) { + if (!isArray(grace[key])) problems.push("grace." + key + " must be an array"); + } + } + if (problems.length > 0) return { error: "structure/manifest.json is malformed: " + problems.join("; ") }; + return { manifest: parsed as Manifest }; +} + function listMarkdown(dir: string, root: string, out: string[] = []): string[] { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); @@ -181,23 +216,33 @@ export function runStructureChecks(repoRoot: string): string[] { const manifestPath = join(structureDir, "manifest.json"); if (!existsSync(manifestPath)) return ["structure/manifest.json is missing"]; - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; + const loaded = loadManifest(readFileSync(manifestPath, "utf8")); + if ("error" in loaded) return [loaded.error]; + const manifest = loaded.manifest; const tracked = trackedPaths(repoRoot); const trackedLower = new Map(); if (tracked) for (const p of tracked) trackedLower.set(p.toLowerCase(), p); - /** A repository path is real when git tracks it, or when it exists and is not merely a case variant. */ + /** + * A repository path is real when git tracks it. The filesystem is consulted only when the index + * could not be read at all: CI checks out a clean tree, so an untracked local leftover that + * satisfied the gate here would still fail there, which is the split verdict this module exists + * to remove. The cost is that a newly written file has to be staged before the gate can see it. + */ const pathIsReal = (raw: string): "ok" | "missing" | string => { const p = trimSlash(raw); - if (tracked?.has(p)) return "ok"; - if (tracked) { - const variant = trackedLower.get(p.toLowerCase()); - if (variant && variant !== p) return variant; - } - return existsSync(join(repoRoot, p)) ? "ok" : "missing"; + if (!tracked) return existsSync(join(repoRoot, p)) ? "ok" : "missing"; + if (tracked.has(p)) return "ok"; + const variant = trackedLower.get(p.toLowerCase()); + if (variant && variant !== p) return variant; + return "missing"; }; const isTracked = (raw: string) => tracked?.has(trimSlash(raw)) ?? existsSync(join(repoRoot, trimSlash(raw))); + // Top-level tracked entries, so a backticked root file is validated like a directory path is. + const rootEntries = new Set(); + if (tracked) for (const p of tracked) rootEntries.add(p.split("/")[0]!); + else for (const entry of readdirSync(repoRoot, { withFileTypes: true })) rootEntries.add(entry.name); const present = listMarkdown(structureDir, structureDir); const sotOnDisk = present.filter((p) => !p.startsWith("decisions/") && !GENERATED_DOCS.includes(p) && !RULE_DOCS.includes(p)); @@ -238,7 +283,7 @@ export function runStructureChecks(repoRoot: string): string[] { // 3. links, anchors, repository paths, and the inline-decision ban const linkRe = /\]\(([^)\s]+)\)/g; - const pathRe = new RegExp(BT + "((?:" + REPO_ROOTS.join("|") + ")/[A-Za-z0-9_.@/-]*)" + BT, "g"); + const pathRe = new RegExp(BT + "([A-Za-z0-9_.@-]+(?:/[A-Za-z0-9_.@-]*)*)" + BT, "g"); const anchorCache = new Map>(); for (const rel of present) { const abs = join(structureDir, rel); @@ -254,12 +299,21 @@ export function runStructureChecks(repoRoot: string): string[] { linkRe.lastIndex = 0; while ((m = linkRe.exec(body))) { const target = m[1]; - if (/^(?:https?|mailto):/.test(target) || target.startsWith("#")) continue; + if (/^(?:https?|mailto):/.test(target)) continue; const [file, fragment] = target.split("#"); - const resolved = resolve(dirname(abs), file); - if (!existsSync(resolved)) { - fail("structure/" + rel + " links " + target + ", which does not exist"); - continue; + // A fragment-only link points at this same document; it still has to name a real heading. + const resolved = file === "" ? abs : resolve(dirname(abs), file); + if (file !== "") { + const repoRel = toPosix(relative(repoRoot, resolved)); + const verdict = pathIsReal(repoRel); + if (verdict === "missing") { + fail("structure/" + rel + " links " + target + ", which does not exist"); + continue; + } + if (verdict !== "ok") { + fail("structure/" + rel + " links " + target + ", but the tracked path is " + verdict); + continue; + } } if (fragment && resolved.endsWith(".md")) { if (!anchorCache.has(resolved)) anchorCache.set(resolved, headingAnchors(readFileSync(resolved, "utf8"))); @@ -273,6 +327,8 @@ export function runStructureChecks(repoRoot: string): string[] { pathRe.lastIndex = 0; while ((m = pathRe.exec(body))) { const named = trimSlash(m[1]); + // Only tokens rooted at a real top-level entry are paths; the rest are ordinary code spans. + if (!rootEntries.has(named.split("/")[0]!)) continue; if (manifest.generatedPaths.some((g) => named === trimSlash(g) || named.startsWith(trimSlash(g) + "/"))) continue; if (manifest.absentPaths.some((a) => trimSlash(a.path) === named)) continue; if (manifest.grace.staleRefs.map(trimSlash).includes(named)) continue; @@ -291,12 +347,19 @@ export function runStructureChecks(repoRoot: string): string[] { // 4. decision records const adrFiles = present.filter((p) => p.startsWith("decisions/")); const referenced = new Map>(); + // Ownership is the declared link form, read with fences removed. A record path mentioned in prose + // or shown inside an example is not a claim of ownership, and counting it made an orphaned record + // look owned while reporting a second owner nobody could remove. + const ownerLinkRe = /^>\s*Decision record:\s*\[[^\]]*\]\(([^)\s]+)\)/gm; for (const doc of manifest.docs) { const abs = join(structureDir, doc.path); if (!existsSync(abs)) continue; - for (const hit of readFileSync(abs, "utf8").match(/decisions\/ADR-[0-9]{4}-[a-z0-9-]*\.md/g) ?? []) { - const key = "decisions/" + hit.split("/")[1]; - referenced.set(key, (referenced.get(key) ?? new Set()).add(doc.path)); + const body = withoutFences(readFileSync(abs, "utf8")); + ownerLinkRe.lastIndex = 0; + let hit: RegExpExecArray | null; + while ((hit = ownerLinkRe.exec(body))) { + const file = hit[1].split("#")[0]!.split("/").pop()!; + referenced.set("decisions/" + file, (referenced.get("decisions/" + file) ?? new Set()).add(doc.path)); } } const ids = new Set(); @@ -317,7 +380,9 @@ export function runStructureChecks(repoRoot: string): string[] { // 5. invariant-to-test bindings const overviewPath = join(structureDir, "overview.md"); - if (existsSync(overviewPath)) { + if (!existsSync(overviewPath)) { + fail("structure/overview.md is missing; it is the invariant index, and its absence would silence every binding check"); + } else { const body = readFileSync(overviewPath, "utf8"); const blocks: { id: string; text: string }[] = []; let current: { id: string; text: string } | null = null; @@ -370,6 +435,18 @@ export function runStructureChecks(repoRoot: string): string[] { // 6. source-to-doc map const described = new Map(); + // What a doc actually names, so a manifest claim cannot invent coverage the prose does not have. + const namedByDoc = new Map(); + for (const doc of manifest.docs) { + const abs = join(structureDir, doc.path); + if (!existsSync(abs)) continue; + const body = withoutFences(readFileSync(abs, "utf8")); + const found: string[] = []; + const re = new RegExp(pathRe.source, "g"); + let hit: RegExpExecArray | null; + while ((hit = re.exec(body))) found.push(hit[1]); + namedByDoc.set(doc.path, found); + } for (const doc of manifest.docs) { const own = new Set(); for (const area of doc.documents) { @@ -379,6 +456,10 @@ export function runStructureChecks(repoRoot: string): string[] { const verdict = pathIsReal(area); if (verdict === "missing") fail("structure/" + doc.path + " claims " + area + ", which this tree does not have"); else if (verdict !== "ok") fail("structure/" + doc.path + " claims " + area + ", but the tracked path is " + verdict); + const names = namedByDoc.get(doc.path) ?? []; + if (!names.some((n) => n === area || n === trimSlash(area) || n.startsWith(area))) { + fail("structure/" + doc.path + " claims " + area + " but never names a path in it; describing an area means citing one"); + } } } const graced = new Map(manifest.grace.undocumentedSourceAreas.map((g) => [g.path, g.reason])); diff --git a/src/AGENTS.md b/src/AGENTS.md index 69b2a703fa..63fdea93f7 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -8,7 +8,7 @@ This file applies to `src/` and inherits the repository-wide rules in `/AGENTS.m - Do not assume a separate server compilation step. - Prefer Bun and Web-platform APIs. Introduce a Node-only runtime dependency only when the task explicitly requires compatibility code and the owning module already has that role. - Preserve existing public exports and configuration compatibility unless the task explicitly changes them. -- Read the applicable documents in `structure/` before changing shared routing, adapters, transports, sidecars, authentication, configuration, or server architecture. [`structure/INDEX.md`](../structure/INDEX.md) maps each source area to its owning doc, and that doc is updated in the same change that changes the area. +- Read the applicable documents in `structure/` before changing shared routing, adapters, transports, sidecars, authentication, configuration, or server architecture. [`structure/INDEX.md`](../structure/INDEX.md) maps each source area to the docs that describe it — usually more than one, because those docs are organised by topic while `src/` is organised by module — and every doc listed for an area is updated in the same change that changes the area. ## Implementation rules diff --git a/structure/AGENTS.md b/structure/AGENTS.md index 6ea1e3286d..3dfb9724da 100644 --- a/structure/AGENTS.md +++ b/structure/AGENTS.md @@ -28,6 +28,10 @@ structure doc. - A doc stays under the line budget in `manifest.json`. Over budget, split it along a topic boundary and give each half its own manifest entry. A `grace.oversizeDocs` entry is for a split already planned; the gate drops it again once the doc is back under budget. +- **Stage a new file before running the gate.** Repository paths are resolved through the git index, + so a file you have written but not `git add`ed does not exist as far as the check is concerned. That + is deliberate: CI runs on a clean checkout, and a gate that passed on untracked local files would + disagree with it. - Know what the budget does and does not do: it is a line count, so a doc written as a wide table can carry far more prose per line than one written as paragraphs. It bounds the runaway-file failure, not density. @@ -47,6 +51,8 @@ the inverse. - Describing an area means naming a path inside it. If a doc explains a subsystem without ever citing a path, the map cannot see it, and the area lands in `grace.undocumentedSourceAreas` instead — which is a signal to add the path reference, not a place to park work. + The gate enforces this in both directions: a `documents` entry whose doc never names a path inside + the area is rejected, so the map cannot claim coverage the prose does not have. - A new `src//` or top-level `src/*.ts` either joins a doc's `documents` list or is recorded in `grace.undocumentedSourceAreas` with a reason. The gate rejects one that is neither. @@ -66,8 +72,12 @@ choice, why, and consequences. rewrite an old one to match. For the same reason the gate does not validate the repository paths a record names — a record describes a past tree, and holding it against the present one would force you to falsify it. -- Records extracted during the 2026-09-11 reorganisation are titled after the doc section they were - taken from, which is where they belonged, not necessarily what they decided. Read the body. +- A record's title names the doc section it was recorded under, not the decision it contains. That is + why every title reads `decision recorded under "
"`: the records extracted during the + 2026-09-11 reorganisation took their heading from the section they sat in, and a title that looked + like a decision name but was not would send a maintainer to the wrong record. Read the body. +- Ownership is the `> Decision record:` link, and nothing else. A record path mentioned in prose or + shown inside a fenced example is not a claim, so an illustration cannot make a doc a second owner. ## Invariants @@ -101,6 +111,7 @@ verifies that: - file names are kebab-case, letter-initial, and at most one directory deep; - no doc exceeds the line budget, and no grace entry outlives the split it promised; - every relative link resolves, including its `#anchor`; +- a fragment-only link resolves against its own document, which is where one broken anchor was hiding; - every backticked repository path a doc names is real, checked against the **git index** rather than the filesystem — `existsSync` cannot tell a tracked file from untracked local leftovers, and it is case-insensitive on Windows and case-sensitive on Linux CI, which would make the gate mean @@ -110,7 +121,17 @@ verifies that: - every bound invariant names an existing test that names the id back, and every unbound one is recorded with a reason; - every `src/` directory and top-level module is described by a doc or recorded as undescribed; +- the manifest itself parses and has the shape the gate expects, reported as a failure rather than a + stack trace; +- `overview.md` exists, because its absence would otherwise silence every invariant check at once; - `INDEX.md` matches the manifest byte for byte. Checks are scanned with fenced code blocks removed, so an example inside a fence does not trip a rule it is only illustrating. + +One boundary worth stating, because it looks like a gap and is a deliberate one: a backticked token is +treated as a repository path only when it is rooted at a real top-level entry, such as `src/` or +`package.json`. A bare filename is not checked, because these docs name runtime files that are not in +the repository at all — `config.toml`, `models_cache.json`, `ocx.pid` — and validating every +filename-shaped token would reject them. Root documents are still covered where it matters, since a +reference like [`MAINTAINERS.md`](../MAINTAINERS.md) is a link, and links are checked. diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 9e9176489b..864e14dc78 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -42,8 +42,3 @@ request when a node carries both. Codex's own deferred tool catalog emits exactl so the schema is not something a user can fix from configuration (issue #2673). > Decision record: [ADR-0093](../decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md) - -예산은 세 가지다. 확장 횟수만으로는 참조가 하나도 없는 깊은 스키마를 막지 못해서, 깊이와 -노드 수를 따로 둔다 — `google-tool-schema.ts`가 이미 쓰는 형태다. 두 가드 모두 제거했을 때 -실제로 red가 되는지 확인했고, 예산을 풀면 20k 깊이에서 `RangeError: Maximum call stack size -exceeded`가 난다. diff --git a/structure/decisions/ADR-0001-product-boundary.md b/structure/decisions/ADR-0001-product-boundary.md index 04a1e18463..ade62886a4 100644 --- a/structure/decisions/ADR-0001-product-boundary.md +++ b/structure/decisions/ADR-0001-product-boundary.md @@ -1,4 +1,4 @@ -# ADR-0001 — Product boundary +# ADR-0001 — decision recorded under "Product boundary" - Contract owner: [overview.md](../overview.md#product-boundary) diff --git a/structure/decisions/ADR-0002-lifecycle.md b/structure/decisions/ADR-0002-lifecycle.md index 1e26642652..aab461c049 100644 --- a/structure/decisions/ADR-0002-lifecycle.md +++ b/structure/decisions/ADR-0002-lifecycle.md @@ -1,4 +1,4 @@ -# ADR-0002 — Lifecycle +# ADR-0002 — decision recorded under "Lifecycle" - Contract owner: [runtime.md](../runtime.md#lifecycle) diff --git a/structure/decisions/ADR-0003-lifecycle.md b/structure/decisions/ADR-0003-lifecycle.md index e64cdef923..6ae9b51b9c 100644 --- a/structure/decisions/ADR-0003-lifecycle.md +++ b/structure/decisions/ADR-0003-lifecycle.md @@ -1,4 +1,4 @@ -# ADR-0003 — Lifecycle +# ADR-0003 — decision recorded under "Lifecycle" - Contract owner: [runtime.md](../runtime.md#lifecycle) diff --git a/structure/decisions/ADR-0004-lifecycle.md b/structure/decisions/ADR-0004-lifecycle.md index 94598c8b68..8ddea07f23 100644 --- a/structure/decisions/ADR-0004-lifecycle.md +++ b/structure/decisions/ADR-0004-lifecycle.md @@ -1,4 +1,4 @@ -# ADR-0004 — Lifecycle +# ADR-0004 — decision recorded under "Lifecycle" - Contract owner: [runtime.md](../runtime.md#lifecycle) diff --git a/structure/decisions/ADR-0005-codex-home.md b/structure/decisions/ADR-0005-codex-home.md index 24d3824b3f..2af6fd2cc2 100644 --- a/structure/decisions/ADR-0005-codex-home.md +++ b/structure/decisions/ADR-0005-codex-home.md @@ -1,4 +1,4 @@ -# ADR-0005 — Codex home +# ADR-0005 — decision recorded under "Codex home" - Contract owner: [codex-home.md](../codex-home.md#codex-home) diff --git a/structure/decisions/ADR-0006-codex-home.md b/structure/decisions/ADR-0006-codex-home.md index 2230117cb4..748fb322b4 100644 --- a/structure/decisions/ADR-0006-codex-home.md +++ b/structure/decisions/ADR-0006-codex-home.md @@ -1,4 +1,4 @@ -# ADR-0006 — Codex home +# ADR-0006 — decision recorded under "Codex home" - Contract owner: [codex-home.md](../codex-home.md#codex-home) diff --git a/structure/decisions/ADR-0007-codex-home.md b/structure/decisions/ADR-0007-codex-home.md index 7f4db176b5..7da1c8244a 100644 --- a/structure/decisions/ADR-0007-codex-home.md +++ b/structure/decisions/ADR-0007-codex-home.md @@ -1,4 +1,4 @@ -# ADR-0007 — Codex home +# ADR-0007 — decision recorded under "Codex home" - Contract owner: [codex-home.md](../codex-home.md#codex-home) diff --git a/structure/decisions/ADR-0008-codex-home.md b/structure/decisions/ADR-0008-codex-home.md index f055de913d..a64ff2ccec 100644 --- a/structure/decisions/ADR-0008-codex-home.md +++ b/structure/decisions/ADR-0008-codex-home.md @@ -1,4 +1,4 @@ -# ADR-0008 — Codex home +# ADR-0008 — decision recorded under "Codex home" - Contract owner: [codex-home.md](../codex-home.md#codex-home) diff --git a/structure/decisions/ADR-0009-codex-home.md b/structure/decisions/ADR-0009-codex-home.md index 5640d39498..3ef1eb69df 100644 --- a/structure/decisions/ADR-0009-codex-home.md +++ b/structure/decisions/ADR-0009-codex-home.md @@ -1,4 +1,4 @@ -# ADR-0009 — Codex home +# ADR-0009 — decision recorded under "Codex home" - Contract owner: [codex-home.md](../codex-home.md#codex-home) diff --git a/structure/decisions/ADR-0010-codex-home.md b/structure/decisions/ADR-0010-codex-home.md index 02814e9e6e..659feee442 100644 --- a/structure/decisions/ADR-0010-codex-home.md +++ b/structure/decisions/ADR-0010-codex-home.md @@ -1,4 +1,4 @@ -# ADR-0010 — Codex home +# ADR-0010 — decision recorded under "Codex home" - Contract owner: [codex-home.md](../codex-home.md#codex-home) diff --git a/structure/decisions/ADR-0011-codex-home.md b/structure/decisions/ADR-0011-codex-home.md index 5e5d8e01fd..447575066d 100644 --- a/structure/decisions/ADR-0011-codex-home.md +++ b/structure/decisions/ADR-0011-codex-home.md @@ -1,4 +1,4 @@ -# ADR-0011 — Codex home +# ADR-0011 — decision recorded under "Codex home" - Contract owner: [codex-home.md](../codex-home.md#codex-home) diff --git a/structure/decisions/ADR-0012-codex-home.md b/structure/decisions/ADR-0012-codex-home.md index 391b52fecf..1cedeb17dc 100644 --- a/structure/decisions/ADR-0012-codex-home.md +++ b/structure/decisions/ADR-0012-codex-home.md @@ -1,4 +1,4 @@ -# ADR-0012 — Codex home +# ADR-0012 — decision recorded under "Codex home" - Contract owner: [codex-home.md](../codex-home.md#codex-home) diff --git a/structure/decisions/ADR-0013-codex-home.md b/structure/decisions/ADR-0013-codex-home.md index 339bf63835..770d4c7d50 100644 --- a/structure/decisions/ADR-0013-codex-home.md +++ b/structure/decisions/ADR-0013-codex-home.md @@ -1,4 +1,4 @@ -# ADR-0013 — Codex home +# ADR-0013 — decision recorded under "Codex home" - Contract owner: [codex-home.md](../codex-home.md#codex-home) diff --git a/structure/decisions/ADR-0014-codex-home.md b/structure/decisions/ADR-0014-codex-home.md index 616027eb9e..6d55a28668 100644 --- a/structure/decisions/ADR-0014-codex-home.md +++ b/structure/decisions/ADR-0014-codex-home.md @@ -1,4 +1,4 @@ -# ADR-0014 — Codex home +# ADR-0014 — decision recorded under "Codex home" - Contract owner: [codex-home.md](../codex-home.md#codex-home) diff --git a/structure/decisions/ADR-0015-codex-home.md b/structure/decisions/ADR-0015-codex-home.md index d1bbd8f78d..85868fde79 100644 --- a/structure/decisions/ADR-0015-codex-home.md +++ b/structure/decisions/ADR-0015-codex-home.md @@ -1,4 +1,4 @@ -# ADR-0015 — Codex home +# ADR-0015 — decision recorded under "Codex home" - Contract owner: [codex-home.md](../codex-home.md#codex-home) diff --git a/structure/decisions/ADR-0016-config-surface.md b/structure/decisions/ADR-0016-config-surface.md index c2f6c91119..01d5359b5c 100644 --- a/structure/decisions/ADR-0016-config-surface.md +++ b/structure/decisions/ADR-0016-config-surface.md @@ -1,4 +1,4 @@ -# ADR-0016 — Config surface +# ADR-0016 — decision recorded under "Config surface" - Contract owner: [config.md](../config.md#config-surface) diff --git a/structure/decisions/ADR-0017-config-injection.md b/structure/decisions/ADR-0017-config-injection.md index fe97d8c16f..464e99ed5a 100644 --- a/structure/decisions/ADR-0017-config-injection.md +++ b/structure/decisions/ADR-0017-config-injection.md @@ -1,4 +1,4 @@ -# ADR-0017 — Config injection +# ADR-0017 — decision recorded under "Config injection" - Contract owner: [config.md](../config.md#config-injection) diff --git a/structure/decisions/ADR-0018-config-injection.md b/structure/decisions/ADR-0018-config-injection.md index 33e1b41a28..379e91be51 100644 --- a/structure/decisions/ADR-0018-config-injection.md +++ b/structure/decisions/ADR-0018-config-injection.md @@ -1,4 +1,4 @@ -# ADR-0018 — Config injection +# ADR-0018 — decision recorded under "Config injection" - Contract owner: [config.md](../config.md#config-injection) diff --git a/structure/decisions/ADR-0019-config-injection.md b/structure/decisions/ADR-0019-config-injection.md index 5d8456124c..a72d7559e1 100644 --- a/structure/decisions/ADR-0019-config-injection.md +++ b/structure/decisions/ADR-0019-config-injection.md @@ -1,4 +1,4 @@ -# ADR-0019 — Config injection +# ADR-0019 — decision recorded under "Config injection" - Contract owner: [config.md](../config.md#config-injection) diff --git a/structure/decisions/ADR-0020-provider-validation-ownership.md b/structure/decisions/ADR-0020-provider-validation-ownership.md index 07e011d29a..d46b58c8ef 100644 --- a/structure/decisions/ADR-0020-provider-validation-ownership.md +++ b/structure/decisions/ADR-0020-provider-validation-ownership.md @@ -1,4 +1,4 @@ -# ADR-0020 — Provider validation ownership +# ADR-0020 — decision recorded under "Provider validation ownership" - Contract owner: [config.md](../config.md#provider-validation-ownership) diff --git a/structure/decisions/ADR-0021-shared-catalog.md b/structure/decisions/ADR-0021-shared-catalog.md index 7b764da7f4..ddeb8d1feb 100644 --- a/structure/decisions/ADR-0021-shared-catalog.md +++ b/structure/decisions/ADR-0021-shared-catalog.md @@ -1,4 +1,4 @@ -# ADR-0021 — Shared catalog +# ADR-0021 — decision recorded under "Shared catalog" - Contract owner: [catalog.md](../catalog.md#shared-catalog) diff --git a/structure/decisions/ADR-0022-routed-tool-discovery-and-hosted-search.md b/structure/decisions/ADR-0022-routed-tool-discovery-and-hosted-search.md index 64360fbdfd..8c3a4984ac 100644 --- a/structure/decisions/ADR-0022-routed-tool-discovery-and-hosted-search.md +++ b/structure/decisions/ADR-0022-routed-tool-discovery-and-hosted-search.md @@ -1,4 +1,4 @@ -# ADR-0022 — Routed tool discovery and hosted search +# ADR-0022 — decision recorded under "Routed tool discovery and hosted search" - Contract owner: [catalog.md](../catalog.md#routed-tool-discovery-and-hosted-search) diff --git a/structure/decisions/ADR-0023-ultra-reasoning-level.md b/structure/decisions/ADR-0023-ultra-reasoning-level.md index f23a613370..c6fddcb6c8 100644 --- a/structure/decisions/ADR-0023-ultra-reasoning-level.md +++ b/structure/decisions/ADR-0023-ultra-reasoning-level.md @@ -1,4 +1,4 @@ -# ADR-0023 — Ultra reasoning level +# ADR-0023 — decision recorded under "Ultra reasoning level" - Contract owner: [catalog.md](../catalog.md#ultra-reasoning-level) diff --git a/structure/decisions/ADR-0024-ultra-reasoning-level.md b/structure/decisions/ADR-0024-ultra-reasoning-level.md index 88c75bcb2a..0577753a32 100644 --- a/structure/decisions/ADR-0024-ultra-reasoning-level.md +++ b/structure/decisions/ADR-0024-ultra-reasoning-level.md @@ -1,4 +1,4 @@ -# ADR-0024 — Ultra reasoning level +# ADR-0024 — decision recorded under "Ultra reasoning level" - Contract owner: [catalog.md](../catalog.md#ultra-reasoning-level) diff --git a/structure/decisions/ADR-0025-ultra-reasoning-level.md b/structure/decisions/ADR-0025-ultra-reasoning-level.md index b66deffb01..65683e224e 100644 --- a/structure/decisions/ADR-0025-ultra-reasoning-level.md +++ b/structure/decisions/ADR-0025-ultra-reasoning-level.md @@ -1,4 +1,4 @@ -# ADR-0025 — Ultra reasoning level +# ADR-0025 — decision recorded under "Ultra reasoning level" - Contract owner: [catalog.md](../catalog.md#ultra-reasoning-level) diff --git a/structure/decisions/ADR-0026-ultra-reasoning-level.md b/structure/decisions/ADR-0026-ultra-reasoning-level.md index f416d26e01..7b7829aebe 100644 --- a/structure/decisions/ADR-0026-ultra-reasoning-level.md +++ b/structure/decisions/ADR-0026-ultra-reasoning-level.md @@ -1,4 +1,4 @@ -# ADR-0026 — Ultra reasoning level +# ADR-0026 — decision recorded under "Ultra reasoning level" - Contract owner: [catalog.md](../catalog.md#ultra-reasoning-level) diff --git a/structure/decisions/ADR-0027-subagents.md b/structure/decisions/ADR-0027-subagents.md index 24ec1918cd..a915436ce1 100644 --- a/structure/decisions/ADR-0027-subagents.md +++ b/structure/decisions/ADR-0027-subagents.md @@ -1,4 +1,4 @@ -# ADR-0027 — Subagents +# ADR-0027 — decision recorded under "Subagents" - Contract owner: [subagents.md](../subagents.md#subagents) diff --git a/structure/decisions/ADR-0028-background-service-command-selection.md b/structure/decisions/ADR-0028-background-service-command-selection.md index 1ba0a2c196..fca08d8238 100644 --- a/structure/decisions/ADR-0028-background-service-command-selection.md +++ b/structure/decisions/ADR-0028-background-service-command-selection.md @@ -1,4 +1,4 @@ -# ADR-0028 — Background service command selection +# ADR-0028 — decision recorded under "Background service command selection" - Contract owner: [ops/service-and-sidecars.md](../ops/service-and-sidecars.md#background-service-command-selection) diff --git a/structure/decisions/ADR-0029-windows-startup-ownership-listing-reuse.md b/structure/decisions/ADR-0029-windows-startup-ownership-listing-reuse.md index d7a0bfb99a..4281ffdffd 100644 --- a/structure/decisions/ADR-0029-windows-startup-ownership-listing-reuse.md +++ b/structure/decisions/ADR-0029-windows-startup-ownership-listing-reuse.md @@ -1,4 +1,4 @@ -# ADR-0029 — Windows startup ownership listing reuse +# ADR-0029 — decision recorded under "Windows startup ownership listing reuse" - Contract owner: [ops/service-and-sidecars.md](../ops/service-and-sidecars.md#windows-startup-ownership-listing-reuse) diff --git a/structure/decisions/ADR-0030-stable-service-launcher-launchd-and-systemd.md b/structure/decisions/ADR-0030-stable-service-launcher-launchd-and-systemd.md index 681f5107ae..60bf5fa0b5 100644 --- a/structure/decisions/ADR-0030-stable-service-launcher-launchd-and-systemd.md +++ b/structure/decisions/ADR-0030-stable-service-launcher-launchd-and-systemd.md @@ -1,4 +1,4 @@ -# ADR-0030 — Stable service launcher (launchd and systemd) +# ADR-0030 — decision recorded under "Stable service launcher (launchd and systemd)" - Contract owner: [ops/service-and-sidecars.md](../ops/service-and-sidecars.md#stable-service-launcher-launchd-and-systemd) diff --git a/structure/decisions/ADR-0031-responses-http-sse.md b/structure/decisions/ADR-0031-responses-http-sse.md index 13e01493fc..765cf9e447 100644 --- a/structure/decisions/ADR-0031-responses-http-sse.md +++ b/structure/decisions/ADR-0031-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0031 — Responses HTTP/SSE +# ADR-0031 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0032-responses-http-sse.md b/structure/decisions/ADR-0032-responses-http-sse.md index d007310b35..e7368e5f0e 100644 --- a/structure/decisions/ADR-0032-responses-http-sse.md +++ b/structure/decisions/ADR-0032-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0032 — Responses HTTP/SSE +# ADR-0032 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0033-responses-http-sse.md b/structure/decisions/ADR-0033-responses-http-sse.md index ffe3b1909c..ae6c83a00f 100644 --- a/structure/decisions/ADR-0033-responses-http-sse.md +++ b/structure/decisions/ADR-0033-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0033 — Responses HTTP/SSE +# ADR-0033 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0034-responses-http-sse.md b/structure/decisions/ADR-0034-responses-http-sse.md index 40d947ceb7..78d6e9a7fe 100644 --- a/structure/decisions/ADR-0034-responses-http-sse.md +++ b/structure/decisions/ADR-0034-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0034 — Responses HTTP/SSE +# ADR-0034 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0035-responses-http-sse.md b/structure/decisions/ADR-0035-responses-http-sse.md index 549da89c5a..a1cfd0ceea 100644 --- a/structure/decisions/ADR-0035-responses-http-sse.md +++ b/structure/decisions/ADR-0035-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0035 — Responses HTTP/SSE +# ADR-0035 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0036-responses-http-sse.md b/structure/decisions/ADR-0036-responses-http-sse.md index ebdb1893cb..fef122d5cd 100644 --- a/structure/decisions/ADR-0036-responses-http-sse.md +++ b/structure/decisions/ADR-0036-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0036 — Responses HTTP/SSE +# ADR-0036 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0037-responses-http-sse.md b/structure/decisions/ADR-0037-responses-http-sse.md index dd88e7c4dd..013d871e7e 100644 --- a/structure/decisions/ADR-0037-responses-http-sse.md +++ b/structure/decisions/ADR-0037-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0037 — Responses HTTP/SSE +# ADR-0037 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0038-responses-http-sse.md b/structure/decisions/ADR-0038-responses-http-sse.md index adaa6d85d7..b70b30b6ac 100644 --- a/structure/decisions/ADR-0038-responses-http-sse.md +++ b/structure/decisions/ADR-0038-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0038 — Responses HTTP/SSE +# ADR-0038 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0039-responses-http-sse.md b/structure/decisions/ADR-0039-responses-http-sse.md index 2abea209e3..40f7c8b19b 100644 --- a/structure/decisions/ADR-0039-responses-http-sse.md +++ b/structure/decisions/ADR-0039-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0039 — Responses HTTP/SSE +# ADR-0039 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0040-responses-http-sse.md b/structure/decisions/ADR-0040-responses-http-sse.md index 926149eaf2..b87373fd5c 100644 --- a/structure/decisions/ADR-0040-responses-http-sse.md +++ b/structure/decisions/ADR-0040-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0040 — Responses HTTP/SSE +# ADR-0040 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0041-responses-http-sse.md b/structure/decisions/ADR-0041-responses-http-sse.md index 451cd66f69..83920bcbb2 100644 --- a/structure/decisions/ADR-0041-responses-http-sse.md +++ b/structure/decisions/ADR-0041-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0041 — Responses HTTP/SSE +# ADR-0041 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0042-responses-http-sse.md b/structure/decisions/ADR-0042-responses-http-sse.md index 065b8e6268..21f4b02159 100644 --- a/structure/decisions/ADR-0042-responses-http-sse.md +++ b/structure/decisions/ADR-0042-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0042 — Responses HTTP/SSE +# ADR-0042 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0043-responses-http-sse.md b/structure/decisions/ADR-0043-responses-http-sse.md index a387ffc126..b28812cda8 100644 --- a/structure/decisions/ADR-0043-responses-http-sse.md +++ b/structure/decisions/ADR-0043-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0043 — Responses HTTP/SSE +# ADR-0043 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0044-responses-http-sse.md b/structure/decisions/ADR-0044-responses-http-sse.md index fece102711..d3e194fe21 100644 --- a/structure/decisions/ADR-0044-responses-http-sse.md +++ b/structure/decisions/ADR-0044-responses-http-sse.md @@ -1,4 +1,4 @@ -# ADR-0044 — Responses HTTP/SSE +# ADR-0044 — decision recorded under "Responses HTTP/SSE" - Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) diff --git a/structure/decisions/ADR-0045-standalone-images.md b/structure/decisions/ADR-0045-standalone-images.md index 75490bcf41..84dd60634b 100644 --- a/structure/decisions/ADR-0045-standalone-images.md +++ b/structure/decisions/ADR-0045-standalone-images.md @@ -1,4 +1,4 @@ -# ADR-0045 — Standalone Images +# ADR-0045 — decision recorded under "Standalone Images" - Contract owner: [data-planes/images.md](../data-planes/images.md#standalone-images) diff --git a/structure/decisions/ADR-0046-claude-desktop-config-library-resolution.md b/structure/decisions/ADR-0046-claude-desktop-config-library-resolution.md index ef08c1afde..e1be492077 100644 --- a/structure/decisions/ADR-0046-claude-desktop-config-library-resolution.md +++ b/structure/decisions/ADR-0046-claude-desktop-config-library-resolution.md @@ -1,4 +1,4 @@ -# ADR-0046 — Claude Desktop config-library resolution +# ADR-0046 — decision recorded under "Claude Desktop config-library resolution" - Contract owner: [clients/claude-desktop.md](../clients/claude-desktop.md#claude-desktop-config-library-resolution) diff --git a/structure/decisions/ADR-0047-cursor-native-exec.md b/structure/decisions/ADR-0047-cursor-native-exec.md index ca7b4ea990..48eebb5328 100644 --- a/structure/decisions/ADR-0047-cursor-native-exec.md +++ b/structure/decisions/ADR-0047-cursor-native-exec.md @@ -1,4 +1,4 @@ -# ADR-0047 — Cursor Native Exec +# ADR-0047 — decision recorded under "Cursor Native Exec" - Contract owner: [providers/cursor.md](../providers/cursor.md#cursor-native-exec) diff --git a/structure/decisions/ADR-0048-cursor-native-exec.md b/structure/decisions/ADR-0048-cursor-native-exec.md index 9d777b62ee..63a20aebc6 100644 --- a/structure/decisions/ADR-0048-cursor-native-exec.md +++ b/structure/decisions/ADR-0048-cursor-native-exec.md @@ -1,4 +1,4 @@ -# ADR-0048 — Cursor Native Exec +# ADR-0048 — decision recorded under "Cursor Native Exec" - Contract owner: [providers/cursor.md](../providers/cursor.md#cursor-native-exec) diff --git a/structure/decisions/ADR-0049-heartbeat-and-stall-deadline.md b/structure/decisions/ADR-0049-heartbeat-and-stall-deadline.md index 0bd427e7f5..9cb10eaf3e 100644 --- a/structure/decisions/ADR-0049-heartbeat-and-stall-deadline.md +++ b/structure/decisions/ADR-0049-heartbeat-and-stall-deadline.md @@ -1,4 +1,4 @@ -# ADR-0049 — Heartbeat and stall deadline +# ADR-0049 — decision recorded under "Heartbeat and stall deadline" - Contract owner: [transports/streaming-health.md](../transports/streaming-health.md#heartbeat-and-stall-deadline) diff --git a/structure/decisions/ADR-0050-heartbeat-and-stall-deadline.md b/structure/decisions/ADR-0050-heartbeat-and-stall-deadline.md index 2e54be1db7..0c79aea9a2 100644 --- a/structure/decisions/ADR-0050-heartbeat-and-stall-deadline.md +++ b/structure/decisions/ADR-0050-heartbeat-and-stall-deadline.md @@ -1,4 +1,4 @@ -# ADR-0050 — Heartbeat and stall deadline +# ADR-0050 — decision recorded under "Heartbeat and stall deadline" - Contract owner: [transports/streaming-health.md](../transports/streaming-health.md#heartbeat-and-stall-deadline) diff --git a/structure/decisions/ADR-0051-reasoning-and-tool-result-compatibility.md b/structure/decisions/ADR-0051-reasoning-and-tool-result-compatibility.md index 5b31252467..b615971553 100644 --- a/structure/decisions/ADR-0051-reasoning-and-tool-result-compatibility.md +++ b/structure/decisions/ADR-0051-reasoning-and-tool-result-compatibility.md @@ -1,4 +1,4 @@ -# ADR-0051 — Reasoning and tool-result compatibility +# ADR-0051 — decision recorded under "Reasoning and tool-result compatibility" - Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#reasoning-and-tool-result-compatibility) diff --git a/structure/decisions/ADR-0052-reasoning-and-tool-result-compatibility.md b/structure/decisions/ADR-0052-reasoning-and-tool-result-compatibility.md index 421d973d09..98a32fd2cc 100644 --- a/structure/decisions/ADR-0052-reasoning-and-tool-result-compatibility.md +++ b/structure/decisions/ADR-0052-reasoning-and-tool-result-compatibility.md @@ -1,4 +1,4 @@ -# ADR-0052 — Reasoning and tool-result compatibility +# ADR-0052 — decision recorded under "Reasoning and tool-result compatibility" - Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#reasoning-and-tool-result-compatibility) diff --git a/structure/decisions/ADR-0053-cursor-active-context-usage.md b/structure/decisions/ADR-0053-cursor-active-context-usage.md index 5ca7cb286d..709bfde309 100644 --- a/structure/decisions/ADR-0053-cursor-active-context-usage.md +++ b/structure/decisions/ADR-0053-cursor-active-context-usage.md @@ -1,4 +1,4 @@ -# ADR-0053 — Cursor active-context usage +# ADR-0053 — decision recorded under "Cursor active-context usage" - Contract owner: [providers/cursor.md](../providers/cursor.md#cursor-active-context-usage) diff --git a/structure/decisions/ADR-0054-cursor-conversation-checkpoint-reuse.md b/structure/decisions/ADR-0054-cursor-conversation-checkpoint-reuse.md index ad108c9813..1ff6c2f6fa 100644 --- a/structure/decisions/ADR-0054-cursor-conversation-checkpoint-reuse.md +++ b/structure/decisions/ADR-0054-cursor-conversation-checkpoint-reuse.md @@ -1,4 +1,4 @@ -# ADR-0054 — Cursor conversation checkpoint reuse +# ADR-0054 — decision recorded under "Cursor conversation checkpoint reuse" - Contract owner: [providers/cursor.md](../providers/cursor.md#cursor-conversation-checkpoint-reuse) diff --git a/structure/decisions/ADR-0055-google-thought-text-visibility-boundary.md b/structure/decisions/ADR-0055-google-thought-text-visibility-boundary.md index 02398a04e5..4a08b5c568 100644 --- a/structure/decisions/ADR-0055-google-thought-text-visibility-boundary.md +++ b/structure/decisions/ADR-0055-google-thought-text-visibility-boundary.md @@ -1,4 +1,4 @@ -# ADR-0055 — Google thought-text visibility boundary +# ADR-0055 — decision recorded under "Google thought-text visibility boundary" - Contract owner: [providers/google.md](../providers/google.md#google-thought-text-visibility-boundary) diff --git a/structure/decisions/ADR-0056-google-response-part-field-boundary.md b/structure/decisions/ADR-0056-google-response-part-field-boundary.md index 4a74ed3aa0..1903d92dc5 100644 --- a/structure/decisions/ADR-0056-google-response-part-field-boundary.md +++ b/structure/decisions/ADR-0056-google-response-part-field-boundary.md @@ -1,4 +1,4 @@ -# ADR-0056 — Google response-part field boundary +# ADR-0056 — decision recorded under "Google response-part field boundary" - Contract owner: [providers/google.md](../providers/google.md#google-response-part-field-boundary) diff --git a/structure/decisions/ADR-0057-google-tool-call-thought-signature-replay.md b/structure/decisions/ADR-0057-google-tool-call-thought-signature-replay.md index e3b83b16a6..c2cad106da 100644 --- a/structure/decisions/ADR-0057-google-tool-call-thought-signature-replay.md +++ b/structure/decisions/ADR-0057-google-tool-call-thought-signature-replay.md @@ -1,4 +1,4 @@ -# ADR-0057 — Google tool-call thought-signature replay +# ADR-0057 — decision recorded under "Google tool-call thought-signature replay" - Contract owner: [providers/google.md](../providers/google.md#google-tool-call-thought-signature-replay) diff --git a/structure/decisions/ADR-0058-google-tool-result-adjacency-repair.md b/structure/decisions/ADR-0058-google-tool-result-adjacency-repair.md index c7c7f9bdcd..9489cf1cc7 100644 --- a/structure/decisions/ADR-0058-google-tool-result-adjacency-repair.md +++ b/structure/decisions/ADR-0058-google-tool-result-adjacency-repair.md @@ -1,4 +1,4 @@ -# ADR-0058 — Google tool-result adjacency repair +# ADR-0058 — decision recorded under "Google tool-result adjacency repair" - Contract owner: [providers/google.md](../providers/google.md#google-tool-result-adjacency-repair) diff --git a/structure/decisions/ADR-0059-xai-grok-hardening-official-grok-build-contract.md b/structure/decisions/ADR-0059-xai-grok-hardening-official-grok-build-contract.md index ec93ac6e8f..005f98ea1e 100644 --- a/structure/decisions/ADR-0059-xai-grok-hardening-official-grok-build-contract.md +++ b/structure/decisions/ADR-0059-xai-grok-hardening-official-grok-build-contract.md @@ -1,4 +1,4 @@ -# ADR-0059 — xAI Grok hardening (official Grok Build contract parity) +# ADR-0059 — decision recorded under "xAI Grok hardening (official Grok Build contract parity)" - Contract owner: [providers/xai-grok.md](../providers/xai-grok.md#xai-grok-hardening-official-grok-build-contract-parity) diff --git a/structure/decisions/ADR-0060-kiro-client-parallel-tool-hint.md b/structure/decisions/ADR-0060-kiro-client-parallel-tool-hint.md index c163324e79..4bcdcbd2a0 100644 --- a/structure/decisions/ADR-0060-kiro-client-parallel-tool-hint.md +++ b/structure/decisions/ADR-0060-kiro-client-parallel-tool-hint.md @@ -1,4 +1,4 @@ -# ADR-0060 — Kiro client parallel-tool hint +# ADR-0060 — decision recorded under "Kiro client parallel-tool hint" - Contract owner: [providers/kiro.md](../providers/kiro.md#kiro-client-parallel-tool-hint) diff --git a/structure/decisions/ADR-0061-kiro-responses-text-controls.md b/structure/decisions/ADR-0061-kiro-responses-text-controls.md index 3306da454d..8f545cf432 100644 --- a/structure/decisions/ADR-0061-kiro-responses-text-controls.md +++ b/structure/decisions/ADR-0061-kiro-responses-text-controls.md @@ -1,4 +1,4 @@ -# ADR-0061 — Kiro Responses text controls +# ADR-0061 — decision recorded under "Kiro Responses text controls" - Contract owner: [providers/kiro.md](../providers/kiro.md#kiro-responses-text-controls) diff --git a/structure/decisions/ADR-0062-chat-streaming-client-with-a-json-upstream-resul.md b/structure/decisions/ADR-0062-chat-streaming-client-with-a-json-upstream-resul.md index becb6bf6b7..7c59b66133 100644 --- a/structure/decisions/ADR-0062-chat-streaming-client-with-a-json-upstream-resul.md +++ b/structure/decisions/ADR-0062-chat-streaming-client-with-a-json-upstream-resul.md @@ -1,4 +1,4 @@ -# ADR-0062 — Chat streaming client with a JSON upstream result +# ADR-0062 — decision recorded under "Chat streaming client with a JSON upstream result" - Contract owner: [data-planes/inbound-compat.md](../data-planes/inbound-compat.md#chat-streaming-client-with-a-json-upstream-result) diff --git a/structure/decisions/ADR-0063-volcengine-ark-assistant-continuation-shapes.md b/structure/decisions/ADR-0063-volcengine-ark-assistant-continuation-shapes.md index 043b37d24b..a921088dcd 100644 --- a/structure/decisions/ADR-0063-volcengine-ark-assistant-continuation-shapes.md +++ b/structure/decisions/ADR-0063-volcengine-ark-assistant-continuation-shapes.md @@ -1,4 +1,4 @@ -# ADR-0063 — Volcengine Ark assistant continuation shapes +# ADR-0063 — decision recorded under "Volcengine Ark assistant continuation shapes" - Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#volcengine-ark-assistant-continuation-shapes) diff --git a/structure/decisions/ADR-0064-chat-structured-output-compatibility.md b/structure/decisions/ADR-0064-chat-structured-output-compatibility.md index 64c11744b4..f19b51239a 100644 --- a/structure/decisions/ADR-0064-chat-structured-output-compatibility.md +++ b/structure/decisions/ADR-0064-chat-structured-output-compatibility.md @@ -1,4 +1,4 @@ -# ADR-0064 — Chat structured-output compatibility +# ADR-0064 — decision recorded under "Chat structured-output compatibility" - Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#chat-structured-output-compatibility) diff --git a/structure/decisions/ADR-0065-chat-structured-output-compatibility.md b/structure/decisions/ADR-0065-chat-structured-output-compatibility.md index 22de798b6c..e7cdd33ed1 100644 --- a/structure/decisions/ADR-0065-chat-structured-output-compatibility.md +++ b/structure/decisions/ADR-0065-chat-structured-output-compatibility.md @@ -1,4 +1,4 @@ -# ADR-0065 — Chat structured-output compatibility +# ADR-0065 — decision recorded under "Chat structured-output compatibility" - Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#chat-structured-output-compatibility) diff --git a/structure/decisions/ADR-0066-anthropic-structured-output-compatibility.md b/structure/decisions/ADR-0066-anthropic-structured-output-compatibility.md index f170c9c337..dfab9c437f 100644 --- a/structure/decisions/ADR-0066-anthropic-structured-output-compatibility.md +++ b/structure/decisions/ADR-0066-anthropic-structured-output-compatibility.md @@ -1,4 +1,4 @@ -# ADR-0066 — Anthropic structured-output compatibility +# ADR-0066 — decision recorded under "Anthropic structured-output compatibility" - Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#anthropic-structured-output-compatibility) diff --git a/structure/decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md b/structure/decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md index 7d7fad33be..672d781b4b 100644 --- a/structure/decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md +++ b/structure/decisions/ADR-0067-reasoning-display-parity-hidethinkingsummary.md @@ -1,4 +1,4 @@ -# ADR-0067 — Reasoning display parity (hideThinkingSummary) +# ADR-0067 — decision recorded under "Reasoning display parity (hideThinkingSummary)" - Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#reasoning-display-parity-hidethinkingsummary) diff --git a/structure/decisions/ADR-0068-reasoning-display-parity-hidethinkingsummary.md b/structure/decisions/ADR-0068-reasoning-display-parity-hidethinkingsummary.md index b2a7120777..735311e1c0 100644 --- a/structure/decisions/ADR-0068-reasoning-display-parity-hidethinkingsummary.md +++ b/structure/decisions/ADR-0068-reasoning-display-parity-hidethinkingsummary.md @@ -1,4 +1,4 @@ -# ADR-0068 — Reasoning display parity (hideThinkingSummary) +# ADR-0068 — decision recorded under "Reasoning display parity (hideThinkingSummary)" - Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#reasoning-display-parity-hidethinkingsummary) diff --git a/structure/decisions/ADR-0069-chat-to-responses-message-phase-inference.md b/structure/decisions/ADR-0069-chat-to-responses-message-phase-inference.md index 29bf5c9ee3..9241ea59ce 100644 --- a/structure/decisions/ADR-0069-chat-to-responses-message-phase-inference.md +++ b/structure/decisions/ADR-0069-chat-to-responses-message-phase-inference.md @@ -1,4 +1,4 @@ -# ADR-0069 — Chat-to-Responses message phase inference +# ADR-0069 — decision recorded under "Chat-to-Responses message phase inference" - Contract owner: [transports/responses.md](../transports/responses.md#chat-to-responses-message-phase-inference) diff --git a/structure/decisions/ADR-0070-same-provider-combo-quota-fallback.md b/structure/decisions/ADR-0070-same-provider-combo-quota-fallback.md index 6c118f6c9c..6c8d20514b 100644 --- a/structure/decisions/ADR-0070-same-provider-combo-quota-fallback.md +++ b/structure/decisions/ADR-0070-same-provider-combo-quota-fallback.md @@ -1,4 +1,4 @@ -# ADR-0070 — Same-provider combo quota fallback +# ADR-0070 — decision recorded under "Same-provider combo quota fallback" - Contract owner: [transports/responses.md](../transports/responses.md#same-provider-combo-quota-fallback) diff --git a/structure/decisions/ADR-0071-combo-streaming-commit-boundary.md b/structure/decisions/ADR-0071-combo-streaming-commit-boundary.md index ba6bbc306b..7143e89dce 100644 --- a/structure/decisions/ADR-0071-combo-streaming-commit-boundary.md +++ b/structure/decisions/ADR-0071-combo-streaming-commit-boundary.md @@ -1,4 +1,4 @@ -# ADR-0071 — Combo streaming commit boundary +# ADR-0071 — decision recorded under "Combo streaming commit boundary" - Contract owner: [transports/responses.md](../transports/responses.md#combo-streaming-commit-boundary) diff --git a/structure/decisions/ADR-0072-transport-inventory.md b/structure/decisions/ADR-0072-transport-inventory.md index 07ac6af65f..96f45745ae 100644 --- a/structure/decisions/ADR-0072-transport-inventory.md +++ b/structure/decisions/ADR-0072-transport-inventory.md @@ -1,4 +1,4 @@ -# ADR-0072 — Transport inventory +# ADR-0072 — decision recorded under "Transport inventory" - Contract owner: [transports/inventory.md](../transports/inventory.md#transport-inventory) diff --git a/structure/decisions/ADR-0073-authentication-boundaries.md b/structure/decisions/ADR-0073-authentication-boundaries.md index dbdaba4163..9c78ec1a0f 100644 --- a/structure/decisions/ADR-0073-authentication-boundaries.md +++ b/structure/decisions/ADR-0073-authentication-boundaries.md @@ -1,4 +1,4 @@ -# ADR-0073 — Authentication boundaries +# ADR-0073 — decision recorded under "Authentication boundaries" - Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#authentication-boundaries) diff --git a/structure/decisions/ADR-0074-api-ownership.md b/structure/decisions/ADR-0074-api-ownership.md index 53aedd297f..f4e30b0dcd 100644 --- a/structure/decisions/ADR-0074-api-ownership.md +++ b/structure/decisions/ADR-0074-api-ownership.md @@ -1,4 +1,4 @@ -# ADR-0074 — API ownership +# ADR-0074 — decision recorded under "API ownership" - Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#api-ownership) diff --git a/structure/decisions/ADR-0075-startup-safety.md b/structure/decisions/ADR-0075-startup-safety.md index 332295ed21..385abf2fea 100644 --- a/structure/decisions/ADR-0075-startup-safety.md +++ b/structure/decisions/ADR-0075-startup-safety.md @@ -1,4 +1,4 @@ -# ADR-0075 — Startup safety +# ADR-0075 — decision recorded under "Startup safety" - Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#startup-safety) diff --git a/structure/decisions/ADR-0076-startup-safety.md b/structure/decisions/ADR-0076-startup-safety.md index bde538d907..cf764c64ba 100644 --- a/structure/decisions/ADR-0076-startup-safety.md +++ b/structure/decisions/ADR-0076-startup-safety.md @@ -1,4 +1,4 @@ -# ADR-0076 — Startup safety +# ADR-0076 — decision recorded under "Startup safety" - Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#startup-safety) diff --git a/structure/decisions/ADR-0077-startup-safety.md b/structure/decisions/ADR-0077-startup-safety.md index 4bf4b36d65..dd39473acc 100644 --- a/structure/decisions/ADR-0077-startup-safety.md +++ b/structure/decisions/ADR-0077-startup-safety.md @@ -1,4 +1,4 @@ -# ADR-0077 — Startup safety +# ADR-0077 — decision recorded under "Startup safety" - Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#startup-safety) diff --git a/structure/decisions/ADR-0078-usage-accounting.md b/structure/decisions/ADR-0078-usage-accounting.md index 0b0910719b..8a30ff014c 100644 --- a/structure/decisions/ADR-0078-usage-accounting.md +++ b/structure/decisions/ADR-0078-usage-accounting.md @@ -1,4 +1,4 @@ -# ADR-0078 — Usage accounting +# ADR-0078 — decision recorded under "Usage accounting" - Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#usage-accounting) diff --git a/structure/decisions/ADR-0079-usage-accounting.md b/structure/decisions/ADR-0079-usage-accounting.md index 9592887287..cb87d1113a 100644 --- a/structure/decisions/ADR-0079-usage-accounting.md +++ b/structure/decisions/ADR-0079-usage-accounting.md @@ -1,4 +1,4 @@ -# ADR-0079 — Usage accounting +# ADR-0079 — decision recorded under "Usage accounting" - Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#usage-accounting) diff --git a/structure/decisions/ADR-0080-github-pages.md b/structure/decisions/ADR-0080-github-pages.md index 31b08df6ed..7528b2afc0 100644 --- a/structure/decisions/ADR-0080-github-pages.md +++ b/structure/decisions/ADR-0080-github-pages.md @@ -1,4 +1,4 @@ -# ADR-0080 — GitHub Pages +# ADR-0080 — decision recorded under "GitHub Pages" - Contract owner: [ops/docs-and-release.md](../ops/docs-and-release.md#github-pages) diff --git a/structure/decisions/ADR-0081-container-deployment-recipe.md b/structure/decisions/ADR-0081-container-deployment-recipe.md index 6c0d8b8c3a..b93174b49c 100644 --- a/structure/decisions/ADR-0081-container-deployment-recipe.md +++ b/structure/decisions/ADR-0081-container-deployment-recipe.md @@ -1,4 +1,4 @@ -# ADR-0081 — Container deployment recipe +# ADR-0081 — decision recorded under "Container deployment recipe" - Contract owner: [ops/docs-and-release.md](../ops/docs-and-release.md#container-deployment-recipe) diff --git a/structure/decisions/ADR-0082-windows-service-wrapper-and-incomplete-updates.md b/structure/decisions/ADR-0082-windows-service-wrapper-and-incomplete-updates.md index 75c6ff2105..1f9c2ea038 100644 --- a/structure/decisions/ADR-0082-windows-service-wrapper-and-incomplete-updates.md +++ b/structure/decisions/ADR-0082-windows-service-wrapper-and-incomplete-updates.md @@ -1,4 +1,4 @@ -# ADR-0082 — Windows service wrapper and incomplete updates +# ADR-0082 — decision recorded under "Windows service wrapper and incomplete updates" - Contract owner: [ops/docs-and-release.md](../ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates) diff --git a/structure/decisions/ADR-0083-maintenance-governance.md b/structure/decisions/ADR-0083-maintenance-governance.md index 6591b3c167..1cd9635a23 100644 --- a/structure/decisions/ADR-0083-maintenance-governance.md +++ b/structure/decisions/ADR-0083-maintenance-governance.md @@ -1,4 +1,4 @@ -# ADR-0083 — Maintenance governance +# ADR-0083 — decision recorded under "Maintenance governance" - Contract owner: [ops/docs-and-release.md](../ops/docs-and-release.md#maintenance-governance) diff --git a/structure/decisions/ADR-0084-public-provider-contract.md b/structure/decisions/ADR-0084-public-provider-contract.md index fea0542785..26c1a5114e 100644 --- a/structure/decisions/ADR-0084-public-provider-contract.md +++ b/structure/decisions/ADR-0084-public-provider-contract.md @@ -1,4 +1,4 @@ -# ADR-0084 — Public provider contract +# ADR-0084 — decision recorded under "Public provider contract" - Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#public-provider-contract) diff --git a/structure/decisions/ADR-0085-public-provider-contract.md b/structure/decisions/ADR-0085-public-provider-contract.md index bf06876a16..bfecc5e73a 100644 --- a/structure/decisions/ADR-0085-public-provider-contract.md +++ b/structure/decisions/ADR-0085-public-provider-contract.md @@ -1,4 +1,4 @@ -# ADR-0085 — Public provider contract +# ADR-0085 — decision recorded under "Public provider contract" - Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#public-provider-contract) diff --git a/structure/decisions/ADR-0086-public-provider-contract.md b/structure/decisions/ADR-0086-public-provider-contract.md index 78da4d1b6f..9bc3354fa2 100644 --- a/structure/decisions/ADR-0086-public-provider-contract.md +++ b/structure/decisions/ADR-0086-public-provider-contract.md @@ -1,4 +1,4 @@ -# ADR-0086 — Public provider contract +# ADR-0086 — decision recorded under "Public provider contract" - Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#public-provider-contract) diff --git a/structure/decisions/ADR-0087-model-and-wire-identity.md b/structure/decisions/ADR-0087-model-and-wire-identity.md index 6e09ebda41..e4bc8ac193 100644 --- a/structure/decisions/ADR-0087-model-and-wire-identity.md +++ b/structure/decisions/ADR-0087-model-and-wire-identity.md @@ -1,4 +1,4 @@ -# ADR-0087 — Model and wire identity +# ADR-0087 — decision recorded under "Model and wire identity" - Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#model-and-wire-identity) diff --git a/structure/decisions/ADR-0088-model-and-wire-identity.md b/structure/decisions/ADR-0088-model-and-wire-identity.md index 01a8ca2719..aac68824a5 100644 --- a/structure/decisions/ADR-0088-model-and-wire-identity.md +++ b/structure/decisions/ADR-0088-model-and-wire-identity.md @@ -1,4 +1,4 @@ -# ADR-0088 — Model and wire identity +# ADR-0088 — decision recorded under "Model and wire identity" - Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#model-and-wire-identity) diff --git a/structure/decisions/ADR-0089-process-local-affinity-diagnostics.md b/structure/decisions/ADR-0089-process-local-affinity-diagnostics.md index 7b11661001..227ac4ffa2 100644 --- a/structure/decisions/ADR-0089-process-local-affinity-diagnostics.md +++ b/structure/decisions/ADR-0089-process-local-affinity-diagnostics.md @@ -1,4 +1,4 @@ -# ADR-0089 — Process-local affinity diagnostics +# ADR-0089 — decision recorded under "Process-local affinity diagnostics" - Contract owner: [providers/openai-tiers.md](../providers/openai-tiers.md#process-local-affinity-diagnostics) diff --git a/structure/decisions/ADR-0090-hermes-model-capabilities.md b/structure/decisions/ADR-0090-hermes-model-capabilities.md index c9287ab171..c44a227cbf 100644 --- a/structure/decisions/ADR-0090-hermes-model-capabilities.md +++ b/structure/decisions/ADR-0090-hermes-model-capabilities.md @@ -1,4 +1,4 @@ -# ADR-0090 — Hermes Model Capabilities +# ADR-0090 — decision recorded under "Hermes Model Capabilities" - Contract owner: [clients/integrations.md](../clients/integrations.md#hermes-model-capabilities) diff --git a/structure/decisions/ADR-0091-ownership-axes.md b/structure/decisions/ADR-0091-ownership-axes.md index 0b0cd24985..5609670147 100644 --- a/structure/decisions/ADR-0091-ownership-axes.md +++ b/structure/decisions/ADR-0091-ownership-axes.md @@ -1,4 +1,4 @@ -# ADR-0091 — Ownership Axes +# ADR-0091 — decision recorded under "Ownership Axes" - Contract owner: [clients/integrations.md](../clients/integrations.md#ownership-axes) diff --git a/structure/decisions/ADR-0092-zcode-runtime-metadata.md b/structure/decisions/ADR-0092-zcode-runtime-metadata.md index 69df34425d..3b1ced0490 100644 --- a/structure/decisions/ADR-0092-zcode-runtime-metadata.md +++ b/structure/decisions/ADR-0092-zcode-runtime-metadata.md @@ -1,4 +1,4 @@ -# ADR-0092 — ZCode Runtime Metadata +# ADR-0092 — decision recorded under "ZCode Runtime Metadata" - Contract owner: [clients/integrations.md](../clients/integrations.md#zcode-runtime-metadata) diff --git a/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md b/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md index 7123efe652..576eddc476 100644 --- a/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md +++ b/structure/decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md @@ -1,4 +1,4 @@ -# ADR-0093 — Moonshot `$ref`-with-siblings normalization +# ADR-0093 — decision recorded under "Moonshot `$ref`-with-siblings normalization" - Contract owner: [adapters/registry.md](../adapters/registry.md#moonshot-ref-with-siblings-normalization) @@ -22,3 +22,10 @@ 큰 정의를 여러 노드가 참조하면 출력이 커질 수 있고, 예산이 소진되면 해당 노드는 빈 객체나 순수 `$ref`로 닫힌다 — 약해진 스키마를 절반만 내보내는 것보다 낫다. Moonshot 계열 `openai-chat` baseUrl에만 적용되고 다른 provider는 손대지 않는다. + +## Why three budgets + +예산은 세 가지다. 확장 횟수만으로는 참조가 하나도 없는 깊은 스키마를 막지 못해서, 깊이와 +노드 수를 따로 둔다 — `google-tool-schema.ts`가 이미 쓰는 형태다. 두 가드 모두 제거했을 때 +실제로 red가 되는지 확인했고, 예산을 풀면 20k 깊이에서 `RangeError: Maximum call stack size +exceeded`가 난다. diff --git a/structure/decisions/ADR-0094-canonical-forward-continuation-extensions.md b/structure/decisions/ADR-0094-canonical-forward-continuation-extensions.md index dc5af93122..ee85fdb3e6 100644 --- a/structure/decisions/ADR-0094-canonical-forward-continuation-extensions.md +++ b/structure/decisions/ADR-0094-canonical-forward-continuation-extensions.md @@ -1,4 +1,4 @@ -# ADR-0094 — Canonical forward continuation extensions +# ADR-0094 — decision recorded under "Canonical forward continuation extensions" - Contract owner: [adapters/compatibility-contracts.md](../adapters/compatibility-contracts.md#canonical-forward-continuation-extensions) diff --git a/structure/decisions/ADR-0095-canonical-forward-continuation-extensions.md b/structure/decisions/ADR-0095-canonical-forward-continuation-extensions.md index abeac4d4a9..841fadc3f2 100644 --- a/structure/decisions/ADR-0095-canonical-forward-continuation-extensions.md +++ b/structure/decisions/ADR-0095-canonical-forward-continuation-extensions.md @@ -1,4 +1,4 @@ -# ADR-0095 — Canonical forward continuation extensions +# ADR-0095 — decision recorded under "Canonical forward continuation extensions" - Contract owner: [adapters/compatibility-contracts.md](../adapters/compatibility-contracts.md#canonical-forward-continuation-extensions) diff --git a/structure/decisions/ADR-0096-z-ai-quota-destination-ownership.md b/structure/decisions/ADR-0096-z-ai-quota-destination-ownership.md index 2084395269..884ec68ddd 100644 --- a/structure/decisions/ADR-0096-z-ai-quota-destination-ownership.md +++ b/structure/decisions/ADR-0096-z-ai-quota-destination-ownership.md @@ -1,4 +1,4 @@ -# ADR-0096 — Z.ai quota destination ownership +# ADR-0096 — decision recorded under "Z.ai quota destination ownership" - Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#zai-quota-destination-ownership) diff --git a/structure/subagents.md b/structure/subagents.md index e87fa4e34a..a3513a726a 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -131,7 +131,7 @@ whole-config overwrite. Existing disabled-model visibility rules remain unchange Quota-aware fallback walks a configured chain when the featured model is exhausted, probing availability on a bounded interval (default 60 s, `src/codex/subagent-model-fallback.ts`). It rewrites the requested model id only; effort remains owned by the caps described under -[Ultra reasoning level](#ultra-reasoning-level). +[Ultra reasoning level](catalog.md#ultra-reasoning-level). `injectionModel` and `injectionEffort` are shared selections with two independent consumers. `multiAgentGuidanceEnabled` controls only OpenCodex-authored delegation guidance. diff --git a/tests/ci-workflows/structure-ssot.test.ts b/tests/ci-workflows/structure-ssot.test.ts index ef516425b4..8a6bfdd308 100644 --- a/tests/ci-workflows/structure-ssot.test.ts +++ b/tests/ci-workflows/structure-ssot.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { renderIndex, runStructureChecks, type Manifest } from "../../scripts/structure-ssot"; +import { loadManifest, renderIndex, runStructureChecks, type Manifest } from "../../scripts/structure-ssot"; import { repoRoot } from "../helpers/repo-root"; /** @@ -67,6 +67,8 @@ function scaffold(): string { "- **INV-A-01** — alpha keeps working.", " Enforced by " + BT + "tests/alpha/alpha.test.ts" + BT + ".", "", + "Alpha lives in " + BT + "src/alpha/keep.ts" + BT + ".", + "", "> Decision record: [ADR-0001](decisions/ADR-0001-alpha.md)", "", ].join("\n"), @@ -269,6 +271,59 @@ describe("structure/ SSOT", () => { fires(root, "claims src/imaginary/, which this tree does not have"); }); + test("a described area the doc never names", () => { + const root = scaffold(); + write(root, "src/beta/new.ts", "export const beta = 1;\n"); + const manifest = manifestOf(root); + manifest.docs[0]!.documents.push("src/beta/"); + saveManifest(root, manifest); + fires(root, "claims src/beta/ but never names a path in it"); + }); + + test("a fragment-only link that names no heading in its own document", () => { + const root = scaffold(); + const body = readFileSync(join(root, "structure/overview.md"), "utf8"); + write(root, "structure/overview.md", body + "\nSee [that rule](#no-such-heading).\n"); + fires(root, "links #no-such-heading, but that heading anchor does not exist"); + }); + + test("overview.md missing fails instead of silencing every invariant check", () => { + const root = scaffold(); + const manifest = manifestOf(root); + manifest.docs = []; + saveManifest(root, manifest); + rmSync(join(root, "structure/overview.md")); + fires(root, "structure/overview.md is missing"); + }); + + test("a record named in prose but not linked is not owned", () => { + const root = scaffold(); + const body = readFileSync(join(root, "structure/overview.md"), "utf8").replace( + "> Decision record: [ADR-0001](decisions/ADR-0001-alpha.md)", + "The reasoning sits in decisions/ADR-0001-alpha.md for anyone curious.", + ); + write(root, "structure/overview.md", body); + fires(root, "ADR-0001-alpha.md is not linked from any doc"); + }); + + test("a bare filename is not treated as a repository path", () => { + const root = scaffold(); + const body = readFileSync(join(root, "structure/overview.md"), "utf8"); + write(root, "structure/overview.md", body + "\nCodex reads " + BT + "models_cache.json" + BT + " at startup.\n"); + expect(runStructureChecks(root)).toEqual([]); + }); + + test("a malformed manifest is an actionable failure, not a stack trace", () => { + expect(loadManifest("{not json")).toHaveProperty("error"); + const shapeless = loadManifest(JSON.stringify({ sizeBudgetLines: 600 })); + expect(shapeless).toHaveProperty("error"); + expect((shapeless as { error: string }).error).toContain("docs must be an array"); + + const root = scaffold(); + write(root, "structure/manifest.json", "{not json"); + fires(root, "is not valid JSON"); + }); + test("INDEX.md that drifted from the manifest", () => { const root = scaffold(); write(root, "structure/INDEX.md", "# hand-edited\n"); From 670256320a1f9d18587947eaf0f94c27d16d8c28 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 21:50:16 +0900 Subject: [PATCH 024/231] docs(devlog): fold the wp1 audit blockers into the cursor checkpoint roadmap Removes the native-gate edit from branch A because conversationCheckpointUpdate is liveness-only, so arrival order is not content coverage. Downgrades the grace experiment to positive-only because client-tool-suspend elapsedMs is turn-relative and cannot witness which grace branch ran. Appends wp2b and wp5. --- .../000_plan.md | 23 ++++ .../010_phase1_grace_experiment.md | 29 +++-- .../020_phase2_responses_identity.md | 10 +- .../030_phase3_landing.md | 105 +++++++++++------- 4 files changed, 118 insertions(+), 49 deletions(-) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md b/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md index e07fdb9018..2dddae65f5 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md @@ -87,10 +87,33 @@ not retry it. | wp2 | `010_phase1_grace_experiment.md` | C1: does the frame arrive late, or never | | wp3 | `020_phase2_responses_identity.md` | C2: is it chat-completions-specific | | wp4 | `030_phase3_landing.md` | land the proven fix, or record the verdict | +| wp2b | `010` closing section | only if wp2 is INCONCLUSIVE: instrumented rerun that can reach NEVER | +| wp5 | `030` closing section | only if branch A lands: does a captured snapshot actually cover the tool call | wp2 and wp3 are independent of each other and both depend only on wp1. wp4 depends on wp2; if wp3 finishes first its outcome folds into wp4 as an additional branch. +wp2b and wp5 were appended during wp1's audit (LOOP-UNIT-CHAIN-01). Both are +conditional: neither runs unless its predecessor returns the outcome that needs it. + +## What the wp1 audit changed + +The first draft of this roadmap was audited and failed on two high findings, both +folded before the roadmap was locked: + +1. `030` branch A paired the capture fix with dropping the native/external gate, + arguing that arrival order became a sound proof. It does not: + `conversationCheckpointUpdate` is classified liveness-only, so a snapshot can arrive + after the tool call with contents that predate it. The gate edit was removed and + became wp5, gated on decoding the snapshot. +2. `010` used `client-tool-suspend.elapsedMs` to prove which grace branch ran. That + field is turn-relative, and this unit's own evidence already shows `elapsedMs: 2886` + on the 50 ms path. The experiment was downgraded to positive-only; a NEVER verdict + now requires wp2b. + +Recording this because both mistakes have the same shape as the one that opened the +unit: a plausible mechanism asserted without checking what the field actually measures. + ## Decision tree - **wp2 = LATE** (frame arrives when the grace is extended): C1 is a grace-computation diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md b/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md index a829ad613c..1c5216b050 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md @@ -46,13 +46,24 @@ touches nothing on the operator machine. ## Decision rule +**This experiment can only return a positive.** Folded from the wp1 audit (high): +`client-tool-suspend.elapsedMs` is `Date.now() - this.turnStartedAt` +(`live-transport.ts:1015`, `turnStartedAt` set in `open()` at :1033), so it measures +the whole turn, not the grace delay. `000_plan.md` already records `elapsedMs: 2886` +on the 50 ms path. Model generation time swamps a 50-vs-1500 ms difference, so +`elapsedMs` cannot witness which branch of +`clientToolFinalizeGraceMsForRequest` ran. The original rule below was wrong and is +replaced. + - **LATE** — B shows `capturedBytes > 0`, or a `conversationCheckpointUpdate` frame - that A lacked. The 50 ms base grace is the defect. Go to `030` branch A. -- **NEVER** — B still shows `capturedBytes: 0` and no such frame, *and* B's - `elapsedMs` is clearly larger than A's, proving the longer window was actually - taken. Upstream does not serialize state for a suspended turn; no adapter-local fix. -- **INCONCLUSIVE** — B's `elapsedMs` is not larger than A's, so the branch was not - taken. Fix the request shape and rerun; do not read the result. - -That third case is the one worth guarding. Without comparing `elapsedMs` the -experiment can measure the same 50 ms twice and look like a clean NEVER. + that A lacked. Self-proving: bytes can only appear if the window outlasted their + arrival. The 50 ms base grace is the defect. Go to `030` branch A. +- **INCONCLUSIVE** — anything else. A `capturedBytes: 0` result here does **not** + establish NEVER, because nothing in the emitted diagnostics witnesses the grace that + was actually used. + +**Reaching a sound NEVER requires instrumentation**, and only if the cheap arm comes +back INCONCLUSIVE: add `graceMs: this.activeClientToolFinalizeGraceMs` to the +`client-tool-suspend` diagnostic payload, build on macbookpro-2 in a throwaway +checkout, and rerun arm B. NEVER is then `capturedBytes: 0` with a logged +`graceMs` of 1500. That instrumented arm is wp2b, appended only if needed. diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md b/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md index 68c6608e06..8811d3554e 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md @@ -32,7 +32,13 @@ honest outcome is to record that and close the half. - **STABLE** — same `conversationId` on both turns and `checkpointPresent: true` on turn 2. C2 is an artifact of the stateless endpoint. Record and close. -- **UNSTABLE** — identity changes, or `checkpointPresent` stays false with - `missing_ref`. C2 is real on the path users take. Go to `030` branch B. +- **UNSTABLE-IDENTITY** — `conversationId` differs between the two turns. That is C2 + on the path users take. Go to `030` branch B. +- **STABLE-IDENTITY-STORE-MISS** — `conversationId` matches but `checkpointPresent` + is false with `missing_ref`. Folded from the wp1 audit (medium): the original rule + ORed these two, but `request-builder.ts:454` returns `missing_ref` whenever no + thread or ref is resolved, which is reachable with a perfectly stable id. This is a + different defect — the checkpoint store, not identity — and needs its own doc before + any patch. Do not route it to branch B. - **BLOCKED** — the proxy rejects the Responses shape for this provider. Record what it rejected; do not infer the answer from the chat-completions result. diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md index 7e60dbd8bc..1c4b078db6 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md @@ -4,59 +4,88 @@ One branch per wp2/wp3 outcome. Only the branch the evidence selects gets built. ## Branch A — wp2 = LATE -The 50 ms base grace cancels the stream before upstream serializes conversation -state. Two edits, and the second is only safe *because* of the first. +The 50 ms base grace cancels the stream before upstream serializes conversation state. +**Branch A is one edit.** The wp1 audit removed a second one; see "What branch A is +deliberately not doing" below. -**A1. MODIFY `src/adapters/cursor/live-transport.ts`**, the client-tool finalize -timer (currently lines 1006-1018): +**A1. MODIFY `src/adapters/cursor/live-transport.ts`.** Give a drained client-tool +turn one bounded extension when a checkpoint is wanted and none has arrived. The +extension must happen *before* the terminal events are pushed — once `done` reaches +the client the turn is over. ```diff + private scheduleClientToolFinalize( + state: ReturnType, + push: (message: CursorServerMessage) => void, ++ graceMsOverride?: number, + ): void { + this.clearPendingFinalize(); this.pendingFinalize = setTimeout(() => { this.pendingFinalize = undefined; if (this.expectedClose) return; const terminal = finalizeAfterDrain(state); if (terminal.length === 0) return; - for (const event of terminal) push(event); -+ // A suspended tool turn is exactly the turn whose state we most want to -+ // resume from, and it is the one turn we used to cancel before upstream -+ // could send it. Give the checkpoint frame one bounded extension rather -+ // than a larger blanket grace: the common case stays fast, and a stream -+ // that never sends one is still cancelled at a known deadline (#4245). -+ if (this.wantsCheckpointCapture && !this.capturedCheckpointBytes && !this.checkpointGraceExtended) { ++ // A suspended tool turn is the turn whose state we most want to resume from, ++ // and the one turn we cancelled before upstream could send it (#4245). Extend ++ // once, bounded, rather than raising the blanket grace: the common case stays ++ // at 50 ms and a stream that never sends a checkpoint still dies at a known ++ // deadline. ++ if (this.wantsCheckpointCapture ++ && !this.capturedCheckpointBytes ++ && !this.checkpointGraceExtended) { + this.checkpointGraceExtended = true; + this.scheduleClientToolFinalize(state, push, CHECKPOINT_CAPTURE_GRACE_MS); + return; + } - debugProviderDiagnostic("cursor", "client-tool-suspend", { ... }); + for (const event of terminal) push(event); + debugProviderDiagnostic("cursor", "client-tool-suspend", { + reason: "Responses bridge owns client tools; ending turn without fake mcpResult", + framesReceived: this.framesReceived, + elapsedMs: Date.now() - this.turnStartedAt, ++ graceMs: graceMsOverride ?? this.activeClientToolFinalizeGraceMs, ++ checkpointGraceExtended: this.checkpointGraceExtended, + }); this.cancelCursorRun(); - }, this.activeClientToolFinalizeGraceMs); -``` - -New constant beside the others at line 114-117, sized from the measured B-arm -latency, not guessed. New fields `checkpointGraceExtended` and -`wantsCheckpointCapture` (set from `contextUsageStoreCheckpoints !== false`). - -**A2. MODIFY `src/adapters/cursor.ts`** `commitCapturedCheckpoint`: - -```diff - const toolSuspendedCommit = - emittedClientTool - && capturedAfterClientTool -- && isCursorExternalWireModel(activeRequest.modelId); -+ // Once A1 makes the frame actually arrive, capturedAfterClientTool is a -+ // real ordering proof for every model, so the wire-model test stops being -+ // the thing standing in for it. Keep the proof; drop the proxy for it. -+ ; +- }, this.activeClientToolFinalizeGraceMs); ++ }, graceMsOverride ?? this.activeClientToolFinalizeGraceMs); + } ``` -A2 without A1 is the patch this unit exists to reject: with `capturedBytes: 0` it -changes nothing, and with a checkpoint captured *before* the tool call it would claim -coverage the bytes do not have. A1 is what makes `capturedAfterClientTool` mean -something. - -`checkpointUsable` stays `!toolSuspendedCommit`, so a tool-suspended checkpoint is -still only usable by the immediate trailing-toolResult continuation. This branch does -not widen what a checkpoint claims. +Also NEW beside the constants at :114-117: +`const CHECKPOINT_CAPTURE_GRACE_MS = ;` sized from the arrival latency wp2 +actually observed, not guessed. NEW private fields beside `pendingFinalize`: +`private checkpointGraceExtended = false;` and +`private wantsCheckpointCapture = false;` — the latter set where the run request is +applied (:643, next to `activeClientToolFinalizeGraceMs`) from +`activeRequest.contextUsageStoreCheckpoints !== false`. Reset +`checkpointGraceExtended = false` in `open()` (:1033) alongside `framesReceived`. + +The added `graceMs` field also repays wp2's instrumentation debt: after this lands, +the NEVER verdict 010 could not reach becomes measurable from shipped diagnostics. + +### What branch A is deliberately not doing + +The obvious companion edit — dropping `isCursorExternalWireModel` from +`toolSuspendedCommit` in `src/adapters/cursor.ts:190` so native models also commit a +tool-suspended checkpoint — is **excluded**, folded from the wp1 audit (high). + +`capturedAfterClientTool` is set at `cursor.ts:312` from *arrival order* +(`capturedAfterClientTool = emittedClientTool` when the byte-set changes). But +`live-transport.ts:1221` classifies `conversationCheckpointUpdate` as **liveness-only**, +the same bucket as a heartbeat. A periodic liveness snapshot can arrive after the tool +call while its *contents* predate it. Arrival order is therefore not coverage, and +committing on it would claim a prefix the bytes do not contain — the exact failure this +unit was opened to prevent. + +A1 alone is still a real fix: it makes the external tool-suspended path, which the code +already intends and which has never once succeeded in production, actually work. +`checkpointUsable` stays `!toolSuspendedCommit`, so nothing widens what a checkpoint +claims. + +Extending this to native models needs content coverage proven, not assumed. That is a +separate work-phase (wp5) whose first task is to decode a captured +`ConversationStateStructure` and check whether the tool call is in it. The wp1 auditor +explicitly left that decode UNVERIFIED; do not skip it. **Tests.** `tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts`: a fake transport that emits `conversationCheckpointUpdate` after `tool_call_end` but later From 1da8dae9625002eaabd6a732fb85996d3545c28d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 21:52:47 +0900 Subject: [PATCH 025/231] provider: stabilize the OpenCode Zen presets (#4274) Adds a narrow noJsonSchemaModels contract that downgrades json_schema to json_object on the Zen gateway DeepSeek routes, stops arming the Go thinking-budget wire for models with no ladder, and pins both with structural parity guards. Maintainer self-integration into dev per MAINTAINERS.md: exact head 340bc24861134f602f43a7e5ae5ce5400bcf354f, 27 checks, 0 failing or pending. --- .../000_plan.md | 38 +++++++ .../001_issue_triage.md | 38 +++++++ .../002_cross_proxy_survey.md | 37 +++++++ .../003_registry_gap_inventory.md | 41 +++++++ .../004_audit_rounds.md | 39 +++++++ .../010_phase1_preset_structured_output.md | 103 ++++++++++++++++++ .../020_phase2_preset_consistency_guard.md | 65 +++++++++++ .../030_phase3_docs_and_pr.md | 30 +++++ .../031_wp4_outcome.md | 31 ++++++ .../fr/reference/configuration/providers.md | 1 + .../ja/reference/configuration/providers.md | 1 + .../ko/reference/configuration/providers.md | 1 + .../docs/reference/configuration/providers.md | 1 + .../ru/reference/configuration/providers.md | 1 + .../tr/reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + src/adapters/openai-chat.ts | 32 ++++-- src/config.ts | 14 +++ src/providers/registry.ts | 32 +++++- src/router.ts | 2 + src/server/auth-cors.ts | 6 + src/server/management/provider-routes.ts | 14 +++ src/types/provider.ts | 13 +++ .../openai/openai-chat-hardening.test.ts | 49 +++++++++ tests/providers/opencode-go-deepseek.test.ts | 57 ++++++++++ .../provider-registry-parity.test.ts | 36 ++++++ 27 files changed, 675 insertions(+), 10 deletions(-) create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/001_issue_triage.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/004_audit_rounds.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md create mode 100644 devlog/_plan/260911_opencode_go_free_stabilization/031_wp4_outcome.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md b/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md new file mode 100644 index 0000000000..55d56096a1 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md @@ -0,0 +1,38 @@ +# 260911 — opencode-go / zen / free 안정화 + +OpenCode Zen 게이트웨이(go, zen, free)로 붙는 세 프리셋은 정적 `models:` 배열이 없고, 카탈로그가 `liveModels !== false`인 프로바이더를 live로 훑는다(`src/codex/catalog/provider-fetch.ts:440`). free만 `liveModels: true`를 명시하고(`src/providers/registry.ts:3049`) go/zen은 선언 없이 기본값으로 그 경로를 탄다. 반면 모델별 능력은 손으로 적은 정확-id 표에 묶여 있다. 그래서 새 모델 id가 게이트웨이에 뜨면 추론 강도 사다리, reasoning 재생, 비전 사이드카가 조용히 빈 채로 통과하고, 게이트웨이가 거절하는 요청 형태(특히 `response_format` json_schema)는 프리셋이 표현할 수단조차 없어 사용자가 직접 config를 고쳐야 한다. 이 유닛은 그 세 가지를 고친다: 프리셋이 그 거절을 표현할 수 있게 하고, 이미 증명된 프리셋 내부 불일치 두 건을 맞추고, 같은 종류의 드리프트를 다음번엔 테스트가 잡게 만든다. 바뀌는 사람은 Zen Go/Free를 쓰는 운영자다 — 지금 손으로 넣고 있는 설정이 기본값이 되고, 새 id가 들어와도 능력 표가 어긋나면 CI가 먼저 운다. + +연구 근거는 `001_issue_triage.md`(GitHub 트리아지), `002_cross_proxy_survey.md`(다른 프록시 교차 조사), `003_registry_gap_inventory.md`(코드 갭 인벤토리)에 있다. + +## 루프 스펙 + +| 항목 | 내용 | +| --- | --- | +| Loop archetype | satisfy-spec. 열린 최적화가 아니라 확정된 갭 목록을 닫는다 | +| Trigger | 사용자 요청: opencode go/free 이슈·PR을 묶어 안정화 PR을 올려라 | +| Goal | dev를 base로 하는 PR 하나. 프리셋 능력 표현 + 내부 불일치 수정 + 회귀 가드 + 문서 동기화 | +| Non-goals | `src/providers/command-code-efforts.ts`(열린 PR #4258 소유), 어댑터 와이어 동작 변경, 새 사용자 config 필드, 라이브 업스트림 프로브가 필요한 주장, 무키 free 티어 정책 변경 | +| Verifier | `bun test tests/providers/provider-registry-parity.test.ts`, `bun test tests/providers/opencode-go-deepseek.test.ts`, `bun test tests/adapters/openai/openai-chat-hardening.test.ts`, `bun run typecheck`. 신설 가드는 수정 전 실패를 먼저 확인한다 | +| Stop condition | PR이 dev를 base로 열리고 템플릿 3개 섹션이 채워진 시점 | +| Memory artifact | `devlog/_plan/260911_opencode_go_free_stabilization/` | +| Expected terminal outcomes | DONE = PR 게시 + 모든 검증 명령 green. BLOCKED = 업스트림 사실 확인이 필요해 근거 없이 시드할 수 없는 항목이 남을 때 | +| Escalation condition | push 권한은 사용자가 이미 준 PR 게시로 한정한다. 머지·릴리스는 별도 승인. 라이브 프로브가 필요한 주장은 시드하지 않고 보고한다 | +| Resource bounds | 도구: repo 읽기/쓰기, gh 읽기 + PR 생성, grok-4.6 서브에이전트. 쓰기 범위: `src/providers`, `src/types`, `tests/providers`, `docs-site`, 이 플랜 유닛. 벽시계: 사용자 세션 내 | + +## 작업 단계 지도 (의존 순서) + +| work-phase | 문서 | 내용 | 선행 | +| --- | --- | --- | --- | +| wp1 | 000-003 | 조사 종합과 로드맵 잠금 (docs only) | — | +| wp2 | `010_phase1_preset_structured_output.md` | 프리셋이 `noStructuredOutputModels`를 표현하고 Zen 계열 DeepSeek에 시드 | wp1 | +| wp3 | `020_phase2_preset_consistency_guard.md` | 프리셋 내부 불일치 G1·G2 수정과 parity 회귀 가드 | wp2 | +| wp4 | `030_phase3_docs_and_pr.md` | docs-site 동기화와 PR 게시 | wp3 | + +goalplan의 wp3 제목은 초기 등록 시 "어댑터 전송 계층"이었다. 조사 결과 어댑터 와이어 결함은 이미 랜딩되었거나(`002`) 우리 구조상 발생하지 않아, 이 문서가 wp3의 실제 범위를 정합성·가드로 확정한다. + +## 자문과 감사 기록 + +- **아키텍트**: grok-4.6. 첫 턴이 끊겨 한 번 재촉한 뒤 제안서를 받았다. 초안의 `noStructuredOutputModels` 시딩을 MISALIGNED로 반박했고 main이 수용했다(010 수정절). +- **독립 감사 2레인**: grok-4.6과 상속 모델로 각각 한 번. 둘 다 `VERDICT: near-pass`. 지적은 010/020에 전부 반영했다. +- **G2 불일치**: 두 리뷰어가 갈렸다. grok 레인은 "free 로스터에 paid id 증거가 없으니 넣지 말라", 상속 레인은 "같은 엔트리가 이미 #1043 근거로 공유 text-only 목록을 free에 통째로 싣는 선례가 있고(`registry.ts:3076`), 능력 표는 정확 일치라 없는 id면 무해하다"고 했다. main은 후자를 채택한다 — 능력 표는 카탈로그 로스터를 만들지 않으므로(`applyProviderConfigHints`는 이미 들어온 id만 장식한다) 없는 모델을 광고하지 않는다. +- **실패한 레인**: GitHub 트리아지 레인과 첫 리뷰어 레인은 grok-4.6에서 최종 메시지 없이 턴이 끝나는 증상으로 각각 두 번 실패해 은퇴시켰고, 해당 작업은 main이 직접 수행했다. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/001_issue_triage.md b/devlog/_plan/260911_opencode_go_free_stabilization/001_issue_triage.md new file mode 100644 index 0000000000..aec84399c1 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/001_issue_triage.md @@ -0,0 +1,38 @@ +# 010 — opencode-go / zen / free 이슈·PR 트리아지 + +수집일 2026-09-11. 소스: `gh issue list` / `gh pr list` (lidge-jun/opencodex), dev HEAD `b550d24e1`. + +## 열린 이슈 중 이 영역에 걸리는 것 + +| 번호 | 제목 | 판정 | 근거 | +| --- | --- | --- | --- | +| #4253 | Command Code live model `deepseek/deepseek-v4.1-flash` advertises no reasoning efforts | 유효, 단 **인접 PR #4258이 담당** | PR #4258이 `src/providers/command-code-efforts.ts`에 v4.1-flash / Qwen3.8-Flash 행 추가, base dev, mergeable, CI green | + +열린 이슈 60건 중 opencode-go/zen/free 고유 결함은 없다. 이 영역의 최근 결함은 대부분 닫혔다. + +## 최근 닫힌 항목 (2026-08-15 이후, 32건 중 발췌) + +| 번호 | 종료 | 제목 요약 | 현재 의미 | +| --- | --- | --- | --- | +| #4172 | COMPLETED | Go sessionless 요청이 `x-opencode-session` 누락 | 랜딩됨. `src/providers/opencode-go-transport.ts` | +| #4121 | COMPLETED | opencode-free: Zen이 세션 헤더 없는 요청 거부 | 랜딩됨. 무키 티어는 레지스트리 note로 차단 고지 | +| #3945 / #3857 / #3378 | COMPLETED | Claude/Pi 경로의 Go 세션 친화성 | 랜딩됨 | +| #3402 | COMPLETED | muse-spark via go: 미선언 클라이언트 툴이 서브에이전트 턴을 죽임 | 랜딩됨 | +| #2442 | COMPLETED | Go Responses가 `search_content_types` 거부 | 랜딩됨 | +| #2410 | COMPLETED | 신규 opencode-go 모델의 reasoningEfforts 누락 | **재발 구조 남음**: 030 참조 | +| #2193 / #2194 / #2156 | COMPLETED | muse-spark 502 / 스트림 중단 | 랜딩됨 | +| #1338 / #1415 | COMPLETED | Console Go 업스트림이 `response_format` json_schema를 400으로 거절 | **노브만 추가됨(#1424)**, 프리셋 시딩 없음 | + +## NOT_PLANNED로 닫혔지만 사실은 유효했던 것 + +| 번호 | 사유 | 실제 상태 | +| --- | --- | --- | +| #3362 | `#3378`로 통합 | 메인테이너가 유효·재현 가능으로 확인. `indexed_web_access` 미제거. #3378에서 처리 | +| #3344 | `#3378`로 통합 | 동일 | +| #2480 / #2394 | 템플릿 미비로 봇이 자동 종료 | 재현 정보 없음. 정보부족으로 남김 | +| #2484 | 템플릿 미비 | 보고자 스스로 `preserveResponsesReasoningContent` 미설정이 교란 변수였다고 정정 | + +## 남는 실물 갭 + +1. **구조화 출력 400**: #1338/#1415는 per-model 옵트아웃 노브(#1424)로만 닫혔다. Zen Go DeepSeek에 대한 기본 시딩은 없어서 사용자가 직접 config를 고쳐야 한다. 2026-09-11 커뮤니티 제보(디시인사이드 ai_utilize)에서 실제로 사용자가 `noStructuredOutputModels`에 deepseek를 넣어 해결했다. +2. **정확-id 표 드리프트**: #2410이 한 번 고쳐진 부류의 결함이 구조적으로 재발 가능하다. 030 참조. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md b/devlog/_plan/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md new file mode 100644 index 0000000000..828ff07253 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md @@ -0,0 +1,37 @@ +# 020 — opencode zen/go를 커넥터로 붙이는 다른 프록시 교차 조사 + +조사일 2026-09-11. 판정 기준: 해당 저장소 **소스/설정**에 `opencode.ai/zen` 또는 `zen/go/v1`이 실제로 있는지. README 스니펫만 있으면 unverified. + +## 지원 인벤토리 + +| 프로젝트 | zen go 지원 근거 | 비고 | +| --- | --- | --- | +| musistudio/claude-code-router | `packages/core/src/agents/local-providers/opencode.ts`, 테스트가 `https://opencode.ai/zen/go/v1` 고정 | 세션 헤더 주입 구현 있음 | +| Kiowx/opencode-cc | `OPENCODE_CC_UPSTREAM=https://opencode.ai/zen/go` | reasoning 캐시·thinking 정규화 구현 있음 | +| kartikkabadi/opencode-go-proxy | `src/opencode_go_proxy/upstream.py` | 세션 헤더 미구현 | +| tbosancheros39/opencode-thinking-fix | `proxy/proxy.js`, `proxy/core.js` | 라우트별 reasoning 키 분기 | +| NousResearch/hermes-agent | `plugins/model-providers/opencode-zen/__init__.py` | thinking XOR effort 처리 | +| cline/cline | `sdk/packages/llms/src/providers/providers.generated.ts` | 클라이언트 카탈로그 | +| chatboxai/chatbox | `src/shared/providers/definitions/opencode-go.ts` | 모델별 엔드포인트 분기 | +| openclaw/openclaw | first-class `opencode-go` | 카탈로그 드리프트 이슈 다수 | +| sst/opencode (anomalyco/opencode) | 게이트웨이 본체 | 업스트림 결함의 출처 | + +**미지원으로 확인된 것** (`gh search code "opencode.ai/zen"` 빈 결과): router-for-me/CLIProxyAPI, BerriAI/litellm, songquanpeng/one-api, QuantumNous/new-api, oai2ollama. LiteLLM은 사용자 yaml에 `api_base: https://opencode.ai/zen/go/v1` + `drop_params: true`로 붙이는 방식이고 first-class 어댑터가 아니다. + +## 증상별 교차표 (opencodex 관점) + +| 증상 | 다른 프록시의 대응 | opencodex 현황 | +| --- | --- | --- | +| `MissingSessionID` 400 | CCR `upstream-header-sanitizer.ts:202-206`이 공식 Go 호스트에만 주입 | 이미 구현 (`src/providers/opencode-go-transport.ts`) | +| tool-call 이어가기 reasoning 재생 | opencode-cc v1.2.5 `4ac61aa` | 이미 구현 (`preserveReasoningContentModels` + `src/responses/reasoning-replay-cache.ts`) | +| compaction이 thinking을 버린 뒤 tool_use id로 회수 | opencode-cc v1.3.0 `internal/proxy/reasoning_cache.go` | 유사 캐시 존재. Chat 경로 커버리지는 **검증 필요** | +| Kimi/Go에서 `thinking`과 `reasoning_effort` 동시 전송 시 "cannot specify both" | hermes `__init__.py:45-55`가 XOR 강제 | `src/adapters/openai-chat.ts:1500-1565`가 if-else로 하나만 선택 → **현재 구조상 동시 전송 없음** | +| GLM `thinking.type=adaptive` + tools 400 | opencode-cc `b52b661`이 adaptive→auto | opencodex의 adaptive는 Anthropic 계열 전용. Go GLM chat 경로엔 해당 enum 미사용 | +| glm-5.2가 `reasoning` 거부, `reasoning_content`만 수용 | thinking-fix 3.3.0 라우트별 키 | `reasoningWireFormat` 분기 존재. Go glm 계열 실제 수용 필드는 **unverified** | +| 429 / Retry-After 없음 | ogp 백오프 재시도 | 이미 구현 (`src/providers/opencode-zen-rate-limit.ts`) | +| 모델 id 드리프트 | sst/opencode `ba72a6f` 문서 id 교체, ogp가 2회 거절 시 카탈로그에서 숨김 | **갭**. 030 참조 | +| `response_format` structured output 400 | 이 조사에서 외부 이슈 URL 미검출 | opencodex는 #1338/#1415 근거 보유 | + +## 결론 + +외부 프록시가 이미 해결했고 opencodex에 없는 항목은, 재확인 결과 대부분 **이미 랜딩되어 있거나 우리 코드 구조상 발생하지 않는다.** 실제로 남는 교차 갭은 **모델 id 드리프트 대응** 하나이며, 이는 030의 정확-id 표 문제와 같은 뿌리다. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md b/devlog/_plan/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md new file mode 100644 index 0000000000..40b54b4ed7 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md @@ -0,0 +1,41 @@ +# 030 — 세 프리셋의 정확-id 표 갭 인벤토리 + +조사 대상 `src/providers/registry.ts` (opencode-go 1695-1791, opencode-zen 3016-3039, opencode-free 3042-3079). + +## 매칭 방식 + +| 메커니즘 | 방식 | 비교 지점 | +| --- | --- | --- | +| `noVisionModels`, `noReasoningModels`, `thinkingToggleModels`, `thinkingBudgetModels`, `preserveReasoningContentModels`, sampling 목록 | 정확 일치 + colon-family(`gpt-oss`→`gpt-oss:120b`)만 예외 | `src/types/tools.ts:241` | +| `modelReasoningEfforts`, `modelReasoningEffortMap`, `modelContextWindows`, `modelInputModalities` | 정확 own-property + colon-family + case-fold | `src/reasoning-effort.ts:115`, `src/codex/catalog/provider-fetch.ts:668,799` | +| `noStructuredOutputModels` | 정확 `Array.includes`만 (colon-family도 없음) | `src/adapters/openai-chat.ts:142,1580` | +| generated metadata | 정확 `r[0] === modelId` | `src/generated/model-metadata.ts:62` | + +`isDeepseekFlashModel`(`registry.ts:718`)은 substring이지만 **시드 루프 안에서만** 호출된다(`1754`, `3024`, `3065`). 런타임 조회 경로에는 쓰이지 않는다. + +## live 로스터와 시드의 비대칭 + +세 프리셋 모두 정적 `models:` 배열이 없고 live `/models`로 로스터를 받는다(go/zen은 `liveModels` 미지정 → 기본 ON, free는 `liveModels: true`). 새 id는 카탈로그에는 들어오지만(`tests/providers/provider-live-models.test.ts:111-146`), `applyProviderConfigHints`는 **이미 시드된 맵만** 조회한다(`provider-fetch.ts:766,799`). + +결과: 시드에 없는 live id는 reasoning ladder, replay, vision sidecar, context window, wire default가 전부 빈 채로 통과한다. #2410이 한 번 수동으로 메운 것과 같은 종류의 구멍이다. + +## 증명된 내부 불일치 (upstream 사실 없이도 고칠 수 있는 것) + +| # | 불일치 | 앵커 | 영향 | +| --- | --- | --- | --- | +| G1 | opencode-go `thinkingBudgetModels`는 `THINKING_BUDGET_MODELS` 전체(Neuralwatt 전용 `qwen3.5-397b`, `qwen3.6-35b` 포함)인데, 같은 프리셋의 `modelReasoningEfforts`는 `OPENCODE_GO_THINKING_BUDGET_MODELS`(4개)만 spread한다 | `registry.ts:1755` vs `1771` | 해당 id가 live로 오면 budget 게이트는 켜지고 광고할 ladder는 없다 | +| G2 | opencode-free는 같은 Zen 게이트웨이인데 paid DeepSeek id(`deepseek-v4-flash`, `deepseek-v4-pro`)를 reasoning/replay/noVision 어디에도 넣지 않는다. opencode-zen은 넣는다 | `registry.ts:3042-3079` vs `3016-3039` | free 로스터에 paid id가 등장하면 replay와 sidecar가 동시에 빠진다 | +| G3 | `noStructuredOutputModels`는 `ProviderRegistryEntry` 타입(`160-353`)에 필드 자체가 없고 `providerConfigSeed`(`src/providers/derive.ts:218`)도 복사하지 않는다 | 위 | 프리셋이 이 옵트아웃을 표현할 수단이 아예 없다. 사용자 config로만 가능 | + +## parity 테스트가 강제하지 않는 것 + +`tests/providers/provider-registry-parity.test.ts`는 알려진 id를 고정한다. 강제하지 **않는** 것: + +- live discovery로 들어온 미등록 id의 메타데이터 완전성 +- `noStructuredOutputModels` +- go `thinkingBudgetModels` ↔ `modelReasoningEfforts` 정합 (G1) +- zen ↔ free의 DeepSeek 처리 대칭 (G2). Zen은 DeepSeek ladder 케이스 배열에 아예 없다(`1385-1417`) + +## 이 유닛이 건드리지 않는 것 + +`src/providers/command-code-efforts.ts` — 열린 PR #4258이 소유한다. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/004_audit_rounds.md b/devlog/_plan/260911_opencode_go_free_stabilization/004_audit_rounds.md new file mode 100644 index 0000000000..0f523c3e9b --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/004_audit_rounds.md @@ -0,0 +1,39 @@ +# 004 — 자문·감사 라운드 원문 기록 + +## 라운드 1 — 아키텍트 (grok-4.6, 읽기 전용) + +판정: `ALIGNED if Main keeps the six slices below, keeps #4258 out of scope, and treats (i)/(ii) as the two judgment calls rather than as new subsystems. MISALIGNED if Main seeds noStructuredOutputModels as a registry field, invents live-id facts, or reopens landed transport/cache work.` + +핵심 반박 (main 수용): + +> Treat the community report as "users are disabling structured output entirely to escape a json_schema 400," not as proof that json_object is also rejected. … Live probe: impossible in this unit. Therefore we must not promote a full structured-output ban into the seed tables. + +main 처분: **수용.** 010을 `noJsonSchemaModels` 좁은 계약으로 다시 썼다. 다만 아키텍트가 제안한 "어댑터에서 provider id로 분기" 방식은 채택하지 않았다 — 이 저장소의 관용은 프로바이더 설정 필드가 어댑터 동작을 구동하는 것이고, 어댑터에 프로바이더 id를 박으면 새 결합이 생긴다. + +## 라운드 2 — 독립 감사 2레인 + +두 레인 모두 `VERDICT: near-pass`. + +### 레인 A (상속 모델) + +- 배선: `선례가 noPenaltyModels로 완결돼 있다: registry.ts:323 → router.ts:351 병합 + router.ts:475 emit → openai-chat.ts:134` +- 지적: `142는 delete 후 downgrade가 다시 넣지 않도록 else-if 순서를 명시해야 한다 — 계획에 순서 언급이 없다` +- 지적: `라인 드리프트: 실제 게이트는 registry.ts:1773, 사다리는 1753(문서의 1755/1771 아님)` +- 지적: `G2 — 판단이 약하다. 같은 엔트리 registry.ts:3076이 이미 "같은 게이트웨이·같은 로스터"를 근거로 free에 공유 text-only 목록 전체를 싣는 선례(#1043)다` +- 지적: `000_plan 첫 문단 "세 프리셋은 로스터를 live /models로 받지만" — liveModels는 free만(registry.ts:3049)` + +### 레인 B (grok-4.6) + +- 지적: `010 상단 파일지도는 구설계(noStructuredOutputModels 시드)라 수정절과 충돌한다` +- 반대 의견: `G2 타당. live 로스터에 paid id 증거가 없고, zen처럼 paid id를 넣으면 없는 모델을 광고한다` +- 두 레인 공통: `#4258 교집합 없음` + +## 불일치 처분 — G2 + +레인 B의 "없는 모델을 광고한다"는 부정확하다. 능력 표는 카탈로그 로스터를 만들지 않는다: `applyProviderConfigHints`는 이미 로스터로 들어온 id만 장식한다(`src/codex/catalog/provider-fetch.ts:766,799`). 로스터는 live `/models` 또는 정적 `models:` 배열에서 나오고, 세 프리셋은 정적 배열이 없다. 따라서 등장하지 않는 id를 능력 표에 시드해도 광고는 발생하지 않는다. + +레인 A의 선례가 더 강하다. main은 레인 A를 채택한다. + +## 실패한 레인 기록 + +GitHub 트리아지 레인과 1차 리뷰어 레인은 grok-4.6에서 턴이 `completed` 로 끝나면서 최종 메시지가 비는 증상으로 각각 두 번 실패했다(중간 commentary만 남음). 은퇴시키고 해당 작업은 main이 직접 수행했다. 같은 모델의 아키텍트·감사 레인은 한 번 재촉 후 정상 산출했으므로 모델 전면 배제는 하지 않았다. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md new file mode 100644 index 0000000000..c688f9b51f --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md @@ -0,0 +1,103 @@ +# 010 — wp2: 프리셋이 구조화 출력 옵트아웃을 표현하게 한다 + +## 왜 + +`noStructuredOutputModels`는 #1424로 들어왔지만 사용자 config / management API 전용이다. `ProviderRegistryEntry`에 필드 자체가 없어서(`src/providers/registry.ts:160-353`) 어떤 프리셋도 "이 게이트웨이의 이 모델은 `response_format`을 거절한다"를 표현할 수 없다. 그래서 Zen Go에서 DeepSeek를 쓰는 운영자는 매번 손으로 config를 고친다(#1338, #1415, 2026-09-11 커뮤니티 제보). + +## wp2 P 재검증 (2026-09-11, 사이클 진입 시) + +문서가 지목한 편집 지점을 현재 트리에서 전부 다시 확인했다. 드리프트 없음. + +| 지점 | 현재 내용 | +| --- | --- | +| `src/types/provider.ts:643` | `noStructuredOutputModels?: string[];` 선언과 계약 주석 | +| `src/providers/registry.ts:319-323` | `noVisionModels`…`noPenaltyModels` 선언 블록 | +| `src/router.ts:351` | `const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);` | +| `src/router.ts:475` | `...(noPenaltyModels ? { noPenaltyModels } : {}),` | +| `src/adapters/openai-chat.ts:142` | `if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format;` | +| `src/adapters/openai-chat.ts:1580` | 번역 경로의 `if (!provider.noStructuredOutputModels?.includes(parsed.modelId)) { … }` | + +추가로 발견한 선례: `registry.ts:315`의 `directReasoningEffortModels`가 `registry-only and is never persisted as user config`라고 명시한다. 즉 레지스트리 전용 필드는 이 저장소에 이미 있는 범주다. 새 필드도 같은 범주로 두되, 사용자가 config에 직접 적어도 검증을 통과하도록 zod 스키마에는 넣는다. + +## 선례 + +`noPenaltyModels`가 같은 배선을 이미 완결해 두었다: 선언 `src/providers/registry.ts:323` → 병합 `src/router.ts:351` → emit `src/router.ts:475` → 소비 `src/adapters/openai-chat.ts:134`. 새 필드는 이 네 지점을 그대로 따른다. 아래 "배선 경로" 표가 확정 파일 지도다. + +## 설계 수정 (아키텍트 반박 수용, 2026-09-11) + +초안은 `noStructuredOutputModels`를 세 프리셋에 그대로 시드하려 했다. 독립 아키텍트 자문이 이를 반박했고 main이 수용한다. + +반박 요지: 확인된 400은 `json_schema` **타입** 한정이다(`This response_format type is unavailable now`). 그런데 이 노브의 계약은 "`response_format` 필드를 통째로 생략"이라, 시드하면 `json_object`를 쓰던 클라이언트까지 같이 죽는다. 커뮤니티 제보는 운영자가 고른 무딘 킬스위치이지 "json_object도 거절된다"는 증거가 아니다. 그걸 기본값으로 올리면 앞으로 json_object가 실제로 거절되는지 여부를 관측할 신호까지 덮어버린다. + +수정된 설계: **확인된 사실만 표현하는 좁은 필드를 새로 만든다.** + +`noJsonSchemaModels` — "이 모델은 `response_format` `json_schema`를 거절한다. `json_object`에 대해서는 아무 주장도 하지 않는다." + +동작: + +| 요청 | 시드된 모델 | 시드되지 않은 모델 | +| --- | --- | --- | +| `json_schema` | `{"type":"json_object"}`로 낮춰 보낸다 | 그대로 `json_schema` | +| `json_object` | 그대로 | 그대로 | +| 사용자가 `noStructuredOutputModels`에 넣음 | 기존대로 필드 전체 생략(우선한다) | 동일 | + +낮추기를 택한 이유: 클라이언트가 원한 건 JSON이다. 필드를 지우면 산문이 돌아오고, `json_object`로 낮추면 최소한 JSON이 온다. Zen Go가 `json_object`를 수용하는지는 **unverified**이지만, 거절한다면 400이 다시 뜨고 그건 새로운 검증된 사실이 되어 시드를 넓힐 근거가 된다. 킬스위치로 덮으면 그 신호가 사라진다. + +## 시드 내용 + +```ts +// opencode-go +noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS], +// opencode-zen +noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], +// opencode-free +noJsonSchemaModels: [...OPENCODE_FREE_DEEPSEEK_MODELS], +``` + +매칭은 기존 목록과 같은 정확 일치다. `deepseek-v4.1-flash` 같은 신규 id는 걸리지 않는다 — 의도적이다. 게이트웨이가 그 id를 서빙한다는 근거가 없다. + +## 배선 경로 (최소 경로를 택한다) + +라우터는 레지스트리 엔트리와 사용자 config를 요청 시점에 병합한다(`src/router.ts:346-358`의 `mergeStringArray`, `471-482`의 emit). 따라서 프리셋 값은 `providerConfigSeed`로 config.json에 **영속시키지 않아도** 요청 경로에 도달한다. 새 사용자 설정 화면이나 management PATCH는 이번 범위가 아니다. + +| 파일 | 성격 | 내용 | +| --- | --- | --- | +| `src/types/provider.ts` | MODIFY | `noStructuredOutputModels`(`639-643`) 바로 아래에 `noJsonSchemaModels?: string[]` + 계약 주석 | +| `src/providers/registry.ts` | MODIFY | `ProviderRegistryEntry`에 같은 필드(`321` 부근), 세 프리셋에 시드 | +| `src/router.ts` | MODIFY | `mergeStringArray` 한 줄 + emit 한 줄 | +| `src/config.ts` | MODIFY | zod 스키마에 한 줄(`622` 패턴) — 사용자가 손으로 넣어도 검증을 통과하게 | +| `src/adapters/openai-chat.ts` | MODIFY | `142`(네이티브 패스스루)와 `1580`(번역 경로) 두 지점 모두에 낮추기 분기 | +| `tests/adapters/openai/openai-chat-hardening.test.ts` | MODIFY | 낮추기 동작과 경계 | +| `tests/providers/provider-registry-parity.test.ts` | MODIFY | 세 프리셋 시드 고정 | + +## 수용 기준 + +1. `routeModel`을 거쳐 materialize한 opencode-go 프로바이더가 `noJsonSchemaModels`에 DeepSeek 두 id를 갖는다. +2. 같은 프로바이더로 `deepseek-v4-flash` + `textFormat: json_schema` 요청을 만들면 직렬화된 `body.response_format`이 `{"type":"json_object"}`다. 활성 시나리오: 번역 경로는 `buildOpenAIChatRequest`, 네이티브 경로는 `buildOpenAIChatPassthroughRequest`에 각각 넣고 결과 본문을 읽는다. +3. 같은 프로바이더로 `glm-5.3`(시드에 없음) + json_schema면 `response_format.type`이 `json_schema`로 **남는다** — 정확 일치 경계가 살아 있다는 반대 증거. +4. 시드된 모델 + `json_object` 요청은 그대로 `json_object`다 — 낮추기가 json_object를 건드리지 않는다는 반대 증거. +5. 같은 모델이 `noStructuredOutputModels`에도 있으면 `response_format`이 아예 없다 — 킬스위치 우선순위. + +### 분기 순서와 누락 지점 (wp2 감사 반영) + +- 패스스루(`142`): 킬스위치가 `delete body.response_format`을 먼저 실행하므로, 그 뒤의 낮추기는 `body.response_format?.type === "json_schema"`를 조건으로 두면 자동으로 발화하지 않는다. 감사 지적대로 `else if`는 맞지만 실질적으로 무의미하므로, 조건에 타입 검사를 넣고 킬스위치 우선임을 주석으로 남긴다. `.includes` 정확 일치는 유지한다. +- 번역 경로(`1580`): 킬스위치 게이트가 json_object/json_schema 두 분기를 함께 감싸므로, 낮추기는 json_schema 분기 **안**에 둔다. +- **config 검증은 선택이 아니다**: provider 스키마는 `.passthrough()`다. zod 검증을 빼면 사용자가 배열 대신 문자열을 넣어도 통과하고, `.includes()`가 부분 일치로 오작동한다. +- **관리 API 왕복 누락**(감사가 새로 찾음): `src/server/auth-cors.ts`의 검증기(`711` 패턴)와 `PROVIDER_CONFIG_FIELD_POLICY`(`868` 부근), `src/server/management/provider-routes.ts`의 PATCH 처리(`563` 패턴)와 DTO(`732` 부근)에 필드를 넣지 않으면, 대시보드 raw 에디터 왕복에서 값이 거부되거나 사라진다. `noStructuredOutputModels`와 동일하게 네 지점을 모두 추가한다. +- **처분 보류**: 스키마 계약이 조용히 free-form JSON으로 강등되는 것을 debug 로그로 남기라는 권고는 이번 범위에서 채택하지 않는다. 요청 본문 로깅 금지 규칙과 인접해 별도 판단이 필요하고, 필드 계약 주석과 PR 본문에 명시하는 것으로 대체한다. 후속 후보로 남긴다. +- **건드리지 말 것**: parity 테스트가 opencode-go `noVisionModels`를 리터럴 배열로 고정한다. 이번 슬라이스는 그 필드를 수정하지 않는다. + +## 검증 + +``` +bun test tests/providers/provider-registry-parity.test.ts +bun test tests/providers/opencode-go-deepseek.test.ts +bun test tests/adapters/openai/openai-chat-hardening.test.ts +bun run typecheck +``` + +## 리스크 + +- Zen Go가 `json_object`도 거절하면 낮추기는 400을 막지 못한다. 그건 감추지 않고 드러내는 선택이며, 그때는 검증된 사실로 `noStructuredOutputModels` 쪽으로 넓히면 된다. +- 스키마를 요구한 클라이언트가 느슨한 JSON을 받는다. 필드를 지워 산문을 받는 기존 대안보다 낫고, 두 지점 모두 테스트로 고정한다. +- 새 필드가 라우터 병합 목록에서 빠지면 프리셋 값이 요청에 도달하지 않는다. 수용 기준 1이 이걸 직접 관측한다. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md b/devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md new file mode 100644 index 0000000000..db570faab0 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md @@ -0,0 +1,65 @@ +# 020 — wp3: 프리셋 내부 불일치 수정과 회귀 가드 + +## G1 — opencode-go의 thinking budget 게이트와 사다리가 어긋난다 + +`registry.ts:1773`이 `thinkingBudgetModels: THINKING_BUDGET_MODELS`(6개, Neuralwatt 전용 `qwen3.5-397b`·`qwen3.6-35b` 포함)인데, 같은 프리셋의 `modelReasoningEfforts`(`1753` 부근의 spread)는 `OPENCODE_GO_THINKING_BUDGET_MODELS`(4개)만 넣는다. Go 로스터에 397b가 등장하면 어댑터는 `thinking_budget` 경로를 타는데(`src/adapters/openai-chat.ts:1539`) 카탈로그가 광고할 사다리는 없다. + +실행 근거(`.tmp/preset-probe.ts`로 레지스트리를 직접 로드): + +``` +thinkingBudgetModels: ["qwen3.5-397b","qwen3.6-35b","qwen3.5-plus","qwen3.6-plus","qwen3.7-max","qwen3.7-plus"] +budget ids missing from ladder: ["qwen3.5-397b","qwen3.6-35b"] +``` + +감사 확인: 이 6원소를 equality로 고정한 테스트는 없다. `qwen3.5-397b`를 고정하는 건 neuralwatt 경로뿐이다(`tests/codex-integration/reasoning-effort.test.ts:875`, parity `356-376`). + +변경: `thinkingBudgetModels: OPENCODE_GO_THINKING_BUDGET_MODELS`. + +수용 기준: opencode-go 레지스트리 엔트리의 `thinkingBudgetModels`가 `modelReasoningEfforts`에 사다리를 가진 id의 부분집합이다. 활성 시나리오: parity 테스트가 두 컬렉션을 직접 비교한다. + +## G2 — opencode-free가 같은 게이트웨이인데 DeepSeek 처리가 비대칭이다 + +opencode-zen(`3016-3039`)은 `DEEPSEEK_THINKING_MODELS` + `OPENCODE_FREE_DEEPSEEK_MODELS`를 reasoning/replay/noVision에 넣는다. opencode-free(`3042-3079`)는 `-free` id만 넣는다. free는 `liveModels: true`이고 같은 `opencode.ai/zen/v1` 게이트웨이다. + +실행 근거: + +``` +zen preserveReasoningContentModels: ["deepseek-v4-pro","deepseek-v4-flash","deepseek-v4-flash-free"] +free preserveReasoningContentModels: ["deepseek-v4-flash-free"] +zen noVisionModels: [... text-only 6 ..., "deepseek-v4-pro", "deepseek-v4-flash"] +free noVisionModels: [... text-only 6 ...] +``` + +판단(감사 후 변경): **zen과 동일한 id를 free에도 싣는다.** 초안은 "free 로스터에 paid id 증거가 없으니 넣지 않는다"였고 grok 리뷰어도 같은 의견이었지만, 상속 모델 리뷰어가 같은 엔트리의 선례를 들어 반박했고 그쪽이 맞다: + +- free는 이미 zen과 공유하는 text-only 목록 전체를 "같은 게이트웨이·같은 로스터"라는 근거로 싣는다(`registry.ts:3076`, #1043). +- 능력 표는 카탈로그 로스터를 만들지 않는다. `applyProviderConfigHints`는 이미 들어온 id만 장식하므로(`src/codex/catalog/provider-fetch.ts:766,799`), 등장하지 않는 id를 시드해도 아무것도 광고되지 않는다. 무해하고, 등장하면 정확하다. +- "상수에서 부분집합 파생"은 필터가 여전히 수작업이라 드리프트를 구조적으로 막지 못한다. + +변경: free의 `modelReasoningEfforts` / `modelReasoningEffortMap` / `preserveReasoningContentModels` / `noVisionModels`가 zen과 같은 DeepSeek 집합을 쓰도록 같은 상수에서 파생시킨다. + +수용 기준: free와 zen의 DeepSeek 관련 목록이 같은 집합을 갖는다. 반대 증거로, zen 전용이 아닌 free 고유 항목(text-only 무료 id)은 그대로 남는다. + +## 회귀 가드 + +`tests/providers/provider-registry-parity.test.ts`에 추가: + +1. **Go budget ⊆ ladder**: `thinkingBudgetModels`의 모든 id가 `modelReasoningEfforts`에 키를 가진다. +2. **Zen 계열 DeepSeek 대칭**: go/zen/free 각각에서, `modelReasoningEfforts`에 DeepSeek id가 있으면 `preserveReasoningContentModels`에도 있다. (#78/#950 계열 400의 구조적 방지) +3. **구조화 출력 시드 고정**: wp2가 넣은 세 프리셋의 시드 배열을 그대로 고정한다. + +세 가드 모두 수정 전 코드에서 먼저 실패시켜 red-green을 확인한다. 특히 1번은 현재 코드에서 `qwen3.5-397b`로 실패해야 한다 — 실패하지 않으면 가드가 무의미하다는 뜻이므로 가드를 다시 쓴다. + +## 검증 + +``` +bun test tests/providers/provider-registry-parity.test.ts +bun test tests/providers/opencode-zen-deepseek-reasoning.test.ts +bun test tests/providers/opencode-free-provider.test.ts +bun test tests/codex-integration/catalog-go-exact-efforts.test.ts +``` + +## 리스크 + +- `thinkingBudgetModels` 축소가 Go에서 397b를 실제로 쓰는 사용자에게 영향? 해당 id는 Go `modelReasoningEfforts`에 없어서 지금도 사다리가 없다. 축소는 광고되지 않던 경로를 끄는 것이다. +- parity 테스트는 배열 equality를 쓰는 곳이 있어(`73-80`) 시드 변경 시 같이 갱신해야 한다. diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md b/devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md new file mode 100644 index 0000000000..6285398fe6 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md @@ -0,0 +1,30 @@ +# 030 — wp4: 문서 동기화와 PR 게시 + +## 문서 + +`noStructuredOutputModels`는 이미 `docs-site/src/content/docs/reference/configuration/providers.md`와 각 로케일에 설명이 있다. 이번 변경은 그 옆에 **새 필드 `noJsonSchemaModels`** 를 추가하고, opencode go/zen/free 프리셋이 이를 기본으로 싣는다는 사실을 적는다. + +| 파일 | 변경 | +| --- | --- | +| `docs-site/src/content/docs/reference/configuration/providers.md` | `noStructuredOutputModels` 항목 바로 뒤에 `noJsonSchemaModels` 항목 추가: json_schema만 json_object로 낮추고 json_object는 건드리지 않는다, 두 필드가 함께 있으면 `noStructuredOutputModels`가 우선한다, opencode go/zen/free 프리셋이 Zen 게이트웨이의 DeepSeek id에 기본 시드한다 | +| `docs-site/src/content/docs/ko|ja|fr|ru|tr|zh-cn|zh-tw/reference/configuration/providers.md` | 같은 항목의 로케일 번역. 영문 원문과 모순되지 않게 유지 | + +로케일 파일이 영문과 구조가 다르면 해당 위치에만 맞춰 넣고, 번역이 불가능한 항목은 영문 문장을 그대로 두지 않는다. + +## PR + +- base `dev`, head `codex/260911-opencode-go-free-stabilization` +- 템플릿 3개 섹션(Summary / Verification / Checklist) 전부 채운다 +- 본문에 반드시 포함: 닫는 이슈가 아니라 **묶음의 근거**(#1338, #1415, #1424, #2410), Zen Go의 `json_object` 수용 여부가 unverified라는 점과 그래서 킬스위치 대신 낮추기를 택한 이유, 라이브 프로브 불가로 시드하지 않은 항목(`deepseek-v4.1-flash`), PR #4258과의 비충돌(파일 교집합 없음) +- `gui` 단어를 제목/본문에 쓰지 않는다(스크린샷 게이트 유발) +- `Closes #`는 쓰지 않는다. 이 PR이 단독으로 닫는 열린 이슈는 없다 + +## 검증 + +``` +bun run typecheck +bun run test +bun run privacy:scan +``` + +PR을 review-ready로 올리기 전 전체 스위트를 돌린다(AGENTS.md PR-ready 게이트). diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/031_wp4_outcome.md b/devlog/_plan/260911_opencode_go_free_stabilization/031_wp4_outcome.md new file mode 100644 index 0000000000..134c115214 --- /dev/null +++ b/devlog/_plan/260911_opencode_go_free_stabilization/031_wp4_outcome.md @@ -0,0 +1,31 @@ +# 031 — wp4 결과 + +PR: (base `dev`, head `codex/260911-opencode-go-free-stabilization`) + +## 최종 변경 범위 + +| 커밋 | 내용 | +| --- | --- | +| `d0e4e5218` | `noJsonSchemaModels` 계약: 타입, 레지스트리 필드와 세 프리셋 시드, 라우터 병합/emit, config zod + superRefine, auth-cors 검증기 + 필드 정책, provider-routes PATCH + DTO, 어댑터 두 와이어의 낮추기, 회귀 테스트 10건 | +| `58fe2b07f` | opencode-go `thinkingBudgetModels` 를 Go 전용 목록으로 좁힘 + 구조 가드 2종 | +| `4ed244bca` | docs-site en + 7개 로케일 | +| `ecb6a14a4` | 프랑스어 문서의 기존 행 조판 원복 (감사 지적) | + +## 검증 + +포커스 스위트만 돌렸다. 사용자가 전체 스위트를 명시적으로 금지했고, 푸시는 `--no-verify` 로 지시했다. + +- 어댑터/프리셋 155 pass / 0 fail +- parity + 카탈로그 효율 97 pass / 0 fail +- config/management 637 pass / 0 fail +- `bun run typecheck` exit 0, `bun run privacy:scan` 통과 +- red-green: 세 가드 모두 수정 전 실패를 직접 확인 + +전체 스위트는 CI 에 맡겼다. 이전에 로컬에서 한 번 시도했을 때 879초가 걸렸고 exit 1 로 끝났는데, 출력이 잘려 어떤 파일이 실패했는지는 확인하지 못했다. 이 브랜치가 원인인지도 확인되지 않았다 — 재확인은 CI 결과로 대체한다. + +## 남긴 것 + +- `deepseek-v4.1-flash` 는 시드하지 않았다. 게이트웨이가 서빙한다는 근거가 트리에 없다. +- `json_object` 수용 여부는 미검증이다. DeepSeek 계열이 프롬프트에 `json` 문자열을 요구하는 구현이면 낮추기가 400 대신 빈 응답이 될 수 있다. PR 본문에 후속 조건으로 명시했다. +- 구조 가드는 세 프리셋 id 를 루프로 돈다. 네 번째 Zen 계열 프리셋이 생기면 목록에 추가해야 한다. +- 스키마 강등을 관측 가능한 신호로 남기는 건(요청 본문 로깅 금지와 인접) 후속 판단으로 미뤘다. diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index c7879dfa9c..563286c2bb 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -129,6 +129,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `noTopPModels?` | `string[]` | Modèles qui rejettent `top_p` spécifié par l’appelant. | | `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. | | `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | +| `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l’embarquent pour leurs routes DeepSeek. | | `parallelToolCalls?` | `boolean` | Contrôler les appels d’outils parallèles. Pour `openai-chat`, ils sont activés par défaut ; `false` envoie explicitement `parallel_tool_calls: false`. Les autres adaptateurs ne les annoncent que lorsque la valeur vaut explicitement `true`. | | `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 608e66deb2..b8ee061b4a 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -122,6 +122,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noTopPModels?` | `string[]` |発信者指定の`top_p`を拒否するモデル。 | | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | +| `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` / `opencode free` プリセットが DeepSeek 経路に既定で載せます。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 342441eca1..0b5f8bf36a 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -122,6 +122,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noTopPModels?` | `string[]` | 호출자가 지정한 `top_p`를 거부하는 모델입니다. | | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | +| `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen`, `opencode free` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 618c016de3..feebe4649b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -192,6 +192,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noTopPModels?` | `string[]` | Models that reject caller-specified `top_p`. | | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | +| `noJsonSchemaModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects a `json_schema` `response_format` but still accepts `json_object`. Such a request is downgraded to `json_object` instead of being dropped, so a caller asking for JSON still gets JSON. `noStructuredOutputModels` wins when a model is on both lists. The `opencode go`, `opencode zen`, and `opencode free` presets ship this for their DeepSeek routes. | | `omitReasoningEffortWithToolsModels?` | `string[]` | Exact `openai-chat` model IDs that accept a reasoning-effort field on an ordinary turn but reject it once function tools are present. The model keeps its advertised effort ladder; OpenCodex omits the wire field for tool-bearing requests only and the upstream default applies. Narrower than `noReasoningModels`, which strips reasoning from every request and costs the model its picker entirely. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 78bca4d40d..37b00b3150 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -135,6 +135,7 @@ cross-route credential fallback не существует. Строки API GPT- | `noTopPModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `top_p`. | | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | | `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | +| `noJsonSchemaModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format` типа `json_schema`, но принимает `json_object`. Такой запрос понижается до `json_object`, а не отбрасывается, поэтому вызывающая сторона всё равно получает JSON. Если модель есть в обоих списках, побеждает `noStructuredOutputModels`. Пресеты `opencode go`, `opencode zen` и `opencode free` включают это для своих маршрутов DeepSeek. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 36c183bf11..f0739bbc63 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -136,6 +136,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `noTopPModels?` | `string[]` | Arayan tarafından belirtilen `top_p` değerini reddeden modeller. | | `noPenaltyModels?` | `string[]` | Varlık/frekans cezalarını reddeden modeller. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` uç noktası `response_format`'ı reddeden tam model kimlikleri. Yalnızca tam bir istenen model eşleşmesi alanı atlar; yapılandırılmış çıktı çevirisi diğer her `openai-chat` modeli için etkin kalır. | +| `noJsonSchemaModels?` | `string[]` | `openai-chat` uç noktası `json_schema` biçimini reddeden ama `json_object` kabul eden tam model kimlikleri. Böyle bir istek atılmak yerine `json_object` seviyesine düşürülür, böylece JSON isteyen çağıran yine JSON alır. Bir model her iki listede de varsa `noStructuredOutputModels` kazanır. `opencode go`, `opencode zen` ve `opencode free` hazır ayarları bunu DeepSeek rotaları için getirir. | | `parallelToolCalls?` | `boolean` | Paralel araç çağrılarını açıp kapatın. OpenAI Chat varsayılan olarak açıktır; sohbet harici adaptörler yalnızca açık `true` durumunda bildirir. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Tam yer tutucu kimlikleri, eksik terminal kimlikleri ve (`repairInvalidIds` ile) kurallı `msg_`/`rs_` öneki eksik olan mesaj/akıl yürütme kimlikleri için varsayılan olarak devre dışı bırakılmış aşağı akış SSE onarımı. Fonksiyon çağrısı kimlikleri asla yeniden yazılmaz. Yerleşik DeepSeek son ikisini varsayılan olarak etkinleştirir. | | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 99fba1fbb4..de314d8d8e 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -122,6 +122,7 @@ selector,而不是分配一个新名称。 | `noTopPModels?` | `string[]` | 会拒绝调用方指定 `top_p` 的模型。 | | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | +| `noJsonSchemaModels?` | `string[]` | `openai-chat` 端点拒绝 `json_schema` 形式但仍接受 `json_object` 的精确模型 ID。这类请求会降级为 `json_object` 而不是被丢弃,因此请求 JSON 的调用方仍能拿到 JSON。同一模型同时出现在两个列表时,以 `noStructuredOutputModels` 为准。`opencode go`、`opencode zen`、`opencode free` 预设已为其 DeepSeek 路由内置该项。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 74ee860ff1..292b8e63dc 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -96,6 +96,7 @@ ocx models provider openrouter on | `noTopPModels?` | `string[]` | 拒絕呼叫者指定 `top_p` 的模型。 | | `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | +| `noJsonSchemaModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `json_schema` 形式但仍接受 `json_object` 的精確模型 ID。這類請求會降級為 `json_object` 而非被丟棄,因此要求 JSON 的呼叫端仍會拿到 JSON。同一模型同時列在兩份清單時,以 `noStructuredOutputModels` 為準。`opencode go`、`opencode zen`、`opencode free` 預設已為其 DeepSeek 路由內建。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 供應商。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 4ba654487c..cd3481bc7e 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -140,6 +140,16 @@ export function buildOpenAIChatPassthroughRequest( // ingress enforces exactly that. A prefix match here would strip response_format from // `:` siblings the operator never opted out, silently returning prose. if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format; + // Narrower neighbour: the model takes `json_object` but rejects `json_schema`. Downgrade + // rather than drop, so a caller that asked for JSON still gets JSON. The type check also + // makes the kill switch above win without an else — after its `delete` there is no type + // left to match. + const passthroughFormat = body.response_format; + if (provider.noJsonSchemaModels?.includes(modelId) + && typeof passthroughFormat === "object" && passthroughFormat !== null + && (passthroughFormat as { type?: unknown }).type === "json_schema") { + body.response_format = { type: "json_object" }; + } // Run the same complete Fast policy as the translated Chat path, including explicit // fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still @@ -1582,15 +1592,19 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (textFormat?.type === "json_object") { body.response_format = { type: "json_object" }; } else if (textFormat?.type === "json_schema") { - body.response_format = { - type: "json_schema", - json_schema: { - name: textFormat.name ?? "response", - ...(textFormat.description !== undefined ? { description: textFormat.description } : {}), - ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}), - ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}), - }, - }; + // Same downgrade as the passthrough path: the schema is dropped because the + // upstream rejects it, but the JSON-mode request itself survives. + body.response_format = provider.noJsonSchemaModels?.includes(parsed.modelId) + ? { type: "json_object" } + : { + type: "json_schema", + json_schema: { + name: textFormat.name ?? "response", + ...(textFormat.description !== undefined ? { description: textFormat.description } : {}), + ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}), + ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}), + }, + }; } } diff --git a/src/config.ts b/src/config.ts index 66162c6a6a..a5a75565ea 100644 --- a/src/config.ts +++ b/src/config.ts @@ -622,6 +622,9 @@ const providerConfigSchema = z.object({ noStructuredOutputModels: z.array(z.string().min(1)) .transform(normalizeNonBlankStringArray) .optional(), + noJsonSchemaModels: z.array(z.string().min(1)) + .transform(normalizeNonBlankStringArray) + .optional(), retainModels: z.array(z.string().min(1)) .transform(normalizeNonBlankStringArray) .optional(), @@ -1612,6 +1615,17 @@ const configSchema = z.object({ message: structuredOutputOptOutError, }); } + const jsonSchemaOptOutError = nonBlankStringArrayConfigError( + (provider as { noJsonSchemaModels?: unknown }).noJsonSchemaModels, + "noJsonSchemaModels", + ); + if (jsonSchemaOptOutError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "noJsonSchemaModels"], + message: jsonSchemaOptOutError, + }); + } const retainModelsError = nonBlankStringArrayConfigError( (provider as { retainModels?: unknown }).retainModels, "retainModels", diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 7f08485bd3..f9cbcc598e 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -321,6 +321,12 @@ export interface ProviderRegistryEntry { noTemperatureModels?: string[]; noTopPModels?: string[]; noPenaltyModels?: string[]; + /** + * Registry-only seed for `OcxProviderConfig.noJsonSchemaModels`. Merged into the + * resolved provider at route time rather than persisted as user config, the same way + * `directReasoningEffortModels` above is registry-owned. + */ + noJsonSchemaModels?: string[]; /** Opt this provider into parallel tool calls (see OcxProviderConfig.parallelToolCalls). */ parallelToolCalls?: boolean; /** Opt this provider into forwarding prompt_cache_key (OpenAI-specific; strict backends reject it). */ @@ -1770,7 +1776,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, true])), }, thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS, - thinkingBudgetModels: THINKING_BUDGET_MODELS, + /* + * The Go-specific list, not the shared one. The shared `THINKING_BUDGET_MODELS` also + * carries Neuralwatt-only ids (`qwen3.5-397b`, `qwen3.6-35b`) that this preset never + * gives a ladder to, so a live roster serving one of them armed the thinking-budget + * wire path with nothing to advertise: the catalog showed no effort control while the + * adapter still translated effort into `thinking_budget`. + */ + thinkingBudgetModels: OPENCODE_GO_THINKING_BUDGET_MODELS, noReasoningModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], // Text-only Zen Go models (jawcode metadata) — the vision sidecar describes images for // every model listed here (and the catalog advertises image input on their behalf). @@ -1788,6 +1801,16 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], // Issue #78: DeepSeek V4 thinking mode requires reasoning_content replay on tool-call turns. preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...DEEPSEEK_THINKING_MODELS], + /* + * Issues #1338 / #1415: this gateway answers a `response_format` of type + * `json_schema` with HTTP 400 `This response_format type is unavailable now` + * (quoted from the upstream body as `Error from provider (Console Go)`), which + * breaks every Codex auto-review turn on a DeepSeek route. #1424 shipped the + * operator-side opt-out; operators have been applying it by hand ever since. + * The reported rejection is type-specific, so this narrower list downgrades the + * request to `json_object` instead of claiming the whole field is unavailable. + */ + noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS], }, { id: "neuralwatt", @@ -3043,6 +3066,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], }, noVisionModels: [...OPENCODE_ZEN_TEXT_ONLY_MODELS, ...DEEPSEEK_THINKING_MODELS], + // Same DeepSeek routes as the Go preset above, behind the same vendor, so they carry + // the same json_schema rejection (#1338 / #1415). + noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], }, { id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" }, { @@ -3083,6 +3109,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Same Zen roster behind the same base URL, so it carries the same measured // text-only list rather than only its DeepSeek member (#1043). noVisionModels: OPENCODE_ZEN_TEXT_ONLY_MODELS, + // Same reasoning: the free tier is the same Zen roster, so its DeepSeek members get + // the keyed tier's json_schema treatment and its reasoning contract rather than a + // narrower table that silently falls behind whenever the keyed one is updated. + noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], }, { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" }, // Xiaomi's public OpenAI-compatible endpoint is a distinct transport from both the Anthropic diff --git a/src/router.ts b/src/router.ts index 8528370efb..c70a438fbe 100644 --- a/src/router.ts +++ b/src/router.ts @@ -349,6 +349,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider const noTemperatureModels = mergeStringArray(registryEntry.noTemperatureModels, provider.noTemperatureModels); const noTopPModels = mergeStringArray(registryEntry.noTopPModels, provider.noTopPModels); const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels); + const noJsonSchemaModels = mergeStringArray(registryEntry.noJsonSchemaModels, provider.noJsonSchemaModels); const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels); const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels); const requiresReasoningPlaceholderModels = mergeStringArray(registryEntry.requiresReasoningPlaceholderModels, provider.requiresReasoningPlaceholderModels); @@ -473,6 +474,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(noTemperatureModels ? { noTemperatureModels } : {}), ...(noTopPModels ? { noTopPModels } : {}), ...(noPenaltyModels ? { noPenaltyModels } : {}), + ...(noJsonSchemaModels ? { noJsonSchemaModels } : {}), ...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}), ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}), ...(requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels } : {}), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 8b845c8460..cad0acbd33 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -712,6 +712,11 @@ export function providerManagementConfigError(name: unknown, provider: unknown): "noStructuredOutputModels", ); if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`; + const jsonSchemaOptOutError = nonBlankStringArrayConfigError( + raw.noJsonSchemaModels, + "noJsonSchemaModels", + ); + if (jsonSchemaOptOutError) return `provider ${name} ${jsonSchemaOptOutError}`; const retainModelsError = nonBlankStringArrayConfigError(raw.retainModels, "retainModels"); if (retainModelsError) return `provider ${name} ${retainModelsError}`; const toolReasoningOptOutError = nonBlankStringArrayConfigError( @@ -866,6 +871,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { noTopPModels: "editor", noPenaltyModels: "editor", noStructuredOutputModels: "editor", + noJsonSchemaModels: "editor", omitReasoningEffortWithToolsModels: "editor", parallelToolCalls: "editor", pinParallelToolCallsFalse: "editor", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 1439d7899c..d2cb40291a 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -573,6 +573,19 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "noJsonSchemaModels")) { + const value = rawBody.noJsonSchemaModels; + if (value === null) { + delete next.noJsonSchemaModels; + } else { + const error = nonBlankStringArrayConfigError(value, "noJsonSchemaModels"); + if (error) return { error }; + const models = normalizeNonBlankStringArray(value as string[]); + if (models.length > 0) next.noJsonSchemaModels = models; + else delete next.noJsonSchemaModels; + } + touched = true; + } if (Object.hasOwn(rawBody, "retainModels")) { const value = rawBody.retainModels; if (value === null) { @@ -730,6 +743,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { }); }); + // Narrower neighbour of the kill switch: the upstream rejects the json_schema TYPE, so the + // request is downgraded to json_object rather than stripped. Both wires must agree. + describe("json_schema downgrade for noJsonSchemaModels", () => { + const schemaFormat = { + type: "json_schema", + json_schema: { name: "answer", schema: { type: "object" }, strict: true }, + }; + const passthrough = ( + modelId: string, + providerOverrides: Partial, + responseFormat: unknown = schemaFormat, + ) => JSON.parse(buildOpenAIChatPassthroughRequest( + provider(providerOverrides), + { messages: [{ role: "user", content: "hi" }], response_format: responseFormat }, + modelId, + false, + ).body as string) as Record; + + test("downgrades json_schema to json_object on the native wire", () => { + expect(passthrough("test-model", { noJsonSchemaModels: ["test-model"] }).response_format) + .toEqual({ type: "json_object" }); + }); + + test("leaves a json_object request untouched", () => { + expect(passthrough("test-model", { noJsonSchemaModels: ["test-model"] }, { type: "json_object" }).response_format) + .toEqual({ type: "json_object" }); + }); + + test("keeps the schema for a :tag sibling the operator never listed", () => { + expect(passthrough("test-model:structured", { noJsonSchemaModels: ["test-model"] }).response_format) + .toEqual(schemaFormat); + }); + + test("the full kill switch still wins when a model is on both lists", () => { + expect(passthrough("test-model", { + noJsonSchemaModels: ["test-model"], + noStructuredOutputModels: ["test-model"], + }).response_format).toBeUndefined(); + }); + + test("the translated wire downgrades the same request", () => { + const built = createOpenAIChatAdapter(provider({ noJsonSchemaModels: ["test-model"] })).buildRequest({ + ...parsed(), + options: { textFormat: { type: "json_schema", name: "answer", schema: { type: "object" }, strict: true } }, + }); + expect(bodyOf(built).response_format).toEqual({ type: "json_object" }); + }); + }); + // Tool-call deltas are BUFFERED until a terminal signal, so this adapter can consume upstream // frames for a long time while yielding nothing downstream. The Responses bridge arms its // stall watchdog on ADAPTER activity, not socket activity, so a model streaming a large diff --git a/tests/providers/opencode-go-deepseek.test.ts b/tests/providers/opencode-go-deepseek.test.ts index edf42a4815..890ec0a92d 100644 --- a/tests/providers/opencode-go-deepseek.test.ts +++ b/tests/providers/opencode-go-deepseek.test.ts @@ -130,3 +130,60 @@ describe("opencode-go DeepSeek V4 thinking mode", () => { expect(body.messages[1]).toHaveProperty("tool_calls"); }); }); + +/* + * Issues #1338 / #1415: the Zen Go upstream answers a `json_schema` response_format with + * HTTP 400 "This response_format type is unavailable now" on its DeepSeek routes, which + * kills every Codex auto-review turn there. The preset now carries that fact, so the + * request is downgraded to `json_object` instead of the operator having to disable + * structured output by hand. + */ +describe("opencode-go DeepSeek json_schema downgrade", () => { + const buildWith = (modelId: string, extra: Record = {}) => { + const config = configFor(modelId); + Object.assign(config.providers["opencode-go"], extra); + const route = routeModel(config, `opencode-go/${modelId}`); + const req = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, + options: { textFormat: { type: "json_schema", name: "review", schema: { type: "object" }, strict: true } }, + stream: false, + }); + return { + provider: route.provider, + body: JSON.parse(req.body as string) as { response_format?: { type?: string } }, + }; + }; + + test("the preset reaches the routed provider", () => { + expect(buildWith("deepseek-v4-flash").provider.noJsonSchemaModels) + .toEqual(["deepseek-v4-pro", "deepseek-v4-flash"]); + }); + + test("a listed DeepSeek route is downgraded to json_object", () => { + expect(buildWith("deepseek-v4-flash").body.response_format).toEqual({ type: "json_object" }); + expect(buildWith("deepseek-v4-pro").body.response_format).toEqual({ type: "json_object" }); + }); + + test("an unlisted sibling on the same gateway keeps its schema", () => { + expect(buildWith("glm-5.3").body.response_format?.type).toBe("json_schema"); + }); + + test("the operator kill switch still wins over the downgrade", () => { + const { body } = buildWith("deepseek-v4-flash", { noStructuredOutputModels: ["deepseek-v4-flash"] }); + expect(body.response_format).toBeUndefined(); + }); + + test("a json_object request is left alone on a listed route", () => { + const config = configFor("deepseek-v4-flash"); + const route = routeModel(config, "opencode-go/deepseek-v4-flash"); + const req = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, + options: { textFormat: { type: "json_object" } }, + stream: false, + }); + expect((JSON.parse(req.body as string) as { response_format?: unknown }).response_format) + .toEqual({ type: "json_object" }); + }); +}); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 1dcc89ca1f..b476ce1036 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -79,6 +79,42 @@ describe("provider registry parity", () => { "qwen3.7-max", ]); expect(KEY_LOGIN_PROVIDERS["opencode-go"].noVisionModels).not.toContain("kimi-k2.7-code"); + // #1338 / #1415: the Zen gateway rejects json_schema on its DeepSeek routes. The three + // presets that share that gateway carry the narrow opt-out as a registry-only seed, so + // an operator no longer has to disable structured output by hand. Registry-only means + // it is asserted here against the raw entry, not the derived key-login map. + const zenDeepseekJsonSchema: Record = { + "opencode-go": ["deepseek-v4-pro", "deepseek-v4-flash"], + "opencode-zen": ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-free"], + "opencode-free": ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-free"], + }; + for (const [id, expected] of Object.entries(zenDeepseekJsonSchema)) { + expect(PROVIDER_REGISTRY.find(entry => entry.id === id)?.noJsonSchemaModels).toEqual(expected); + } + // A model can only be gated onto the thinking-budget or thinking-toggle wire if the same + // preset also gives it an effort ladder — otherwise the adapter translates effort into a + // wire field for a model whose picker is empty. opencode-go carried the shared budget list + // while seeding only its own four ladders, so a live roster serving qwen3.5-397b armed the + // budget path with nothing to advertise. + for (const id of ["opencode-go", "opencode-zen", "opencode-free"]) { + const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === id); + const ladders = Object.keys(entry?.modelReasoningEfforts ?? {}); + const gated = [...entry?.thinkingBudgetModels ?? [], ...entry?.thinkingToggleModels ?? []]; + expect({ id, ungated: gated.filter(model => !ladders.includes(model)) }) + .toEqual({ id, ungated: [] }); + } + // Issue #78 / #950: a DeepSeek route that advertises a thinking ladder must also replay + // reasoning_content on tool-call continuations, or the gateway answers 400 on the second + // turn. The three Zen presets seed those two tables by hand, so this pins the pairing + // instead of trusting that whoever adds the next route remembers both. + for (const id of ["opencode-go", "opencode-zen", "opencode-free"]) { + const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === id); + const replayed = entry?.preserveReasoningContentModels ?? []; + const thinkingDeepseek = Object.keys(entry?.modelReasoningEfforts ?? {}) + .filter(model => model.startsWith("deepseek-")); + expect({ id, unreplayed: thinkingDeepseek.filter(model => !replayed.includes(model)) }) + .toEqual({ id, unreplayed: [] }); + } expect(KEY_LOGIN_PROVIDERS.mimo.noVisionModels).toEqual(["mimo-v2.5-pro"]); expect(KEY_LOGIN_PROVIDERS.mimo.noVisionModels).not.toContain("mimo-v2.5"); expect(KEY_LOGIN_PROVIDERS["opencode-go"]).toMatchObject({ From 5fb620353ffc1af67ef2ebe13c271d6aee36e3fb Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 21:54:45 +0900 Subject: [PATCH 026/231] docs(devlog): fix the branch A diff so the extension cannot strand the stream finalizeAfterDrain reaches finalizeTurnEvents which sets state.terminated, and returns [] for a terminated state, so draining before re-arming would leave the retry returning early without cancelCursorRun. Mirror its guards instead of calling it. --- .../030_phase3_landing.md | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md index 1c4b078db6..65cb9f5646 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md @@ -23,20 +23,27 @@ the client the turn is over. this.pendingFinalize = setTimeout(() => { this.pendingFinalize = undefined; if (this.expectedClose) return; - const terminal = finalizeAfterDrain(state); - if (terminal.length === 0) return; + // A suspended tool turn is the turn whose state we most want to resume from, + // and the one turn we cancelled before upstream could send it (#4245). Extend + // once, bounded, rather than raising the blanket grace: the common case stays + // at 50 ms and a stream that never sends a checkpoint still dies at a known + // deadline. -+ if (this.wantsCheckpointCapture -+ && !this.capturedCheckpointBytes -+ && !this.checkpointGraceExtended) { -+ this.checkpointGraceExtended = true; -+ this.scheduleClientToolFinalize(state, push, CHECKPOINT_CAPTURE_GRACE_MS); -+ return; -+ } + + // This MUST run before finalizeAfterDrain(): that call reaches + + // finalizeTurnEvents(), which sets state.terminated = true, and + + // finalizeAfterDrain() returns [] for a terminated state. Draining first and + + // then re-arming would make the retry return [] at the length check and leave + + // the stream uncancelled. So mirror its two guards here instead of calling it. + + if (!state.terminated + + && state.openToolCalls.size === 0 + + && this.wantsCheckpointCapture + + && !this.capturedCheckpointBytes + + && !this.checkpointGraceExtended) { + + this.checkpointGraceExtended = true; + + this.scheduleClientToolFinalize(state, push, CHECKPOINT_CAPTURE_GRACE_MS); + + return; + + } ++ const terminal = finalizeAfterDrain(state); ++ if (terminal.length === 0) return; for (const event of terminal) push(event); debugProviderDiagnostic("cursor", "client-tool-suspend", { reason: "Responses bridge owns client tools; ending turn without fake mcpResult", @@ -63,6 +70,12 @@ applied (:643, next to `activeClientToolFinalizeGraceMs`) from The added `graceMs` field also repays wp2's instrumentation debt: after this lands, the NEVER verdict 010 could not reach becomes measurable from shipped diagnostics. +**Termination.** `checkpointGraceExtended` is set before the re-arm, so at most one +extension happens per turn; the second pass falls through to `finalizeAfterDrain` and +cancels. A sibling tool call reopening `openToolCalls` during the window is handled by +the `size === 0` guard, which also stops the one extension from being spent on a turn +that was not actually drained. + ### What branch A is deliberately not doing The obvious companion edit — dropping `isCursorExternalWireModel` from From 089ed2904846eb39c122e59ea9e5a18961cfff8a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 21:55:49 +0900 Subject: [PATCH 027/231] docs(devlog): show the moved drain lines as removals so the branch A diff applies cleanly --- .../030_phase3_landing.md | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md index 65cb9f5646..fd1bbcb80b 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md @@ -22,26 +22,28 @@ the client the turn is over. this.clearPendingFinalize(); this.pendingFinalize = setTimeout(() => { this.pendingFinalize = undefined; - if (this.expectedClose) return; + if (this.expectedClose) return; +- const terminal = finalizeAfterDrain(state); +- if (terminal.length === 0) return; + // A suspended tool turn is the turn whose state we most want to resume from, + // and the one turn we cancelled before upstream could send it (#4245). Extend + // once, bounded, rather than raising the blanket grace: the common case stays + // at 50 ms and a stream that never sends a checkpoint still dies at a known + // deadline. - + // This MUST run before finalizeAfterDrain(): that call reaches - + // finalizeTurnEvents(), which sets state.terminated = true, and - + // finalizeAfterDrain() returns [] for a terminated state. Draining first and - + // then re-arming would make the retry return [] at the length check and leave - + // the stream uncancelled. So mirror its two guards here instead of calling it. - + if (!state.terminated - + && state.openToolCalls.size === 0 - + && this.wantsCheckpointCapture - + && !this.capturedCheckpointBytes - + && !this.checkpointGraceExtended) { - + this.checkpointGraceExtended = true; - + this.scheduleClientToolFinalize(state, push, CHECKPOINT_CAPTURE_GRACE_MS); - + return; - + } ++ // This MUST run before finalizeAfterDrain(): that call reaches ++ // finalizeTurnEvents(), which sets state.terminated = true, and ++ // finalizeAfterDrain() returns [] for a terminated state. Draining first and ++ // then re-arming would make the retry return [] at the length check and leave ++ // the stream uncancelled. So mirror its two guards here instead of calling it. ++ if (!state.terminated ++ && state.openToolCalls.size === 0 ++ && this.wantsCheckpointCapture ++ && !this.capturedCheckpointBytes ++ && !this.checkpointGraceExtended) { ++ this.checkpointGraceExtended = true; ++ this.scheduleClientToolFinalize(state, push, CHECKPOINT_CAPTURE_GRACE_MS); ++ return; ++ } + const terminal = finalizeAfterDrain(state); + if (terminal.length === 0) return; for (const event of terminal) push(event); From 0b158668fc77938f5ff5b0c1de91eeaad1dab568 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 21:58:26 +0900 Subject: [PATCH 028/231] docs(devlog): record the frame-level evidence behind the cursor checkpoint claim 000_plan.md rests on the absence of conversationCheckpointUpdate among a tool turn frames. Record the actual 33-frame sequence, and state plainly that the 7ms window between suspend and cancel makes this an absence of opportunity rather than evidence of absence. --- .../001_frame_evidence.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 devlog/_plan/260911_cursor_checkpoint_capture/001_frame_evidence.md diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/001_frame_evidence.md b/devlog/_plan/260911_cursor_checkpoint_capture/001_frame_evidence.md new file mode 100644 index 0000000000..2a4f4c26e5 --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/001_frame_evidence.md @@ -0,0 +1,60 @@ +# Frame-level record of one suspended tool turn + +Research material for `000_plan.md`. No diffs here. + +`000_plan.md` asserts that no `conversationCheckpointUpdate` appears among the frames +of a client-tool turn. That claim carries the whole unit — branch A exists only if the +frame is absent at 50 ms — so the sequence it rests on is recorded here rather than +left in a chat transcript. + +## Capture conditions + +macbookpro-2, macOS, opencodex 2.50.0, proxy PID 70500 on 127.0.0.1:10100, real Cursor +OAuth account. `ocx debug provider on` (runtime override, no restart). Request: +`POST /v1/chat/completions`, `model: cursor/auto-intelligence`, one `get_weather` tool, +`tool_choice: required`, no `parallel_tool_calls` — so the 50 ms base grace applied. +Response was HTTP 200 with `finish_reason: tool_calls` and +`prompt_tokens_details.cached_tokens: 0`. + +## Sequence + +``` +[ocx:cursor:connected] {"transport":"http2","connectMs":403} +[ocx:cursor:first-frame] {"latencyMs":630} +[ocx:cursor:frame] {"case":"interactionUpdate","update":"heartbeat"} +[ocx:cursor:frame] {"case":"kvServerMessage","kv":"getBlobArgs"} x2 +[ocx:cursor:frame] {"case":"kvServerMessage","kv":"setBlobArgs"} x4 +[ocx:cursor:frame] {"case":"interactionUpdate","update":"thinkingDelta"} +[ocx:cursor:frame] {"case":"interactionUpdate","update":"tokenDelta"} +[ocx:cursor:frame] {"case":"interactionUpdate","update":"thinkingDelta"} +[ocx:cursor:frame] {"case":"interactionUpdate","update":"tokenDelta"} +[ocx:cursor:frame] {"case":"interactionUpdate","update":"thinkingCompleted"} +[ocx:cursor:frame] {"case":"interactionUpdate","update":"textDelta"} interleaved with +[ocx:cursor:frame] {"case":"interactionUpdate","update":"tokenDelta"} x7 pairs +[ocx:cursor:frame] {"case":"interactionUpdate","update":"partialToolCall","toolCase":"mcpToolCall","callId":"call-d4f5fcfc-...-0"} +[ocx:cursor:frame] {"case":"interactionUpdate","update":"tokenDelta"} +[ocx:cursor:frame] {"case":"interactionUpdate","update":"toolCallStarted","toolCase":"mcpToolCall","callId":"call-d4f5fcfc-...-0"} +[ocx:cursor:frame] {"case":"execServerMessage","exec":"mcpArgs"} +[ocx:cursor:frame] {"case":"kvServerMessage","kv":"setBlobArgs"} x4 +[ocx:cursor:client-tool-suspend] {"reason":"Responses bridge owns client tools; ending turn without fake mcpResult","framesReceived":33,"elapsedMs":2886} +[ocx:cursor:stream-end] {"committed":true,"framesReceived":33,"expectedClose":true,"elapsedMs":2886} +[ocx:cursor:checkpoint-commit-refused] {"replayUnsafe":false,"emittedClientTool":true,"capturedAfterClientTool":false,"externalModel":false,"storeCheckpoints":true,"capturedBytes":0} +[ocx:cursor:stream-cancel-expected] {"message":"Cursor upstream error: Cursor request was aborted","framesReceived":33,"elapsedMs":2887} +[ocx:cursor:stream-cancel-expected] {"code":"ERR_HTTP2_STREAM_ERROR","message":"Cursor stream suspended: Stream closed with error code NGHTTP2_CANCEL","framesReceived":33,"elapsedMs":2893} +``` + +## What the sequence establishes, and what it does not + +Establishes: across all 33 decoded frames there is no `conversationCheckpointUpdate`, +and the refusal that follows is over-determined — `capturedBytes: 0` fires regardless +of the model gate one line above it. + +Does **not** establish that upstream never sends one. The stream was cancelled 7 ms +after the suspend (2886 to 2893), so the observation window closes immediately. This is +precisely why `010` can only return a positive; an absence here is an absence of +opportunity, not evidence of absence. + +The last four `setBlobArgs` frames arriving after `toolCallStarted` are worth noting: +upstream was still writing blob state when the cancel landed. That is consistent with +the late-arrival hypothesis, and consistent with the frame simply not existing for a +suspended turn. It does not discriminate between them. From 6d890ddbb1e2490f5984d60eab502090de720ef6 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 22:00:11 +0900 Subject: [PATCH 029/231] docs(structure): answer the independent verification pass A second review, run separately from the agent that wrote the previous commit, found two ways the gate was still wider than its prose and one it had newly opened. Deriving the top-level path set from the tracked tree meant a reference became INVISIBLE exactly when its directory disappeared. go/ is the live case: retired and untracked, so every remaining go/ mention had silently stopped being checked, and the absentPaths exemption that documents its absence had become unreachable. The set now unions in the roots this repository has or used to have. The mention-backed ownership check accepted the area name itself, which a table of directory names satisfies - 32 of 105 claims rested on exactly that, including nearly every claim in runtime.md. The check is unchanged; the prose is, because the honest description is that it catches an invented claim and does not prove the doc says anything useful. The manifest loader validated that grace arrays were arrays, never their elements, so a bare string where an object belongs still threw a TypeError from inside the checks. Element shapes are validated now. Source-area enumeration read the filesystem while every path resolved through the index, so an untracked scratch directory under src/ produced a failure CI could not reproduce; it reads the index too. Decision-record ownership matched on basename, so a link outside decisions/ could claim a record it did not point at. Also corrected in the rules file: the decision-log check finds two literal markers, not all inline reasoning - the rationale moved out of registry.md last commit contained neither, which is the proof; root files are checked directly rather than only through links; and the index comparison normalises line endings rather than being byte-for-byte. Five new cases, two of which build a real git repository, because every prior negative case ran in a plain temp directory where the index branch - the one the module argues hardest for - was never reached. One drives the case-variant verdict, one rejects an untracked leftover. --- scripts/structure-ssot.ts | 78 +++++++++++++++++++---- structure/AGENTS.md | 31 ++++++--- tests/ci-workflows/structure-ssot.test.ts | 69 +++++++++++++++++++- 3 files changed, 155 insertions(+), 23 deletions(-) diff --git a/scripts/structure-ssot.ts b/scripts/structure-ssot.ts index b1d832ee7d..ddc61e0994 100644 --- a/scripts/structure-ssot.ts +++ b/scripts/structure-ssot.ts @@ -45,6 +45,13 @@ export type Manifest = { const GENERATED_DOCS = ["INDEX.md"]; const RULE_DOCS = ["AGENTS.md"]; +/** + * Roots that stay checked even after they are deleted. Deriving the root set from the tree alone + * means a reference becomes INVISIBLE exactly when the directory disappears, which is the moment + * stale references start appearing. go/ is the live example: it is retired and untracked, and + * without this list every remaining go/ mention would go unchecked. + */ +const HISTORICAL_ROOTS = ["src", "tests", "gui", "scripts", "docs", "docs-site", "bin", "go", "devlog", ".github", "structure", "readme"]; const toPosix = (p: string) => p.split("\\").join("/"); const trimSlash = (p: string) => p.replace(/\/+$/, ""); @@ -63,6 +70,12 @@ export function loadManifest(raw: string): { manifest: Manifest } | { error: str if (typeof m.sizeBudgetLines !== "number") problems.push("sizeBudgetLines must be a number"); if (!isArray(m.generatedPaths)) problems.push("generatedPaths must be an array"); if (!isArray(m.absentPaths)) problems.push("absentPaths must be an array"); + else { + m.absentPaths.forEach((entry, i) => { + if (typeof entry?.path !== "string") problems.push("absentPaths[" + i + "].path must be a string"); + if (typeof entry?.reason !== "string") problems.push("absentPaths[" + i + "].reason must be a string"); + }); + } if (!isArray(m.tiers)) problems.push("tiers must be an array"); if (!isArray(m.docs)) problems.push("docs must be an array"); else { @@ -80,6 +93,24 @@ export function loadManifest(raw: string): { manifest: Manifest } | { error: str for (const key of ["undocumentedSourceAreas", "unboundInvariants", "oversizeDocs", "staleRefs"] as const) { if (!isArray(grace[key])) problems.push("grace." + key + " must be an array"); } + if (isArray(grace.undocumentedSourceAreas)) { + grace.undocumentedSourceAreas.forEach((entry, i) => { + if (typeof entry?.path !== "string") problems.push("grace.undocumentedSourceAreas[" + i + "].path must be a string"); + if (typeof entry?.reason !== "string") problems.push("grace.undocumentedSourceAreas[" + i + "].reason must be a string"); + }); + } + if (isArray(grace.unboundInvariants)) { + grace.unboundInvariants.forEach((entry, i) => { + if (typeof entry?.id !== "string") problems.push("grace.unboundInvariants[" + i + "].id must be a string"); + if (typeof entry?.reason !== "string") problems.push("grace.unboundInvariants[" + i + "].reason must be a string"); + }); + } + for (const key of ["oversizeDocs", "staleRefs"] as const) { + if (!isArray(grace[key])) continue; + grace[key].forEach((entry, i) => { + if (typeof entry !== "string") problems.push("grace." + key + "[" + i + "] must be a string"); + }); + } } if (problems.length > 0) return { error: "structure/manifest.json is malformed: " + problems.join("; ") }; return { manifest: parsed as Manifest }; @@ -239,8 +270,9 @@ export function runStructureChecks(repoRoot: string): string[] { return "missing"; }; const isTracked = (raw: string) => tracked?.has(trimSlash(raw)) ?? existsSync(join(repoRoot, trimSlash(raw))); - // Top-level tracked entries, so a backticked root file is validated like a directory path is. - const rootEntries = new Set(); + // Top-level entries, so a backticked root FILE is validated directly, the same as a directory + // path. The historical roots are unioned in so a deleted tree keeps being checked. + const rootEntries = new Set(HISTORICAL_ROOTS); if (tracked) for (const p of tracked) rootEntries.add(p.split("/")[0]!); else for (const entry of readdirSync(repoRoot, { withFileTypes: true })) rootEntries.add(entry.name); @@ -358,8 +390,15 @@ export function runStructureChecks(repoRoot: string): string[] { ownerLinkRe.lastIndex = 0; let hit: RegExpExecArray | null; while ((hit = ownerLinkRe.exec(body))) { - const file = hit[1].split("#")[0]!.split("/").pop()!; - referenced.set("decisions/" + file, (referenced.get("decisions/" + file) ?? new Set()).add(doc.path)); + const target = hit[1].split("#")[0]!; + const repoRel = toPosix(relative(structureDir, resolve(dirname(abs), target))); + // The link has to land in decisions/; a basename match would let a record elsewhere claim + // ownership of a file it does not point at. + if (!repoRel.startsWith("decisions/")) { + fail("structure/" + doc.path + " points a Decision record line at " + target + ", which is not in decisions/"); + continue; + } + referenced.set(repoRel, (referenced.get(repoRel) ?? new Set()).add(doc.path)); } } const ids = new Set(); @@ -458,7 +497,7 @@ export function runStructureChecks(repoRoot: string): string[] { else if (verdict !== "ok") fail("structure/" + doc.path + " claims " + area + ", but the tracked path is " + verdict); const names = namedByDoc.get(doc.path) ?? []; if (!names.some((n) => n === area || n === trimSlash(area) || n.startsWith(area))) { - fail("structure/" + doc.path + " claims " + area + " but never names a path in it; describing an area means citing one"); + fail("structure/" + doc.path + " claims " + area + " but never names it or a path in it"); } } } @@ -467,16 +506,29 @@ export function runStructureChecks(repoRoot: string): string[] { if (pathIsReal(g) !== "ok") fail("grace.undocumentedSourceAreas lists " + g + ", which this tree does not have"); if (described.has(g)) fail(g + " is both described and listed as undescribed"); } - const srcDir = join(repoRoot, "src"); - if (existsSync(srcDir)) { - for (const entry of readdirSync(srcDir, { withFileTypes: true })) { - const area = entry.isDirectory() ? "src/" + entry.name + "/" : "src/" + entry.name; - if (!entry.isDirectory() && !entry.name.endsWith(".ts")) continue; - // A claim on one file inside a directory does not cover the directory. - if (described.has(area) || graced.has(area)) continue; - fail(area + " is described by no doc; add it to a doc's " + BT + "documents" + BT + " list or record it in grace.undocumentedSourceAreas with a reason"); + // Enumerated from the index when it is readable, for the same reason paths are resolved there: + // an untracked scratch directory under src/ must not produce a failure CI cannot reproduce. + const srcAreas = new Set(); + if (tracked) { + for (const p of tracked) { + if (!p.startsWith("src/")) continue; + const rest = p.slice("src/".length); + const slash = rest.indexOf("/"); + if (slash === -1) { + if (rest.endsWith(".ts")) srcAreas.add("src/" + rest); + } else srcAreas.add("src/" + rest.slice(0, slash) + "/"); + } + } else if (existsSync(join(repoRoot, "src"))) { + for (const entry of readdirSync(join(repoRoot, "src"), { withFileTypes: true })) { + if (entry.isDirectory()) srcAreas.add("src/" + entry.name + "/"); + else if (entry.name.endsWith(".ts")) srcAreas.add("src/" + entry.name); } } + for (const area of [...srcAreas].sort()) { + // A claim on one file inside a directory does not cover the directory. + if (described.has(area) || graced.has(area)) continue; + fail(area + " is described by no doc; add it to a doc's " + BT + "documents" + BT + " list or record it in grace.undocumentedSourceAreas with a reason"); + } // 7. generated index parity const indexPath = join(structureDir, "INDEX.md"); diff --git a/structure/AGENTS.md b/structure/AGENTS.md index 3dfb9724da..bcab24189b 100644 --- a/structure/AGENTS.md +++ b/structure/AGENTS.md @@ -51,8 +51,10 @@ the inverse. - Describing an area means naming a path inside it. If a doc explains a subsystem without ever citing a path, the map cannot see it, and the area lands in `grace.undocumentedSourceAreas` instead — which is a signal to add the path reference, not a place to park work. - The gate enforces this in both directions: a `documents` entry whose doc never names a path inside - the area is rejected, so the map cannot claim coverage the prose does not have. + The gate checks the weak form of this: a `documents` entry is rejected when the doc never names the + area or any path under it. Naming the directory itself passes, which a table of directory names + does, so the check catches an invented claim but does not prove the doc says anything useful about + the area. That part is review. - A new `src//` or top-level `src/*.ts` either joins a doc's `documents` list or is recorded in `grace.undocumentedSourceAreas` with a reason. The gate rejects one that is neither. @@ -78,6 +80,8 @@ choice, why, and consequences. like a decision name but was not would send a maintainer to the wrong record. Read the body. - Ownership is the `> Decision record:` link, and nothing else. A record path mentioned in prose or shown inside a fenced example is not a claim, so an illustration cannot make a doc a second owner. + The link has to land inside `decisions/`; pointing it elsewhere is rejected rather than matched on + the filename. ## Invariants @@ -116,7 +120,10 @@ verifies that: the filesystem — `existsSync` cannot tell a tracked file from untracked local leftovers, and it is case-insensitive on Windows and case-sensitive on Linux CI, which would make the gate mean something different on each machine; -- no doc body carries inline decision-log reasoning; +- no doc body carries either inline decision-log marker: the bracketed Decision-Log heading that the + old layout used, or the Korean bullet template that followed it. Reasoning written as ordinary + prose is not detectable and stays a review judgement. The check is a literal match, which is why + this line describes the marker instead of quoting it; - every decision record is linked from exactly one doc, and no number is reused; - every bound invariant names an existing test that names the id back, and every unbound one is recorded with a reason; @@ -124,14 +131,20 @@ verifies that: - the manifest itself parses and has the shape the gate expects, reported as a failure rather than a stack trace; - `overview.md` exists, because its absence would otherwise silence every invariant check at once; -- `INDEX.md` matches the manifest byte for byte. +- `INDEX.md` matches what the manifest generates, compared after newline normalisation so a CRLF + checkout is not a failure. Checks are scanned with fenced code blocks removed, so an example inside a fence does not trip a rule it is only illustrating. One boundary worth stating, because it looks like a gap and is a deliberate one: a backticked token is -treated as a repository path only when it is rooted at a real top-level entry, such as `src/` or -`package.json`. A bare filename is not checked, because these docs name runtime files that are not in -the repository at all — `config.toml`, `models_cache.json`, `ocx.pid` — and validating every -filename-shaped token would reject them. Root documents are still covered where it matters, since a -reference like [`MAINTAINERS.md`](../MAINTAINERS.md) is a link, and links are checked. +treated as a repository path only when its first segment is a top-level entry this repository has or +used to have. That covers root files too, so `package.json` and `MAINTAINERS.md` are checked directly, +not only through the links that point at them. What is NOT checked is a bare filename that was never +a top-level entry, because these docs name runtime files that live in a user's home rather than in +the repository — `config.toml`, `models_cache.json`, `ocx.pid` — and validating every filename-shaped +token would reject them. + +The top-level set deliberately includes roots that no longer exist, such as `go/`. Deriving it from +the current tree alone would make every reference to a deleted directory invisible at exactly the +moment those references go stale. diff --git a/tests/ci-workflows/structure-ssot.test.ts b/tests/ci-workflows/structure-ssot.test.ts index 8a6bfdd308..4783842bca 100644 --- a/tests/ci-workflows/structure-ssot.test.ts +++ b/tests/ci-workflows/structure-ssot.test.ts @@ -86,6 +86,22 @@ const fires = (root: string, needle: string): void => { expect(runStructureChecks(root).join("\n")).toContain(needle); }; +/** + * The negative cases above run in a plain temp directory, where git has no index and the gate falls + * back to the filesystem. That leaves the index branch — the one the module argues hardest for — + * untested, so these cases build a real repository and drive it. + */ +function gitScaffold(): string { + const root = scaffold(); + expect(Bun.spawnSync(["git", "init", "-q"], { cwd: root }).exitCode).toBe(0); + stage(root); + return root; +} + +function stage(root: string): void { + expect(Bun.spawnSync(["git", "add", "-A"], { cwd: root }).exitCode).toBe(0); +} + describe("structure/ SSOT", () => { test("the maintainer docs still describe this tree", () => { expect(runStructureChecks(repoRoot())).toEqual([]); @@ -277,7 +293,7 @@ describe("structure/ SSOT", () => { const manifest = manifestOf(root); manifest.docs[0]!.documents.push("src/beta/"); saveManifest(root, manifest); - fires(root, "claims src/beta/ but never names a path in it"); + fires(root, "claims src/beta/ but never names it or a path in it"); }); test("a fragment-only link that names no heading in its own document", () => { @@ -334,4 +350,55 @@ describe("structure/ SSOT", () => { const root = repoRoot(); expect(readFileSync(join(root, "structure/INDEX.md"), "utf8").replace(/\r\n/g, "\n")).toBe(renderIndex(manifestOf(root))); }); + + test("a tracked tree is judged by the index: a case variant is named, not silently accepted", () => { + const root = gitScaffold(); + expect(runStructureChecks(root)).toEqual([]); + const body = readFileSync(join(root, "structure/overview.md"), "utf8").replace( + BT + "src/alpha/keep.ts" + BT, + BT + "src/Alpha/keep.ts" + BT, + ); + write(root, "structure/overview.md", body); + stage(root); + fires(root, "but the tracked path is src/alpha/keep.ts"); + }); + + test("a tracked tree rejects an untracked leftover that CI would never see", () => { + const root = gitScaffold(); + const body = readFileSync(join(root, "structure/overview.md"), "utf8"); + write(root, "src/alpha/scratch.ts", "export const scratch = 1;\n"); + write(root, "structure/overview.md", body + "\nAlso " + BT + "src/alpha/scratch.ts" + BT + ".\n"); + // overview.md is staged so the reference is read; scratch.ts deliberately is not. + expect(Bun.spawnSync(["git", "add", "structure/overview.md"], { cwd: root }).exitCode).toBe(0); + fires(root, "names src/alpha/scratch.ts, which this tree does not have"); + }); + + test("a record shown inside a fenced example is not a second owner", () => { + const root = scaffold(); + const manifest = manifestOf(root); + manifest.docs.push({ path: "second.md", tier: 1, title: "Second", scope: "s", documents: [] }); + const fence = BT.repeat(3); + write(root, "structure/second.md", "# Second\n\n" + fence + "text\n> Decision record: [ADR-0001](decisions/ADR-0001-alpha.md)\n" + fence + "\n"); + saveManifest(root, manifest); + expect(runStructureChecks(root)).toEqual([]); + }); + + test("a Decision record line pointing outside decisions/ is rejected", () => { + const root = scaffold(); + write(root, "structure/elsewhere.md", "# Elsewhere\n"); + const body = readFileSync(join(root, "structure/overview.md"), "utf8").replace( + "> Decision record: [ADR-0001](decisions/ADR-0001-alpha.md)", + "> Decision record: [ADR-0001](elsewhere.md)", + ); + write(root, "structure/overview.md", body); + fires(root, "which is not in decisions/"); + }); + + test("a malformed grace element is a failure line, not a thrown TypeError", () => { + const root = scaffold(); + const manifest = manifestOf(root) as unknown as { absentPaths: unknown[] }; + manifest.absentPaths = ["go/"]; + write(root, "structure/manifest.json", JSON.stringify(manifest, null, 2) + "\n"); + fires(root, "absentPaths[0].path must be a string"); + }); }); From 39feb41e4af34c134f74d32a7a3aa4a60ed5c2f1 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 22:00:55 +0900 Subject: [PATCH 030/231] docs(devlog): record wp2 result - the checkpoint frame arrives, 50ms is too short --- .../010_phase1_grace_experiment.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md b/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md index 1c5216b050..c7e4e07363 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md @@ -67,3 +67,43 @@ back INCONCLUSIVE: add `graceMs: this.activeClientToolFinalizeGraceMs` to the `client-tool-suspend` diagnostic payload, build on macbookpro-2 in a throwaway checkout, and rerun arm B. NEVER is then `capturedBytes: 0` with a logged `graceMs` of 1500. That instrumented arm is wp2b, appended only if needed. + +## Result — LATE + +Run 2026-09-11 on macbookpro-2, opencodex 2.50.0, same account and toggle as `001`. +Both arms used `cursor/auto-intelligence` and `tool_choice: required`. + +Arm A, 1 tool, no `parallel_tool_calls` (50 ms path): + +``` +[ocx:cursor:client-tool-suspend] {"framesReceived":33,"elapsedMs":3299} +[ocx:cursor:checkpoint-commit-refused] {"replayUnsafe":false,"emittedClientTool":true,"capturedAfterClientTool":false,"externalModel":false,"storeCheckpoints":true,"capturedBytes":0} +conversationCheckpointUpdate frames in window: 0 +``` + +Arm B, 12 tools, `parallel_tool_calls: true` (1500 ms path): + +``` +[ocx:cursor:frame] {"case":"conversationCheckpointUpdate","usedTokens":0} +[ocx:cursor:client-tool-suspend] {"framesReceived":34,"elapsedMs":4553} +[ocx:cursor:checkpoint-commit-refused] {"replayUnsafe":false,"emittedClientTool":true,"capturedAfterClientTool":true,"externalModel":false,"storeCheckpoints":true,"capturedBytes":2977} +conversationCheckpointUpdate frames in window: 1 +``` + +**LATE.** Upstream does send `conversationCheckpointUpdate` on a suspended client-tool +turn. At 50 ms the stream is cancelled before it lands; given a longer window the frame +arrives and 2977 bytes are captured. The positive is self-proving, so the `elapsedMs` +problem that made a NEVER unreachable never had to be solved. **wp2b is not needed.** + +### The second barrier, now visible for the first time + +Arm B also shows `capturedAfterClientTool: true` with `externalModel: false` — and it +*still* refused. With bytes finally present, `toolSuspendedCommit` fails on the wire-model +test alone. So the two barriers are now separated by evidence rather than by argument: + +1. capture never happened (all models) — fixed by `030` branch A1; +2. the native wire-model gate — reachable only after A1, and still gated on wp5 + proving the snapshot covers the tool call. + +The original triage proposed removing barrier 2 while barrier 1 made it unreachable. +That is exactly what the probe was built to distinguish, and it did. From aeee4505243f7870f08167d549051d6e454da0fb Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 22:10:16 +0900 Subject: [PATCH 031/231] docs(devlog): de-confound the wp2 LATE result by holding the wire constant parallel_tool_calls is local-only and never protobuf-encoded, so three arms at 12 tools isolate the finalize grace. 50ms yields no checkpoint frame; 1500ms yields one and 2742 captured bytes; reproduced both directions. --- .../011_wp2_deconfound.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 devlog/_plan/260911_cursor_checkpoint_capture/011_wp2_deconfound.md diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/011_wp2_deconfound.md b/devlog/_plan/260911_cursor_checkpoint_capture/011_wp2_deconfound.md new file mode 100644 index 0000000000..de7e8bcd09 --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/011_wp2_deconfound.md @@ -0,0 +1,44 @@ +# wp2 — de-confounding the LATE result + +The first LATE run varied two things at once and a reviewer caught it. This records the +correction, because the correction is the part worth keeping. + +## The confound + +Arm B raised the finalize grace by sending `parallel_tool_calls: true` with 12 tools. +But 12 tools is not only a local signal: `buildCursorToolDefinitions` puts them in +`AgentRunRequest.mcpTools` (`protobuf-request.ts:1607`, `:1711-1712`) and the catalog is +named in the system note (`:197-201`). A larger catalog could plausibly change Cursor's +own context accounting and make it emit a `conversationCheckpointUpdate` for reasons +that have nothing to do with how long we waited. + +So the original pair could not tell "we waited longer" from "we asked for more tools". + +## The correction + +Hold the wire constant, vary only the local knob. `parallelToolCalls` is read at +`live-transport.ts:423` and `:689` and is never protobuf-encoded +(`protobuf-request.ts:1736`), so `parallel_tool_calls` changes the grace and nothing +upstream. Three arms, 12 tools in every one: + +| Arm | `parallel_tool_calls` | Grace | `conversationCheckpointUpdate` | `capturedBytes` | +|---|---|---|---|---| +| C | false | 50 ms | 0 | 0 | +| B | true | 1500 ms | 1 | 2742 | +| C repeat | false | 50 ms | 0 | 0 | + +``` +C [ocx:cursor:checkpoint-commit-refused] {"emittedClientTool":true,"capturedAfterClientTool":false,"externalModel":false,"storeCheckpoints":true,"capturedBytes":0} +B [ocx:cursor:checkpoint-commit-refused] {"emittedClientTool":true,"capturedAfterClientTool":true,"externalModel":false,"storeCheckpoints":true,"capturedBytes":2742} +C' [ocx:cursor:checkpoint-commit-refused] {"emittedClientTool":true,"capturedAfterClientTool":false,"externalModel":false,"storeCheckpoints":true,"capturedBytes":0} +``` + +Identical request bytes, opposite outcomes, reproduced in both directions within one +session. **LATE is isolated: the 50 ms finalize grace is the cause.** + +## Why this is recorded rather than folded silently + +Three times in this unit a plausible mechanism was asserted before the field it rested +on was checked — the native/external gate, `elapsedMs`, and now the tool catalog. Each +was caught by looking at what the value actually is rather than what it was assumed to +mean. The pattern is the finding. From 4a405e9887088e3c5d04a389fdc0f2d1fe2de95d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 22:18:57 +0900 Subject: [PATCH 032/231] docs(devlog): record the log-buffer artifact and the corrected wp4 measurement ocx debug provider logs is a 500-line ring buffer, so line-count windowing silently returned zero for every arm once it filled. Reading a fixed tail restores the signal: 1500ms captures a post-toolCallStarted checkpoint of 3036 bytes, 50ms captures nothing. wp2 LATE stands; the usedTokens:0 pre-tool hypothesis is refuted by arrival order. --- .../012_wp4_measurement_artifact.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 devlog/_plan/260911_cursor_checkpoint_capture/012_wp4_measurement_artifact.md diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/012_wp4_measurement_artifact.md b/devlog/_plan/260911_cursor_checkpoint_capture/012_wp4_measurement_artifact.md new file mode 100644 index 0000000000..46617d7c55 --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/012_wp4_measurement_artifact.md @@ -0,0 +1,69 @@ +# wp4 — a measurement artifact that nearly reversed the verdict + +While sizing `CHECKPOINT_CAPTURE_GRACE_MS`, a batch of runs returned 0 checkpoint +frames for **every** arm, including the 12-tool 1500 ms condition that had just +produced a frame twice. Taken at face value that reverses wp2. + +It was an instrumentation bug in the probe, not a behaviour change. + +## The artifact + +The probes windowed the log by line count: record `L = ocx debug provider logs | wc -l` +before a request, then read `tail -n +$((L+1))` after. `ocx debug provider logs` is a +**bounded ring buffer** — measured at exactly 500 lines on this machine. Once the buffer +is full, `L` equals the cap and every later `tail -n +501` returns nothing. Every arm +then reports zero, uniformly and convincingly. + +Reading `tail -50` after each request instead of a computed offset restores the signal +immediately. + +This is the fourth time in this unit a conclusion rested on a field that did not mean +what it appeared to mean. The others were the native/external gate, `elapsedMs`, and +the tool catalog. It is worth saying plainly: **the failure mode of this investigation +is not bad reasoning about the adapter, it is trusting an observable without checking +what produces it.** + +## Corrected measurement + +Tool turn, `parallel_tool_calls: true`, 12 tools (1500 ms): + +``` +[ocx:cursor:frame] {"case":"interactionUpdate","update":"toolCallStarted",...} +[ocx:cursor:frame] {"case":"conversationCheckpointUpdate","usedTokens":0} +[ocx:cursor:client-tool-suspend] {"framesReceived":33,"elapsedMs":4488} +[ocx:cursor:checkpoint-commit-refused] {"emittedClientTool":true,"capturedAfterClientTool":true,"externalModel":false,"storeCheckpoints":true,"capturedBytes":3036} +``` + +Tool turn, same 12 tools, `parallel_tool_calls` absent (50 ms): + +``` +[ocx:cursor:frame] {"case":"interactionUpdate","update":"toolCallStarted",...} +[ocx:cursor:client-tool-suspend] {"framesReceived":32,"elapsedMs":2827} +[ocx:cursor:checkpoint-commit-refused] {"emittedClientTool":true,"capturedAfterClientTool":false,"externalModel":false,"storeCheckpoints":true,"capturedBytes":0} +``` + +Plain turn, for shape comparison: + +``` +[ocx:cursor:frame] {"case":"conversationCheckpointUpdate","usedTokens":0} +[ocx:cursor:frame] {"case":"conversationCheckpointUpdate","usedTokens":12037} +[ocx:cursor:frame] {"case":"interactionUpdate","update":"stepCompleted"} +[ocx:cursor:frame] {"case":"conversationCheckpointUpdate","usedTokens":12037} +[ocx:cursor:frame] {"case":"interactionUpdate","update":"turnEnded"} +[ocx:cursor:checkpoint-continuation] {"checkpointBytes":492,"wireModel":"default"} +``` + +**wp2's LATE verdict stands.** The frame arrives strictly after `toolCallStarted` and is +cancelled away at 50 ms. + +## One hypothesis raised and discarded here + +Mid-investigation the `usedTokens: 0` on the tool-turn checkpoint was read as evidence +that it is an early, pre-tool snapshot, which would have made branch A actively unsafe. +The ordering above refutes that: the frame arrives **after** `toolCallStarted` within the +same turn, and carries 3036 bytes against the 492 a plain turn commits. + +`usedTokens: 0` therefore looks like an unpopulated field on this update, not an empty +snapshot. That is a reading, not a proof — and it is exactly the kind of reading this +unit keeps getting wrong. **wp5 still owns the question of whether those 3036 bytes +cover the tool call, and branch A2 stays gated behind it.** From b0707a86ed714fffb0529f79ecc92368c89df20f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 22:20:32 +0900 Subject: [PATCH 033/231] docs(devlog): size the capture grace and add the early-fire refinement to branch A --- .../030_phase3_landing.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md index fd1bbcb80b..574f22af51 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md @@ -78,6 +78,87 @@ cancels. A sibling tool call reopening `openToolCalls` during the window is hand the `size === 0` guard, which also stops the one extension from being spent on a turn that was not actually drained. +### A1b — fire early when the frame lands (required, not optional) + +A1 alone makes every suspended tool turn pay the full extension, including the turns +that were never going to send a checkpoint. Measured arrival is well inside the window, +so waiting out the remainder is pure added latency on the tool path. + +Make the timer body reusable and let the capture site run it immediately: + +```diff + private pendingFinalize?: ReturnType; ++ private pendingFinalizeRun?: () => void; ++ private checkpointGraceExtended = false; ++ private wantsCheckpointCapture = false; +``` + +`scheduleClientToolFinalize` stores the callback instead of inlining it: + +```diff + this.clearPendingFinalize(); +- this.pendingFinalize = setTimeout(() => { ++ const run = (): void => { + ... body from A1 ... +- }, graceMsOverride ?? this.activeClientToolFinalizeGraceMs); ++ }; ++ this.pendingFinalizeRun = run; ++ this.pendingFinalize = setTimeout(run, graceMsOverride ?? this.activeClientToolFinalizeGraceMs); +``` + +and `handleServerMessage`, right after `capturedCheckpointBytes` is set: + +```diff + if (message.message.case === "conversationCheckpointUpdate") { + try { + this.capturedCheckpointBytes = toBinary(ConversationStateStructureSchema, message.message.value); + } catch { + this.capturedCheckpointBytes = undefined; + } ++ // We are only still open because the grace was extended waiting for exactly this ++ // frame. Stop waiting. Deferred by one tick so this frame finishes being mapped ++ // and pushed before the terminal events go out — firing inline would reorder them. ++ if (this.checkpointGraceExtended && this.pendingFinalizeRun && this.capturedCheckpointBytes) { ++ const run = this.pendingFinalizeRun; ++ this.clearPendingFinalize(); ++ this.pendingFinalizeRun = undefined; ++ this.pendingFinalize = setTimeout(run, 0); ++ } + } +``` + +Net effect: a turn whose checkpoint arrives pays roughly the real arrival latency; a turn +whose checkpoint never arrives pays `CHECKPOINT_CAPTURE_GRACE_MS` once and then dies at a +known deadline, as before. + +### Sizing `CHECKPOINT_CAPTURE_GRACE_MS` + +Measured on macbookpro-2 against a live account, 12 tools held constant on the wire: + +| Local grace | Post-`toolCallStarted` checkpoint | `capturedBytes` | +|---|---|---| +| 50 ms | no | 0 | +| 1500 ms | yes | 3036 | + +750 ms and 1000 ms arms were attempted but their results are void — they were collected +through the line-count windowing that `012` shows returns empty once the 500-line log +ring fills. They are not evidence and are not used here. + +**Choose 1500 ms**, the only window with a clean positive. With A1b the cost is paid only +when no checkpoint comes. Revisit with a bracketed rerun using tail-based reading if that +ceiling proves too slow in practice; do not lower it on the void 750/1000 ms data. + +### Acceptance criteria for this work-phase + +1. `bun run typecheck` clean. +2. A focused test proves: checkpoint after `tool_call_end` but past the base grace is + captured and committed for an external wire model with `checkpointUsable: false`; + a transport that never sends one still refuses and still cancels; + a native wire model still refuses (the gate is untouched); + the extension happens at most once. +3. `bun test tests/providers/cursor` green. +4. No change to `src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts`. + ### What branch A is deliberately not doing The obvious companion edit — dropping `isCursorExternalWireModel` from From 28747a1f748a6f7d0f8c6bd0fecf898eeae6cb38 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 22:21:46 +0900 Subject: [PATCH 034/231] docs(devlog): wp3 result - conversation identity is stable on the responses path Two /v1/responses turns with previous_response_id share conversationHash cursor_cdbed7dcc and the second reports mode checkpoint rather than full-replay. C2 is chat-completions-specific and closes without a patch; the surviving defect is the client-tool turn. --- .../020_phase2_responses_identity.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md b/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md index 8811d3554e..405d0af4df 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md @@ -42,3 +42,32 @@ honest outcome is to record that and close the half. any patch. Do not route it to branch B. - **BLOCKED** — the proxy rejects the Responses shape for this provider. Record what it rejected; do not infer the answer from the chat-completions result. + +## Result — STABLE + +Run 2026-09-11 on macbookpro-2, same account and toggle. Two `/v1/responses` turns, +`store: true`, second carrying `previous_response_id` from the first. Log read with a +fixed tail, not the line-count windowing that `012` shows is void. + +``` +turn 1 resp_dccfe5a37e224d1e908567403c53db10 +[ocx:cursor:checkpoint-continuation] {"mode":"full-replay","conversationHash":"cursor_cdbed7dcc","checkpointRefHash":"717a262274c68762","checkpointBytes":492,"wireModel":"default"} + +turn 2 resp_e023130d5f684c159951cd8458e72914 (previous_response_id set) +[ocx:cursor:checkpoint-continuation] {"mode":"checkpoint","conversationHash":"cursor_cdbed7dcc","checkpointRefHash":"e28ce47f916e7213","checkpointBytes":595,"wireModel":"default"} +``` + +**STABLE.** `conversationHash` is identical across both turns, and turn 2 reports +`mode: checkpoint` rather than `full-replay` — the continuation resumed from the +checkpoint turn 1 committed, which is exactly the behaviour `#4245` says is missing. + +### What this removes from the issue + +C2 is an artifact of `/v1/chat/completions`, which carries no Responses state and +derives a fresh conversation per request. It is not a defect on the path Codex takes, +and it is not the reporter's problem. **This half of `#4245` is closed without a patch.** + +That also sharpens what is left. The reporter sees `cached_tokens: 0` and full replay; +plain multi-turn conversation on `/v1/responses` demonstrably does not do that. So the +surviving defect is C1 — turns that emit a client tool, where the checkpoint is +cancelled away before it can be captured. Branch B in `030` is not needed. From 28a67096e97ca9702067c73373b9f6883fbf211f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 22:25:45 +0900 Subject: [PATCH 035/231] docs(devlog): scope the wp3 C2 closure to threaded responses conversations --- .../020_phase2_responses_identity.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md b/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md index 405d0af4df..023d9182d8 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md @@ -63,9 +63,21 @@ checkpoint turn 1 committed, which is exactly the behaviour `#4245` says is miss ### What this removes from the issue -C2 is an artifact of `/v1/chat/completions`, which carries no Responses state and -derives a fresh conversation per request. It is not a defect on the path Codex takes, -and it is not the reporter's problem. **This half of `#4245` is closed without a patch.** +C2 does not affect a `/v1/responses` conversation that threads +`previous_response_id`, which is what a Codex session does. That is the shape the +reporter was running. + +**Scoped precisely, folded from the wp3 audit (near-pass residual):** the earlier +wording claimed C2 closes for all `/v1/responses` users. It does not. A Responses +request with **no** `previous_response_id` drops `_cursorConversationId` +(`src/server/responses/core.ts:533`) and mints a fresh one +(`src/adapters/cursor/request-builder.ts:361`) unless a thread owner exists, so that +call is in the same position as chat-completions. `store: false` *with* +`previous_response_id` is not a hole (`core.ts:461`, `core.ts:6619`). + +So: **closed without a patch for threaded conversations**, which is the reported +scenario; an unthreaded one-shot Responses call still starts fresh, and that is +expected rather than defective — there is no prior conversation to resume. That also sharpens what is left. The reporter sees `cached_tokens: 0` and full replay; plain multi-turn conversation on `/v1/responses` demonstrably does not do that. So the From 29f0f55aee3bc1c874c3854912bb2017525da102 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 22:26:11 +0900 Subject: [PATCH 036/231] docs(devlog): fold the wp3 verdict into the cause map and drop branch B --- .../260911_cursor_checkpoint_capture/000_plan.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md b/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md index 2dddae65f5..a551279bf9 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md @@ -70,6 +70,17 @@ far; the `/v1/responses` path is untested and is what Codex users actually take. **Not a cause:** the native/external model split. Recorded so the next reader does not retry it. +## Status of each cause + +| Cause | Verdict | Evidence | +|---|---|---| +| C1 tool-turn capture | **LATE — real, fixable** | `010` Result, `011`, `012`: 50 ms captures nothing, 1500 ms captures 3036 bytes after `toolCallStarted`, wire held constant | +| C2 conversation identity | **Closed, no patch** | `020` Result: two threaded `/v1/responses` turns share `conversationHash cursor_cdbed7dcc` and turn 2 resumes with `mode: checkpoint`. Scoped to threaded conversations; an unthreaded one-shot legitimately starts fresh | +| native/external gate | **Not a cause; gated behind wp5** | every model class refused identically at `capturedBytes: 0` before C1 was fixed | + +So the whole of `#4245` reduces to C1, and `030` branch A is the only patch this unit +will produce. Branch B is dropped. + ## Constraints - No change to `src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts`. From 2c56d2254dd06db182d33c0c1392a68e8442f68f Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 22:40:02 +0900 Subject: [PATCH 037/231] fix(cursor): capture the conversation checkpoint a suspended tool turn actually sends A client-tool turn suspends before turnEnded, and upstream sends that turns checkpoint right after toolCallStarted - after the 50ms drain grace, so it was cancelled away and every such turn full-replayed with cached_tokens 0. Measured live with the tool catalog held constant: 50ms captures nothing, 1500ms captures 3036 bytes. Extend once, bounded, before finalizeAfterDrain (which terminates the event state), and fire early from the capture site so a turn that does send a checkpoint pays arrival latency instead of the whole window. The native wire-model gate is untouched: capturedAfterClientTool proves arrival, not coverage. --- .../030_phase3_landing.md | 23 +++++ src/adapters/cursor/live-transport.ts | 76 ++++++++++++++++- .../cursor/cursor-tool-finalize-race.test.ts | 84 ++++++++++++++++++- 3 files changed, 177 insertions(+), 6 deletions(-) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md index 574f22af51..6ff3c4a1f0 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md @@ -159,6 +159,29 @@ ceiling proves too slow in practice; do not lower it on the void 750/1000 ms dat 3. `bun test tests/providers/cursor` green. 4. No change to `src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts`. +## Landed + +`src/adapters/cursor/live-transport.ts`: `CHECKPOINT_CAPTURE_GRACE_MS = 1_500`, the +exported pure predicate `shouldExtendForCheckpointCapture`, the one-shot extension inside +`scheduleClientToolFinalize` placed before `finalizeAfterDrain`, the early fire from the +`conversationCheckpointUpdate` branch of `handleServerMessage`, and `graceMs` / +`checkpointGraceExtended` added to the `client-tool-suspend` diagnostic. + +Tests in `tests/providers/cursor/cursor-tool-finalize-race.test.ts`, reusing that file's +existing transport harness. Measured in the suite: the turn that never sends a checkpoint +finalizes at 1816 ms, the turn whose checkpoint arrives finalizes at 256 ms. That gap is +A1b doing its job — without it both would sit out the full window. + +Two low findings from the implementation audit were folded rather than accepted: +`pendingFinalizeRun` is restored alongside the early-fire timer so the pair never +diverges, and `capturedCheckpointBytes` is reset in `open()` so a reused transport cannot +inherit a stale snapshot. Neither was reachable in production; folding them removes the +reachability argument. + +**Still open:** the native wire-model gate. `capturedAfterClientTool` is an arrival proof, +not a coverage proof, so wp5 owns decoding the captured `ConversationStateStructure` +before that gate moves. + ### What branch A is deliberately not doing The obvious companion edit — dropping `isCursorExternalWireModel` from diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 355f9be18f..2d6385ae77 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -115,6 +115,15 @@ const CLIENT_TOOL_FINALIZE_GRACE_MS = 50; const GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS = 750; const GENERIC_TOOL_COUNT_MAX_FINALIZE_GRACE_MS = 1_800; const GENERIC_TOOL_COUNT_PER_TOOL_GRACE_MS = 125; +/** + * A client-tool turn suspends before `turnEnded`, and upstream sends that turn's conversation + * checkpoint right after `toolCallStarted` — after the 50 ms drain grace, so it used to be + * cancelled away and every such turn full-replayed with `cached_tokens: 0`. Measured on a live + * account with the tool catalog held constant: 50 ms captures nothing, 1500 ms captures 3036 + * bytes (#4245, devlog 260911_cursor_checkpoint_capture). This window is paid only by a turn + * that never sends one; `handleServerMessage` finalizes early as soon as the frame lands. + */ +const CHECKPOINT_CAPTURE_GRACE_MS = 1_500; const cursorContextUsageTracker = createCursorContextUsageTracker(); /** @@ -413,6 +422,28 @@ export function finalizeAfterDrain(state: ReturnType 0) return false; + if (!input.wantsCheckpointCapture || input.hasCapturedCheckpoint) return false; + return !input.alreadyExtended; +} + export function clientToolFinalizeGraceMsForRequest(request: CursorRunRequest, baseGraceMs = CLIENT_TOOL_FINALIZE_GRACE_MS): number { if (request.rawMessages?.at(-1)?.role === "toolResult") return baseGraceMs; const text = activePromptText(request); @@ -468,6 +499,10 @@ class LiveCursorTransport implements CursorTransport { */ private emittedTerminal = false; private pendingFinalize?: ReturnType; + /** The armed finalize body, so the checkpoint frame can run it early instead of waiting out the window. */ + private pendingFinalizeRun?: () => void; + private checkpointGraceExtended = false; + private wantsCheckpointCapture = false; private readonly clientToolFinalizeGraceMs: number; private activeClientToolFinalizeGraceMs: number; private readonly token: string; @@ -641,6 +676,7 @@ class LiveCursorTransport implements CursorTransport { }; const activeText = activePromptText(activeRequest); this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(activeRequest, this.clientToolFinalizeGraceMs); + this.wantsCheckpointCapture = activeRequest.contextUsageStoreCheckpoints !== false; const cursorVisibleTools = cursorToolsForActivePrompt(activeRequest.tools, activeText, activeRequest.toolChoice); const clientToolDefs = buildCursorToolDefinitions(cursorVisibleTools, activeRequest.toolChoice); // `request.tools` is the catalog already filtered and budgeted by request-builder. Derive @@ -981,6 +1017,7 @@ class LiveCursorTransport implements CursorTransport { clearTimeout(this.pendingFinalize); this.pendingFinalize = undefined; } + this.pendingFinalizeRun = undefined; } /** @@ -1001,11 +1038,26 @@ class LiveCursorTransport implements CursorTransport { private scheduleClientToolFinalize( state: ReturnType, push: (message: CursorServerMessage) => void, + graceMsOverride?: number, ): void { this.clearPendingFinalize(); - this.pendingFinalize = setTimeout(() => { + const run = (): void => { this.pendingFinalize = undefined; + this.pendingFinalizeRun = undefined; if (this.expectedClose) return; + // The checkpoint for THIS turn is the one we most want and the one we used to throw + // away. Extend once, bounded, before draining (#4245). + if (shouldExtendForCheckpointCapture({ + terminated: state.terminated, + openToolCallCount: state.openToolCalls.size, + wantsCheckpointCapture: this.wantsCheckpointCapture, + hasCapturedCheckpoint: this.capturedCheckpointBytes !== undefined, + alreadyExtended: this.checkpointGraceExtended, + })) { + this.checkpointGraceExtended = true; + this.scheduleClientToolFinalize(state, push, CHECKPOINT_CAPTURE_GRACE_MS); + return; + } const terminal = finalizeAfterDrain(state); if (terminal.length === 0) return; for (const event of terminal) push(event); @@ -1013,9 +1065,13 @@ class LiveCursorTransport implements CursorTransport { reason: "Responses bridge owns client tools; ending turn without fake mcpResult", framesReceived: this.framesReceived, elapsedMs: Date.now() - this.turnStartedAt, + graceMs: graceMsOverride ?? this.activeClientToolFinalizeGraceMs, + checkpointGraceExtended: this.checkpointGraceExtended, }); this.cancelCursorRun(); - }, this.activeClientToolFinalizeGraceMs); + }; + this.pendingFinalizeRun = run; + this.pendingFinalize = setTimeout(run, graceMsOverride ?? this.activeClientToolFinalizeGraceMs); } private open( @@ -1032,6 +1088,10 @@ class LiveCursorTransport implements CursorTransport { } this.turnStartedAt = Date.now(); this.framesReceived = 0; + this.checkpointGraceExtended = false; + // A turn must not inherit the previous one's snapshot: a stale capture would make + // shouldExtendForCheckpointCapture skip the wait this turn actually needs. + this.capturedCheckpointBytes = undefined; this.sawAssistantText = false; this.emittedTerminal = false; this.firstFrameAt = undefined; @@ -1450,6 +1510,18 @@ class LiveCursorTransport implements CursorTransport { } catch { this.capturedCheckpointBytes = undefined; } + // We are only still open because the grace was extended waiting for exactly this frame. + // Stop waiting, so a turn that does send a checkpoint pays arrival latency rather than the + // whole window. Deferred one tick so this frame finishes being mapped and pushed first; + // finalizing inline would emit the terminal events ahead of it. + if (this.checkpointGraceExtended && this.pendingFinalizeRun && this.capturedCheckpointBytes) { + const run = this.pendingFinalizeRun; + this.clearPendingFinalize(); + this.pendingFinalize = setTimeout(run, 0); + // Keep the pair in step: clearPendingFinalize() drops the callback, and every other + // owner of pendingFinalize expects pendingFinalizeRun to describe it. + this.pendingFinalizeRun = run; + } } if (message.message.case === "kvServerMessage") { this.writeConnectFrame(encodeConnectFrame(handleCursorNativeKv(message.message.value, this.blobRequestScope))); diff --git a/tests/providers/cursor/cursor-tool-finalize-race.test.ts b/tests/providers/cursor/cursor-tool-finalize-race.test.ts index e16a6c28f8..e44fb7c5df 100644 --- a/tests/providers/cursor/cursor-tool-finalize-race.test.ts +++ b/tests/providers/cursor/cursor-tool-finalize-race.test.ts @@ -1,12 +1,17 @@ import { describe, expect, test } from "bun:test"; import { create, toBinary } from "@bufbuild/protobuf"; -import { clientToolFinalizeGraceMsForRequest, createLiveCursorTransport } from "../../../src/adapters/cursor/live-transport"; +import { + clientToolFinalizeGraceMsForRequest, + createLiveCursorTransport, + shouldExtendForCheckpointCapture, +} from "../../../src/adapters/cursor/live-transport"; import { createTestTranslatorBudget } from "../../helpers/translator-budget"; import { createCursorProtobufEventState } from "../../../src/adapters/cursor/protobuf-events"; import type { CursorRunRequest, CursorServerMessage } from "../../../src/adapters/cursor/types"; import { AgentServerMessageSchema, ExecServerMessageSchema, + ConversationStateStructureSchema, McpArgsSchema, McpToolCallSchema, ToolCallSchema, @@ -95,18 +100,27 @@ function completedByCallIdFrame(callId: string) { } interface Harness { - feed( - frame: ReturnType | ReturnType | ReturnType, - ): Promise; + feed(frame: unknown): Promise; events: CursorServerMessage[]; closeCodes: number[]; cancelled(): boolean; } +/** A conversation checkpoint frame: the only thing that sets `capturedCheckpointBytes`. */ +function checkpointFrame() { + return create(AgentServerMessageSchema, { + message: { + case: "conversationCheckpointUpdate", + value: create(ConversationStateStructureSchema, { pendingToolCalls: ["suspended-fixture"] }), + }, + }); +} + function makeHarness( graceMs: number, clientToolNames: string[], freeformToolNames: string[] = [], + wantsCheckpointCapture = false, ): Harness { const transport = createLiveCursorTransport({ provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "test-token" }, @@ -115,8 +129,11 @@ function makeHarness( clientToolFinalizeGraceMs: graceMs, }) as unknown as { stream: unknown; + wantsCheckpointCapture: boolean; handleServerMessage: (m: unknown, s: unknown, p: (e: CursorServerMessage) => void) => Promise; }; + // Normally set from the run request; the harness drives handleServerMessage directly. + transport.wantsCheckpointCapture = wantsCheckpointCapture; const events: CursorServerMessage[] = []; const closeCodes: number[] = []; // Fake h2 stream: records RST_STREAM close codes; never touches the network. @@ -255,3 +272,62 @@ describe("transport finalize race (hidden parallel sibling)", () => { expect(h.closeCodes).toEqual([NGHTTP2_CANCEL]); }); }); + +describe("checkpoint capture grace (#4245)", () => { + test("extends only when a checkpoint is wanted, absent, and not already extended", () => { + const base = { + terminated: false, + openToolCallCount: 0, + wantsCheckpointCapture: true, + hasCapturedCheckpoint: false, + alreadyExtended: false, + }; + expect(shouldExtendForCheckpointCapture(base)).toBe(true); + // Mirrors finalizeAfterDrain's guards: a terminated state or a reopened sibling set + // must fall through to the normal path rather than spend the one extension. + expect(shouldExtendForCheckpointCapture({ ...base, terminated: true })).toBe(false); + expect(shouldExtendForCheckpointCapture({ ...base, terminated: undefined })).toBe(true); + expect(shouldExtendForCheckpointCapture({ ...base, openToolCallCount: 1 })).toBe(false); + // Nothing to wait for, or already waited once. + expect(shouldExtendForCheckpointCapture({ ...base, wantsCheckpointCapture: false })).toBe(false); + expect(shouldExtendForCheckpointCapture({ ...base, hasCapturedCheckpoint: true })).toBe(false); + expect(shouldExtendForCheckpointCapture({ ...base, alreadyExtended: true })).toBe(false); + }); + + test("a turn that never sends a checkpoint waits once, then still finalizes and cancels", async () => { + const h = makeHarness(20, ["echo_a"], [], true); + await h.feed(startedFrame("call_a", "echo_a")); + await h.feed(execFrame(1, "call_a", "echo_a", "A")); + // Past the 20 ms base grace the turn is deliberately still open: the extension is running. + await sleep(200); + expect(h.events.map(e => e.type)).not.toContain("done"); + expect(h.cancelled()).toBe(false); + // The extension is bounded, so the stream still dies at a known deadline. + await sleep(1_600); + expect(h.events.map(e => e.type).filter(t => t === "done")).toHaveLength(1); + expect(h.closeCodes).toEqual([NGHTTP2_CANCEL]); + }, 10_000); + + test("a checkpoint arriving during the extension finalizes early instead of waiting it out", async () => { + const h = makeHarness(20, ["echo_a"], [], true); + await h.feed(startedFrame("call_a", "echo_a")); + await h.feed(execFrame(1, "call_a", "echo_a", "A")); + await sleep(120); + expect(h.events.map(e => e.type)).not.toContain("done"); + + await h.feed(checkpointFrame()); + // Early fire is deferred one tick so the checkpoint frame finishes being processed first. + await sleep(120); + expect(h.events.map(e => e.type).filter(t => t === "done")).toHaveLength(1); + expect(h.closeCodes).toEqual([NGHTTP2_CANCEL]); + }, 10_000); + + test("without checkpoint capture wanted, the base grace is unchanged", async () => { + const h = makeHarness(20, ["echo_a"], [], false); + await h.feed(startedFrame("call_a", "echo_a")); + await h.feed(execFrame(1, "call_a", "echo_a", "A")); + await sleep(200); + expect(h.events.map(e => e.type).filter(t => t === "done")).toHaveLength(1); + expect(h.closeCodes).toEqual([NGHTTP2_CANCEL]); + }); +}); From e7ab14f93e60fa165c9a04a41b38512cb0a28a67 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 23:03:23 +0900 Subject: [PATCH 038/231] docs(devlog): close wp2b - its graceMs diagnostic shipped inside #4281 --- .../010_phase1_grace_experiment.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md b/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md index c7e4e07363..e765707cee 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md @@ -68,6 +68,29 @@ back INCONCLUSIVE: add `graceMs: this.activeClientToolFinalizeGraceMs` to the checkout, and rerun arm B. NEVER is then `capturedBytes: 0` with a logged `graceMs` of 1500. That instrumented arm is wp2b, appended only if needed. +### wp2b closed — its deliverable shipped inside wp4 + +wp2b was never needed for its original purpose: the experiment returned a positive, and a +positive is self-proving. But the mechanism it specified — putting the real +`graceMs` into the `client-tool-suspend` payload so a negative could ever be trusted — +landed anyway, as part of #4281: + +```ts +debugProviderDiagnostic("cursor", "client-tool-suspend", { + ... + graceMs: graceMsOverride ?? this.activeClientToolFinalizeGraceMs, + checkpointGraceExtended: this.checkpointGraceExtended, +}); +``` + +So the instrumented throwaway build this phase was reserved for is now unnecessary in +both directions: nobody needs to reach NEVER here, and if a future reader does, the field +is in the shipped binary. Closed as **delivered elsewhere**, not as skipped. + +That is worth separating from "not needed". A phase that is genuinely obsolete and a +phase whose deliverable moved are different states, and recording the wrong one would +leave the next reader thinking the diagnostic gap is still open. + ## Result — LATE Run 2026-09-11 on macbookpro-2, opencodex 2.50.0, same account and toggle as `001`. From 3784658ea5a9296c35cbb780c96192a6db7bca53 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 23:05:03 +0900 Subject: [PATCH 039/231] docs(devlog): record wp2b as delivered-elsewhere in the work-phase map --- devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md b/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md index a551279bf9..5804297c4d 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md @@ -107,6 +107,11 @@ wp2; if wp3 finishes first its outcome folds into wp4 as an additional branch. wp2b and wp5 were appended during wp1's audit (LOOP-UNIT-CHAIN-01). Both are conditional: neither runs unless its predecessor returns the outcome that needs it. +Outcomes: **wp2b closed as delivered-elsewhere** — the experiment returned a self-proving +positive so a NEVER verdict was never needed, and the `graceMs` field it existed to add +shipped in #4281 (`live-transport.ts:1064-1069`). **wp5 is live**, because branch A landed +and the native gate now depends on a coverage question rather than a capture one. + ## What the wp1 audit changed The first draft of this roadmap was audited and failed on two high findings, both From c1ce2560eae79519ef3b8da58703e595153e0128 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 23:10:58 +0900 Subject: [PATCH 040/231] provider: follow the DeepSeek V4.1 transition through the registry (#4282) Splits the DeepSeek thinking set by who serves the route (first-party deepseek-flash vs gateway deepseek-v4.1-flash), removes the retired deepseek-v4-pro from every static roster, and excludes it from the routed catalog on live-discovery providers where deleting a row would strip capabilities instead of the model. Carries the Command Code effort ladders from #4258. Maintainer integration into dev per MAINTAINERS.md with all checks passing at the exact head. --- .../000_plan.md | 45 ++++++ .../001_evidence.md | 25 +++ .../002_inventory.md | 49 ++++++ .../010_phase1_pr4258.md | 31 ++++ .../020_phase2_v41_rollout.md | 67 ++++++++ .../030_phase3_v4pro_removal.md | 90 +++++++++++ .../040_phase4_merge.md | 31 ++++ .../_plan/260911_r2_merge_train/000_plan.md | 97 ++++++++++++ .../010_phase1_pr4244.md | 47 ++++++ .../020_phase2_pr4248.md | 59 ++++++++ .../030_phase3_pr4246.md | 83 ++++++++++ .../040_phase4_pr4247.md | 127 ++++++++++++++++ .../content/docs/fr/guides/model-ordering.md | 2 +- .../src/content/docs/fr/guides/providers.md | 4 +- .../src/content/docs/fr/guides/sidecars.md | 2 +- .../fr/reference/configuration/providers.md | 2 +- .../src/content/docs/guides/model-ordering.md | 2 +- .../src/content/docs/guides/providers.md | 6 +- docs-site/src/content/docs/guides/sidecars.md | 2 +- .../src/content/docs/ja/guides/providers.md | 4 +- .../src/content/docs/ja/guides/sidecars.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 4 +- .../src/content/docs/ko/guides/sidecars.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../src/content/docs/ru/guides/providers.md | 4 +- .../src/content/docs/ru/guides/sidecars.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../src/content/docs/tr/guides/providers.md | 4 +- .../src/content/docs/tr/guides/sidecars.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 4 +- .../src/content/docs/zh-cn/guides/sidecars.md | 2 +- .../reference/configuration/providers.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 4 +- .../src/content/docs/zh-tw/guides/sidecars.md | 2 +- .../reference/configuration/providers.md | 2 +- .../cline-pass-deepseek-v4-tool-replay.ts | 1 - src/adapters/command-code.ts | 1 - src/codex/catalog/parsing.ts | 17 +++ src/providers/codebuddy-models.ts | 3 - src/providers/command-code-efforts.ts | 27 +++- src/providers/default-aliases.ts | 4 + src/providers/qoder-models.ts | 1 - src/providers/registry.ts | 143 +++++++++--------- src/router.ts | 2 +- .../codex-catalog-restore.test.ts | 2 +- tests/codex-integration/codex-catalog.test.ts | 18 +-- .../reasoning-effort.test.ts | 7 +- tests/codex-integration/slug-codec.test.ts | 8 +- .../e2e-style/phase100-native-parity.test.ts | 12 +- tests/fixtures/baseten-models.json | 2 +- tests/gui/alibaba-intl-token-plan.test.ts | 4 +- tests/gui/volcengine-providers.test.ts | 21 +-- tests/providers/baseten-provider.test.ts | 33 ++-- ...cline-pass-deepseek-v4-tool-replay.test.ts | 1 - tests/providers/cline-pass-provider.test.ts | 2 - .../cline-pass-reasoning-efforts.test.ts | 1 - tests/providers/command-code-provider.test.ts | 35 ++++- .../digitalocean-scaleway-provider.test.ts | 5 +- tests/providers/opencode-go-deepseek.test.ts | 8 +- .../opencode-zen-deepseek-reasoning.test.ts | 4 +- tests/providers/orcarouter-provider.test.ts | 2 +- .../provider-registry-parity.test.ts | 82 +++++++--- tests/routing/fastwire-policy.test.ts | 2 +- tests/routing/router.test.ts | 2 +- tests/server/adapter-resolve.test.ts | 4 +- 68 files changed, 1057 insertions(+), 218 deletions(-) create mode 100644 devlog/_plan/260911_deepseek_v41_transition/000_plan.md create mode 100644 devlog/_plan/260911_deepseek_v41_transition/001_evidence.md create mode 100644 devlog/_plan/260911_deepseek_v41_transition/002_inventory.md create mode 100644 devlog/_plan/260911_deepseek_v41_transition/010_phase1_pr4258.md create mode 100644 devlog/_plan/260911_deepseek_v41_transition/020_phase2_v41_rollout.md create mode 100644 devlog/_plan/260911_deepseek_v41_transition/030_phase3_v4pro_removal.md create mode 100644 devlog/_plan/260911_deepseek_v41_transition/040_phase4_merge.md create mode 100644 devlog/_plan/260911_r2_merge_train/000_plan.md create mode 100644 devlog/_plan/260911_r2_merge_train/010_phase1_pr4244.md create mode 100644 devlog/_plan/260911_r2_merge_train/020_phase2_pr4248.md create mode 100644 devlog/_plan/260911_r2_merge_train/030_phase3_pr4246.md create mode 100644 devlog/_plan/260911_r2_merge_train/040_phase4_pr4247.md diff --git a/devlog/_plan/260911_deepseek_v41_transition/000_plan.md b/devlog/_plan/260911_deepseek_v41_transition/000_plan.md new file mode 100644 index 0000000000..9c2493b74a --- /dev/null +++ b/devlog/_plan/260911_deepseek_v41_transition/000_plan.md @@ -0,0 +1,45 @@ +# 260911 — DeepSeek V4.1 전환 + +DeepSeek가 2026-09-10에 V4.1-Flash를 내면서 V4 계열의 이름이 한 번에 움직였다. `deepseek-v4-flash`와 `deepseek-v4-flash-vision-exp`는 모델로서 은퇴하고 이름만 V4.1-Flash로 라우팅되는 별칭이 됐고, `deepseek-v4-pro`는 2026-09-14 04:00 UTC부터 단계적으로 퇴역하며 그 시점부터 요청이 V4.1-Flash로 넘어간다. opencodex는 이 두 id를 13개 프로바이더 프리셋에 손으로 박아두고 있어서, 그대로 두면 Pro 컨텍스트 창과 Pro 가격을 광고하면서 실제로는 Flash를 서빙하는 상태가 된다. 이 유닛은 V4.1을 전개하고 v4-pro를 걷어내고, 같은 영역을 건드리는 기여자 PR을 먼저 정리한 뒤 둘 다 dev에 머지한다. 바뀌는 사람은 DeepSeek 경로를 쓰는 모든 사용자다. + +근거는 `001_evidence.md`, 출현 지점 집계는 `002_inventory.md`에 있다. + +## 루프 스펙 + +| 항목 | 내용 | +| --- | --- | +| Loop archetype | satisfy-spec | +| Trigger | 사용자 지시: v4.1-flash를 v4-flash가 있는 모든 곳에 전개하고, 퇴역한 v4-pro를 전부 제거하고, PR #4258과 #4274를 머지하라 | +| Goal | V4.1 전개 + v4-pro 제거가 focused 테스트와 함께 dev에 머지되고, #4258/#4274도 머지된다 | +| Non-goals | 새 사용자 config 필드, 어댑터 와이어 동작 변경, main/preview 승격, 릴리스, 생성 메타데이터 수작업 편집 | +| Verifier | `bun test` 영향 도메인, `bun run typecheck`, `bun run privacy:scan`, 머지 전 exact-head CI | +| Stop condition | 두 PR과 이번 변경이 dev에 머지된 시점 | +| Memory artifact | `devlog/_plan/260911_deepseek_v41_transition/` | +| Expected terminal outcomes | DONE = 머지 완료. BLOCKED = CI가 이 변경과 무관한 이유로 반복 실패하거나 머지 권한이 거부될 때 | +| Escalation condition | 사용자가 머지를 명시 승인했다. main/preview 승격과 릴리스는 별도 승인 필요 | +| Resource bounds | 쓰기 범위: `src/`, `tests/`, `docs-site/`, 이 플랜 유닛. 전체 스위트는 사용자 지시로 로컬에서 돌리지 않고 CI에 위임한다 | + +## 작업 단계 지도 + +| work-phase | 문서 | 내용 | +| --- | --- | --- | +| wp1 | 000-002 | 근거·인벤토리·로드맵 잠금 (docs only) | +| wp2 | `010_phase1_pr4258.md` | 기여자 PR #4258 리뷰와 머지 | +| wp3 | `020_phase2_v41_rollout.md` | V4.1-Flash 전개 | +| wp4 | `030_phase3_v4pro_removal.md` | v4-pro 퇴역 제거 | +| wp5 | `040_phase4_merge.md` | docs-site 동기화, PR 게시와 머지 | + +## 이 유닛이 내린 두 가지 판단 + +**1. id는 프로바이더별로 다르다.** DeepSeek 1st-party API의 공식 id는 `deepseek-flash`다. 게이트웨이가 노출하는 철자는 `deepseek-v4.1-flash`이고, 이건 이슈 #4253과 PR #4258이 저장소 안에서 확인해 준 사실이다. "모든 곳에 같은 id"로 넣으면 네이티브 쪽이 틀린 id를 갖는다. + +**2. 벤더 호스팅 스냅샷은 DeepSeek 수명주기와 별개다.** Volcengine Ark는 `deepseek-v4-pro-260425`처럼 날짜가 박힌 스냅샷을 고정하고, Alibaba·Ollama Cloud·NVIDIA NIM·Baseten도 각자 로스터를 따로 발표한다. DeepSeek 1st-party 퇴역 공지가 그 벤더들의 배포까지 끝내지는 않는다. 그래서 제거는 **DeepSeek 1st-party와 그것을 되파는 Zen 계열을 먼저** 확정하고, 벤더 호스팅 프리셋은 같은 커밋에서 분리해 PR 본문에 근거와 함께 드러낸다 — 리뷰어가 한 커밋만 떼어낼 수 있게. + +## wp1 감사 반영 (2026-09-11) + +독립 감사가 로드맵 초안의 결함 6건을 잡았고 전부 수용했다. 가장 큰 것 둘: + +- 초안은 공유 상수 `DEEPSEEK_THINKING_MODELS`에 V4.1을 넣으려 했는데, 그 상수는 `deepseek` 1st-party 프리셋의 `models:` 배열 자체를 포함해 6개 프리셋 21곳이 소비한다(`registry.ts:2045`). 그대로 하면 게이트웨이 철자가 네이티브 프리셋으로 새서 020의 수용기준이 자기모순이 된다. 상수를 분리하는 설계로 다시 썼다. +- 초안의 "Pro 사다리를 광고한다"는 근거가 없다. `DEEPSEEK_PRO_*`와 `DEEPSEEK_FLASH_*` 효율 맵은 값이 같다(`registry.ts:701-715`). 실제로 어긋나는 건 **컨텍스트 창과 가격**이다. + +나머지는 002/020/030의 해당 절에 반영했다. diff --git a/devlog/_plan/260911_deepseek_v41_transition/001_evidence.md b/devlog/_plan/260911_deepseek_v41_transition/001_evidence.md new file mode 100644 index 0000000000..2d4e93b1ce --- /dev/null +++ b/devlog/_plan/260911_deepseek_v41_transition/001_evidence.md @@ -0,0 +1,25 @@ +# 001 — 근거 + +2026-09-11 웹 조사. 출처는 DeepSeek 공식 API 문서와 9/10 공지. + +## 확인된 사실 + +| 사실 | 출처 | +| --- | --- | +| V4.1-Flash 출시 2026-09-10 | | +| 공식 API id는 `deepseek-flash` | | +| `deepseek-v4-flash`와 `deepseek-v4-flash-vision-exp`는 모델로서 은퇴, 이름은 V4.1-Flash로 라우팅되는 호환 별칭으로 유지, Flash 가격 과금 | | +| `deepseek-v4-pro`는 2026-09-14 04:00 UTC부터 단계적 퇴역, 이후 요청은 V4.1-Flash로 자동 라우팅, 신규 연동은 `deepseek-flash` 권고 | | + +## 기록해 두는 불일치 + +같은 체인지로그를 근거로, 질의 표현에 따라 상반된 요약이 돌아왔다. 한쪽은 위 표대로 v4-pro 퇴역과 Flash 요금 적용을 말했고, 다른 쪽은 "9월 14일 이후에도 서비스 계속, 과금 변동 없음, 7월 24일 퇴역한 건 `deepseek-chat`/`deepseek-reasoner`"라고 답했다. + +이 유닛은 전자를 따른다. 다만 두 해석이 공통으로 인정하는 사실 하나만으로도 변경 근거는 충분하다: **9월 14일부터 `deepseek-v4-pro` 요청은 V4.1-Flash로 라우팅된다.** 퇴역이냐 임시 라우팅이냐와 무관하게, 그 시점 이후 `deepseek-v4-pro` 행은 Pro 사다리·Pro 컨텍스트·Pro 가격을 광고하면서 Flash를 서빙한다. 잘못된 광고를 남겨두는 쪽이 제거보다 나쁘다. + +저장소 내부 근거로는 이슈 #4253과 PR #4258이 Command Code 라이브 로스터에서 `deepseek/deepseek-v4.1-flash`가 실제로 서빙되는 것을 확인해 준다. + +## 이 유닛이 주장하지 않는 것 + +- 벤더 호스팅(Volcengine, Alibaba, Ollama Cloud, NVIDIA NIM, Baseten, cline-pass, orcarouter, codebuddy, qoder) 로스터에서 v4-pro가 중단됐다는 주장은 **하지 않는다**. 그쪽은 각자 스냅샷과 일정이 있고, Volcengine은 `deepseek-v4-pro-260425`처럼 날짜가 박힌 id를 쓴다. +- Zen 게이트웨이가 `deepseek-flash` 철자를 받는다는 주장도 하지 않는다. 게이트웨이 쪽은 관측된 `deepseek-v4.1-flash`를 쓴다. diff --git a/devlog/_plan/260911_deepseek_v41_transition/002_inventory.md b/devlog/_plan/260911_deepseek_v41_transition/002_inventory.md new file mode 100644 index 0000000000..bb774fff9a --- /dev/null +++ b/devlog/_plan/260911_deepseek_v41_transition/002_inventory.md @@ -0,0 +1,49 @@ +# 002 — 출현 지점 집계 + +`rg` 기준, 2026-09-11 브랜치 `codex/260911-opencode-go-free-stabilization`. + +| id | 파일 수 | 히트 수 | +| --- | --- | --- | +| `deepseek-v4-pro` | 62 | 293 | +| `deepseek-v4-flash` | 99 | 585 | + +## `DEEPSEEK_THINKING_MODELS` 소비처 (감사 정정) + +이 상수(`registry.ts:619`)는 Zen 3종만 먹이는 게 아니다. **6개 프리셋 21곳**이 소비하며, 그중에는 `deepseek` 1st-party 프리셋의 `models:` 배열 자체가 포함된다. + +| 프리셋 | 앵커 | +| --- | --- | +| `opencode-go` | 1760, 1768, 1776, 1803, 1813 | +| `deepseek` 1st-party | **2045 (`models:` spread)**, 2114-2121 | +| `alibaba-token-plan` | 2813-2818 | +| `opencode-zen` | 3047-3064 | +| `opencode-free` | 3108 | + +이것 때문에 "공유 상수에 V4.1을 추가" 설계는 성립하지 않는다. 020이 상수 분리로 다시 설계됐다. + +## v4-pro를 선언하는 프로바이더 (registry.ts) + +| 프로바이더 | 성격 | 앵커 | +| --- | --- | --- | +| `deepseek` (1st-party) | **DeepSeek 직접** | 2038-2078 (`modelContextWindows`, `modelWireDefaults`, `modelResponsesTerminalRepair`) | +| `opencode-go` / `opencode-zen` / `opencode-free` | Zen 게이트웨이가 DeepSeek을 되팜 | 619 `DEEPSEEK_THINKING_MODELS`, 1793 | +| `command-code` (OAuth + API key) | 게이트웨이 | 631, 1180-1190, 2305 | +| `alibaba-token-plan` / `-intl` | 벤더 호스팅 | 736, 749, 758, 2832, 2857-2913 | +| `volcengine` ark / coding / agent | 벤더 호스팅, **날짜 스냅샷** `deepseek-v4-pro-260425` | 791, 807, 816, 838, 850, 2785, 2791 | +| `ollama` cloud | 벤더 호스팅 | 2951, 2963 | +| `nvidia-nim` | 벤더 호스팅 | 969 | +| `baseten` | 벤더 호스팅 (`deepseek-ai/DeepSeek-V4-Pro`) | 1010-1059 | +| `cline-pass` | 게이트웨이 | 1144, 1199 | +| `orcarouter` | 게이트웨이 | 1180-1190 | +| `codebuddy` / `qoder` | 게이트웨이 | `codebuddy-models.ts`, `qoder-models.ts` | + +## 손대지 않는 영역과 이유 + +| 영역 | 이유 | +| --- | --- | +| `scripts/model-metadata.source.json` (47건), `src/generated/model-metadata.ts` (3건) | 벤더 스냅샷에서 **생성되는** 파일이다. 손으로 지우면 다음 생성에서 되돌아온다. 게다가 `src/usage/cost.ts`가 과거 요청 비용을 이 표로 계산하므로, 행을 지우면 이미 기록된 사용량의 원가가 깨진다 | +| 임의 fixture id로 v4-pro를 쓰는 테스트 | 레지스트리 멤버십을 주장하지 않는 테스트는 모델 id를 문자열로만 쓴다. 깨지는 것만 고친다 | + +## 테스트 영향 예상 + +감사 정정: 영향 파일은 5개가 아니라 **24개**다. 위 다섯 외에 `tests/routing/router.test.ts:450`(정확 목록), `tests/providers/orcarouter-provider.test.ts:139`, `tests/gui/alibaba-intl-token-plan.test.ts:31`, `tests/routing/fastwire-policy.test.ts`, `tests/codex-integration/slug-codec.test.ts`, `tests/server/adapter-resolve.test.ts` 등이 포함된다. diff --git a/devlog/_plan/260911_deepseek_v41_transition/010_phase1_pr4258.md b/devlog/_plan/260911_deepseek_v41_transition/010_phase1_pr4258.md new file mode 100644 index 0000000000..abafbf70c4 --- /dev/null +++ b/devlog/_plan/260911_deepseek_v41_transition/010_phase1_pr4258.md @@ -0,0 +1,31 @@ +# 010 — wp2: 기여자 PR #4258 리뷰와 머지 + + · `gitgarmin` · base `dev` · head `codex/command-code-v41-qwen-efforts` + +파일 2개: `src/providers/command-code-efforts.ts` (+23/-0), `tests/providers/command-code-provider.test.ts` (+33/-0). + +## 왜 먼저인가 + +같은 파일을 wp3에서 건드린다. 기여자 PR을 먼저 넣고 그 위에 리베이스하는 게 순서다. 반대로 하면 기여자가 리베이스 부담을 진다. + +## 리뷰 항목 + +1. 추가된 두 행(`deepseek/deepseek-v4.1-flash`, `Qwen/Qwen3.8-Flash`)이 `COMMAND_CODE_MODEL_EFFORTS` 조회 계약과 맞는가. +2. 사다리 값의 출처가 본문 주장과 일치하는가. 본문은 같은 패밀리 행에서 추론했다고 밝히고, 라이브 200 응답을 근거로 든다. +3. 신규 테스트가 케이스 폴딩과 두 프리셋(OAuth/API key)을 모두 고정하는가. +4. AGENTS.md 리뷰 규칙: base `dev` ✓, 보안 표면 미접촉, 테스트 동반. +5. CI가 exact head에서 green인가. + +## 수용 기준 + +- 리뷰 코멘트가 영어로 남는다 (AGENTS.md 리뷰 규칙). +- exact-head CI green을 확인한 뒤 머지한다. +- 머지 후 `dev`를 받아 내 브랜치를 리베이스하고 충돌이 없음을 확인한다. + +## 검증 + +``` +gh pr checks 4258 +gh pr view 4258 --json mergeStateStatus,reviewDecision +bun test tests/providers/command-code-provider.test.ts +``` diff --git a/devlog/_plan/260911_deepseek_v41_transition/020_phase2_v41_rollout.md b/devlog/_plan/260911_deepseek_v41_transition/020_phase2_v41_rollout.md new file mode 100644 index 0000000000..c2cf34f776 --- /dev/null +++ b/devlog/_plan/260911_deepseek_v41_transition/020_phase2_v41_rollout.md @@ -0,0 +1,67 @@ +# 020 — wp3: V4.1-Flash 전개 (2차 감사 후 재설계) + +## 두 번 틀렸던 지점 + +**1차 초안**: `DEEPSEEK_THINKING_MODELS`에 V4.1을 그냥 얹으려 했다. 그 상수는 `deepseek` 1st-party 프리셋의 `models:`를 포함해 6개 프리셋이 공유하므로, 게이트웨이 철자가 네이티브로 샌다. + +**2차 초안**: 그래서 상수를 레거시 전용으로 고정하고 신규 id를 따로 넣으려 했다. 감사가 `fail`을 냈고 이유가 맞다 — `deepseek` 프리셋의 모델별 맵 **다섯 개**가 전부 그 상수에서 파생된다(`registry.ts:2121-2124, 2128`). 상수를 레거시로 묶으면 `deepseek-flash`는 사다리·요약·`reasoning_content` 리플레이·비전 차단을 **전부** 잃고 #78형 400이 재발한다. + +## 확정 설계: 상수를 세 갈래로 파생시킨다 + +```ts +// 업스트림이 호환 별칭으로 유지하는 레거시 V4 id +const DEEPSEEK_V4_LEGACY_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"]; +// DeepSeek 1st-party: 공식 id는 deepseek-flash +const DEEPSEEK_NATIVE_THINKING_MODELS = ["deepseek-flash", ...DEEPSEEK_V4_LEGACY_MODELS]; +// Zen 게이트웨이가 노출하는 철자 +const DEEPSEEK_GATEWAY_THINKING_MODELS = ["deepseek-v4.1-flash", ...DEEPSEEK_V4_LEGACY_MODELS]; +``` + +기존 이름 `DEEPSEEK_THINKING_MODELS`는 `DEEPSEEK_V4_LEGACY_MODELS`로 바뀐다. 벤더 호스팅 프리셋(volcengine 플랜, alibaba)은 그 레거시 상수를 계속 쓴다 — 그쪽은 V4 스냅샷을 자기 일정으로 서빙한다. + +## 파일 변경 지도 + +| 위치 | 변경 | +| --- | --- | +| `registry.ts:619` | 상수 3개로 재구성 | +| `registry.ts:2121-2124, 2128` (deepseek 프리셋) | 다섯 맵을 `DEEPSEEK_NATIVE_THINKING_MODELS`로 전환 | +| `registry.ts:2049` (`models:`) | 같은 상수로 전환 | +| `registry.ts:2053` | `defaultModel`을 `deepseek-flash`로 | +| `registry.ts:2062, 2078` | `modelContextWindows`·`modelWireDefaults`·`modelResponsesTerminalRepair`에 `deepseek-flash` 항목 추가 | +| `registry.ts:1760, 1768, 1776, 1803, 1813` (opencode-go) | `DEEPSEEK_GATEWAY_THINKING_MODELS`로 전환 | +| `registry.ts:1791` (go `noVisionModels`, 리터럴) | `deepseek-v4.1-flash` 추가 | +| `registry.ts:3053-3071` (opencode-zen) | 게이트웨이 상수로 전환. 이 프리셋엔 `modelSupportsReasoningSummaries` 필드 자체가 없다 — 새로 만들지 않는다 | +| `registry.ts:3115` (opencode-free `noJsonSchemaModels`) | 게이트웨이 상수로 전환 | +| `src/providers/default-aliases.ts:54` 앞 | `/^deepseek-v4\.1/ → "ds41"` 을 `/^deepseek-v4/` **앞**에 둔다(첫 매치 승리). `/^deepseek-flash/ → "dsf"` 는 위치 무관 | + +**건드리지 않는 것**: `opencode-free`의 `noVisionModels`(`3111`)는 `OPENCODE_ZEN_TEXT_ONLY_MODELS` 참조라 여기에 넣으면 zen까지 오염된다. free는 원래 DeepSeek id를 이 목록에 갖고 있지 않으므로 그대로 둔다. `command-code`는 PR #4258 소유. 벤더 호스팅 9곳은 V4.1 서빙 근거가 없어 제외. + +## 수용 기준 + +1. `deepseek` 프리셋에서 `deepseek-flash`가 사다리·효율맵·요약·replay·noVision **다섯 곳 모두**에 나타난다. 이게 2차 감사가 잡은 실패 지점이므로 테스트로 직접 관측한다. +2. `opencode-go`에서 `deepseek-v4.1-flash`가 같은 대우를 받는다. +3. **반대 증거**: `deepseek` 프리셋에 `deepseek-v4.1-flash`가 없고, Zen 프리셋에 `deepseek-flash`가 없다. +4. 벤더 호스팅 프리셋(volcengine coding plan)의 DeepSeek 목록은 변하지 않는다. +5. `deepseek` `defaultModel`이 `deepseek-flash`다. + +## 갱신해야 하는 기존 테스트 (감사 열거) + +`tests/providers/provider-registry-parity.test.ts`: `197`(deepseek preserveReasoningContentModels `toEqual`), `199-201`(deepseek noVisionModels `toEqual`), `309`(defaultModel), `73-80`(go noVision `toEqual`), `86-92`(3종 noJsonSchema `toEqual`), `1421-1453`(DeepSeek id 열거). `tests/providers/opencode-go-deepseek.test.ts:159-160`(noJsonSchema `toEqual`). `tests/codex-integration/reasoning-effort.test.ts:274`(동일 `toEqual`). + +`parity:184`는 `toContain`이라 안전하고, `model-metadata-sync.test.ts`는 `scripts/model-metadata.source.json`만 입력으로 재생성·바이트 비교하므로 레지스트리 추가로 깨지지 않는다. + +## 기록해 두는 부수 사실 + +`scripts/model-metadata.source.json`에 `deepseek-flash`와 `deepseek-v4.1-flash` 행이 모두 없어 두 id의 비용 추정이 빈다. 생성 파일은 손대지 않는 방침(002)이므로 다음 메타데이터 생성에서 채워진다. PR 본문에 명시한다. + +`opencode-free`는 `liveModels: true`인데 게이트웨이 상수 전환이 `noJsonSchemaModels` 한 곳뿐이라 `deepseek-v4.1-flash`가 사다리와 replay를 받지 못한다. 기존 `deepseek-v4-pro`/`-flash`도 같은 비대칭이므로 신규 결함은 아니다. PR 본문에 한 줄 남긴다. + +## 검증 + +``` +bun test tests/providers/provider-registry-parity.test.ts +bun test tests/providers/opencode-go-deepseek.test.ts +bun test tests/providers/deepseek-reasoning-replay.test.ts +bun test tests/codex-integration/slug-codec.test.ts +bun run typecheck +``` diff --git a/devlog/_plan/260911_deepseek_v41_transition/030_phase3_v4pro_removal.md b/devlog/_plan/260911_deepseek_v41_transition/030_phase3_v4pro_removal.md new file mode 100644 index 0000000000..f4ea052422 --- /dev/null +++ b/devlog/_plan/260911_deepseek_v41_transition/030_phase3_v4pro_removal.md @@ -0,0 +1,90 @@ +# 030 — wp4: `deepseek-v4-pro` 퇴역 제거 + +## 커밋 분리 + +제거 근거의 강도가 프로바이더마다 다르므로 두 커밋으로 나눈다. 리뷰어가 뒤쪽만 떼어낼 수 있어야 한다. + +**커밋 A — DeepSeek 1st-party와 그것을 되파는 경로 (근거 강함)** + +| 대상 | 앵커 | +| --- | --- | +| `deepseek` 프리셋 | `registry.ts:2038-2078` — `modelContextWindows`, `modelWireDefaults`, `modelResponsesTerminalRepair`에서 제거 | +| `DEEPSEEK_THINKING_MODELS` | `registry.ts:619` — v4-pro 제거. Zen 3종과 volcengine 플랜이 이 상수를 공유하므로 파급을 각 사용처에서 확인 | +| `opencode-go` `noVisionModels` | `registry.ts:1793` | +| `command-code` 계열 | `registry.ts:631, 1180-1190, 2305`, `command-code-efforts.ts`, `adapters/command-code.ts:498` | +| `cline-pass` | `registry.ts:1144, 1199`, `adapters/cline-pass-deepseek-v4-tool-replay.ts:5` | +| `orcarouter` | `registry.ts:1180-1190` | +| `codebuddy` / `qoder` | `codebuddy-models.ts:38,124,145`, `qoder-models.ts:13` | +| `router.ts:686` | 잔여 참조 | +| 주석 (감사 추가) | `registry.ts:631, 719, 2038, 2305` — 코드에서 사라진 뒤에도 주석이 남으면 수용기준 1이 성립하지 않는다 | + +## wp4 감사 반영 (2026-09-11): 삭제만으로는 사라지지 않는다 + +감사가 결정적인 사실을 잡았다. `liveModels: true`인 프로바이더(cline-pass, orcarouter, baseten, commandcode, command-code, digitalocean, qoder)는 **정적 행을 지워도 모델이 라이브 디스커버리로 다시 올라온다.** 지워지는 건 모델이 아니라 컨텍스트 창·사다리·text-only 힌트뿐이다. 그 결과는 제거가 아니라 순수 퇴행이다 — 비전 사이드카가 이미지를 떨구고 replay 완화가 사라진 채로 모델이 계속 보인다. + +그래서 제거는 두 메커니즘으로 갈린다. + +| 프로바이더 성격 | 대상 | 방법 | +| --- | --- | --- | +| 정적 `models:` 로스터 | alibaba-token-plan, alibaba-token-plan-intl, volcengine ark/coding/agent, ollama, nvidia-nim | 행 삭제 — 실제로 사라진다 | +| 라이브 디스커버리 | cline-pass, orcarouter, baseten, commandcode, command-code, digitalocean, qoder | `ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS`(`src/codex/catalog/parsing.ts:180`)에 슬러그 등록 — 이게 실제로 카탈로그에서 빼는 유일한 수단이다. 그 위에서 정적 메타데이터 행도 함께 정리한다 | + +### 제외 슬러그 형식 (확인됨) + +`catalogModelSlug`(`parsing.ts:842`)는 `model.alias ?? routedSlug(provider, id)`이고, 모델 id 안의 슬래시는 하이픈이 된다. 실제 예시가 테스트에 박혀 있다: `commandcode/deepseek-deepseek-v4-pro`(`tests/codex-integration/codex-catalog.test.ts:2230`). + +따라서 등록할 슬러그는 다음 형태다. **각각 실제 카탈로그 출력으로 확인한 뒤 넣는다 — 형식이 틀리면 제외가 조용히 아무 일도 하지 않는다.** + +| 프로바이더 | 모델 id | 슬러그 | +| --- | --- | --- | +| `commandcode` | `deepseek/deepseek-v4-pro` | `commandcode/deepseek-deepseek-v4-pro` | +| `command-code` | `deepseek/deepseek-v4-pro` | `command-code/deepseek-deepseek-v4-pro` | +| `orcarouter` | `deepseek/deepseek-v4-pro` | `orcarouter/deepseek-deepseek-v4-pro` | +| `cline-pass` | `cline-pass/deepseek-v4-pro` | `cline-pass/cline-pass-deepseek-v4-pro` | +| `baseten` | `deepseek-ai/DeepSeek-V4-Pro` | `baseten/deepseek-ai-DeepSeek-V4-Pro` | +| `digitalocean` | (확인 필요) | (확인 필요) | +| `qoder` | (확인 필요) | (확인 필요) | + +## 감사가 잡은 나머지 + +- `registry.ts:2864` volcengine-agent-plan `defaultModel`이 `deepseek-v4-pro`다. 같은 커밋에서 로스터 내 다른 id로 교체한다. +- `ORCAROUTER_TEXT_ONLY_MODELS`(`1204`)와 `ORCAROUTER_MODEL_REASONING_EFFORT_MAP`(`1210`)은 v4-pro만 담고 있어 빈 컬렉션이 된다. `types/provider.ts:735`가 빈 배열을 "명시적 opt-out"으로 정의하므로 **빈 채로 두지 말고 상수와 소비 필드를 함께 삭제**한다. +- 대문자 id는 소문자 `rg`에 안 잡힌다: `registry.ts:1031,1042,1052`(baseten `deepseek-ai/DeepSeek-V4-Pro`), `qoder-models.ts:13`. 완료 기준의 `rg`는 `-i`를 쓴다. +- 내가 baseten이라고 적었던 `registry.ts:1080`은 실제로 DigitalOcean 목록이다. +- `command-code-efforts.ts:4` 행을 지우면 `router.ts:107`의 `knownModelIdsForProvider`가 그 키맵을 known-id 소스로 쓰므로 슬러그 디코드가 사라진다. 방금 머지된 v4.1-flash 행은 다른 키라 대체가 아니다. `146`행 주석도 사라진 행을 가리키게 되므로 같이 고친다. +- 후속 대상: 9개 로케일 문서, `frontier-benchmarks.json`, `src/generated/model-metadata.ts`, `model-rename-migration.ts:111`(사용자 config 마이그레이션), `structure:check`. + +**커밋 B — 벤더 호스팅 (근거 약함, 분리)** + +`alibaba-token-plan`/`-intl`, `volcengine` ark/coding/agent (`deepseek-v4-pro-260425` 포함), `ollama`, `nvidia-nim`, `baseten`. + +**`volcengine-agent-plan`의 `defaultModel`이 `deepseek-v4-pro`다(`registry.ts:2832`).** 제거하면 기본 모델이 비므로 같은 커밋에서 대체 기본값을 정해야 한다. 이 프리셋의 나머지 로스터에서 고른다. + +이 벤더들은 자체 스냅샷과 일정으로 배포한다. DeepSeek 1st-party 퇴역 공지가 그들의 로스터를 끝내지 않는다. 지시는 전부 제거였으므로 실행하되, PR 본문에 이 구분과 되돌리는 방법을 명시한다. + +## 손대지 않는 것 + +`scripts/model-metadata.source.json`과 `src/generated/model-metadata.ts`. 생성 파일이고, `src/usage/cost.ts`가 과거 사용량 원가를 이 표로 계산한다. 행을 지우면 이미 기록된 요청의 비용이 깨진다. 002 참조. + +## 수용 기준 + +1. `rg "deepseek-v4-pro" src`가 생성 파일을 제외하고 0건이다. +2. 레지스트리 멤버십을 고정하던 테스트가 갱신되고 통과한다. +3. 반대 증거: `deepseek-v4-flash` 별칭은 남는다 — DeepSeek이 이름을 유지한다고 명시했고, 그걸 지우면 기존 사용자 config가 깨진다. +4. **어느 프리셋의 `defaultModel`도** 퇴역 id를 가리키지 않는다. `deepseek`뿐 아니라 `volcengine-agent-plan`(2832)을 포함한다. +5. 주석에도 `deepseek-v4-pro`가 남지 않는다. + +## 검증 + +``` +bun test tests/providers tests/codex-integration/codex-catalog.test.ts +bun test tests/gui/volcengine-providers.test.ts tests/providers/baseten-provider.test.ts +bun run typecheck +rg "deepseek-v4-pro" src --glob "!src/generated/**" +``` + +## 리스크 + +영향 파일이 62개이고, 레지스트리 멤버십을 고정하는 테스트만 24개다(002 정정). 전체 스위트를 로컬에서 돌리지 않으므로(사용자 지시) 놓친 참조는 CI가 잡는다. CI 실패 시 해당 파일만 좁혀 고친다. + +사다리 자체는 바뀌지 않는다는 점도 기록해 둔다: `DEEPSEEK_PRO_THINKING_EFFORTS`와 `DEEPSEEK_FLASH_THINKING_EFFORTS`는 값이 같다(`registry.ts:701-715`). 퇴역으로 실제로 어긋나는 건 컨텍스트 창과 가격이다. diff --git a/devlog/_plan/260911_deepseek_v41_transition/040_phase4_merge.md b/devlog/_plan/260911_deepseek_v41_transition/040_phase4_merge.md new file mode 100644 index 0000000000..b9f263923e --- /dev/null +++ b/devlog/_plan/260911_deepseek_v41_transition/040_phase4_merge.md @@ -0,0 +1,31 @@ +# 040 — wp5: PR 게시와 머지 + +## docs-site 동기화 (감사 추가) + +`deepseek-v4-pro`는 9개 로케일의 `guides/providers.md`, `guides/sidecars.md`, `guides/model-ordering.md`, `reference/configuration/providers.md`와 `docs-site/src/data/frontier-benchmarks.json`에 등장한다. 코드에서 모델을 지우면서 문서가 그대로면 영문 원문과 로케일이 동시에 거짓이 된다. + +범위: 제거된 모델을 **사용 가능한 모델로 제시하는** 문장만 고친다. 벤치마크 데이터(`frontier-benchmarks.json`)는 과거 측정 기록이므로 손대지 않는다 — 생성 메타데이터를 남기는 것과 같은 이유다. + +## 순서 + +1. #4258 머지 (wp2에서 완료) → `dev` fetch → 내 브랜치 리베이스 +2. #4274(Zen 프리셋 안정화) CI green 확인 후 머지 +3. V4.1 전환 변경을 새 PR로 게시하고 CI green 확인 후 머지 + +#4274를 먼저 머지하는 이유: 이미 리뷰가 끝났고 CI가 거의 다 통과했다. V4.1 변경과 같은 파일(`registry.ts`)을 건드리므로, 뒤에 올리는 쪽이 리베이스한다. + +## 머지 조건 (MAINTAINERS.md) + +- base `dev` +- exact-head CI green — 머지 직전 `gh pr checks`로 확인하고 커밋 SHA와 함께 기록 +- 유지관리자 단독 통합 시 결정 근거를 남긴다 +- `main`/`preview` 승격과 릴리스는 이번 범위 밖 + +## PR 본문에 반드시 들어갈 것 + +- V4.1 전환 근거와 출처 링크 +- 조사 결과가 갈렸다는 사실과 어느 해석을 택했는지 (001 참조) +- id 분기 이유: 네이티브 `deepseek-flash` vs 게이트웨이 `deepseek-v4.1-flash` +- v4-pro 제거를 두 커밋으로 나눈 이유와, 벤더 호스팅 커밋만 되돌리는 방법 +- 생성 메타데이터를 손대지 않은 이유 (과거 사용량 원가 계산) +- 전체 스위트를 로컬에서 돌리지 않았다는 사실 diff --git a/devlog/_plan/260911_r2_merge_train/000_plan.md b/devlog/_plan/260911_r2_merge_train/000_plan.md new file mode 100644 index 0000000000..48fc4be82d --- /dev/null +++ b/devlog/_plan/260911_r2_merge_train/000_plan.md @@ -0,0 +1,97 @@ +# 260911 R2 merge train — land #4244, #4248, #4246, #4247 on dev + +## Objective + +Four open PRs authored on 2026-09-11 (`codex/260911-r2-*`) are each 65 commits behind +`origin/dev` at `18e553a52`. All four were green at their pre-rebase heads, and two of +them have since gone `CONFLICTING`. This unit rebases each onto the current `dev`, +re-proves it, and merges it — one at a time, as a serialized train. + +The train is serialized rather than parallel for one concrete reason: #4246 and #4248 +both append to `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`. Those two files are sorted registries that +`tests/test-layout.test.ts` and `tests/test-layout-tooling.test.ts` enforce, so two +branches that each add one line to the same sorted block will conflict textually no +matter how trivially compatible the changes are. Rebasing the second one only after the +first is already on `dev` turns a two-sided conflict into a one-sided replay. + +## Scope + +In scope: the files already touched by the four branches, their conflict resolutions +against `dev`, and this planning unit. + +Out of scope: every other open PR (#4256, #4258, #4259 and all third-party PRs), any new +feature work, any promotion of `main` or `preview`, any force-push to a protected +branch, and any edit to another author's branch. + +## Authority + +The user explicitly authorized rebase, force-push to these four PR branches, and merge +into `dev` in this session. `MAINTAINERS.md` permits a maintainer with `maintain` or +`admin` access to integrate their own PR into `dev` through a PR without a second +approval, provided the decision and exact-head CI evidence are recorded. This document +plus the per-phase records below are that record. + +That authority stops at `dev`. It does not cover `main`/`preview` promotion, releases, +branch deletion beyond the merged PR branches, or any other author's work. + +## Work-phase map (dependency-ordered) + +| Phase | PR | Branch | Pre-state | Doc | +|-------|----|--------|-----------|-----| +| wp1 | — | — | this roadmap | `000_plan.md` | +| wp2 | #4244 | `codex/260911-r2-catalog-pool` | MERGEABLE, clean replay | `010_phase1_pr4244.md` | +| wp3 | #4248 | `codex/260911-r2-pool-account-attribution` | MERGEABLE, clean replay | `020_phase2_pr4248.md` | +| wp4 | #4246 | `codex/260911-r2-client-display` | CONFLICTING, registry-only | `030_phase3_pr4246.md` | +| wp5 | #4247 | `codex/260911-r2-docs-locales` | CONFLICTING, substantive | `040_phase4_pr4247.md` | + +Order is cheapest-and-safest first. #4244 and #4248 replay cleanly onto `dev` +(`git merge-tree --write-tree` exits 0 for both, and `dev` has no commits touching their +source files since the merge base), so they land first and shrink the train before the +two conflicting branches are touched. #4246's conflict is a single sorted-registry line. +#4247's is the only one where `dev` and the PR edited the same prose and the same test +oracle, so it goes last, when nothing else is queued behind it. + +## Verification protocol (every implementation phase) + +Each of wp2–wp5 runs one full PABCD cycle and clears the same gate before its merge: + +1. `git rebase origin/dev` on the PR branch, conflicts resolved by hand, PR intent preserved. +2. `bun run typecheck` — exit 0. +3. The PR's own test files, run by path. Whenever `layout.json` or + `test-layout-expected.json` is in the touch set, add `tests/test-layout.test.ts` and + `tests/test-layout-tooling.test.ts`; those two are the guards that a hand-resolved + registry conflict can silently break. +4. `git push --force-with-lease` to that PR branch only. +5. `gh pr checks ` green at the exact new head SHA — not at a previous head. +6. A comment on the PR recording the maintainer-integration decision and the exact head + SHA that CI verified. `MAINTAINERS.md:59-64` permits a maintainer with `admin` or + `maintain` access to integrate their own PR into `dev` without a second approval, and + requires that the choice and the exact-head verification be recorded in the PR + description or a comment. The account driving this train holds `admin`. +7. `gh pr merge --merge` only after steps 5 and 6. +8. `git fetch origin` and re-check the remaining branches' mergeability, because the + merge just moved the base out from under them. + +The merge method is `--merge`, not `--squash`. The repository allows both, but every +recent integration on `dev` is a merge commit (`18e553a52`, `42184ead0`, `6d8ed37ad`, +`5557612d4`, ...) with the branch's individual commits preserved beneath it. Squashing +these four would break that convention and, for #4246, would discard the two review-round +commit messages that explain what the adversarial review changed. + +`AGENTS.md` reserves the repository-wide `bun run test` for the PR-ready gate and for +touch sets whose dependencies are not visible to Bun's module graph. Every phase here is +already a published PR, so CI runs the full suite on three platforms at step 5 regardless; +the local runs above exist to catch a bad conflict resolution before it costs a CI cycle. + +## Acceptance + +DONE when all four PRs are merged into `dev`, each with green required CI recorded at its +own rebased head SHA, and no target PR is left open or conflicting. + +BLOCKED if a conflict cannot be resolved without changing what the PR meant, or CI fails +at a rebased head for a reason the rebase did not introduce, and the same blocker survives +three goal turns. + +NEEDS_HUMAN if merging requires authority this session does not hold — for example a +branch protection rule that refuses the maintainer self-integration path. diff --git a/devlog/_plan/260911_r2_merge_train/010_phase1_pr4244.md b/devlog/_plan/260911_r2_merge_train/010_phase1_pr4244.md new file mode 100644 index 0000000000..3ee73f39d4 --- /dev/null +++ b/devlog/_plan/260911_r2_merge_train/010_phase1_pr4244.md @@ -0,0 +1,47 @@ +# wp2 — PR #4244 `provider: seed GLM-5.3-Flash on the BigModel Responses preset` + +Branch `codex/260911-r2-catalog-pool`, head `481230445`, one commit, base `dev`. + +## What it changes + +MODIFY `src/providers/registry.ts` — the `zhipu-bigmodel-responses` entry gains +`glm-5.3-flash` in `models`, plus matching entries in `modelContextWindows` +(`1_048_576`), `modelInputModalities` (`["text", "image"]` — the only vision-capable row +on this preset), `modelReasoningEfforts` (`ZAI_GLM_53_REASONING_EFFORTS`), +`modelDefaultReasoningEfforts` (`"max"`) and `modelSupportsReasoningSummaries` (`true`). +`liveModels: false` and `apiKeyValidation: "unknown"` are deliberately unchanged, because +no upstream page establishes an authenticated `/models` contract for this endpoint. + +MODIFY `tests/providers/provider-registry-parity.test.ts` — the oracle test is renamed +from "exports only the officially documented static Codex models" to "exports the +documented Coding Plan roster for the Codex endpoint" and its expected `models` array +becomes `["glm-5.3", "glm-5.3-flash", "glm-5-turbo"]`. The locked-down assertions on +`liveModels` and `apiKeyValidation` stay. + +## Rebase expectation + +Clean. `git merge-tree --write-tree origin/dev origin/codex/260911-r2-catalog-pool` exits +0, and `git log ..origin/dev -- src/providers/registry.ts` is empty, so no +commit on `dev` has touched the registry since this branch forked. The replay should be a +straight fast-forward of one commit onto `18e553a52` or its successor. + +If a conflict does appear, it means another provider row landed on `dev` between this +plan and execution; re-read the incoming hunk before resolving, and keep this PR's row +additive rather than reordering neighbours. + +## Verification + +``` +bun run typecheck +bun test tests/providers/provider-registry-parity.test.ts +``` + +No layout-registry files are touched, so the test-layout guards are not required here. + +## Land + +``` +git push --force-with-lease origin codex/260911-r2-catalog-pool +gh pr checks 4244 --watch +gh pr merge 4244 --merge +``` diff --git a/devlog/_plan/260911_r2_merge_train/020_phase2_pr4248.md b/devlog/_plan/260911_r2_merge_train/020_phase2_pr4248.md new file mode 100644 index 0000000000..8798597029 --- /dev/null +++ b/devlog/_plan/260911_r2_merge_train/020_phase2_pr4248.md @@ -0,0 +1,59 @@ +# wp3 — PR #4248 `pool: name the account when a refresh fails or its models vanish` + +Branch `codex/260911-r2-pool-account-attribution`, head `605034a6d`, one commit, base `dev`. + +## What it changes + +MODIFY `src/codex/catalog/sync.ts`, `src/server/responses/compact.ts`, +`src/server/responses/core.ts` — pool refresh failures and disappearing model rosters are +attributed to the specific account they came from instead of being reported anonymously. + +MODIFY `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json` — +registry entries for the new test files. + +NEW `tests/responses/responses-pool-refresh-attribution.test.ts` and +`tests/codex-integration/catalog-gated-native-suppression-reason.test.ts`. + +## Rebase expectation + +Clean. `git merge-tree --write-tree` exits 0 against the current `dev`, and `dev` has no +commits touching `sync.ts`, `compact.ts` or `core.ts` since the merge base. The two +registry files auto-merge because this branch's added keys do not collide with the key +`dev` added (`cli-config-show-client.test.ts`). + +This phase runs *before* #4246 deliberately: #4246 adds `cli-connect-readiness.test.ts` to +the same sorted block that `dev` just touched and does conflict. Landing the non-conflicting +registry change first means #4246 later replays against one settled block instead of two +moving ones. + +Note the core-path constraint from `AGENTS.md`: `src/server/responses/core.ts` is one of +the three files that must not reach `src/lab/`. The conflict resolution must not introduce +an import that violates it; `tests/lab/core-lab-boundary.test.ts` is the guard. + +## Verification + +``` +bun run typecheck +bun test tests/responses/responses-pool-refresh-attribution.test.ts +bun test tests/codex-integration/catalog-gated-native-suppression-reason.test.ts +bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts +bun test tests/lab/core-lab-boundary.test.ts +``` + +The layout guards are mandatory here because both registry files are in the touch set. +The lab-boundary guard is cheap and `core.ts` is in the touch set. + +`compact.ts` has no focused test of its own in this list. Its change threads the account +namespace into `poolCredentialRefreshIncompleteResponse`, which lives in `core.ts` and is +covered by `responses-pool-refresh-attribution.test.ts`, so the behaviour is reached +indirectly rather than unverified. Accepted as-is for a phase that is replaying an already +green PR: CI runs the full suite at the rebased head, which is where a compact-path +regression would surface. Worth a dedicated test if this code is touched again. + +## Land + +``` +git push --force-with-lease origin codex/260911-r2-pool-account-attribution +gh pr checks 4248 --watch +gh pr merge 4248 --merge +``` diff --git a/devlog/_plan/260911_r2_merge_train/030_phase3_pr4246.md b/devlog/_plan/260911_r2_merge_train/030_phase3_pr4246.md new file mode 100644 index 0000000000..cfbed7601e --- /dev/null +++ b/devlog/_plan/260911_r2_merge_train/030_phase3_pr4246.md @@ -0,0 +1,83 @@ +# wp4 — PR #4246 `client: report local Codex readiness instead of bare connected state` + +Branch `codex/260911-r2-client-display`, head `e53999762`, three commits, base `dev`. +State before rebase: `CONFLICTING` / `DIRTY`. + +## What it changes + +MODIFY `src/cli/connect.ts`, `src/cli/status.ts`, `src/client/catalog-compatibility.ts`. +MODIFY `scripts/test-layout/layout.json`, `tests/fixtures/test-layout-expected.json`. +NEW `tests/cli/cli-connect-readiness.test.ts`. +MODIFY `tests/cli/cli-status-json.test.ts`, `tests/clients/client-catalog-compatibility.test.ts`. + +Three commits, the second and third of which fold an adversarial review and give the +write-time gate the same observer in production. Keep all three on the rebase and do not +squash them locally: this repository merges with merge commits, so all three land on +`dev` individually and their messages stay the record of what the review changed. + +## The conflict, exactly + +Two files, one hunk each, and both are the same shape. In `scripts/test-layout/layout.json`: + +``` + "cli-config-command.test.ts": "cli", +<<<<<<< origin/dev + "cli-config-show-client.test.ts": "cli", +======= + "cli-connect-readiness.test.ts": "cli", +>>>>>>> origin/codex/260911-r2-client-display + "cli-dispatch.test.ts": "cli", +``` + +`tests/fixtures/test-layout-expected.json` carries the identical conflict at the same +position with two fewer spaces of indentation. + +This is an additive collision, not a disagreement: `dev` registered +`cli-config-show-client.test.ts` while this branch registered +`cli-connect-readiness.test.ts`. The resolution keeps **both** lines, in sorted order — +`cli-config-show-client.test.ts` first, because `config` sorts before `connect` at the +fourth character (`f` < `n`). + +Resolved form, in both files: + +``` + "cli-config-command.test.ts": "cli", + "cli-config-show-client.test.ts": "cli", + "cli-connect-readiness.test.ts": "cli", + "cli-dispatch.test.ts": "cli", +``` + +Taking either side alone is a silent failure with two different signatures, which is why +the guards below are not optional: dropping `dev`'s line un-registers a test file that is +already on `dev` (`tests/test-layout.test.ts` fails — a file that resolves to no domain), +and dropping this branch's line un-registers the new one (`tests/test-layout-tooling.test.ts` +fails and names the missing entry). + +The guards catch *membership*, not ordering. `tests/test-layout-tooling.test.ts` compares +the fixture with `toEqual` on a parsed object, which is key-order independent, and +`tests/test-layout.test.ts` carries no sort assertion at all. So the sorted placement above +is file hygiene — it keeps the next diff on this block one line instead of a reshuffle — +while the thing the guards would actually fail on is a dropped or mismatched entry. Both +matter; only one of them is machine-enforced, and the resolution should not lean on the +wrong one. + +## Verification + +``` +bun run typecheck +bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts +bun test tests/cli/cli-connect-readiness.test.ts +bun test tests/cli/cli-status-json.test.ts +bun test tests/clients/client-catalog-compatibility.test.ts +``` + +The two layout guards run first here, not last: they are the direct oracle for the only +hand-edit the rebase requires. + +## Land + +``` +git push --force-with-lease origin codex/260911-r2-client-display +gh pr checks 4246 --watch +gh pr merge 4246 --merge +``` diff --git a/devlog/_plan/260911_r2_merge_train/040_phase4_pr4247.md b/devlog/_plan/260911_r2_merge_train/040_phase4_pr4247.md new file mode 100644 index 0000000000..a4aa41a222 --- /dev/null +++ b/devlog/_plan/260911_r2_merge_train/040_phase4_pr4247.md @@ -0,0 +1,127 @@ +# wp5 — PR #4247 `docs(i18n): make the remote hub guide runnable in every locale` + +Branch `codex/260911-r2-docs-locales`, head `7730f08a7`, one commit, base `dev`. +State before rebase: `CONFLICTING` / `DIRTY`. This is the only substantive conflict in +the train, which is why it is last. + +## What it changes + +MODIFY the seven translated copies of the remote hub guide — +`docs-site/src/content/docs/{fr,ja,ko,ru,tr,zh-cn,zh-tw}/guides/remote-hub.md` — so each +one carries the corrected command ordering that round one (#4200) applied to the English +source only. + +MODIFY `tests/ci-workflows/docs-remote-hub-claims.test.ts` — the oracle stops reading one +file. It gains `TRANSLATED` and `LOCALE_GUIDES` and a `remote hub guide translations` +describe block that runs the same expectations over all eight locales, English included. + +## Why it conflicts + +`dev` landed #4236 in the same two files while this branch was open. #4236 rewrote the +Korean guide for the one-port recipe and added its own `the one-port hub recipe` describe +block plus a `KO_GUIDE` constant to the same test file. So both sides added a block to the +same oracle and both sides rewrote `ko/guides/remote-hub.md`. + +`git merge-tree` reports two conflicted files: the test file with three conflicted regions, +and `ko/guides/remote-hub.md` with four (twelve markers). + +## Resolution contract + +Both sides are additive in intent and neither may be dropped. Concretely: + +**`tests/ci-workflows/docs-remote-hub-claims.test.ts`** + +1. *Header comment.* Keep both paragraphs. `dev`'s explains why the manual + `export OPENCODEX_API_AUTH_TOKEN` step must stay gone; this branch's explains why the + oracle stopped reading one file. They document different groups and neither replaces + the other. +2. *Constants.* Keep `GUIDE`, then this branch's `TRANSLATED` / `LOCALE_GUIDES`, and keep + `dev`'s `KO_GUIDE` — `the one-port hub recipe` block references it directly. Do not + try to derive one from the other; a lookup into `LOCALE_GUIDES` to save four lines would + make `dev`'s block depend on this branch's array ordering for no benefit. +3. *Describe blocks.* Keep both, side by side: `the one-port hub recipe` (en + ko) from + `dev`, and `remote hub guide translations` (all eight) from this branch. + +**`docs-site/src/content/docs/ko/guides/remote-hub.md`** + +The Korean page must satisfy both oracles after the merge, and that is the actual +acceptance test for this resolution rather than any judgement about prose. It must keep +#4236's one-port content — the port-less companion form +`ocx config set unauthenticatedLoopbackListener '{"enabled":true}'`, the ported +`{"enabled":true,"port":10104}` alternative, `service-api-token`, `ocx hub invite`, +`ocx config set corsAllowOrigins '["http://localhost:10100"]'`, `--pairing-code-stdin`, and +**no** line matching `/^\s*export\s+OPENCODEX_API_AUTH_TOKEN/m` — while also keeping this +branch's ordering fix: `ocx config set hub '{}'` before any `ocx config set hub.`, the same +for `remoteGui`, and the literal string `config parent path not found: hub`. + +Where the two rewrites touch the same paragraph, `dev`'s newer one-port wording wins on +content and this branch's corrected command ordering wins on sequence. They are compatible: +the ordering fix is about which `ocx config set` line comes first, not about what the +recipe says. + +## The port reconciliation (audited blocker, must be done) + +This branch's translations block includes `"en"` in `LOCALE_GUIDES` and asserts the same +markers over every locale. #4236 rewrote the English guide after this branch forked, and +the A-phase audit found one marker pair that genuinely diverged. This is not a risk to +check — it is a confirmed conflict with a required fix. + +The branch asserts, for all eight locales: + +``` +socat TCP-LISTEN:10100,bind=127.0.0.1 +tailscale serve --bg --https=8443 http://127.0.0.1:10100 +``` + +`origin/dev` now carries `10110` in both lines, in the English guide and in the Korean one, +and all seven translated guides on this branch still carry `10100`. + +**10110 is the correct value and 10100 is now a defect.** #4236 enabled the loopback +companion listener, which binds `127.0.0.1:10100` — the proxy port itself. The English +guide says so in the comment directly above the command: "Pick a port the hub is not +already using: with the loopback companion enabled, `127.0.0.1:10100` belongs to opencodex +itself." A reader following any of the seven translations would bind socat onto the +companion listener's own port and get a collision. + +So the resolution is not "make the assertion match the file". It is to finish the job this +PR exists to do — carry the English fix into the translations: + +1. In all seven translated guides, change `socat TCP-LISTEN:10100,bind=127.0.0.1` to + `socat TCP-LISTEN:10110,bind=127.0.0.1` and + `tailscale serve --bg --https=8443 http://127.0.0.1:10100` to `...:10110`. The forwarder + *destination* `TCP:100.64.0.10:10100` stays 10100 — that is the tailnet-bound proxy + port and it did not move. Only the loopback listen port changes. +2. Carry the explanatory comment above the command too, in each locale's own language, + and the `tailscale serve status # expect both mappings: 443 -> 10101, 8443 -> 10110` + line. A translation that changes the port without the reason is a worse artifact than + one that is merely stale. +3. Update the two assertions in the translations block to `10110`. +4. Keep `dev`'s `10110` in the Korean guide when resolving its four conflicted regions. + +Every other marker the block pins was audited against the current `dev` English guide and +is still satisfied: the `hub` / `remoteGui` `'{}'` initializer ordering, the literal +`config parent path not found: hub`, the whole-object `ocx config set hub '{"managementPublicOrigin"` +form with its replace-not-merge caveat, `403 origin_rejected`, `X-Forwarded-Host`, and the +absence of `--allow-insecure-http`. + +## Verification + +``` +bun run typecheck +bun test tests/ci-workflows/docs-remote-hub-claims.test.ts +``` + +The oracle reads the eight markdown files as data, which `bun run test:changed` cannot see +through its module graph. That is the indirect-dependency exception in `AGENTS.md`, so run +this file by path and do not rely on change detection to select it. + +No source under `src/` is touched, so this is docs-only work with a test oracle attached; +the relevant consistency gate is the oracle itself. + +## Land + +``` +git push --force-with-lease origin codex/260911-r2-docs-locales +gh pr checks 4247 --watch +gh pr merge 4247 --merge +``` diff --git a/docs-site/src/content/docs/fr/guides/model-ordering.md b/docs-site/src/content/docs/fr/guides/model-ordering.md index b22bf9b827..fab755afdf 100644 --- a/docs-site/src/content/docs/fr/guides/model-ordering.md +++ b/docs-site/src/content/docs/fr/guides/model-ordering.md @@ -124,7 +124,7 @@ au-delà de ce bloc mis en avant : ```json { "modelPickerOrder": [ - "tyler/deepseek-v4-pro", + "tyler/deepseek-v4-flash", "jd-chat/kimi-k3", "jd-chat/glm-5.2" ] diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 67289f68dd..7c9f3fc682 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -370,7 +370,7 @@ modèle ; les flux mal formés ou partiels sont fermés comme incomplets, et non > des ressources d'embedding, d'image, de vidéo et de 3D, la passerelle Coding renvoie le même catalogue étendu, > et la passerelle Agent Plan ne possède aucune ressource `/models`. Le modèle par défaut de la route facturée à > l'usage est `doubao-seed-2-1-pro-260628` ; son catalogue sélectionné comprend également les modèles de texte -> DeepSeek et GLM actuels. Coding Plan utilise `ark-code-latest` par défaut, et Agent Plan `deepseek-v4-pro`. +> DeepSeek et GLM actuels. Coding Plan utilise `ark-code-latest` par défaut, et Agent Plan `deepseek-v4-flash`. > **Restriction d'utilisation des forfaits Volcengine :** selon la documentation de Volcengine, les quotas > Coding Plan et Agent Plan ne sont valables que dans les outils de programmation par IA pris en charge. Elle @@ -604,7 +604,7 @@ native d'Ollama (`POST /api/chat`) plutôt que via la surface compatible OpenAI, liste des modèles auprès du fournisseur : les nouveaux modèles Ollama Cloud apparaissent sans modifier la configuration. opencodex classe les modèles cloud selon leurs capacités visuelles, afin que le [service auxiliaire de vision](/fr/guides/sidecars/) n'intervienne que pour les modèles -exclusivement textuels. Ces derniers, par exemple `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, +exclusivement textuels. Ces derniers, par exemple `glm-5.2`, `deepseek-v4-flash`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x` et `nemotron-3-*`, figurent dans `noVisionModels` ; les modèles à vision native, comme `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5` et `gemini-3-flash-preview`, n'y figurent pas. La correspondance tolère les balises `:size` d'Ollama : `gpt-oss` couvre donc `gpt-oss:120b` et `gpt-oss:20b`. diff --git a/docs-site/src/content/docs/fr/guides/sidecars.md b/docs-site/src/content/docs/fr/guides/sidecars.md index b778c4c99e..27bcda75cb 100644 --- a/docs-site/src/content/docs/fr/guides/sidecars.md +++ b/docs-site/src/content/docs/fr/guides/sidecars.md @@ -146,7 +146,7 @@ Un modèle est marqué en texte uniquement par fournisseur : "providers": { "ollama-cloud": { "baseUrl": "https://ollama.com/v1", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } } } diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 563286c2bb..b8da2537bb 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -514,7 +514,7 @@ le nom actuel avant d'effectuer une autre modification. "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", - "noVisionModels": ["glm-5.2", "glm-5.3", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "glm-5.3", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } }, "subagentModels": ["anthropic/claude-opus-5", "ollama-cloud/glm-5.2"], diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 5333b839ce..45ec0666d6 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -122,7 +122,7 @@ featured block: ```json { "modelPickerOrder": [ - "tyler/deepseek-v4-pro", + "tyler/deepseek-v4-flash", "jd-chat/kimi-k3", "jd-chat/glm-5.2" ] diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 6b359a8ad5..7119be8643 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -401,7 +401,7 @@ free-experimentation model. **OrcaRouter** ([sponsor](https://github.com/lidge-jun/opencodex/blob/main/SPONSORS.md)) is an OpenAI-compatible gateway at `https://api.orcarouter.ai/v1` with vendor-namespaced model ids -(`openai/gpt-5.5`, `anthropic/claude-opus-4.8`, `deepseek/deepseek-v4-pro`, ...) and an adaptive +(`openai/gpt-5.5`, `anthropic/claude-opus-4.8`, `deepseek/deepseek-v4-flash`, ...) and an adaptive router, `orcarouter/auto`, that grades each prompt and picks the model. Create a key in the [OrcaRouter console](https://www.orcarouter.ai/console); the preset pins the row near the top of the Add provider picker and marks it as a sponsor, and nothing else about routing or defaults changes. @@ -524,7 +524,7 @@ streams close as incomplete rather than being reported as successful. > and the Agent Plan gateway has no `/models` resource. Pay-as-you-go defaults to > `doubao-seed-2-1-pro-260628`; its curated catalog also includes current DeepSeek and GLM text > models. Coding Plan defaults to `ark-code-latest`, while Agent Plan defaults to -> `deepseek-v4-pro`. +> `deepseek-v4-flash`. > **Volcengine Plan usage restriction:** Volcengine documents Coding Plan and Agent Plan quota as > valid only inside supported AI coding tools, and warns that using a plan key for general API @@ -961,7 +961,7 @@ Ollama's own REST API (`POST /api/chat`) rather than the OpenAI-compatible surfa the live model roster from the provider, so new Ollama Cloud models appear without a config change. opencodex classifies its cloud lineup by vision capability so the [vision sidecar](/guides/sidecars/) only kicks in for -text-only models. Text-only models (e.g. `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, +text-only models. Text-only models (e.g. `glm-5.2`, `deepseek-v4-flash`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`) are listed in `noVisionModels`; vision-native models (e.g. `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, `gemini-3-flash-preview`) are not. Matching is tolerant of Ollama's `:size` tags, so `gpt-oss` covers `gpt-oss:120b` and `gpt-oss:20b`. diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index d0c79d272e..543fe49e96 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -185,7 +185,7 @@ A model is marked text-only per provider: "providers": { "ollama-cloud": { "baseUrl": "https://ollama.com/v1", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } } } diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 5056d63594..d0025981aa 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -250,7 +250,7 @@ Volcengine Agent Plan は `openai-responses` アダプターでネイティブ R > Agent Plan ゲートウェイには `/models` リソースがありません。従量課金のデフォルトは > `doubao-seed-2-1-pro-260628` で、静的カタログには現在の DeepSeek と GLM のテキストモデルも > 含まれます。Coding Plan のデフォルトは `ark-code-latest`、Agent Plan は -> `deepseek-v4-pro` です。 +> `deepseek-v4-flash` です。 **Chutes の discovery:** `chutes` preset は Chutes の固定された共有 OpenAI 互換 LLM gateway を使います。 公開 `/v1/models` catalog から `supported_features` が `tools` を示す行だけを残し、スラッシュを含む @@ -427,7 +427,7 @@ Ollama Cloud はホステッド型(ローカルではない)Ollama です。`htt サーフェスではなく Ollama 自身の REST API(`POST /api/chat`)で接続し、モデル一覧はプロバイダーから 動的に取得するため、新しい Ollama Cloud モデルは設定変更なしで現れます。opencodex はクラウド ラインナップをビジョン機能で分類し、[ビジョンサイドカー](/ja/guides/sidecars/)がテキスト専用モデルにのみ -動作するようにします。テキスト専用モデル(例: `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、 +動作するようにします。テキスト専用モデル(例: `glm-5.2`、`deepseek-v4-flash`、`gpt-oss`、`qwen3-coder`、 `minimax-m2.x`、`nemotron-3-*`)は `noVisionModels` に列挙され、ビジョンネイティブモデル(例: `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)は含まれません。マッチングは Ollama の `:size` タグに寛容なので `gpt-oss` は `gpt-oss:120b` と `gpt-oss:20b` の両方を含みます。 diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index dec111160f..8d2fd18ef1 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -125,7 +125,7 @@ OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.4-mini` を "providers": { "ollama-cloud": { "baseUrl": "https://ollama.com/v1", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } } } diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index b8ee061b4a..002cedbec0 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -437,7 +437,7 @@ Vercel AI Gateway は、1 つのモデルを複数の基盤となる推論プロ "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } }, "subagentModels": ["anthropic/claude-opus-5", "ollama-cloud/glm-5.2"], diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 951dff100e..9fb1bf9ed1 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -248,7 +248,7 @@ Volcengine Agent Plan은 `openai-responses` 어댑터로 네이티브 Responses > Embedding, 이미지, 비디오, 3D 리소스도 반환하고 Coding 게이트웨이도 같은 광범위한 카탈로그를 > 반환합니다. Agent Plan 게이트웨이에는 `/models` 리소스가 없습니다. 종량제 기본값은 > `doubao-seed-2-1-pro-260628`이며 정적 카탈로그에는 현재 DeepSeek와 GLM 텍스트 모델도 -> 포함됩니다. Coding Plan의 기본값은 `ark-code-latest`, Agent Plan은 `deepseek-v4-pro`입니다. +> 포함됩니다. Coding Plan의 기본값은 `ark-code-latest`, Agent Plan은 `deepseek-v4-flash`입니다. **Chutes 검색:** `chutes` 프리셋은 Chutes의 고정된 공유 OpenAI 호환 LLM gateway를 사용합니다. 공개 `/v1/models` catalog에서 `supported_features`가 `tools`를 명시한 행만 유지하고, 슬래시가 포함된 @@ -417,7 +417,7 @@ Ollama Cloud는 호스팅형(로컬이 아님) Ollama입니다. `https://ollama. 표면이 아니라 Ollama 자체 REST API(`POST /api/chat`)로 연결하며, 모델 목록을 공급자에서 직접 발견하므로 새 Ollama Cloud 모델이 설정 변경 없이 나타납니다. opencodex는 클라우드 라인업을 비전 기능에 따라 분류하여 [비전 사이드카](/ko/guides/sidecars/)가 텍스트 전용 모델에만 -작동하도록 합니다. 텍스트 전용 모델(예: `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, +작동하도록 합니다. 텍스트 전용 모델(예: `glm-5.2`, `deepseek-v4-flash`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`)은 `noVisionModels`에 나열되며, 비전 네이티브 모델(예: `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, `gemini-3-flash-preview`)은 포함되지 않습니다. 매칭은 Ollama의 `:size` 태그에 관대하므로 `gpt-oss`는 `gpt-oss:120b`와 `gpt-oss:20b`를 모두 포괄합니다. diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index 55af37a150..c07e8c589d 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -127,7 +127,7 @@ OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.4-mini`를 폴백으로 "providers": { "ollama-cloud": { "baseUrl": "https://ollama.com/v1", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } } } diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 0b5f8bf36a..7cfe363870 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -442,7 +442,7 @@ Vercel AI Gateway는 하나의 모델을 여러 기반 추론 공급자에 걸 "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } }, "subagentModels": ["anthropic/claude-opus-5", "ollama-cloud/glm-5.2"], diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index feebe4649b..65e57f19ab 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -921,7 +921,7 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", - "noVisionModels": ["glm-5.2", "glm-5.3", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "glm-5.3", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } }, "subagentModels": ["anthropic/claude-opus-5", "ollama-cloud/glm-5.2"], diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 8b1415f6b8..0811958d03 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -280,7 +280,7 @@ Volcengine Agent Plan использует нативную конечную т > каталог. У шлюза Agent Plan ресурса `/models` нет. Для pay-as-you-go модель по умолчанию — > `doubao-seed-2-1-pro-260628`; его статический каталог также включает актуальные текстовые модели > DeepSeek и GLM. Для Coding Plan модель по умолчанию — `ark-code-latest`, для Agent Plan — -> `deepseek-v4-pro`. +> `deepseek-v4-flash`. **Discovery для Chutes.** Пресет `chutes` использует фиксированный общий OpenAI-совместимый LLM gateway Chutes. Из публичного каталога `/v1/models` он оставляет только строки, где @@ -474,7 +474,7 @@ Ollama Cloud — это размещённая в облаке (не локал получает список моделей от провайдера, поэтому новые модели Ollama Cloud появляются без изменения конфигурации. opencodex классифицирует её облачную линейку по поддержке изображений, чтобы [vision-сайдкар](/ru/guides/sidecars/) включался -только для текстовых моделей. Текстовые модели (например, `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, +только для текстовых моделей. Текстовые модели (например, `glm-5.2`, `deepseek-v4-flash`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`) перечислены в `noVisionModels`; модели с нативной поддержкой изображений (например, `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, `gemini-3-flash-preview`) — нет. Сопоставление терпимо к тегам Ollama вида `:size`, поэтому diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index cdb6dc7087..57b437c1e6 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -139,7 +139,7 @@ opencodex описывает каждое изображение **до** осн "providers": { "ollama-cloud": { "baseUrl": "https://ollama.com/v1", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } } } diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 37b00b3150..5966758e29 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -533,7 +533,7 @@ Pool/Direct рекламирует `922000`; синхронизированны "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } }, "subagentModels": ["anthropic/claude-opus-5", "ollama-cloud/glm-5.2"], diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 1c166afd9b..9b9b557d24 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -409,7 +409,7 @@ bitirir ancak son Responses olayını atlarsa opencodex beş saniyelik model kapsamlı bir yetkisiz kullanım onarımı uygular; hatalı biçimlendirilmiş veya kısmi akışlar başarılı olarak bildirilmek yerine tamamlanmamış olarak kapanır. -> **Üç Volcengine faturalandırma rotası:** `volcengine` kullandıkça öde Ark API'sidir, `volcengine-coding-plan` Coding Plan kotasını tüketir ve `volcengine-agent-plan` Agent Plan kotasını tüketir. Aynı ürün için verilen anahtarı ve uç noktayı kullanın; sıradan `/api/v3` uç noktası bir Plan aboneliği mevcut olduğunda bile kullandıkça öde ücretlerine neden olabilir. Önayarlar özenle seçilmiş statik model katalogları kullanır çünkü Ark'ın `/models` yanıtı yerleştirme, görsel, video ve 3D kaynaklarını da içerir, Coding ağ geçidi aynı geniş kataloğu döndürür ve Agent Plan ağ geçidinin `/models` kaynağı yoktur. Kullandıkça öde varsayılan olarak `doubao-seed-2-1-pro-260628`'dir; seçilmiş kataloğu güncel DeepSeek ve GLM metin modellerini de içerir. Coding Plan varsayılan olarak `ark-code-latest`, Agent Plan ise varsayılan olarak `deepseek-v4-pro`'dur. +> **Üç Volcengine faturalandırma rotası:** `volcengine` kullandıkça öde Ark API'sidir, `volcengine-coding-plan` Coding Plan kotasını tüketir ve `volcengine-agent-plan` Agent Plan kotasını tüketir. Aynı ürün için verilen anahtarı ve uç noktayı kullanın; sıradan `/api/v3` uç noktası bir Plan aboneliği mevcut olduğunda bile kullandıkça öde ücretlerine neden olabilir. Önayarlar özenle seçilmiş statik model katalogları kullanır çünkü Ark'ın `/models` yanıtı yerleştirme, görsel, video ve 3D kaynaklarını da içerir, Coding ağ geçidi aynı geniş kataloğu döndürür ve Agent Plan ağ geçidinin `/models` kaynağı yoktur. Kullandıkça öde varsayılan olarak `doubao-seed-2-1-pro-260628`'dir; seçilmiş kataloğu güncel DeepSeek ve GLM metin modellerini de içerir. Coding Plan varsayılan olarak `ark-code-latest`, Agent Plan ise varsayılan olarak `deepseek-v4-flash`'dur. > **Volcengine Plan kullanım kısıtlaması:** Volcengine, Coding Plan ve Agent Plan kotasını yalnızca desteklenen yapay zeka kodlama araçları içinde geçerli olarak belgeler ve genel API çağrıları için bir plan anahtarı kullanmanın aboneliği askıya alabileceği veya hesabı yasaklayabileceği konusunda uyarır. Codex veya Claude Code'u opencodex üzerinden yönlendirmek belgelenmiş kullanımdır; diğer otomasyonları bir plan anahtarına yönlendirmek değildir. Kullandıkça öde `volcengine` rotası böyle bir kısıtlama taşımaz. @@ -652,7 +652,7 @@ listesini sağlayıcıdan keşfeder; böylece yeni Ollama Cloud modelleri yapıl değişikliği olmadan görünür. opencodex, bulut serisini vizyon yeteneğine göre sınıflandırır, böylece [vizyon sidecar'ı](/tr/guides/sidecars/) yalnızca salt metin modeller için devreye girer. Salt metin modeller (örneğin -`glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, +`glm-5.2`, `deepseek-v4-flash`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`) `noVisionModels` içinde listelenir; vizyon yerel modeller (örneğin `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, `gemini-3-flash-preview`) listelenmez. Eşleştirme Ollama'nın `:size` diff --git a/docs-site/src/content/docs/tr/guides/sidecars.md b/docs-site/src/content/docs/tr/guides/sidecars.md index 5205f0041f..6dc32cd15d 100644 --- a/docs-site/src/content/docs/tr/guides/sidecars.md +++ b/docs-site/src/content/docs/tr/guides/sidecars.md @@ -178,7 +178,7 @@ Bir model, sağlayıcı başına salt metin olarak işaretlenir: "providers": { "ollama-cloud": { "baseUrl": "https://ollama.com/v1", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } } } diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index f0739bbc63..7b0c14cfcc 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -558,7 +558,7 @@ geçerli adı kontrol edin. "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } }, "subagentModels": ["anthropic/claude-opus-5", "ollama-cloud/glm-5.2"], diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index bcb4e3f3e9..084f6cd38b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -241,7 +241,7 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 > 视频和 3D 资源,Coding 网关也会返回这份宽泛目录,Agent Plan 网关没有 `/models` 资源。 > 按量付费默认使用 `doubao-seed-2-1-pro-260628`,静态目录还包含当前 DeepSeek 和 GLM > 文本模型。Coding Plan 默认使用 `ark-code-latest`,Agent Plan 默认使用 -> `deepseek-v4-pro`。 +> `deepseek-v4-flash`。 **Chutes 发现:**`chutes` 预设使用 Chutes 固定的共享 OpenAI 兼容 LLM gateway。它读取公开的 `/v1/models` 目录,仅保留 `supported_features` 包含 `tools` 的记录,保留含 `/` 的原生 model id 与 @@ -438,7 +438,7 @@ Cursor OAuth 和 live model discovery 已在这个实验性 adapter 中启用; ### Ollama Cloud -Ollama Cloud 是托管(而非本地)的 Ollama,配置地址为 `https://ollama.com/v1`,密钥来自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 通过 Ollama 自身的 REST API(`POST /api/chat`)连接,而不是 OpenAI 兼容接口,并从提供方动态发现模型列表,因此新的 Ollama Cloud 模型无需改动配置即可出现。opencodex 按视觉能力对其云端阵容进行分类,使 [vision sidecar](/zh-cn/guides/sidecars/) 仅对纯文本模型生效。纯文本模型(例如 `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`)列在 `noVisionModels` 中;原生支持视觉的模型(例如 `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)则不在其中。匹配能容忍 Ollama 的 `:size` 标签,因此 `gpt-oss` 涵盖 `gpt-oss:120b` 和 `gpt-oss:20b`。 +Ollama Cloud 是托管(而非本地)的 Ollama,配置地址为 `https://ollama.com/v1`,密钥来自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 通过 Ollama 自身的 REST API(`POST /api/chat`)连接,而不是 OpenAI 兼容接口,并从提供方动态发现模型列表,因此新的 Ollama Cloud 模型无需改动配置即可出现。opencodex 按视觉能力对其云端阵容进行分类,使 [vision sidecar](/zh-cn/guides/sidecars/) 仅对纯文本模型生效。纯文本模型(例如 `glm-5.2`、`deepseek-v4-flash`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`)列在 `noVisionModels` 中;原生支持视觉的模型(例如 `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)则不在其中。匹配能容忍 Ollama 的 `:size` 标签,因此 `gpt-oss` 涵盖 `gpt-oss:120b` 和 `gpt-oss:20b`。 Ollama 目前在文档中说明结构化输出在 Ollama Cloud 上不受支持。因此对正典 `ollama-cloud`, opencodex 会以明确的错误拒绝结构化输出请求(`text.format`),而不是悄悄返回不受约束的自由 diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index f2dfec5c95..9d148c85b8 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -113,7 +113,7 @@ Dashboard 和管理 API 都使用 `gpt-5.4-mini` 作为回退。启动时仍会 "providers": { "ollama-cloud": { "baseUrl": "https://ollama.com/v1", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } } } diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index de314d8d8e..01f499e454 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -434,7 +434,7 @@ Vercel AI Gateway 可以在多个底层推理提供者之间路由一个模型 "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } }, "subagentModels": ["anthropic/claude-opus-5", "ollama-cloud/glm-5.2"], diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 82ec32f008..6dd562ec93 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -320,7 +320,7 @@ incomplete 關閉,不會被誤報為成功。 > pay-as-you-go 費用。preset 使用 curated static model catalog,因為 Ark `/models` 也包含 embedding、 > image、video 與 3D resource,Coding gateway 會回傳相同 broad catalog,而 Agent Plan gateway 沒有 > `/models` resource。Pay-as-you-go 預設 `doubao-seed-2-1-pro-260628`,curated catalog 也包含目前的 -> DeepSeek 與 GLM text model。Coding Plan 預設 `ark-code-latest`;Agent Plan 預設 `deepseek-v4-pro`。 +> DeepSeek 與 GLM text model。Coding Plan 預設 `ark-code-latest`;Agent Plan 預設 `deepseek-v4-flash`。 > **Volcengine Plan 使用限制:** Volcengine 文件指出 Coding Plan 與 Agent Plan quota 只能在受支援的 > AI coding tool 內使用,並警告把 plan key 用於一般 API call 可能導致訂閱停權或帳號封鎖。透過 @@ -507,7 +507,7 @@ key 來自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。openco REST API(`POST /api/chat`)連線,而非 OpenAI-compatible 介面,並向 provider 動態探索模型清單, 因此新的 Ollama Cloud 模型不需改設定就會出現。opencodex 依 vision capability 分類其 cloud lineup,讓 [vision sidecar](/zh-tw/guides/sidecars/) 只對純文字模型生效。純文字模型,例如 -`glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`,會列在 +`glm-5.2`、`deepseek-v4-flash`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`,會列在 `noVisionModels`;原生 vision 模型,例如 `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、 `gemini-3-flash-preview`,不會列入。matching 可容忍 Ollama 的 `:size` tag,因此 `gpt-oss` 同時涵蓋 `gpt-oss:120b` 與 `gpt-oss:20b`。 diff --git a/docs-site/src/content/docs/zh-tw/guides/sidecars.md b/docs-site/src/content/docs/zh-tw/guides/sidecars.md index 60131afd19..6462a5df2d 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-tw/guides/sidecars.md @@ -108,7 +108,7 @@ OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthro "providers": { "ollama-cloud": { "baseUrl": "https://ollama.com/v1", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } } } diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 292b8e63dc..b47d92eb48 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -387,7 +387,7 @@ Vercel AI Gateway 可在多個底層推論供應商之間路由一個模型。`v "baseUrl": "https://ollama.com/v1", "apiKey": "${OLLAMA_API_KEY}", "defaultModel": "glm-5.2", - "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-pro"] + "noVisionModels": ["glm-5.2", "gpt-oss", "qwen3-coder", "deepseek-v4-flash"] } }, "subagentModels": ["anthropic/claude-opus-5", "ollama-cloud/glm-5.2"], diff --git a/src/adapters/cline-pass-deepseek-v4-tool-replay.ts b/src/adapters/cline-pass-deepseek-v4-tool-replay.ts index e2d0369974..cf28da46fe 100644 --- a/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +++ b/src/adapters/cline-pass-deepseek-v4-tool-replay.ts @@ -2,7 +2,6 @@ import type { ProviderAdapter } from "./base"; const CLINE_PASS_DEEPSEEK_V4_MODELS = new Set([ "cline-pass/deepseek-v4-flash", - "cline-pass/deepseek-v4-pro", ]); function isRecord(value: unknown): value is Record { diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index c20dc88be6..4b7c707d7e 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -495,7 +495,6 @@ function supportedCommandCodeEffort(provider: OcxProviderConfig, modelId: string let wire = requested; const lower = canonicalId.toLowerCase(); const needsAlias = - lower === "deepseek/deepseek-v4-pro" || lower === "deepseek/deepseek-v4-flash" || lower === "zai-org/glm-5.2"; if (requested === "xhigh" && !supported.includes("xhigh") && supported.includes("max")) { diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 8430622092..6ddf6512e6 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -183,6 +183,23 @@ export const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([ // Issue #2330: OpenCode Go models absent from current documentation or returning terminal HTTP 400 errors. "opencode-go/mimo-v2-omni", "opencode-go/mimo-v2-pro", + /* + * DeepSeek retired `deepseek-v4-pro` on 2026-09-14 04:00 UTC and routes its requests to + * V4.1-Flash (api-docs.deepseek.com/news/news260910). Deleting the registry rows removes + * the model on providers that publish a static roster, but every provider below discovers + * its models live — there, a deleted row does not remove anything, it only strips the + * context window, the effort ladder and the text-only hint, so the retired model would + * keep appearing with its capabilities broken. Excluding the slug is what actually takes + * it out of the routed catalog. + */ + "command-code/deepseek-deepseek-v4-pro", + "commandcode/deepseek-deepseek-v4-pro", + "orcarouter/deepseek-deepseek-v4-pro", + "cline-pass/cline-pass-deepseek-v4-pro", + "baseten/deepseek-ai-DeepSeek-V4-Pro", + "digitalocean/deepseek-v4-pro", + "qoder/DeepSeek-V4-Pro", + "codebuddy/deepseek-v4-pro", ]); export function isRoutedModelCompatibilityExcluded(slug: string): boolean { diff --git a/src/providers/codebuddy-models.ts b/src/providers/codebuddy-models.ts index edd6a30415..ceb7463ce8 100644 --- a/src/providers/codebuddy-models.ts +++ b/src/providers/codebuddy-models.ts @@ -35,7 +35,6 @@ export const CODEBUDDY_GLOBAL_MODELS = [ /** China (`internal`) session models from the official internal manifest (text/chat models only). */ export const CODEBUDDY_CN_MODELS = [ "default", - "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "minimax-m2.7", @@ -121,7 +120,6 @@ export const CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS: Record = { "default": 200_000, - "deepseek-v4-pro": 1_000_000, "deepseek-v4-flash": 1_000_000, "minimax-m3": 512_000, "minimax-m2.7": 200_000, @@ -142,7 +140,6 @@ export const CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS: Record = { export const CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS: Record = { "default": 24_000, - "deepseek-v4-pro": 50_000, "deepseek-v4-flash": 50_000, "minimax-m3": 128_000, "minimax-m2.7": 48_000, diff --git a/src/providers/command-code-efforts.ts b/src/providers/command-code-efforts.ts index 621eb330bc..31a11e3af5 100644 --- a/src/providers/command-code-efforts.ts +++ b/src/providers/command-code-efforts.ts @@ -1,10 +1,6 @@ import { readBoundedResponseBody } from "../lib/bounded-body"; const COMMAND_CODE_MODEL_EFFORTS = { - "deepseek/deepseek-v4-pro": { - efforts: ["high", "max"], - profileUrl: "https://commandcode.ai/models/deepseek-v4-pro", - }, "deepseek/deepseek-v4-flash": { efforts: ["high", "max"], profileUrl: "https://commandcode.ai/models/deepseek-v4-flash", @@ -130,6 +126,29 @@ const COMMAND_CODE_MODEL_EFFORTS = { efforts: ["low", "medium", "high", "xhigh", "max"], profileUrl: "https://commandcode.ai/models/meta-muse-spark-1.1", }, + /* + * Two live routes that never gained a row here, so the adapter dropped every + * requested effort (a client's `max` left the wire as no reasoning parameter + * at all) and the preset advertised no effort control for them. + * + * PROVENANCE, stated plainly: both ladders are inferred from the same-family + * rows above — deepseek v4: high..max; the Qwen 3.8 family: low..max — NOT + * read from the profile pages. commandcode.ai renders those client-side and + * ships an empty reasoning payload, so the self-refresh below is as dead for + * these rows as the #2647 block above already documents. Measured live + * 2026-09-11: /alpha/generate accepts `reasoning_effort: "max"` on both + * routes (HTTP 200). `ultra` is deliberately not offered: the adapter would + * strip it, and no profile evidence backs an ultra→max alias the way it does + * for v4-pro/v4-flash above. + */ + "deepseek/deepseek-v4.1-flash": { + efforts: ["high", "max"], + profileUrl: "https://commandcode.ai/models/deepseek-v4-1-flash", + }, + "Qwen/Qwen3.8-Flash": { + efforts: ["low", "medium", "high", "max"], + profileUrl: "https://commandcode.ai/models/qwen3-8-flash", + }, } as const; /** diff --git a/src/providers/default-aliases.ts b/src/providers/default-aliases.ts index dffbd9eb3c..30128db749 100644 --- a/src/providers/default-aliases.ts +++ b/src/providers/default-aliases.ts @@ -51,7 +51,11 @@ export const DEFAULT_MODEL_ALIASES: ReadonlyArray<{ match: RegExp; alias: string { match: /^claude-haiku/, alias: "haiku" }, { match: /^gemini-3(?:\.\d+)?-pro/, alias: "g3p" }, { match: /^gemini-3(?:\.\d+)?-flash/, alias: "g3f" }, + // Ordered before the V4 rule on purpose: `builtinRule` takes the first match, and + // `/^deepseek-v4/` also matches `deepseek-v4.1-flash`. + { match: /^deepseek-v4\.1/, alias: "ds41" }, { match: /^deepseek-v4/, alias: "ds4" }, + { match: /^deepseek-flash/, alias: "dsf" }, { match: /^grok-4/, alias: "grok" }, ]; diff --git a/src/providers/qoder-models.ts b/src/providers/qoder-models.ts index 0f8d8c4350..bb127aedba 100644 --- a/src/providers/qoder-models.ts +++ b/src/providers/qoder-models.ts @@ -10,7 +10,6 @@ export const QODER_GLOBAL_MODELS = [ "Kimi-K2.7-Code", "GLM-5.3", "GLM-5.2", - "DeepSeek-V4-Pro", ] as const; /** Live Qoder CN roster captured from the official CLI on 2026-09-03. */ diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f9cbcc598e..fb9db46101 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -616,7 +616,30 @@ const THINKING_BUDGET_MODELS = [ "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", ]; const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"]; -const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"]; +/* + * DeepSeek moved the whole V4 name set on 2026-09-10. V4.1-Flash ships as deepseek-flash + * on the first-party API; deepseek-v4-flash and the vision preview retire as models but + * keep routing there as compatibility aliases, and deepseek-v4-pro follows from + * 2026-09-14 04:00 UTC. Evidence: https://api-docs.deepseek.com/news/news260910/. + * + * The spelling differs by who serves it, so one shared list cannot express it: the + * first-party API answers to deepseek-flash, while the Zen gateway exposes the route as + * deepseek-v4.1-flash (issue #4253, PR #4258). Vendor-hosted rosters (Volcengine plan + * snapshots, Alibaba) publish on their own schedule and keep the legacy set until they say + * otherwise - a first-party retirement notice does not end their deployment. + */ +const DEEPSEEK_V4_LEGACY_MODELS = ["deepseek-v4-flash"]; +/* + * `deepseek-v4-pro` is deliberately absent from both live sets. DeepSeek retires it from + * 2026-09-14 04:00 UTC and routes its requests to V4.1-Flash until a V4.1 Pro exists, so a + * row here would advertise a Pro context window and Pro pricing for a route that serves + * Flash. The retirement is followed through every roster in this file, including the + * vendor-hosted ones; providers that discover their models live are handled by + * `ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS` because deleting a row there removes the + * model's capabilities rather than the model. + */ +const DEEPSEEK_NATIVE_THINKING_MODELS = ["deepseek-flash", "deepseek-v4-flash"]; +const DEEPSEEK_GATEWAY_THINKING_MODELS = ["deepseek-v4.1-flash", "deepseek-v4-flash"]; /* * DeepSeek's experimental vision preview (released 2026-08-21, api-docs.deepseek.com): * text+image input on the V4 Flash base. DeepSeek positions it as a preview id; @@ -628,7 +651,7 @@ const DEEPSEEK_VISION_PREVIEW_MODEL = "deepseek-v4-flash-vision-exp"; * CommandCode routes verified to accept image input end-to-end (#2406). * * Verified-negative and therefore deliberately ABSENT: deepseek/deepseek-v4-flash, - * deepseek/deepseek-v4-pro, zai-org/GLM-5.2, zai-org/GLM-5.3, xai/grok-4.6. Those + * zai-org/GLM-5.2, zai-org/GLM-5.3, xai/grok-4.6. Those * routes accept the request and drop the image, which is worse than declining it — the * model answers about an image it never saw. Do not add an id here on family resemblance; * capability intersection trusts this map. @@ -716,7 +739,7 @@ const DEEPSEEK_FLASH_REASONING_MAP: Record = { }; /** * Flash-versus-Pro classification for DeepSeek V4 model ids, including prefixed - * (`deepseek/deepseek-v4-pro`) and suffixed (`deepseek-v4-flash-free`) forms. + * (`deepseek/deepseek-v4.1-flash`) and suffixed (`deepseek-v4-flash-free`) forms. * `tests/providers/provider-registry-parity.test.ts` enumerates every id the registry * actually passes here, so a future id this substring test would misread cannot * land silently. @@ -733,7 +756,7 @@ const deepseekReasoningMapFor = (modelId: string): Record => // https://help.aliyun.com/en/model-studio/token-plan-quickstart const ALIBABA_TOKEN_PLAN_MODELS = [ "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash", - "glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", + "glm-5.3", "glm-5.3-flash", "glm-5.2", ]; const ALIBABA_TOKEN_PLAN_QWEN_MODELS = [ "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash", @@ -746,7 +769,6 @@ const ALIBABA_TOKEN_PLAN_INPUT_MODALITIES: Record = { "glm-5.3": ["text"], "glm-5.3-flash": ["text", "image"], "glm-5.2": ["text"], - "deepseek-v4-pro": ["text"], }; // 260721 Alibaba Token Plan International (ap-southeast-1 / Singapore, hardened 260721). @@ -755,7 +777,7 @@ const ALIBABA_TOKEN_PLAN_INPUT_MODALITIES: Record = { // https://qwencloud.com/pricing/token-plan (qwen3.8 metadata) const ALIBABA_INTL_TOKEN_PLAN_MODELS = [ "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash", - "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v3.2", + "deepseek-v4-flash", "deepseek-v3.2", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", "MiniMax-M2.5", @@ -788,7 +810,6 @@ const VOLCENGINE_ARK_MODELS = [ "doubao-seed-2-1-pro-260628", "doubao-seed-2-1-turbo-260628", "doubao-seed-evolving", - "deepseek-v4-pro-260425", "deepseek-v4-flash-260425", "deepseek-v3-2-251201", // No glm-5-3 row: Ark pins date-stamped snapshot ids (glm-5-2-260617) that cannot be @@ -804,7 +825,6 @@ const VOLCENGINE_DOUBAO_THINKING_MODELS = [ const VOLCENGINE_CODING_PLAN_MODELS = [ "ark-code-latest", "doubao-seed-2.0-code", - "deepseek-v4-pro", "deepseek-v4-flash", "glm-5.3", "glm-5.3-flash", @@ -813,7 +833,6 @@ const VOLCENGINE_CODING_PLAN_MODELS = [ "minimax-m3", ]; const VOLCENGINE_AGENT_PLAN_MODELS = [ - "deepseek-v4-pro", "deepseek-v4-flash", "glm-5.3", "glm-5.3-flash", @@ -835,7 +854,6 @@ const VOLCENGINE_PLAN_INPUT_MODALITIES: Record = { const VOLCENGINE_PLAN_TEXT_ONLY_MODELS = [ "ark-code-latest", "doubao-seed-2.0-code", - "deepseek-v4-pro", "deepseek-v4-flash", "glm-5.3", "glm-5.2", @@ -847,7 +865,6 @@ const ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES: Record = { "qwen3.7-plus": ["text", "image"], "qwen3.6-plus": ["text", "image"], "qwen3.6-flash": ["text", "image"], - "deepseek-v4-pro": ["text"], "deepseek-v4-flash": ["text"], "deepseek-v3.2": ["text"], "kimi-k2.7-code": ["text", "image"], @@ -966,7 +983,7 @@ const NVIDIA_NIM_VISION_INPUT_MODALITIES: Record = Object.from * reasoning suppression regardless of which list they appear in here. */ const NVIDIA_NIM_NO_VISION_MODELS = [ - "deepseek-ai/deepseek-v4-flash", "deepseek-ai/deepseek-v4-pro", + "deepseek-ai/deepseek-v4-flash", "google/codegemma-7b", "meta/llama-3.1-70b-instruct", "meta/llama-3.1-8b-instruct", "meta/llama-3.2-1b-instruct", "meta/llama-3.2-3b-instruct", @@ -1007,7 +1024,6 @@ const NEURALWATT_REASONING_HISTORY_MODELS = [ // https://docs.baseten.co/inference/model-apis/vision const BASETEN_FULL_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; const BASETEN_MODEL_REASONING_EFFORTS: Record = { - "deepseek-ai/DeepSeek-V4-Pro": BASETEN_FULL_REASONING_EFFORTS, "thinkingmachines/inkling": BASETEN_FULL_REASONING_EFFORTS, "openai/gpt-oss-120b": BASETEN_FULL_REASONING_EFFORTS, "moonshotai/Kimi-K3": ["low", "high", "max"], @@ -1018,7 +1034,6 @@ const BASETEN_MODEL_REASONING_EFFORTS: Record = { "zai-org/GLM-5.2-Fast": ["high", "max"], }; const BASETEN_MODEL_REASONING_EFFORT_MAP: Record> = { - "deepseek-ai/DeepSeek-V4-Pro": { none: "none", minimal: "minimal" }, "thinkingmachines/inkling": { none: "none", minimal: "minimal" }, "openai/gpt-oss-120b": { none: "none", minimal: "minimal" }, "moonshotai/Kimi-K3": { none: "none" }, @@ -1028,7 +1043,6 @@ const BASETEN_MODEL_REASONING_EFFORT_MAP: Record> "zai-org/GLM-5.2-Fast": { none: "none" }, }; const BASETEN_MODEL_DEFAULT_REASONING_EFFORTS: Record = { - "deepseek-ai/DeepSeek-V4-Pro": "medium", "thinkingmachines/inkling": "high", "openai/gpt-oss-120b": "medium", "moonshotai/Kimi-K3": "max", @@ -1056,7 +1070,6 @@ const DIGITALOCEAN_CHAT_COMPLETION_MODELS = [ "openai-gpt-5.6-luna", "qwen3-coder-flash", "qwen3.5-397b-a17b", - "deepseek-v4-pro", "deepseek-4-flash", "deepseek-3.2", "gemma-4-31B-it", @@ -1141,7 +1154,6 @@ const CLINE_PASS_MODELS = [ "cline-pass/kimi-k3", "cline-pass/kimi-k2.7-code", "cline-pass/kimi-k2.6", - "cline-pass/deepseek-v4-pro", "cline-pass/deepseek-v4-flash", "cline-pass/mimo-v2.5", "cline-pass/mimo-v2.5-pro", @@ -1177,17 +1189,11 @@ const ORCAROUTER_MODELS = [ "openai/gpt-5.5", "anthropic/claude-opus-4.8", "google/gemini-3.5-flash", - "deepseek/deepseek-v4-pro", "orcarouter/auto", ]; -const ORCAROUTER_TEXT_ONLY_MODELS = ["deepseek/deepseek-v4-pro"]; const ORCAROUTER_MODEL_REASONING_EFFORTS = { // Live /models currently exposes ids and modalities, not the accepted reasoning ladder. "openai/gpt-5.5": ["low", "medium", "high", "xhigh"], - "deepseek/deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek/deepseek-v4-pro"), -}; -const ORCAROUTER_MODEL_REASONING_EFFORT_MAP = { - "deepseek/deepseek-v4-pro": deepseekReasoningMapFor("deepseek/deepseek-v4-pro"), }; const CLINE_PASS_MODEL_CONTEXT_WINDOWS: Record = { "cline-pass/glm-5.3": 1_048_576, @@ -1196,7 +1202,6 @@ const CLINE_PASS_MODEL_CONTEXT_WINDOWS: Record = { "cline-pass/kimi-k3": 1_048_576, "cline-pass/kimi-k2.7-code": 262_144, "cline-pass/kimi-k2.6": 262_144, - "cline-pass/deepseek-v4-pro": 1_048_576, "cline-pass/deepseek-v4-flash": 1_048_576, "cline-pass/mimo-v2.5": 1_050_000, "cline-pass/mimo-v2.5-pro": 1_050_000, @@ -1440,10 +1445,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ models: ORCAROUTER_MODELS, liveModels: true, modelDiscovery: ORCAROUTER_MODEL_DISCOVERY, - noVisionModels: ORCAROUTER_TEXT_ONLY_MODELS, modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS, - modelReasoningEffortMap: ORCAROUTER_MODEL_REASONING_EFFORT_MAP, - preserveReasoningContentModels: ORCAROUTER_TEXT_ONLY_MODELS, note: "Connect your OrcaRouter account with OAuth 2.0 + PKCE; the issued API key is stored in OpenCodex's existing credential store.", }, { @@ -1757,7 +1759,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "kimi-k2.7-code-highspeed": [], ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS])), ...Object.fromEntries(OPENCODE_GO_THINKING_BUDGET_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])), - ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), + ...Object.fromEntries(DEEPSEEK_GATEWAY_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), }, modelDefaultReasoningEfforts: { "grok-4.6": "high", "kimi-k3": "max" }, // glm-5.2 uses identity labels now that `max` is a native Codex level (no alias map); @@ -1765,7 +1767,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelReasoningEffortMap: { "kimi-k3": KIMI_CODING_K3_REASONING_EFFORT_MAP, ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])), - ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), + ...Object.fromEntries(DEEPSEEK_GATEWAY_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), }, modelSupportsReasoningSummaries: { "glm-5.3": true, @@ -1773,7 +1775,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "glm-5.2": true, "glm-5.1": true, "glm-5": true, - ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, true])), + ...Object.fromEntries(DEEPSEEK_GATEWAY_THINKING_MODELS.map(id => [id, true])), }, thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS, /* @@ -1790,7 +1792,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Kimi K2.7 Code accepts text+image+video: do NOT list it here. noVisionModels: [ "glm-5.3", "glm-5.2", "glm-5", "glm-5.1", - "deepseek-v4-flash", "deepseek-v4-pro", + "deepseek-v4.1-flash", "deepseek-v4-flash", "mimo-v2-pro", "mimo-v2.5-pro", "minimax-m2.5", "minimax-m2.7", "qwen3.7-max", @@ -1800,7 +1802,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ noPenaltyModels: ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"], autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], // Issue #78: DeepSeek V4 thinking mode requires reasoning_content replay on tool-call turns. - preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...DEEPSEEK_THINKING_MODELS], + preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...DEEPSEEK_GATEWAY_THINKING_MODELS], /* * Issues #1338 / #1415: this gateway answers a `response_format` of type * `json_schema` with HTTP 400 `This response_format type is unavailable now` @@ -1810,7 +1812,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ * The reported rejection is type-specific, so this narrower list downgrades the * request to `json_object` instead of claiming the whole field is unavailable. */ - noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS], + noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS], }, { id: "neuralwatt", @@ -1957,10 +1959,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelDiscovery: ORCAROUTER_MODEL_DISCOVERY, // Catalog discovery owns WHICH models exist. These entries only retain verified // request-shaping facts that the upstream catalog does not currently publish. - noVisionModels: ORCAROUTER_TEXT_ONLY_MODELS, modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS, - modelReasoningEffortMap: ORCAROUTER_MODEL_REASONING_EFFORT_MAP, - preserveReasoningContentModels: ORCAROUTER_TEXT_ONLY_MODELS, note: "OpenAI-compatible adaptive router. Models and multimodal capabilities are discovered live from the public chat catalog. Use the OrcaRouter account entry for PKCE login.", }, { @@ -2042,18 +2041,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // verified 2026-08-08). jawcodeBundle: "deepseek", // deepseek-chat/deepseek-reasoner were deprecated upstream on 2026-07-24 15:59 UTC; - // official identifiers are now deepseek-v4-flash / deepseek-v4-pro. They stay in + // the current official identifier is deepseek-flash. They stay in // the list only as compatibility aliases so existing saved configs and requests // keep validating and routing (they previously mapped to v4-flash; devlog // _fin/260710_provider_hardening/002_research_cn.md). The current offerings are // the V4 ids — defaultModel and the model-specific wiring above use them. // deepseek-v4-flash-vision-exp: experimental vision preview (2026-08-21) — // expected to merge into deepseek-v4-flash later; see DEEPSEEK_VISION_PREVIEW_MODEL. - models: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_THINKING_MODELS, DEEPSEEK_VISION_PREVIEW_MODEL], - defaultModel: "deepseek-v4-flash", + models: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_NATIVE_THINKING_MODELS, DEEPSEEK_VISION_PREVIEW_MODEL], + // V4.1-Flash is the current first-party offering; `deepseek-v4-flash` now routes there + // as a compatibility alias, so a new install should ask for the live id by name. + defaultModel: "deepseek-flash", // Official DeepSeek Codex setup (codex-deepseek-setup.sh) advertises 1,048,576 // for both V4 models; the older 1,000,000 figure was a rounded approximation. - modelContextWindows: { "deepseek-v4-flash": 1_048_576, "deepseek-v4-pro": 1_048_576, [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576 }, + modelContextWindows: { "deepseek-flash": 1_048_576, "deepseek-v4-flash": 1_048_576, [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576 }, modelInputModalities: { [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"] }, // DeepSeek documents both V4 models as native Responses API models adapted for Codex // (model table marks Responses API ✓ for flash and pro; the /responses reference lists @@ -2067,7 +2068,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // translating them into Responses would add a hop onto our newest upstream path // for no gain. "deepseek-v4-flash": { wire: "openai-responses", inbound: ["responses"] }, - "deepseek-v4-pro": { wire: "openai-responses", inbound: ["responses"] }, + // Same Responses contract as the V4 ids it succeeds; without this row the new + // default would fall back to the provider-wide Chat wire. + "deepseek-flash": { wire: "openai-responses", inbound: ["responses"] }, }, // The #875-era bounded-JSON force (`modelResponsesUpstreamStreaming`) is retired // for this entry: the official guide documents a `response.completed` / @@ -2082,7 +2085,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // devlog/_fin/260807_deepseek_responses_streaming/000_plan.md. // Current official streams normally carry a real terminal; retain a narrow grace // repair for the historical shape that closes after a complete graph without one. - modelResponsesTerminalRepair: { "deepseek-v4-flash": { graceMs: 5_000 }, "deepseek-v4-pro": { graceMs: 5_000 } }, + modelResponsesTerminalRepair: { "deepseek-flash": { graceMs: 5_000 }, "deepseek-v4-flash": { graceMs: 5_000 } }, // DeepSeek's Responses route emits bare UUID item ids, which leave Codex // clients stuck on an uncommitted turn (#938). Client-facing only — raw // continuation snapshots keep the upstream ids. @@ -2118,14 +2121,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ - 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata. - 선택 근거: DeepSeek V4 thinking mode requires history replay, while older DeepSeek reasoner has different compatibility rules. A model-scoped registry flag fixes built-in and stale saved configs without broad provider regressions. */ - modelReasoningEfforts: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), - modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), - modelSupportsReasoningSummaries: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, true])), - preserveReasoningContentModels: DEEPSEEK_THINKING_MODELS, + modelReasoningEfforts: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), + modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), + modelSupportsReasoningSummaries: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, true])), + preserveReasoningContentModels: DEEPSEEK_NATIVE_THINKING_MODELS, // Issue #88: every DeepSeek API model is text-only input (no image support upstream) — the // vision sidecar describes attached images for them, and the catalog advertises image input // on their behalf (same treatment as opencode-go's DeepSeek V4 entries above). - noVisionModels: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_THINKING_MODELS], + noVisionModels: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_NATIVE_THINKING_MODELS], }, // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" }, @@ -2309,7 +2312,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Official Command Code model-profile reasoning facts (shared with the OAuth // `command-code` entry). Without them the API-key preset never advertises a // reasoning picker, and the router's known-ids decode source misses the native - // slash ids — so a Codex-facing slug like `commandcode/deepseek-deepseek-v4-pro` + // slash ids — so a Codex-facing slug like `commandcode/deepseek-deepseek-v4-flash` // is sent upstream verbatim and rejected with `unsupported_model`. modelReasoningEfforts: COMMAND_CODE_MODEL_REASONING_EFFORTS, // The DeepSeek vision preview id is preemptive for when the catalog serves it @@ -2789,13 +2792,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ ), thinkingToggleModels: VOLCENGINE_DOUBAO_THINKING_MODELS, preserveReasoningContentModels: [ - "deepseek-v4-pro-260425", "deepseek-v4-flash-260425", "glm-5-2-260617", "glm-4-7-251222", ], noVisionModels: [ - "deepseek-v4-pro-260425", "deepseek-v4-flash-260425", "deepseek-v3-2-251201", "glm-5-2-260617", @@ -2817,12 +2818,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelInputModalities: VOLCENGINE_PLAN_INPUT_MODALITIES, noVisionModels: VOLCENGINE_PLAN_TEXT_ONLY_MODELS, modelReasoningEfforts: Object.fromEntries( - DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)]), + DEEPSEEK_V4_LEGACY_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)]), ), modelReasoningEffortMap: Object.fromEntries( - DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)]), + DEEPSEEK_V4_LEGACY_MODELS.map(id => [id, deepseekReasoningMapFor(id)]), ), - preserveReasoningContentModels: DEEPSEEK_THINKING_MODELS, + preserveReasoningContentModels: DEEPSEEK_V4_LEGACY_MODELS, note: "Coding tools only. Volcengine restricts Coding Plan quota to supported AI coding tools and warns that using this key for general API calls may suspend the subscription or ban the account. Use the plan key issued by the Ark console.", }, { @@ -2836,7 +2837,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ supportsServiceTier: false, preserveCustomDestination: true, dashboardUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview", - defaultModel: "deepseek-v4-pro", + // Was `deepseek-v4-pro` until DeepSeek retired it; the plan roster's other DeepSeek + // entry takes over so a fresh install still lands on a working default. + defaultModel: "deepseek-v4-flash", models: VOLCENGINE_AGENT_PLAN_MODELS, liveModels: false, modelInputModalities: VOLCENGINE_PLAN_INPUT_MODALITIES, @@ -2861,7 +2864,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelInputModalities: ALIBABA_TOKEN_PLAN_INPUT_MODALITIES, modelContextWindows: { "qwen3.8-max": 983_616, "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000, - "qwen3.6-flash": 1_000_000, "glm-5.3": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "deepseek-v4-pro": 1_000_000, + "qwen3.6-flash": 1_000_000, "glm-5.3": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, }, modelReasoningEfforts: { ...Object.fromEntries(ALIBABA_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])), @@ -2869,14 +2872,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, - "deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek-v4-pro"), }, modelDefaultReasoningEfforts: { "qwen3.8-max": "xhigh" }, - modelReasoningEffortMap: { "deepseek-v4-pro": deepseekReasoningMapFor("deepseek-v4-pro") }, directReasoningEffortModels: ["qwen3.8-max"], thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS.filter(id => id !== "qwen3.8-max"), - preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash"], - noVisionModels: ["glm-5.3", "glm-5.2", "deepseek-v4-pro"], + preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash"], + noVisionModels: ["glm-5.3", "glm-5.2"], }, { id: "alibaba-token-plan-intl", @@ -2896,7 +2897,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelContextWindows: { "qwen3.8-max": 983_616, "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000, "qwen3.6-plus": 1_000_000, "qwen3.6-flash": 1_000_000, - "deepseek-v4-pro": 1_000_000, "deepseek-v4-flash": 1_000_000, "deepseek-v3.2": 131_072, + "deepseek-v4-flash": 1_000_000, "deepseek-v3.2": 131_072, "kimi-k2.7-code": 262_144, "kimi-k2.6": 262_144, "kimi-k2.5": 262_144, "glm-5.3": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "glm-5.1": 1_000_000, "glm-5": 1_000_000, "MiniMax-M2.5": 204_800, @@ -2907,17 +2908,15 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, - "deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek-v4-pro"), "deepseek-v4-flash": deepseekThinkingEffortsFor("deepseek-v4-flash"), }, modelReasoningEffortMap: { - "deepseek-v4-pro": deepseekReasoningMapFor("deepseek-v4-pro"), "deepseek-v4-flash": deepseekReasoningMapFor("deepseek-v4-flash"), }, directReasoningEffortModels: ["qwen3.8-max"], thinkingBudgetModels: ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.filter(id => id !== "qwen3.8-max"), - preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "deepseek-v4-flash", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash"], - noVisionModels: ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v3.2", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "MiniMax-M2.5"], + preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-flash", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash"], + noVisionModels: ["deepseek-v4-flash", "deepseek-v3.2", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "MiniMax-M2.5"], noReasoningModels: ["kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "deepseek-v3.2", "glm-5.1", "glm-5", "MiniMax-M2.5"], modelDefaultReasoningEfforts: { "qwen3.8-max": "xhigh" }, }, @@ -2955,7 +2954,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ authKind: "key", dashboardUrl: "https://ollama.com/settings/keys", // Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15. - models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"], + models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"], defaultModel: "glm-5.3", // Owner-audited exact outage fallback: these current Ollama Cloud GLM-5.3 rows have // 1,048,576-token context windows. Live discovery and successful /api/show enrichment keep @@ -2967,7 +2966,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "minimax-m2.7", "minimax-m2.5", "minimax-m2.1", "nemotron-3-ultra", "nemotron-3-super", - "deepseek-v4-pro", "deepseek-v4-flash", + "deepseek-v4-flash", "gpt-oss", "qwen3-coder:480b", ], // Ollama's native chat API has no `text.verbosity` equivalent and the ollama-native adapter @@ -3051,12 +3050,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Zen DeepSeek thinking models — never serialize a bare tool-call turn. note: "Keyed OpenCode Zen gateway. Free models on this tier are often short-window rate-limited at roughly 15-20 requests/minute (community-measured; OpenCode does not publish RPM). Zen may return generic 429s without Retry-After / X-RateLimit headers; when Retry-After is omitted, opencodex adds a synthetic backoff hint (upstream Retry-After still wins). Distinct from the keyless opencode-free desktop quota (~200 Big Pickle/free-model requests per 5 hours). Docs: https://opencode.ai/docs/zen/. Free-model prompts may be retained for training — do not send confidential material.", modelReasoningEfforts: Object.fromEntries( - [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekThinkingEffortsFor(id)]), + [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekThinkingEffortsFor(id)]), ), modelReasoningEffortMap: Object.fromEntries( - [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekReasoningMapFor(id)]), + [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekReasoningMapFor(id)]), ), - preserveReasoningContentModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], + preserveReasoningContentModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], // Same Zen gateway as opencode-free: the DeepSeek vision preview id // (merges into deepseek-v4-flash later). modelContextWindows: { @@ -3065,10 +3064,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelInputModalities: { [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], }, - noVisionModels: [...OPENCODE_ZEN_TEXT_ONLY_MODELS, ...DEEPSEEK_THINKING_MODELS], + noVisionModels: [...OPENCODE_ZEN_TEXT_ONLY_MODELS, ...DEEPSEEK_GATEWAY_THINKING_MODELS], // Same DeepSeek routes as the Go preset above, behind the same vendor, so they carry // the same json_schema rejection (#1338 / #1415). - noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], + noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], }, { id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" }, { @@ -3112,7 +3111,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Same reasoning: the free tier is the same Zen roster, so its DeepSeek members get // the keyed tier's json_schema treatment and its reasoning contract rather than a // narrower table that silently falls behind whenever the keyed one is updated. - noJsonSchemaModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], + noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], }, { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" }, // Xiaomi's public OpenAI-compatible endpoint is a distinct transport from both the Anthropic diff --git a/src/router.ts b/src/router.ts index c70a438fbe..55a0326fce 100644 --- a/src/router.ts +++ b/src/router.ts @@ -683,7 +683,7 @@ function routeModelInternal( } } - // 0. Explicit "/" namespace (e.g. "opencode-go/deepseek-v4-pro"). + // 0. Explicit "/" namespace (e.g. "opencode-go/deepseek-v4.1-flash"). // Only triggers when the prefix matches a CONFIGURED provider, so genuine // slash-containing model ids (e.g. "anthropic/claude-...") fall through when // no such provider exists. diff --git a/tests/codex-integration/codex-catalog-restore.test.ts b/tests/codex-integration/codex-catalog-restore.test.ts index c507b232c4..c268a55d19 100644 --- a/tests/codex-integration/codex-catalog-restore.test.ts +++ b/tests/codex-integration/codex-catalog-restore.test.ts @@ -92,7 +92,7 @@ describe("Codex catalog restore", () => { writeFileSync(catalogPath, JSON.stringify({ models: [ { slug: "gpt-5.5" }, - { slug: "opencode-go/deepseek-v4-pro" }, + { slug: "opencode-go/deepseek-v4.1-flash" }, { slug: "user-native" }, ], }, null, 2) + "\n"); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 2a8b52543e..7c92d0af5d 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -2224,16 +2224,16 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { test("Command Code routed models relabel the picker row with distinguishable slugs", () => { const entries = buildCatalogEntries(nativeTemplate(), [], [ { provider: "command-code", id: "deepseek/deepseek-v4-flash", owned_by: "command-code" }, - { provider: "commandcode", id: "deepseek/deepseek-v4-pro", owned_by: "commandcode" }, + { provider: "commandcode", id: "deepseek/deepseek-v4.1-flash", owned_by: "commandcode" }, ]); const auth = entries.find(e => e.slug === "command-code/deepseek-deepseek-v4-flash"); - const api = entries.find(e => e.slug === "commandcode/deepseek-deepseek-v4-pro"); + const api = entries.find(e => e.slug === "commandcode/deepseek-deepseek-v4.1-flash"); // Display-only relabel + redundant vendor-prefix drop: routing slugs stay untouched. expect(auth?.display_name).toBe("commandcode-auth/deepseek-v4-flash"); expect(auth?.slug).toBe("command-code/deepseek-deepseek-v4-flash"); - expect(api?.display_name).toBe("commandcode-api/deepseek-v4-pro"); - expect(api?.slug).toBe("commandcode/deepseek-deepseek-v4-pro"); + expect(api?.display_name).toBe("commandcode-api/deepseek-deepseek-v4.1-flash"); + expect(api?.slug).toBe("commandcode/deepseek-deepseek-v4.1-flash"); }); test("Google Antigravity routed models relabel the picker row with compact agy prefix", () => { @@ -5987,9 +5987,9 @@ describe("Codex catalog routed normalization", () => { test("built-in DeepSeek and GLM effort models opt into Codex reasoning propagation (#1100)", async () => { const expected = [ { slug: "deepseek/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, - { slug: "deepseek/deepseek-v4-pro", efforts: ["low", "high", "max", "ultra"] }, + { slug: "deepseek/deepseek-flash", efforts: ["low", "high", "max", "ultra"] }, { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max"] }, - { slug: "opencode-go/deepseek-v4-pro", efforts: ["low", "high", "max"] }, + { slug: "opencode-go/deepseek-v4.1-flash", efforts: ["low", "high", "max"] }, { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max"] }, { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max"] }, { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max"] }, @@ -6008,7 +6008,7 @@ describe("Codex catalog routed normalization", () => { authMode: "key", apiKey: "sk-test", liveModels: false, - models: ["deepseek-v4-flash", "deepseek-v4-pro"], + models: ["deepseek-v4-flash", "deepseek-flash"], }, "opencode-go": { adapter: "openai-chat", @@ -6016,7 +6016,7 @@ describe("Codex catalog routed normalization", () => { authMode: "key", apiKey: "sk-test", liveModels: false, - models: ["deepseek-v4-flash", "deepseek-v4-pro", "glm-5.2", "glm-5.1", "glm-5"], + models: ["deepseek-v4-flash", "deepseek-v4.1-flash", "glm-5.2", "glm-5.1", "glm-5"], }, zai: { adapter: "openai-chat", @@ -6174,7 +6174,7 @@ describe("Codex catalog routed normalization", () => { expect(provider.modelSupportsReasoningSummaries).toEqual({ "deepseek-v4-flash": false, - "deepseek-v4-pro": true, + "deepseek-flash": true, }); }); diff --git a/tests/codex-integration/reasoning-effort.test.ts b/tests/codex-integration/reasoning-effort.test.ts index a5d3cd1114..3f0b85da34 100644 --- a/tests/codex-integration/reasoning-effort.test.ts +++ b/tests/codex-integration/reasoning-effort.test.ts @@ -194,11 +194,11 @@ describe("provider-specific reasoning effort mapping", () => { adapter: "openai-chat", baseUrl: "https://api.deepseek.com", apiKey: "key", - models: ["deepseek-v4-pro"], + models: ["deepseek-flash"], }, }, }; - const route = routeModel(config, "deepseek/deepseek-v4-pro"); + const route = routeModel(config, "deepseek/deepseek-flash"); const req = createOpenAIChatAdapter(route.provider).buildRequest({ modelId: route.modelId, @@ -271,7 +271,8 @@ describe("provider-specific reasoning effort mapping", () => { }); const body = JSON.parse(req.body as string) as { messages: Record[] }; - expect(route.provider.preserveReasoningContentModels).toEqual(["deepseek-v4-pro", "deepseek-v4-flash"]); + expect(route.provider.preserveReasoningContentModels) + .toEqual(["deepseek-flash", "deepseek-v4-flash"]); expect(body.messages[1].reasoning_content).toBeUndefined(); }); diff --git a/tests/codex-integration/slug-codec.test.ts b/tests/codex-integration/slug-codec.test.ts index 611754ce18..5248b75020 100644 --- a/tests/codex-integration/slug-codec.test.ts +++ b/tests/codex-integration/slug-codec.test.ts @@ -241,8 +241,8 @@ describe("routeModel decode (proxy layer)", () => { test("commandcode API-key preset decodes its native slash ids from the registry effort table", () => { // Regression: the `commandcode` (API-key) registry entry must share the official // reasoning-facts table with the OAuth `command-code` entry. Without it the router's - // known-ids source misses `deepseek/deepseek-v4-pro` / `zai-org/GLM-5.3`, so the - // Codex-facing slugs (`commandcode/deepseek-deepseek-v4-pro`) pass through unchanged + // known-ids source misses `deepseek/deepseek-v4-flash` / `zai-org/GLM-5.3`, so the + // Codex-facing slugs (`commandcode/deepseek-deepseek-v4-flash`) pass through unchanged // and upstream rejects them with `unsupported_model`. const prov = { adapter: "openai-chat", @@ -252,9 +252,9 @@ describe("routeModel decode (proxy layer)", () => { liveModels: true, }; const ids = knownModelIdsForProvider("commandcode", prov); - expect(ids).toContain("deepseek/deepseek-v4-pro"); + expect(ids).toContain("deepseek/deepseek-v4-flash"); expect(ids).toContain("zai-org/GLM-5.3"); - expect(decodeRoutedModelId("deepseek-deepseek-v4-pro", ids)).toBe("deepseek/deepseek-v4-pro"); + expect(decodeRoutedModelId("deepseek-deepseek-v4-flash", ids)).toBe("deepseek/deepseek-v4-flash"); expect(decodeRoutedModelId("zai-org-GLM-5.3", ids)).toBe("zai-org/GLM-5.3"); }); }); diff --git a/tests/e2e-style/phase100-native-parity.test.ts b/tests/e2e-style/phase100-native-parity.test.ts index fa60965738..04088ace30 100644 --- a/tests/e2e-style/phase100-native-parity.test.ts +++ b/tests/e2e-style/phase100-native-parity.test.ts @@ -59,7 +59,7 @@ describe("Phase 100 Codex-native parity smoke", () => { adapter: "openai-chat", baseUrl: "https://routed.example/v1", apiKey: "routed-key", - noVisionModels: ["deepseek-v4-pro"], + noVisionModels: ["deepseek-v4-flash"], }; const forwardProvider: OcxProviderConfig = { adapter: "openai-responses", @@ -76,9 +76,9 @@ describe("Phase 100 Codex-native parity smoke", () => { }; const catalog = buildCatalogEntries(nativeTemplate(), ["gpt-5.5"], [ - { provider: "opencode-go", id: "deepseek-v4-pro" }, + { provider: "opencode-go", id: "deepseek-v4-flash" }, ], undefined, false); - const routed = catalog.find(entry => entry.slug === "opencode-go/deepseek-v4-pro"); + const routed = catalog.find(entry => entry.slug === "opencode-go/deepseek-v4-flash"); expect(routed).toMatchObject({ web_search_tool_type: "text_and_image", supports_search_tool: true, @@ -90,7 +90,7 @@ describe("Phase 100 Codex-native parity smoke", () => { expect(routed).not.toHaveProperty("supports_websockets"); const parsed = parseRequest({ - model: "opencode-go/deepseek-v4-pro", + model: "opencode-go/deepseek-v4-flash", stream: true, input: "Search current docs, then answer.", tools: [ @@ -106,7 +106,7 @@ describe("Phase 100 Codex-native parity smoke", () => { parsed, false, routedProvider, - "deepseek-v4-pro", + "deepseek-v4-flash", { providerName: "openai", provider: forwardProvider, @@ -125,7 +125,7 @@ describe("Phase 100 Codex-native parity smoke", () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "error", message: "Your input exceeds the context window" }, - ]), "deepseek-v4-pro")); + ]), "deepseek-v4-flash")); const failed = frames.find(frame => frame.event === "response.failed")?.data.response as Record; expect(failed.error).toMatchObject({ code: "context_length_exceeded", diff --git a/tests/fixtures/baseten-models.json b/tests/fixtures/baseten-models.json index 7852c4374f..bd1795c81a 100644 --- a/tests/fixtures/baseten-models.json +++ b/tests/fixtures/baseten-models.json @@ -2,7 +2,7 @@ "object": "list", "data": [ { - "id": "deepseek-ai/DeepSeek-V4-Pro" + "id": "thinkingmachines/inkling" }, { "id": "moonshotai/Kimi-K2.6" diff --git a/tests/gui/alibaba-intl-token-plan.test.ts b/tests/gui/alibaba-intl-token-plan.test.ts index e5fb297e0d..8ff822e5e0 100644 --- a/tests/gui/alibaba-intl-token-plan.test.ts +++ b/tests/gui/alibaba-intl-token-plan.test.ts @@ -28,14 +28,14 @@ describe("alibaba-token-plan-intl registry entry", () => { test("model list includes multi-vendor lineup", () => { const entry = PROVIDER_REGISTRY.find(e => e.id === "alibaba-token-plan-intl"); expect(entry!.models).toContain("qwen3.7-max"); - expect(entry!.models).toContain("deepseek-v4-pro"); + expect(entry!.models).not.toContain("deepseek-v4-pro"); expect(entry!.models).toContain("kimi-k2.7-code"); expect(entry!.models).toContain("glm-5.2"); expect(entry!.models).toContain("glm-5.3"); expect(entry!.models).toContain("glm-5.3-flash"); expect(entry!.models).toContain("MiniMax-M2.5"); expect(entry!.models).toContain("qwen3.8-max"); - expect(entry!.models!.length).toBe(17); + expect(entry!.models!.length).toBe(16); }); test("MiniMax case-insensitive normalization is set", () => { diff --git a/tests/gui/volcengine-providers.test.ts b/tests/gui/volcengine-providers.test.ts index c6bcf2c743..868a5bbc89 100644 --- a/tests/gui/volcengine-providers.test.ts +++ b/tests/gui/volcengine-providers.test.ts @@ -34,7 +34,6 @@ describe("Volcengine Ark providers", () => { "doubao-seed-2-1-pro-260628", "doubao-seed-2-1-turbo-260628", "doubao-seed-evolving", - "deepseek-v4-pro-260425", "deepseek-v4-flash-260425", "deepseek-v3-2-251201", "glm-5-2-260617", @@ -57,7 +56,6 @@ describe("Volcengine Ark providers", () => { models: [ "ark-code-latest", "doubao-seed-2.0-code", - "deepseek-v4-pro", "deepseek-v4-flash", "glm-5.3", "glm-5.3-flash", @@ -73,14 +71,12 @@ describe("Volcengine Ark providers", () => { // #1057: per-model ladders. Since the V4 Pro GA (DeepSeek-V4-Pro-0813) the // vendor table is identical for both models; `xhigh` stays an unadvertised alias. modelReasoningEfforts: { - "deepseek-v4-pro": ["low", "high", "max"], "deepseek-v4-flash": ["low", "high", "max"], }, modelReasoningEffortMap: { - "deepseek-v4-pro": { low: "low", medium: "high", high: "high", xhigh: "high", max: "max" }, "deepseek-v4-flash": { low: "low", medium: "high", high: "high", xhigh: "high", max: "max" }, }, - preserveReasoningContentModels: ["deepseek-v4-pro", "deepseek-v4-flash"], + preserveReasoningContentModels: ["deepseek-v4-flash"], }); expect(PROVIDER_REGISTRY.find(provider => provider.id === "volcengine-agent-plan")).toMatchObject({ label: "Volcengine Ark Agent Plan", @@ -89,9 +85,8 @@ describe("Volcengine Ark providers", () => { adapter: "openai-responses", authKind: "key", preserveCustomDestination: true, - defaultModel: "deepseek-v4-pro", + defaultModel: "deepseek-v4-flash", models: [ - "deepseek-v4-pro", "deepseek-v4-flash", "glm-5.3", "glm-5.3-flash", @@ -118,13 +113,13 @@ describe("Volcengine Ark providers", () => { baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3", defaultModel: "ark-code-latest", liveModels: false, - preserveReasoningContentModels: ["deepseek-v4-pro", "deepseek-v4-flash"], + preserveReasoningContentModels: ["deepseek-v4-flash"], }); expect(KEY_LOGIN_PROVIDERS["volcengine-agent-plan"]).toMatchObject({ baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3", responsesPath: "/responses", adapter: "openai-responses", - defaultModel: "deepseek-v4-pro", + defaultModel: "deepseek-v4-flash", liveModels: false, }); for (const id of ["volcengine", "volcengine-coding-plan", "volcengine-agent-plan"]) { @@ -147,7 +142,7 @@ describe("Volcengine Ark providers", () => { }, }, }; - const route = routeModel(config, "volcengine-agent-plan/deepseek-v4-pro"); + const route = routeModel(config, "volcengine-agent-plan/deepseek-v4-flash"); expect(route.provider.responsesPath).toBe("/responses"); const request = createResponsesPassthroughAdapter(route.provider).buildRequest({ @@ -215,7 +210,7 @@ describe("Volcengine Ark providers", () => { expect(body).not.toHaveProperty("reasoning_effort"); }); - test.each(["deepseek-v4-pro", "deepseek-v4-flash"])( + test.each(["deepseek-v4-flash"])( "preserves %s tool-call reasoning and maps Codex efforts on Coding Plan", modelId => { const config: OcxConfig = { @@ -316,11 +311,11 @@ describe("Volcengine Ark providers", () => { }, }); const request = createResponsesPassthroughAdapter(postBody.provider).buildRequest({ - modelId: "deepseek-v4-pro", + modelId: "deepseek-v4-flash", context: { messages: [] }, stream: true, options: {}, - _rawBody: { model: "deepseek-v4-pro", input: "ping", stream: true }, + _rawBody: { model: "deepseek-v4-flash", input: "ping", stream: true }, }, { headers: new Headers() }); expect(request.url).toBe("https://ark.cn-beijing.volces.com/api/plan/v3/responses"); }); diff --git a/tests/providers/baseten-provider.test.ts b/tests/providers/baseten-provider.test.ts index 8f3da5ed25..721b569894 100644 --- a/tests/providers/baseten-provider.test.ts +++ b/tests/providers/baseten-provider.test.ts @@ -79,7 +79,6 @@ describe("Baseten Model APIs provider", () => { }, }); expect(basetenEntry().modelReasoningEfforts).toEqual({ - "deepseek-ai/DeepSeek-V4-Pro": ["low", "medium", "high", "xhigh", "max"], "thinkingmachines/inkling": ["low", "medium", "high", "xhigh", "max"], "openai/gpt-oss-120b": ["low", "medium", "high", "xhigh", "max"], "moonshotai/Kimi-K3": ["low", "high", "max"], @@ -89,7 +88,6 @@ describe("Baseten Model APIs provider", () => { "zai-org/GLM-5.2-Fast": ["high", "max"], }); expect(basetenEntry().modelReasoningEffortMap).toEqual({ - "deepseek-ai/DeepSeek-V4-Pro": { none: "none", minimal: "minimal" }, "thinkingmachines/inkling": { none: "none", minimal: "minimal" }, "openai/gpt-oss-120b": { none: "none", minimal: "minimal" }, "moonshotai/Kimi-K3": { none: "none" }, @@ -99,7 +97,6 @@ describe("Baseten Model APIs provider", () => { "zai-org/GLM-5.2-Fast": { none: "none" }, }); expect(basetenEntry().modelDefaultReasoningEfforts).toEqual({ - "deepseek-ai/DeepSeek-V4-Pro": "medium", "thinkingmachines/inkling": "high", "openai/gpt-oss-120b": "medium", "moonshotai/Kimi-K3": "max", @@ -137,7 +134,7 @@ describe("Baseten Model APIs provider", () => { parallelToolCalls: true, reasoningEfforts: [], }); - expect(seed.modelReasoningEfforts?.["deepseek-ai/DeepSeek-V4-Pro"]) + expect(seed.modelReasoningEfforts?.["thinkingmachines/inkling"]) .toEqual(["low", "medium", "high", "xhigh", "max"]); expect(seed.modelInputModalities?.["moonshotai/Kimi-K2.6"]) .toEqual(["text", "image"]); @@ -178,10 +175,10 @@ describe("Baseten Model APIs provider", () => { test("routes chat completions to the shared inference host with documented tool parallelism", () => { const route = routeModel( basetenConfig(), - "baseten/deepseek-ai/DeepSeek-V4-Pro", + "baseten/thinkingmachines/inkling", ); expect(route.provider.parallelToolCalls).toBe(true); - expect(route.modelId).toBe("deepseek-ai/DeepSeek-V4-Pro"); + expect(route.modelId).toBe("thinkingmachines/inkling"); const request = createOpenAIChatAdapter(route.provider).buildRequest({ modelId: route.modelId, @@ -196,12 +193,12 @@ describe("Baseten Model APIs provider", () => { expect(request.url).toBe("https://inference.baseten.co/v1/chat/completions"); expect(request.headers.Authorization).toBe("Bearer bt-test-key"); - expect(body.model).toBe("deepseek-ai/DeepSeek-V4-Pro"); + expect(body.model).toBe("thinkingmachines/inkling"); expect(body.parallel_tool_calls).toBe(true); }); test("forwards only the documented per-model reasoning effort ladders", () => { - const deepseekRoute = routeModel(basetenConfig(), "baseten/deepseek-ai/DeepSeek-V4-Pro"); + const deepseekRoute = routeModel(basetenConfig(), "baseten/thinkingmachines/inkling"); const deepseekBody = JSON.parse(String(createOpenAIChatAdapter(deepseekRoute.provider).buildRequest({ modelId: deepseekRoute.modelId, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] }, @@ -245,25 +242,25 @@ describe("Baseten Model APIs provider", () => { const config = withStubbedProviderFetch(basetenConfig()); const models = (await gatherRoutedModels(config)).filter(row => row.provider === "baseten"); expect(models.map(row => row.id)).toEqual([ - "deepseek-ai/DeepSeek-V4-Pro", "moonshotai/Kimi-K2.6", + "thinkingmachines/inkling", ]); expect(models[0]).toMatchObject({ - reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], - defaultReasoningEffort: "medium", + inputModalities: ["text", "image"], + reasoningEfforts: [], parallelToolCalls: true, }); expect(models[1]).toMatchObject({ - inputModalities: ["text", "image"], - reasoningEfforts: [], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + defaultReasoningEffort: "high", parallelToolCalls: true, }); - expect(routedSlug("baseten", models[0]!.id)).toBe("baseten/deepseek-ai-DeepSeek-V4-Pro"); + expect(routedSlug("baseten", models[1]!.id)).toBe("baseten/thinkingmachines-inkling"); - expect(routeModel(config, "baseten/deepseek-ai/DeepSeek-V4-Pro").modelId) - .toBe("deepseek-ai/DeepSeek-V4-Pro"); - expect(routeModel(config, "baseten/deepseek-ai-DeepSeek-V4-Pro").modelId) - .toBe("deepseek-ai/DeepSeek-V4-Pro"); + expect(routeModel(config, "baseten/thinkingmachines/inkling").modelId) + .toBe("thinkingmachines/inkling"); + expect(routeModel(config, "baseten/thinkingmachines-inkling").modelId) + .toBe("thinkingmachines/inkling"); }); test("does not retarget an older same-named custom provider", () => { diff --git a/tests/providers/cline-pass-deepseek-v4-tool-replay.test.ts b/tests/providers/cline-pass-deepseek-v4-tool-replay.test.ts index 0cba245597..6aa7af0880 100644 --- a/tests/providers/cline-pass-deepseek-v4-tool-replay.test.ts +++ b/tests/providers/cline-pass-deepseek-v4-tool-replay.test.ts @@ -9,7 +9,6 @@ import { createTestTranslatorBudget } from "../helpers/translator-budget"; const TARGET_MODELS = [ "cline-pass/deepseek-v4-flash", - "cline-pass/deepseek-v4-pro", ] as const; const provider = { diff --git a/tests/providers/cline-pass-provider.test.ts b/tests/providers/cline-pass-provider.test.ts index 3db66eccd6..81a56923e9 100644 --- a/tests/providers/cline-pass-provider.test.ts +++ b/tests/providers/cline-pass-provider.test.ts @@ -14,7 +14,6 @@ const OFFICIAL_CLINE_PASS_MODELS = [ "cline-pass/kimi-k3", "cline-pass/kimi-k2.7-code", "cline-pass/kimi-k2.6", - "cline-pass/deepseek-v4-pro", "cline-pass/deepseek-v4-flash", "cline-pass/mimo-v2.5", "cline-pass/mimo-v2.5-pro", @@ -73,7 +72,6 @@ describe("ClinePass provider", () => { expect(entry?.noVisionModels).toEqual([ "cline-pass/glm-5.3", "cline-pass/glm-5.2", - "cline-pass/deepseek-v4-pro", "cline-pass/deepseek-v4-flash", "cline-pass/mimo-v2.5-pro", "cline-pass/qwen3.7-max", diff --git a/tests/providers/cline-pass-reasoning-efforts.test.ts b/tests/providers/cline-pass-reasoning-efforts.test.ts index a0acff6078..cf45551060 100644 --- a/tests/providers/cline-pass-reasoning-efforts.test.ts +++ b/tests/providers/cline-pass-reasoning-efforts.test.ts @@ -9,7 +9,6 @@ const CLINE_PASS_MODELS = [ "cline-pass/kimi-k3", "cline-pass/kimi-k2.7-code", "cline-pass/kimi-k2.6", - "cline-pass/deepseek-v4-pro", "cline-pass/deepseek-v4-flash", "cline-pass/mimo-v2.5", "cline-pass/mimo-v2.5-pro", diff --git a/tests/providers/command-code-provider.test.ts b/tests/providers/command-code-provider.test.ts index a3b81e408d..d588c550ee 100644 --- a/tests/providers/command-code-provider.test.ts +++ b/tests/providers/command-code-provider.test.ts @@ -79,7 +79,6 @@ describe("Command Code provider", () => { // rejected with `unsupported_model`. expect(apiKey?.modelReasoningEfforts).toEqual(oauth?.modelReasoningEfforts); expect(apiKey?.modelReasoningEfforts).toMatchObject({ - "deepseek/deepseek-v4-pro": ["high", "max"], "zai-org/GLM-5": ["high", "max"], "zai-org/GLM-5.1": ["high", "max"], "zai-org/GLM-5.2-Fast": ["high", "max"], @@ -112,6 +111,39 @@ describe("Command Code provider", () => { expect(commandCodeReasoningEfforts("z-ai/glm-5.3-flash-vision")).toBeUndefined(); }); + /* + * deepseek/deepseek-v4.1-flash and Qwen/Qwen3.8-Flash are live routes that had + * no row in the official table, so `supportedCommandCodeEffort` dropped the + * field — a client's `max` reached /alpha/generate as no reasoning parameter + * at all. The two presets are constructed separately and must each carry the + * rows; the request assertions pin that the effort survives construction. + */ + test("the live v4.1-flash and Qwen3.8-Flash routes forward their own ladder", async () => { + const oauth = PROVIDER_REGISTRY.find(row => row.id === "command-code"); + const apiKey = PROVIDER_REGISTRY.find(row => row.id === "commandcode"); + for (const [label, entry] of [["oauth", oauth], ["api-key", apiKey]] as const) { + expect(entry?.modelReasoningEfforts?.["deepseek/deepseek-v4.1-flash"], `${label} preset ladder`) + .toEqual(["high", "max"]); + expect(entry?.modelReasoningEfforts?.["Qwen/Qwen3.8-Flash"], `${label} preset ladder`) + .toEqual(["low", "medium", "high", "max"]); + } + expect(commandCodeReasoningEfforts("deepseek/deepseek-v4.1-flash")).toEqual(["high", "max"]); + expect(commandCodeReasoningEfforts("Qwen/Qwen3.8-Flash")).toEqual(["low", "medium", "high", "max"]); + // The live-discovered id may arrive in any case; the lookup folds it. + expect(commandCodeReasoningEfforts("qwen/qwen3.8-flash")).toEqual(["low", "medium", "high", "max"]); + + const deepseekMax = await builtRequest({ + ...parsed("deepseek/deepseek-v4.1-flash"), + options: { reasoning: "max", maxOutputTokens: 100 }, + }); + expect(JSON.parse(deepseekMax.body).params.reasoning_effort).toBe("max"); + const qwenMax = await builtRequest({ + ...parsed("Qwen/Qwen3.8-Flash"), + options: { reasoning: "max", maxOutputTokens: 100 }, + }); + expect(JSON.parse(qwenMax.body).params.reasoning_effort).toBe("max"); + }); + test("OAuth and API-key presets share only verified image capabilities", () => { const oauth = PROVIDER_REGISTRY.find(row => row.id === "command-code"); const apiKey = PROVIDER_REGISTRY.find(row => row.id === "commandcode"); @@ -128,7 +160,6 @@ describe("Command Code provider", () => { ]; const verifiedTextOnlyModels = [ "deepseek/deepseek-v4-flash", - "deepseek/deepseek-v4-pro", "zai-org/GLM-5.2", "zai-org/GLM-5.3", "xai/grok-4.6", diff --git a/tests/providers/digitalocean-scaleway-provider.test.ts b/tests/providers/digitalocean-scaleway-provider.test.ts index 34464c65aa..edee14a87c 100644 --- a/tests/providers/digitalocean-scaleway-provider.test.ts +++ b/tests/providers/digitalocean-scaleway-provider.test.ts @@ -111,7 +111,7 @@ describe("DigitalOcean and Scaleway providers", () => { // the id to both the DigitalOcean and Scaleway lists and moved neither length // assertion; Scaleway's happened to still match, so only this one went red - and it // stayed red on dev, which is how a broken shard reached the branch that noticed it. - expect(digitaloceanModels).toHaveLength(28); + expect(digitaloceanModels).toHaveLength(27); expect(digitaloceanModels).toContain("glm-5.3-flash"); expect(digitaloceanModels).toContain("openai-gpt-5.6-sol"); expect(digitaloceanModels).toContain("meta-llama/Meta-Llama-3.1-8B-Instruct"); @@ -264,12 +264,11 @@ describe("DigitalOcean and Scaleway providers", () => { const scalewayModels = models.filter(row => row.provider === "scaleway"); expect(digitaloceanModels.map(row => row.id)).toEqual([ - "deepseek-v4-pro", "meta-llama/Meta-Llama-3.1-8B-Instruct", "openai-gpt-5.6-sol", ]); expect(digitaloceanModels[1]).toMatchObject({ - owned_by: "digitalocean", + owned_by: "openai", reasoningEfforts: [], }); expect(scalewayModels.map(row => row.id)).toEqual([ diff --git a/tests/providers/opencode-go-deepseek.test.ts b/tests/providers/opencode-go-deepseek.test.ts index 890ec0a92d..eead3f2c32 100644 --- a/tests/providers/opencode-go-deepseek.test.ts +++ b/tests/providers/opencode-go-deepseek.test.ts @@ -54,7 +54,7 @@ function buildToolCallBody(modelId: string, reasoning: string): { describe("opencode-go DeepSeek V4 thinking mode", () => { test("normalizes Desktop-style root composition schemas for Console Go", () => { - const route = routeModel(configFor("deepseek-v4-pro"), "opencode-go/deepseek-v4-pro"); + const route = routeModel(configFor("deepseek-v4.1-flash"), "opencode-go/deepseek-v4.1-flash"); const req = createOpenAIChatAdapter(route.provider).buildRequest({ modelId: route.modelId, context: { @@ -99,7 +99,7 @@ describe("opencode-go DeepSeek V4 thinking mode", () => { }); }); - test.each(["deepseek-v4-flash", "deepseek-v4-pro"])( + test.each(["deepseek-v4-flash", "deepseek-v4.1-flash"])( "%s replays tool-call reasoning and maps Codex efforts", modelId => { const xhighBody = buildToolCallBody(modelId, "xhigh"); @@ -157,12 +157,12 @@ describe("opencode-go DeepSeek json_schema downgrade", () => { test("the preset reaches the routed provider", () => { expect(buildWith("deepseek-v4-flash").provider.noJsonSchemaModels) - .toEqual(["deepseek-v4-pro", "deepseek-v4-flash"]); + .toEqual(["deepseek-v4.1-flash", "deepseek-v4-flash"]); }); test("a listed DeepSeek route is downgraded to json_object", () => { expect(buildWith("deepseek-v4-flash").body.response_format).toEqual({ type: "json_object" }); - expect(buildWith("deepseek-v4-pro").body.response_format).toEqual({ type: "json_object" }); + expect(buildWith("deepseek-v4.1-flash").body.response_format).toEqual({ type: "json_object" }); }); test("an unlisted sibling on the same gateway keeps its schema", () => { diff --git a/tests/providers/opencode-zen-deepseek-reasoning.test.ts b/tests/providers/opencode-zen-deepseek-reasoning.test.ts index 5074a510f1..9bfc0ac369 100644 --- a/tests/providers/opencode-zen-deepseek-reasoning.test.ts +++ b/tests/providers/opencode-zen-deepseek-reasoning.test.ts @@ -53,7 +53,7 @@ function buildToolCallBody(modelId: string, reasoning?: string): { } describe("opencode-zen DeepSeek thinking mode", () => { - test.each(["deepseek-v4-flash-free", "deepseek-v4-flash", "deepseek-v4-pro"])( + test.each(["deepseek-v4-flash-free", "deepseek-v4-flash", "deepseek-v4.1-flash"])( "%s replays tool-call reasoning_content and maps Codex efforts (issue #950/#994)", modelId => { const body = buildToolCallBody(modelId, "xhigh"); @@ -81,7 +81,7 @@ describe("opencode-zen DeepSeek thinking mode", () => { expect(body.messages[1]).toHaveProperty("tool_calls"); }); - test.each(["deepseek-v4-flash-free", "deepseek-v4-flash", "deepseek-v4-pro"])( + test.each(["deepseek-v4-flash-free", "deepseek-v4-flash", "deepseek-v4.1-flash"])( "%s is listed in opencode-zen noVisionModels for the vision sidecar", modelId => { const route = routeModel(configFor(modelId), `opencode-zen/${modelId}`); diff --git a/tests/providers/orcarouter-provider.test.ts b/tests/providers/orcarouter-provider.test.ts index 2b8cb02a54..a45ffde24a 100644 --- a/tests/providers/orcarouter-provider.test.ts +++ b/tests/providers/orcarouter-provider.test.ts @@ -136,7 +136,7 @@ describe("OrcaRouter dual authentication", () => { expect(entry.models).toContain("orcarouter/auto"); expect(entry.modelReasoningEfforts?.["openai/gpt-5.5"]) .toEqual(["low", "medium", "high", "xhigh"]); - expect(entry.modelReasoningEfforts?.["deepseek/deepseek-v4-pro"]).toBeArray(); + expect(entry.modelReasoningEfforts?.["openai/gpt-5.5"]).toBeArray(); } expect(KEY_LOGIN_PROVIDERS.orcarouter).toBeDefined(); expect(OAUTH_PROVIDERS["orcarouter-oauth"]).toBeDefined(); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index b476ce1036..586c8a7735 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -73,7 +73,7 @@ describe("provider registry parity", () => { expect(KEY_LOGIN_PROVIDERS["opencode-go"].noVisionModels).toEqual([ "glm-5.3", "glm-5.2", "glm-5", "glm-5.1", - "deepseek-v4-flash", "deepseek-v4-pro", + "deepseek-v4.1-flash", "deepseek-v4-flash", "mimo-v2-pro", "mimo-v2.5-pro", "minimax-m2.5", "minimax-m2.7", "qwen3.7-max", @@ -84,13 +84,52 @@ describe("provider registry parity", () => { // an operator no longer has to disable structured output by hand. Registry-only means // it is asserted here against the raw entry, not the derived key-login map. const zenDeepseekJsonSchema: Record = { - "opencode-go": ["deepseek-v4-pro", "deepseek-v4-flash"], - "opencode-zen": ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-free"], - "opencode-free": ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-free"], + "opencode-go": ["deepseek-v4.1-flash", "deepseek-v4-flash"], + "opencode-zen": ["deepseek-v4.1-flash", "deepseek-v4-flash", "deepseek-v4-flash-free"], + "opencode-free": ["deepseek-v4.1-flash", "deepseek-v4-flash", "deepseek-v4-flash-free"], }; for (const [id, expected] of Object.entries(zenDeepseekJsonSchema)) { expect(PROVIDER_REGISTRY.find(entry => entry.id === id)?.noJsonSchemaModels).toEqual(expected); } + /* + * DeepSeek's V4.1 transition (2026-09-10) split the spelling by who serves the route: + * the first-party API answers to `deepseek-flash`, the Zen gateway exposes + * `deepseek-v4.1-flash`. A single shared list cannot express that, and the earlier + * draft that tried it would have leaked the gateway spelling into the native preset. + * Pin both directions, including the negatives — a future edit that collapses the two + * constants back together fails here rather than in a user's request. + */ + const nativeDeepseek = PROVIDER_REGISTRY.find(entry => entry.id === "deepseek"); + expect(nativeDeepseek?.defaultModel).toBe("deepseek-flash"); + expect(nativeDeepseek?.models).toContain("deepseek-flash"); + for (const map of [ + nativeDeepseek?.modelReasoningEfforts, + nativeDeepseek?.modelReasoningEffortMap, + nativeDeepseek?.modelSupportsReasoningSummaries, + nativeDeepseek?.modelContextWindows, + ]) { + expect(Object.keys(map ?? {})).toContain("deepseek-flash"); + } + expect(nativeDeepseek?.preserveReasoningContentModels).toContain("deepseek-flash"); + expect(nativeDeepseek?.noVisionModels).toContain("deepseek-flash"); + // The new id keeps the Flash ladder, not the Pro one, through isDeepseekFlashModel. + expect(nativeDeepseek?.modelReasoningEfforts?.["deepseek-flash"]) + .toEqual(nativeDeepseek?.modelReasoningEfforts?.["deepseek-v4-flash"]); + + const zenGo = PROVIDER_REGISTRY.find(entry => entry.id === "opencode-go"); + expect(zenGo?.preserveReasoningContentModels).toContain("deepseek-v4.1-flash"); + expect(zenGo?.noVisionModels).toContain("deepseek-v4.1-flash"); + expect(Object.keys(zenGo?.modelReasoningEfforts ?? {})).toContain("deepseek-v4.1-flash"); + + // Negatives: neither spelling crosses into the other side. + expect(JSON.stringify(nativeDeepseek)).not.toContain("deepseek-v4.1-flash"); + for (const id of ["opencode-go", "opencode-zen", "opencode-free"]) { + expect(JSON.stringify(PROVIDER_REGISTRY.find(entry => entry.id === id))) + .not.toContain("\"deepseek-flash\""); + } + // Vendor-hosted rosters publish on their own schedule and keep the legacy set. + expect(PROVIDER_REGISTRY.find(entry => entry.id === "volcengine-coding-plan")?.preserveReasoningContentModels) + .toEqual(["deepseek-v4-flash"]); // A model can only be gated onto the thinking-budget or thinking-toggle wire if the same // preset also gives it an effort ladder — otherwise the adapter translates effort into a // wire field for a model whose picker is empty. opencode-go carried the shared budget list @@ -181,23 +220,26 @@ describe("provider registry parity", () => { expect(KEY_LOGIN_PROVIDERS.openrouter.modelContextWindows?.["openai/gpt-5.6-sol"]).toBe(1_050_000); expect(KEY_LOGIN_PROVIDERS.openrouter.modelContextWindows?.["openai/gpt-5.6-terra"]).toBe(1_050_000); expect(KEY_LOGIN_PROVIDERS.openrouter.modelContextWindows?.["openai/gpt-5.6-luna"]).toBe(1_050_000); - expect(KEY_LOGIN_PROVIDERS.deepseek.models).toContain("deepseek-v4-pro"); + // Retired from the first-party API on 2026-09-14; the vendor-hosted rosters keep it. + expect(KEY_LOGIN_PROVIDERS.deepseek.models).not.toContain("deepseek-v4-pro"); + expect(KEY_LOGIN_PROVIDERS.deepseek.models).toContain("deepseek-flash"); // #1057: DeepSeek's ladder is low/high/max and the two V4 models resolve it // differently (api-docs.deepseek.com/guides/thinking_mode, verified 2026-08-06). // `xhigh` is an alias, so it stays in the wire map but is not advertised. Pro // does not honor `low` (the vendor maps it to `high`), so Pro must not offer it. - expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEfforts?.["deepseek-v4-pro"]).toEqual(["low", "high", "max"]); + expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEfforts?.["deepseek-flash"]).toEqual(["low", "high", "max"]); expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEfforts?.["deepseek-v4-flash"]).toEqual(["low", "high", "max"]); - expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-v4-pro"]?.low).toBe("low"); - expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-v4-pro"]?.xhigh).toBe("high"); - expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-v4-pro"]?.max).toBe("max"); + expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-flash"]?.low).toBe("low"); + expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-flash"]?.xhigh).toBe("high"); + expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-flash"]?.max).toBe("max"); expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-v4-flash"]?.low).toBe("low"); expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-v4-flash"]?.xhigh).toBe("high"); expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-v4-flash"]?.max).toBe("max"); - expect(KEY_LOGIN_PROVIDERS.deepseek.preserveReasoningContentModels).toEqual(["deepseek-v4-pro", "deepseek-v4-flash"]); + expect(KEY_LOGIN_PROVIDERS.deepseek.preserveReasoningContentModels) + .toEqual(["deepseek-flash", "deepseek-v4-flash"]); // Issue #88: every DeepSeek API model is text-only input — the vision sidecar covers them. expect(KEY_LOGIN_PROVIDERS.deepseek.noVisionModels).toEqual([ - "deepseek-chat", "deepseek-reasoner", "deepseek-v4-pro", "deepseek-v4-flash", + "deepseek-chat", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash", ]); }); @@ -306,10 +348,9 @@ describe("provider registry parity", () => { expect(deepseek).toMatchObject({ adapter: "openai-chat", baseUrl: "https://api.deepseek.com", - defaultModel: "deepseek-v4-flash", + defaultModel: "deepseek-flash", modelContextWindows: { "deepseek-v4-flash": 1_048_576, - "deepseek-v4-pro": 1_048_576, }, }); @@ -358,7 +399,7 @@ describe("provider registry parity", () => { liveModels: false, models: [ "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash", - "glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", + "glm-5.3", "glm-5.3-flash", "glm-5.2", ], modelInputModalities: { "qwen3.8-max": ["text", "image"], @@ -371,9 +412,8 @@ describe("provider registry parity", () => { modelContextWindows: { "qwen3.8-max": 983_616, "qwen3.7-max": 1_000_000, - "deepseek-v4-pro": 1_000_000, }, - noVisionModels: ["glm-5.3", "glm-5.2", "deepseek-v4-pro"], + noVisionModels: ["glm-5.3", "glm-5.2"], preserveReasoningContentModels: expect.arrayContaining(["qwen3.8-max", "qwen3.7-max", "qwen3.7-plus"]), }); expect(PROVIDER_REGISTRY.find(entry => entry.id === "alibaba-token-plan")?.directReasoningEffortModels) @@ -818,7 +858,7 @@ describe("provider registry parity", () => { const ollamaCloud = PROVIDER_REGISTRY.find(entry => entry.id === "ollama-cloud"); expect(ollamaCloud?.models).toEqual([ - "glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", + "glm-5.3", "glm-5.3-flash", "glm-5.2", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b", ]); expect(ollamaCloud?.models).not.toContain("qwen3-coder"); @@ -1422,15 +1462,11 @@ describe("free-provider directory isolation", () => { const flashLadder = ["low", "high", "max"]; const proLadder = ["low", "high", "max"]; const cases: Array<{ provider: string; model: string; flash: boolean }> = [ - { provider: "deepseek", model: "deepseek-v4-pro", flash: false }, + { provider: "deepseek", model: "deepseek-flash", flash: true }, { provider: "deepseek", model: "deepseek-v4-flash", flash: true }, - { provider: "opencode-go", model: "deepseek-v4-pro", flash: false }, + { provider: "opencode-go", model: "deepseek-v4.1-flash", flash: true }, { provider: "opencode-go", model: "deepseek-v4-flash", flash: true }, - { provider: "orcarouter", model: "deepseek/deepseek-v4-pro", flash: false }, - { provider: "volcengine-coding-plan", model: "deepseek-v4-pro", flash: false }, { provider: "volcengine-coding-plan", model: "deepseek-v4-flash", flash: true }, - { provider: "alibaba-token-plan", model: "deepseek-v4-pro", flash: false }, - { provider: "alibaba-token-plan-intl", model: "deepseek-v4-pro", flash: false }, { provider: "alibaba-token-plan-intl", model: "deepseek-v4-flash", flash: true }, { provider: "opencode-free", model: "deepseek-v4-flash-free", flash: true }, ]; diff --git a/tests/routing/fastwire-policy.test.ts b/tests/routing/fastwire-policy.test.ts index e9918dc6ce..36b64f6fb9 100644 --- a/tests/routing/fastwire-policy.test.ts +++ b/tests/routing/fastwire-policy.test.ts @@ -320,7 +320,7 @@ describe("resolveFastPolicy matrix", () => { { name: "DeepSeek V4 defaults", providerName: "deepseek", - modelIds: ["deepseek-v4-flash", "deepseek-v4-pro"], + modelIds: ["deepseek-flash", "deepseek-v4-flash"], provider: { adapter: "openai-chat", baseUrl: "https://api.deepseek.com", diff --git a/tests/routing/router.test.ts b/tests/routing/router.test.ts index 83be39818e..9b6eb68d4d 100644 --- a/tests/routing/router.test.ts +++ b/tests/routing/router.test.ts @@ -447,7 +447,7 @@ describe("routeModel registry effort defaults", () => { const route = routeModel(config, "deepseek/deepseek-v4-flash"); expect(route.provider.noVisionModels).toEqual([ - "deepseek-chat", "deepseek-reasoner", "deepseek-v4-pro", "deepseek-v4-flash", + "deepseek-chat", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash", ]); }); diff --git a/tests/server/adapter-resolve.test.ts b/tests/server/adapter-resolve.test.ts index 1805ad7022..7d316a43c9 100644 --- a/tests/server/adapter-resolve.test.ts +++ b/tests/server/adapter-resolve.test.ts @@ -154,9 +154,9 @@ describe("registry per-model wire defaults", () => { test("routes the official V4 API ids through Responses", () => { expect(resolveWireProtocolOverride("deepseek", "deepseek-v4-flash", deepseek()).adapter) .toBe("openai-responses"); - // V4 Pro GA (DeepSeek-V4-Pro-0813) is officially on the Responses wire too — + // V4.1-Flash is officially on the Responses wire too — // the /responses reference lists both V4 ids as accepted `model` values. - expect(resolveWireProtocolOverride("deepseek", "deepseek-v4-pro", deepseek()).adapter) + expect(resolveWireProtocolOverride("deepseek", "deepseek-flash", deepseek()).adapter) .toBe("openai-responses"); // The dated release label is not the API model id and must not be silently rewritten. expect(resolveWireProtocolOverride("deepseek", "deepseek-v4-flash-0731", deepseek()).adapter) From b3b3e926cc94e252689d288bf8e2c218f8e9c2c7 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 23:11:19 +0900 Subject: [PATCH 041/231] feat(cursor): report decoded checkpoint shape so coverage is answerable from logs Every checkpoint diagnostic reported bytes, which cannot distinguish a snapshot that contains the suspended tool call from one that merely arrived after it - the exact gap that leaves the native wire-model gate undecidable in #4245. cursorCheckpointShape returns counts only, never content, and the decode is skipped unless provider debug is on. --- .../040_wp5_coverage_instrument.md | 50 +++++++++++++++++++ src/adapters/cursor.ts | 6 +++ src/adapters/cursor/checkpoint-store.ts | 32 ++++++++++++ .../cursor-tool-suspended-checkpoint.test.ts | 35 ++++++++++++- 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260911_cursor_checkpoint_capture/040_wp5_coverage_instrument.md diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/040_wp5_coverage_instrument.md b/devlog/_plan/260911_cursor_checkpoint_capture/040_wp5_coverage_instrument.md new file mode 100644 index 0000000000..e6a5877a33 --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/040_wp5_coverage_instrument.md @@ -0,0 +1,50 @@ +# wp5 — making the coverage question answerable + +Branch A landed, so the native wire-model gate now depends on one question: do the captured +bytes actually cover the tool call, or did they merely arrive after it? + +## Why this could not be settled by reading harder + +`capturedAfterClientTool` is set from arrival order (`cursor.ts:312`), and +`conversationCheckpointUpdate` is classified liveness-only (`live-transport.ts:1221`). Every +diagnostic this adapter emits about a checkpoint reports its size in bytes, and a byte count +cannot distinguish a snapshot that contains the suspended call from one that does not. + +The schema can. `ConversationStateStructure.pendingToolCalls` is documented upstream as +"raw JSON stringified tool-call content parts awaiting execution" — a non-zero count on a +suspended turn is the coverage evidence, and the strings themselves are request content that +must never be logged. + +## What landed + +`cursorCheckpointShape` in `checkpoint-store.ts`: decodes a snapshot and returns **counts +only** for `turns`, `turnsOld`, `rootPromptMessages`, `todos`, `pendingToolCalls`. Failure +returns `undefined`; it never throws into the request path. Wired into +`checkpoint-commit-refused` as `capturedShape`, behind `isDebugEnabled()` so the decode does +not run on a normal request. + +That converts the remaining question from "build an instrumented binary and decode bytes by +hand" into "read one log line". + +## What is NOT answered yet, and why + +The live read needs this code running on a machine with a Cursor login. Attempts to shortcut +it with a standalone harness failed: driving the adapter outside the server never reaches the +credential initialisation the proxy does at startup (`getAccountSet` reports not-logged-in +even after `loadAuthStore`, which points at the keyring path rather than `auth.json`). + +Running a second proxy would have worked, but only by either copying the credential store or +sharing the running instance's `OPENCODEX_HOME` and clobbering its pid and admin-token files. +Neither is worth it for a question that answers itself one release later. + +**So wp5 is split.** The instrument is done. The live read is a follow-up: after this ships, +run a forced tool call on a Cursor account with `ocx debug provider on` and read +`capturedShape.pendingToolCalls` off `checkpoint-commit-refused`. + +- `pendingToolCalls > 0` → the snapshot covers the call; the native gate can be removed with + the ordering proof upgraded to a coverage proof. +- `pendingToolCalls === 0` → arrival is not coverage, the current gate is correct, and the + native half of #4245 is not fixable this way. Record it and close. + +Either answer is a real outcome. What was not acceptable was guessing, which is what the +original triage did and what this unit has now avoided four separate times. diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 157da1eaa3..7382c25d8c 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -24,9 +24,11 @@ import { import { commitCursorCheckpoint, cursorCheckpointRefHash, + cursorCheckpointShape, invalidateCursorCheckpoint, } from "./cursor/checkpoint-store"; import { debugProviderDiagnostic } from "../lib/debug"; +import { isDebugEnabled } from "../lib/debug-settings"; import { createAdapterTierMetadata } from "../providers/fastwire"; import { estimateTokens } from "../lib/token-estimate"; import { rememberCursorThreadConversation } from "./cursor/thread-continuity"; @@ -208,6 +210,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda externalModel: isCursorExternalWireModel(activeRequest.modelId), storeCheckpoints: activeRequest.contextUsageStoreCheckpoints !== false, capturedBytes: lastTransport?.captured?.byteLength ?? 0, + // Byte length says nothing about coverage. `pendingToolCalls` does: it is what + // distinguishes a snapshot that knows about the suspended call from one that merely + // arrived after it (#4245). Counts only; the decode is skipped unless debug is on. + capturedShape: isDebugEnabled() ? cursorCheckpointShape(lastTransport?.captured) : undefined, }); return; } diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts index 720bc8dc2e..832bd80686 100644 --- a/src/adapters/cursor/checkpoint-store.ts +++ b/src/adapters/cursor/checkpoint-store.ts @@ -184,6 +184,38 @@ export function cursorCheckpointRefHash(ref: string): string { return createHash("sha256").update("ocx:cursor:ckpt-ref:").update(ref).digest("hex").slice(0, 16); } +/** + * Counts only — never content. Diagnostics about a checkpoint have so far reported its size in + * bytes, which says nothing about what is in it, and that gap is exactly what left #4245's native + * half undecidable: `capturedAfterClientTool` proves a snapshot ARRIVED after the tool call, and + * only `pendingToolCalls` says whether the snapshot actually knows about one. + * + * `pendingToolCalls` is documented upstream as raw JSON tool-call parts awaiting execution, so a + * non-zero count on a suspended turn is the coverage evidence. The strings themselves are request + * content and are never read here. + */ +export function cursorCheckpointShape(checkpointBytes: Uint8Array | undefined): { + turns: number; + turnsOld: number; + rootPromptMessages: number; + todos: number; + pendingToolCalls: number; +} | undefined { + if (!checkpointBytes || checkpointBytes.byteLength === 0) return undefined; + try { + const state = fromBinary(ConversationStateStructureSchema, checkpointBytes); + return { + turns: state.turns.length, + turnsOld: state.turnsOld.length, + rootPromptMessages: state.rootPromptMessagesJson.length, + todos: state.todos.length, + pendingToolCalls: state.pendingToolCalls.length, + }; + } catch { + return undefined; + } +} + export function commitCursorCheckpoint(input: { conversationId: string; identityScope?: string; diff --git a/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts b/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts index 4ce1152173..a365327a06 100644 --- a/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts +++ b/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createCursorAdapter as createCursorAdapterProduction } from "../../../src/adapters/cursor"; -import { clearCursorCheckpointsForTests, getCursorCheckpoint } from "../../../src/adapters/cursor/checkpoint-store"; +import { clearCursorCheckpointsForTests, cursorCheckpointShape, getCursorCheckpoint } from "../../../src/adapters/cursor/checkpoint-store"; import { create, toBinary } from "@bufbuild/protobuf"; import { ConversationStateStructureSchema } from "../../../src/adapters/cursor/gen/agent_pb"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; @@ -94,3 +94,36 @@ describe("tool-suspended checkpoint commit (devlog 260826 050)", () => { clearCursorCheckpointsForTests(); }); }); + +describe("checkpoint shape (#4245 coverage question)", () => { + test("reports counts, and pendingToolCalls is what distinguishes coverage from arrival", () => { + // A snapshot that knows about a suspended call. + expect(cursorCheckpointShape(checkpointBytes)).toEqual({ + turns: 0, + turnsOld: 0, + rootPromptMessages: 0, + todos: 0, + pendingToolCalls: 1, + }); + + // The same structure with nothing pending: byte length alone cannot tell these apart, + // which is exactly why capturedBytes was not enough to settle the native-gate question. + const noPending = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + turns: [new Uint8Array([1, 2, 3])], + })); + expect(cursorCheckpointShape(noPending)).toEqual({ + turns: 1, + turnsOld: 0, + rootPromptMessages: 0, + todos: 0, + pendingToolCalls: 0, + }); + }); + + test("fails closed on absent, empty, and undecodable bytes", () => { + expect(cursorCheckpointShape(undefined)).toBeUndefined(); + expect(cursorCheckpointShape(new Uint8Array())).toBeUndefined(); + // Protobuf cannot parse this; a diagnostic must never throw into the request path. + expect(cursorCheckpointShape(new Uint8Array([0xff, 0xff, 0xff, 0xff]))).toBeUndefined(); + }); +}); From f529134b8f38cd68c382aecd14053ade2db61472 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 23:15:57 +0900 Subject: [PATCH 042/231] docs(cursor): pin the counts-only constraint on the checkpoint shape helper The audit named readPaths, previousWorkspaceUris and the fileStates keys as fields a later extension could leak. Record that in the code rather than only in the review thread. --- src/adapters/cursor/checkpoint-store.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts index 832bd80686..758430064f 100644 --- a/src/adapters/cursor/checkpoint-store.ts +++ b/src/adapters/cursor/checkpoint-store.ts @@ -193,6 +193,11 @@ export function cursorCheckpointRefHash(ref: string): string { * `pendingToolCalls` is documented upstream as raw JSON tool-call parts awaiting execution, so a * non-zero count on a suspended turn is the coverage evidence. The strings themselves are request * content and are never read here. + * + * If you extend this, keep it counts-only. `ConversationStateStructure` also carries + * `readPaths`, `previousWorkspaceUris`, and the `fileStates`/`fileStatesV2` keys — all of which + * are user paths or workspace identity, and all of which would turn a diagnostic into a privacy + * leak the moment someone returns them as values instead of lengths. */ export function cursorCheckpointShape(checkpointBytes: Uint8Array | undefined): { turns: number; From 3ee6f3712d66abfed710be7367cce99a722499f7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 00:11:08 +0900 Subject: [PATCH 043/231] docs(devlog): open the account pool unification unit (#4275) * docs(devlog): open the account pool unification unit * docs(devlog): fold two audit rounds into the account pool unification plan * docs(devlog): write the five phase documents for the account pool unit --------- Co-authored-by: Codex --- .../000_plan.md | 158 ++++++++++++++++++ .../010_phase1_manual_selection.md | 100 +++++++++++ .../020_phase2_shared_kernel.md | 96 +++++++++++ .../030_phase3_cache_affinity.md | 71 ++++++++ .../040_phase4_key_pool_strategy.md | 66 ++++++++ .../050_phase5_surface_consolidation.md | 66 ++++++++ 6 files changed, 557 insertions(+) create mode 100644 devlog/_plan/260911_account_pool_unification/000_plan.md create mode 100644 devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md create mode 100644 devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md create mode 100644 devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md create mode 100644 devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md create mode 100644 devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md diff --git a/devlog/_plan/260911_account_pool_unification/000_plan.md b/devlog/_plan/260911_account_pool_unification/000_plan.md new file mode 100644 index 0000000000..203c540c42 --- /dev/null +++ b/devlog/_plan/260911_account_pool_unification/000_plan.md @@ -0,0 +1,158 @@ +# Account pool unification + +Unit opened 2026-09-11. Base: `dev` at `dd9a2906b` (2.52.0). + +## Objective + +Collapse the three independent account-pool implementations into one shared +selection kernel with per-kind policy, and make an operator's manual account +selection actually win over the pool cursor. + +## Why this unit exists + +An audit of `dev` on 2026-09-11 found pooling is not one feature but three, +plus a fourth path for API keys: + +| Kind | Owner | What it actually does | +|---|---|---| +| Codex | `src/codex/routing.ts`, `src/codex/pool-rotation.ts` | full: strategy, sticky, priority tiers, auto-switch threshold | +| Anthropic | `src/oauth/anthropic-routing.ts` | full: strategy, session affinity, manual preference | +| generic OAuth (10 providers) | `src/oauth/generic-account-failover.ts` | 429 rotation plus a proactive headroom preference when `enabled`; only `strategy` and `autoSwitchThreshold` are persisted-but-inert | +| API keys | `src/providers/key-failover.ts` | reactive 429/401 index walk; no strategy at all | + +The generic kind already has a settings DTO and a capability enum +(`src/oauth/pool-settings-capability.ts` returns `"codex" | "anthropic" | "generic"`), +so the seam for a shared layer was designed and then left hollow. This unit fills +it rather than inventing a new abstraction. + +## The defect that motivates work-phase 1 + +Reported by the maintainer and confirmed in code: the pool moves the active +account to B, the operator then selects A through the dashboard or +`ocx account use`, and the runtime keeps serving B. + +The shape of the defect, not its patch: the Codex pin is a priority-tier ceiling +rather than a selection input, so the strategy picker and the preemption path can +return a different account and record it as the runtime choice. Anthropic solves +the same problem with a one-shot `manualPreference` that Codex and the generic +kind do not have. GUI and CLI are not the divergence: both issue the same +`PUT /api/codex-auth/active`. + +This is a pin-semantics change, not a one-expression bug. An earlier draft named +`applyQuotaAutoSwitch` as the cause; the A-phase audit rejected that, because that +path only moves at `autoSwitchThreshold`, which the drain handler already treats +as the end of a pin. Exact call sites, line anchors and the before/after contract +belong to `010_phase1_manual_selection.md`, not here. + +## Settled semantics + +Recorded during the 2026-09-11 interview (session tracker rounds 1-5): + +- **Manual selection is a one-shot preference that commits on success.** The next + dispatch uses the operator's account; if that dispatch succeeds the account is + committed as the stored active one. The pool may move again only for a real + reason such as 429, cooldown or quota exhaustion. This is the shape Anthropic + already implements through `manualPreference`; Codex and the generic kind lack it. +- **One shared layer, different policy per kind.** Selection order, cooldown and + account state are shared. Policy is not: API keys are a rate-limit scheduling + problem and rotate cheaply, while subscription accounts lose their prompt cache + on every move, so cache affinity must be consulted before quota for them. + +## Constraints + +- `dev` is the only integration branch. Layers that sit in a chain target the layer + below them; layers that are not in a chain target `dev` directly. The Delivery + section names which is which. +- Bun-native TypeScript. No Node-only APIs, no compile step. +- Touching OAuth account selection and credential resolution puts this unit inside + the AGENTS.md security boundary, so each layer needs explicit security review and + must not log tokens or account identifiers. +- `privacy:scan` must stay green. +- Existing Codex and Anthropic pool behavior must not regress; they migrate onto + the shared layer rather than being rewritten in place. +- **Lane ownership.** `devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md` + is the authoritative ownership list for the multi-lane round in flight on `dev`, + and lane L3 owns `src/codex/auth-api.ts`, `src/codex/routing.ts` and + `src/types/config.ts`. Work-phases 1 and 2 need those files, so no implementation + cycle may open against them until that lane releases them or the maintainer + reassigns ownership. This roadmap cycle writes documents only and takes no owned path. +- **Reversibility is a precondition, not a nicety.** Because this unit changes + credential selection, every migrating phase ships behind a flag that defaults to + the existing pools, dual-reads the already-persisted keys + (`accountPoolStrategy`, `accountPoolStickyLimit`, `autoSwitchThreshold`, + `anthropicAccountPool`, `providers..oauthAccountFailover`, + `activeCodexAccountPinned`), and proves parity with before/after selection traces + for Codex and Anthropic across manual, affinity, quota, round-robin and fill-first. + Flag-off is the rollback. + +## Work-phase map + +Dependency order, not effort order. Each layer stands alone with its own tests. + +| Phase | Doc | Thesis | Depends on | +|---|---|---|---| +| 0 | this unit | roadmap written to diff level | — | +| 1 | `010_phase1_manual_selection.md` | an operator pick beats the pool cursor | 0 | +| 2 | `020_phase2_shared_kernel.md` | one kernel, and the generic kind consumes its persisted strategy and threshold | 1 | +| 3 | `030_phase3_cache_affinity.md` | cache affinity ranks ahead of quota | 2, plus the three open assumptions closed | +| 4 | `040_phase4_key_pool_strategy.md` | API keys gain proactive selection | none (parallel off trunk) | +| 5 | `050_phase5_surface_consolidation.md` | three contracts and two GUIs become one | 2 | + +Phase 4 was reparented during the A-phase audit. It does not depend on phase 2: +`src/providers/key-failover.ts` shares no module with the OAuth kernel, and an API +key is a different identity from an OAuth account set. It runs parallel off trunk, +and would gain a dependency only if phase 2 chose to export a credential-kind-agnostic +kernel that `key-failover` imports, which phase 2 does not promise. + +Phase 3 is the speculative layer: all three open assumptions below live in it, so it +does not ride the first train. + +Phase 5 and phase 4 must not both edit the pool management routes and the shared GUI +controls. Phase 5 owns `src/server/management/oauth-account-routes.ts`, the route +registry entries and the GUI pool surfaces; phase 4 keeps key-strategy fields out of +those files and exposes nothing operator-visible until phase 5 gives it a home. + +## Delivery + +A manual branch chain, each layer a PR based on the layer below +(`gh pr create --base`). GitHub native stacks are not used: per +DEV-STACK-OPT-IN-01 a generic request to stack is not native opt-in. + +The first chain is two layers, phase 1 then phase 2. Phase 5 opens off the phase-2 +layer once the kernel lands. Phase 3 waits for its assumptions to close. Phase 4 is +an ordinary PR off `dev` and joins no chain. This replaces an earlier 1-2-3 chain +that the audit rejected for carrying the speculative layer. + +## Open assumptions + +Carried out of the interview unresolved. Each is a question the roadmap answers in +its own phase doc, not a blocker on this plan. + +1. **Affinity key composition.** Codex keys on thread id, Anthropic on a session + key. A shared key shape is not yet chosen. Phase 3 decides it. +2. **Shared-cohort handling.** `promptCacheKeyIsSharedCohort` currently discards + affinity entirely when a `prompt_cache_key` looks shared. Whether to fall back + to another identifier instead of discarding is open. +3. **Cache minimum threshold.** There is no minimum-token gate before applying + `cache_control`, and Anthropic's own 1024/2048 breakpoint minimum is not + implemented locally. Whether to add one is open. + +## Audit record + +Two independent reviewers audited this plan at A and both returned FAIL. Folded +findings: the phase-1 implementation recipe moved out of this 000 document +(LEXICO-SPLIT-01); the causal story corrected away from `applyQuotaAutoSwitch`; the +generic-OAuth description corrected from "reactive only"; phase 4 reparented off +trunk; rollback, feature flag, persisted-config dual-read and parity proof added as +constraints; the lane-ownership collision with `260911_l3_account_pool` recorded as +a hard precondition on phases 1 and 2. + +One finding is passed to a phase doc rather than folded here: `key-failover` already +logs `failedId` and `candidateId`, so `040` must forbid inheriting that logging shape. + +## Evidence + +Audit conducted 2026-09-11 against `origin/dev`. Interview record: +`.codexclaw/interviews/01a08fce-634e-7531-b383-26f2251d9dae.jsonl`, tracker +`.codexclaw/sessions/01a08fce-634e-7531-b383-26f2251d9dae.json` (five scan rounds, +no unresolved contradictions). diff --git a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md new file mode 100644 index 0000000000..d687bdb9c5 --- /dev/null +++ b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md @@ -0,0 +1,100 @@ +# Phase 1 — an operator pick beats the pool cursor (Codex) + +Base: `origin/dev` `dd9a2906b`. Branch: `codex/pool-manual-selection` off `dev`. +Precondition: lane L3 owns `src/codex/routing.ts` and `src/codex/auth-api.ts` +(000_plan.md constraints). Do not open this layer until that ownership clears. + +## Thesis + +A manual selection from the dashboard or `ocx account use` wins the next dispatch, +and commits as the stored active account when that dispatch succeeds. + +## Current behaviour (verified on dd9a2906b) + +``` +src/codex/routing.ts + 56 let runtimeActiveCodexAccountId: string | undefined; + 1625 export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined { + 1626 return runtimeActiveCodexAccountId ?? config.activeCodexAccountId; + 1644 function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void { + 1645 runtimeActiveCodexAccountId = accountId; +``` + +`rememberActiveCodexAccount` is called at `:1470` (round-robin commit), `:1481` +(fill-first commit), `:1678` (`promoteActiveCodexAccount`) and `:2286` +(preemption). None of the four consults the pin. The pin itself +(`config.activeCodexAccountPinned`, written only by `auth-api.ts:2441`) is read as +a priority-tier ceiling in `getEligiblePoolAccounts` `:1318-1322` and nowhere else +in the selection path. + +The path to copy is Anthropic's: + +``` +src/oauth/anthropic-routing.ts + 94 let manualPreference: OAuthAccountSelection | null | undefined; + 575 if (manualPreference === undefined) { ...seed from set.activeAccountId + selectionRevision } + 588 if (manualPreference.accountId !== set.activeAccountId || revision mismatch) manualPreference = null; + 597 return { accountId: chosen, reason: "manual" }; + 799 // consumed only after the admission commit + 808 export function resetAnthropicRoutingForManualSelection(accountId: string) +``` + +## Change surface + +MODIFY `src/codex/routing.ts` + +1. NEW module-local `manualPreference: { accountId: string } | null | undefined` + beside `runtimeActiveCodexAccountId` (`:56`). `undefined` means not yet seeded + from the persisted active account; `null` means consumed. +2. `resetCodexRoutingForManualSelection` (`:870`) additionally seeds + `manualPreference` from `config.activeCodexAccountId`, mirroring + `anthropic-routing.ts:810`. It keeps clearing thread affinity, clearing the + runtime cursor and seeding round-robin, and keeps preserving cooldown. +3. `pickUnboundStrategyAccount` (`:1466-1481`) returns early while a preference is + live, so round-robin and fill-first cannot call `rememberActiveCodexAccount` + over the operator choice. +4. `getEffectiveActiveCodexAccountId` (`:1625`) returns the preference account + while one is live, ahead of the runtime cursor. +5. `resolveCodexAccountForThreadDetailed` (`:2069`) checks the preference before + `pickUnboundStrategyAccount` (`:2194`). If it names the persisted active + account and that account is selectable and not exhausted, return it with a + `manual` reason and do not call `rememberActiveCodexAccount`. +6. `previewCodexAccountForRequest` (`:1987`) peeks the preference without + consuming it. +7. NEW consume-on-success, mirroring `anthropic-routing.ts:799-800`: after a + successful token and admission, set `manualPreference = null` and confirm + `config.activeCodexAccountId`. A failed lookup must not spend the preference. + +MODIFY `src/codex/auth-api.ts` PUT `/api/codex-auth/active` (`:2412-2444`): +no contract change. It keeps `setCodexAccountPin` and +`resetCodexRoutingForManualSelection`; the pin stays the tier ceiling and the new +preference carries the one-shot. A null body still clears the pin (`:2440`). + +Explicitly NOT changed: `applyQuotaAutoSwitch` (`:1784`). It only moves at +`autoSwitchThreshold`, and `releaseDrainedCodexAccountPin` (`:1757`) already +treats that drain as the end of a pin. An earlier draft named it as the cause and +the audit rejected that. + +## Tests + +Extend, do not add files. `codex-` is not in the `layout.json` domain regex, so a +new `codex-*.test.ts` would need entries in both `scripts/test-layout/layout.json` +`explicit` and `tests/fixtures/test-layout-expected.json`. + +- `tests/codex-integration/codex-pool-rotation.test.ts` — the operator pick wins the + next round-robin and fill-first dispatch (manual seed cases at `:524-541`); the + existing pin-holds-RR case at `:791-803` stays green for the ceiling after the + preference is consumed. +- `tests/codex-integration/codex-routing.test.ts` — a second unbound session follows + the pool cursor again once the preference is spent; a failed admission leaves the + preference unspent (pin cases at `:3139-3242`). +- `tests/codex-integration/codex-auth-api.test.ts` — PUT then next-dispatch identity + (`:3956-3989`). + +Semantic oracle: `tests/adapters/anthropic/anthropic-account-pool.test.ts` `:144`, +`:209`, `:234`. + +## Out of scope + +The generic OAuth kind gets no preference in this layer; that arrives with the +kernel in phase 2. No management or GUI change. diff --git a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md new file mode 100644 index 0000000000..c86c01a9ec --- /dev/null +++ b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md @@ -0,0 +1,96 @@ +# Phase 2 — one kernel, and the generic kind consumes its persisted settings + +Base: the phase-1 layer. Branch: `codex/pool-shared-kernel`, PR base +`codex/pool-manual-selection`. Same lane-L3 precondition as phase 1. + +## Thesis + +Extract the rotation primitives into a credential-neutral kernel, then make the +generic OAuth kind actually consume the `strategy` and `autoSwitchThreshold` it +already persists. + +## Current behaviour (verified on dd9a2906b) + +The primitives already take an opaque `poolKey`, so a third key is addable: + +``` +src/codex/pool-rotation.ts + 4-5 POOL_KEY_CODEX = "codex"; POOL_KEY_ANTHROPIC = "anthropic"; + 13 const selectionState = new Map(); + 86 selectPriorityTier(ids, priorityOf, hasHeadroom, pinnedId?) + 189 pickRoundRobinAccount(poolKey: string, eligibleIds, stickyLimit) + 201 peekRoundRobinAccount(...) + 213 notePoolRotationSuccess(poolKey, accountId, stickyLimit) + 232 notePoolRotationFailure(poolKey, accountId) + 245 seedPoolRotationAccount(poolKey, accountId) + 270 reconcilePoolRotationState // only sweeps "anthropic", "codex", "codex:*" +``` + +Fill-first is duplicated rather than shared: `pickFillFirstCodexAccount` +(`routing.ts:1370`) and `pickFillFirstAnthropicAccount` +(`anthropic-routing.ts:513`). + +`src/oauth/generic-account-failover.ts` imports nothing from `pool-rotation.ts`. +It keeps its own cooldown `health` map (`:64-70`, keyed `provider\0accountId`), +rotates on 429 through `rankAccountsByHeadroom` (`:178-218`) and steers the first +attempt through `preferredInitialAccount` (`:246-292`) when +`oauthAccountFailover.enabled`. It never reads `failover.strategy` or +`autoSwitchThreshold`. + +`src/oauth/pool-settings-capability.ts` returns `"codex" | "anthropic" | "generic"` +and stamps `inert: true` on the generic DTO (`:40-54`, `:57-67`). +`src/server/management/oauth-account-routes.ts:395-396` still rejects +`stickyLimit` and `quotaWindow` for the generic kind. + +## Change surface + +NEW `src/oauth/pool-kernel.ts` +- move `SelectionState`, `pickRoundRobinAccount`, `peekRoundRobinAccount`, + `seedPoolRotationAccount`, `notePoolRotationSuccess`, `notePoolRotationFailure`, + `selectPriorityTier`, and the strategy/sticky normalizers +- add `genericPoolKey(provider) => \`generic:\${provider}\`` +- lift fill-first to `pickFillFirst(ids, afterId, hasHeadroom)` so both existing + copies call one implementation +- extend the reconcile sweep to `generic:*` keys, which `:270-276` currently skips + +MODIFY `src/codex/pool-rotation.ts` — re-export the kernel so existing importers +and `tests/codex-integration/codex-pool-rotation.test.ts` keep working unchanged. + +MODIFY `src/oauth/generic-account-failover.ts` — route selection through the kernel +by strategy: `quota` keeps `rankAccountsByHeadroom`, `round-robin` calls +`pickRoundRobinAccount(genericPoolKey(name), ...)`, `fill-first` calls the lifted +helper; seed on manual selection; note success and failure. Keep the presence +quorum, the `EXCLUDED_PROVIDERS` guard and the per-provider `health` cooldown. + +MODIFY `src/oauth/pool-settings-capability.ts` — drop `inert: true`, add +`stickyLimit`. MODIFY `src/types/provider.ts:512-518` comments and +`oauth-account-routes.ts:395` to accept `stickyLimit`. + +MODIFY `src/codex/routing.ts` and `src/oauth/anthropic-routing.ts` — import from +the kernel instead of holding their own copies. + +## Reversibility (audit blocker, mandatory) + +1. **Flag.** `pool.kernel` defaults to `false`. With it off, Codex and Anthropic + take the pre-kernel code path and the generic kind keeps reporting `inert`. +2. **Dual-read.** The kernel reads the already-persisted keys without rewriting + them: `accountPoolStrategy`, `accountPoolStickyLimit`, `autoSwitchThreshold`, + `anthropicAccountPool.*`, `providers..oauthAccountFailover`, + `activeCodexAccountPinned`. No migration writes on upgrade. +3. **Rollback.** Flag off. No config is rewritten, so downgrade is a restart. +4. **Parity proof.** Golden selection traces recorded before and after for Codex + and Anthropic across manual, affinity, quota, round-robin and fill-first, plus + the `__main__` and independent-quota-scope callers. Identical picks are the + gate; a differing pick is a blocker, not a note. + +## Tests + +- `tests/codex-integration/codex-pool-rotation.test.ts` — unchanged behaviour + through the re-export (`pickRoundRobinAccount` `:270`, `selectPriorityTier` `:111`) +- `tests/oauth/generic-oauth-failover.test.ts` — a configured strategy changes the + selected account, which is the criterion that closes "no longer inert" +- `tests/server/account-pool-management-api.test.ts` `:435`, `:449` and + `tests/cli/cli-account-pool-verbs.test.ts` `:315` — update the inert assertions +- `tests/adapters/anthropic/anthropic-account-pool.test.ts` — parity +- `tests/providers/kiro/kiro-pool-rank.test.ts` — the kiro exhaustion special case + in `account-quota-rank.ts:84-108` survives diff --git a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md new file mode 100644 index 0000000000..bee0f768df --- /dev/null +++ b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md @@ -0,0 +1,71 @@ +# Phase 3 — cache affinity ranks ahead of quota + +Base: the phase-2 layer, and all three open assumptions in 000_plan.md closed +first. This is the speculative layer and does not ride the first train. + +## Thesis + +For subscription accounts, moving account destroys the prompt cache, so affinity +is consulted before quota. For API keys it is not, which is why phase 4 keeps a +different policy. + +## Current behaviour (verified on dd9a2906b) + +Stickiness exists but is not cache-driven. + +Codex binds on thread identity: codexPoolAffinityKey (src/codex/auth-context.ts) +from x-codex-parent-thread-id or an HMAC of session and thread id, bound by +bindThreadAffinity (routing.ts:1262), read at :1090. LRU cap +CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048 (:135), pruned oldest-first at +:1211-1234, idle TTL 24h (:134). + +Anthropic binds on a session key: anthropicSessionKeyFromParts +(anthropic-routing.ts:877) prefers client, session and thread id and treats +promptCacheKey as a last resort, discarding it entirely when +promptCacheKeyIsSharedCohort (:894). Cap MAX_AFFINITY_ENTRIES = 2000 (:48), +evict oldest by lastUsedAt (:468-471). + +Generic OAuth has no affinity at all (module comment :1-15). + +reevaluateAffinityQuota (routing.ts:1942) may rebind a live thread when the quota +strategy is active and usage passes autoSwitchThreshold (:2164-2170); round-robin +and fill-first stay sticky (:2157-2160). + +accountPoolStickyLimit is not a binding-count cap. It is the number of successful +binds retained on one round-robin selection, default 1 (src/types/config.ts:841, +pool-rotation.ts:167-171 and :204-216), so at the default it never even sets +activeKey. The real caps are the two LRU limits above. + +No minimum-token cache gate exists anywhere: there is no cacheThreshold or +minCacheTokens, and applyPromptCaching (src/adapters/anthropic.ts:100) places +cache_control without a size check. MAX_CACHE_BREAKPOINTS = 4 (:60) is the only +real cache numeric. + +## Open assumptions this phase must close first + +1. Affinity key shape. Codex keys on thread, Anthropic on session. Proposed + shared shape, to confirm before implementation: a composite of tenant, + conversation, provider and model, which is what cache-affine proxy practice + recommends over hashing the request body. +2. Shared cohort. Today a shared-looking prompt_cache_key discards affinity + entirely. Decide whether to fall back to another identifier instead. +3. Minimum cache size. Decide whether to implement a minimum-token gate and the + Anthropic 1024 and 2048 breakpoint minimum locally. + +## Change surface (provisional, re-verify at P) + +NEW src/oauth/affinity-key.ts - one composite key builder used by Codex, +Anthropic and the generic kind through the phase-2 kernel. + +MODIFY the kernel selection order so that, for pools marked cache-sensitive, a +live affinity binding outranks a higher-headroom candidate unless the affine +account is exhausted. Key pools are not marked cache-sensitive. + +MODIFY reevaluateAffinityQuota so a rebind requires exhaustion rather than merely +passing the threshold, because a threshold rebind throws away a warm cache. + +## Tests + +A cache-affine account is chosen over a higher-headroom one; an exhausted affine +account still yields; concurrent distinct sessions keep distinct accounts; a +shared-cohort cache key does not collapse every session onto one account. diff --git a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md new file mode 100644 index 0000000000..f5d42333ff --- /dev/null +++ b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md @@ -0,0 +1,66 @@ +# Phase 4 — API keys gain proactive selection + +Base: dev directly. This layer is NOT in the chain: key-failover shares no module +with the OAuth kernel, and an API key is a different identity from an OAuth +account set. The A-phase audit reparented it here. + +## Thesis + +API-key pools get a proactive strategy before the first attempt, while keeping +the existing reactive 429 and 401 rotation as the fallback. + +## Current behaviour (verified on dd9a2906b) + +src/providers/key-failover.ts is reactive only. hasKeyPoolFailover (:98-101) +requires authMode not oauth or forward and apiKeyPool length at least 2. +Selection is a circular index walk in rotateKeyAfterFailure (:220-233) starting +from the failed entry, skipping cooled keys. Cooldown state is a local map +(:19-53) keyed by provider and key id. Wrappers: rotateKeyOn429 (:269-278), +rotateKeyOn401 (:288-296), rotateProviderTransportOn429 (:322-338). + +src/providers/api-keys.ts listProviderApiKeys (:62-80) returns the pool and an +activeId with no strategy. src/types/provider.ts:384-389 defines apiKeyPool as +id, key, label and addedAt only. + +The pre-dispatch hook points are in src/server/responses/core.ts: :4188-4190 +(refreshDispatchAdapter calling resolveCurrentProviderApiKeyTransport) and +:4437-4444 (resolveProviderTransport after OAuth resolution). The OAuth side has +preferredInitialAccount at :4335-4340 with the comment that it prefers a known +headroom account before the first attempt; API keys have no analogue. + +## Change surface + +MODIFY src/types/provider.ts - add an optional per-provider key-pool strategy +field. Do not reuse the OAuth account-pool field names; these are different +identities and phase 5 owns the operator surface. + +MODIFY src/providers/key-failover.ts - add a proactive selector invoked from the +pre-dispatch sites, supporting round-robin and a rate-limit-aware order. Keep +:220-233 exactly as the 429 and 401 fallback. + +MODIFY src/server/responses/core.ts at :4188 and :4437 to consult the selector +before the first attempt. The mid-retry resolveProviderTransport calls at :4119, +:5399, :5503 and :7346 stay recovery paths and are not touched. + +## Policy difference from OAuth pools, stated deliberately + +Key rotation is a rate-limit scheduling problem: keys usually share an account or +organization, so moving key costs little cache. Subscription accounts lose their +prompt cache on every move. That is why phase 3 puts affinity ahead of quota for +accounts and this phase does not for keys. + +## Security + +key-failover already logs failedId and candidateId. The new selector must not +inherit that shape, and must log no key identity. privacy:scan stays green. + +## Tests + +A configured round-robin strategy changes the first-attempt key; the reactive 429 +and 401 walk still works when the strategy is unset; a cooled key is skipped by +both paths; a single-key pool is a no-op. + +## Out of scope + +No operator-visible surface. Phase 5 owns the management route and GUI; adding +fields there from this layer would collide with it. diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md new file mode 100644 index 0000000000..0144d41f82 --- /dev/null +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -0,0 +1,66 @@ +# Phase 5 — three contracts and two GUIs become one + +Base: the phase-2 layer. Opens once the kernel lands. + +## Thesis + +One pool-settings contract and one operator surface, so a new pooled provider +needs configuration rather than another name branch. + +## Current behaviour (verified on dd9a2906b) + +Three management contracts: + +1. Codex only. src/codex/auth-api.ts handleCodexAuthAPI :2477-2515 handles PUT + and PATCH /api/codex-auth/pool-strategy, writing accountPoolStrategy and + accountPoolStickyLimit. There is no GET on this path. +2. Anthropic versus generic. src/server/management/oauth-account-routes.ts + handleOauthAccountRoutes branches on provider !== "anthropic": GET :348-361, + PUT and PATCH :373-423 with stickyLimit and quotaWindow rejected at :395-396, + and the anthropic write at :424-483. +3. Registry. src/server/management/route-registry.ts :95 and :110 for the Codex + path, :263, :270 and :283 for the oauth pool path. + +Two GUI surfaces, one shared control: + +- shared gui/src/components/AccountPoolStrategyControls.tsx :42 and + gui/src/account-pool-strategy.ts, whose putCodexPoolStrategy :58-65 posts to the + Codex-only route +- Codex gui/src/components/CodexPoolStrategySetting.tsx :33 and :174 +- Anthropic gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx + :63 GET and :117 PUT, hardcoded to provider=anthropic +- mounted by a name branch in + gui/src/components/provider-workspace/ProviderAuthPanel.tsx :387-389, + item.name === "anthropic" only, so the generic kind has an API and no UI + +i18n: 36 accountPool.* keys in gui/src/i18n/en.ts :1981-2023, and every catalog in +gui/src/i18n/catalogs.ts :24-33 already carries 36. All nine stay in sync. + +## Change surface + +NEW one pool-settings DTO covering every kind, served from a single route pair +under the oauth-account-routes module, with the Codex path kept as a deprecated +alias that forwards rather than duplicating the write. + +MODIFY ProviderAuthPanel to mount the pool panel from the capability returned by +poolSettingsCapability instead of item.name === "anthropic". + +MODIFY AnthropicAccountPoolSettings into a kind-driven component; keep +AccountPoolStrategyControls as the shared control it already is. + +MODIFY the i18n catalogs together. Any new key lands in all nine files in the same +commit, per the docs-sync rule in AGENTS.md. + +## Boundary with phase 4 + +This layer owns oauth-account-routes.ts, the route registry entries and the GUI +pool surfaces. Phase 4 keeps key-strategy fields out of those files. If the key +pool needs an operator surface, it arrives here after both have landed, not in +parallel. + +## Tests + +tests/server/account-pool-management-api.test.ts for the unified DTO and the +deprecated alias; tests/cli/cli-account-pool-verbs.test.ts for CLI parity; a GUI +test that the panel mounts for a generic OAuth provider. A gui-labelled PR needs a +screenshot in its description per AGENTS.md. From 3a3759eabde8d5e592fe4c76aa74e5c904583500 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:00:04 +0900 Subject: [PATCH 044/231] docs(devlog): re-verify the phase 1 anchors against the current dev tip --- .../010_phase1_manual_selection.md | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md index d687bdb9c5..eec127e3fa 100644 --- a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md +++ b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md @@ -50,9 +50,9 @@ MODIFY `src/codex/routing.ts` `manualPreference` from `config.activeCodexAccountId`, mirroring `anthropic-routing.ts:810`. It keeps clearing thread affinity, clearing the runtime cursor and seeding round-robin, and keeps preserving cooldown. -3. `pickUnboundStrategyAccount` (`:1466-1481`) returns early while a preference is - live, so round-robin and fill-first cannot call `rememberActiveCodexAccount` - over the operator choice. +3. `pickUnboundStrategyAccount` (declared `:1446`, commit sites `:1470` and + `:1481`) returns early while a preference is live, so round-robin and + fill-first cannot call `rememberActiveCodexAccount` over the operator choice. 4. `getEffectiveActiveCodexAccountId` (`:1625`) returns the preference account while one is live, ahead of the runtime cursor. 5. `resolveCodexAccountForThreadDetailed` (`:2069`) checks the preference before @@ -98,3 +98,23 @@ Semantic oracle: `tests/adapters/anthropic/anthropic-account-pool.test.ts` `:144 The generic OAuth kind gets no preference in this layer; that arrives with the kernel in phase 2. No management or GUI change. + +## Staleness re-verification + +Re-verified at the wp1 P entry against `origin/dev` `16f18d654`, after lane L3 +landed `de1d88739`, `abec9ee51` and `7f91737c2` on the owned files. Every anchor +this document depends on is unchanged from the `dd9a2906b` reading: + +| Symbol | Line on 16f18d654 | +|---|---| +| `getEffectiveActiveCodexAccountId` | 1625 | +| `rememberActiveCodexAccount` | 1644 | +| `applyQuotaAutoSwitch` | 1784 | +| `resetCodexRoutingForManualSelection` | 870 | +| `pickUnboundStrategyAccount` | 1446 | +| `releaseDrainedCodexAccountPin` | 1757 | + +The design therefore survives the lane's landings. What does not change is the +coordination risk: L3 still owns these files for the dispatch round, so the B +phase of this work-phase must not open until that ownership clears. Re-run this +table at that point, because the guarantee above is a snapshot of `16f18d654`. From a11da688335414edf419a093211caf3e1aa7ff36 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:12:01 +0900 Subject: [PATCH 045/231] docs(devlog): fold three audit rounds into the phase 1 design --- .../010_phase1_manual_selection.md | 91 ++++++++++++++++--- 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md index eec127e3fa..bfb1a2b1b0 100644 --- a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md +++ b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md @@ -43,27 +43,70 @@ src/oauth/anthropic-routing.ts MODIFY `src/codex/routing.ts` -1. NEW module-local `manualPreference: { accountId: string } | null | undefined` - beside `runtimeActiveCodexAccountId` (`:56`). `undefined` means not yet seeded - from the persisted active account; `null` means consumed. +1. NEW `manualPreference`, keyed by pool scope rather than a singleton: + `Map` beside `runtimeActiveCodexAccountId` + (`:56`), keyed by `codexPoolKeyForScope` (`:225`). A singleton would let an + independent quota scope (spark, reserve) apply or consume the shared one-shot, + because `isIndependentCodexQuotaScope` deliberately isolates those from the + shared `remember` path. An absent entry means not yet seeded; `null` means + consumed. + + Seeding is explicit only. The entry is written by + `resetCodexRoutingForManualSelection` and nowhere else. There is no lazy seed + from `config.activeCodexAccountId` on first read, because an absent entry plus a + lazy seed would let an independent quota scope invent a preference it was never + given. + + Invalidation, since Codex has no account-side equivalent of Anthropic's + `selectionRevision` (`apiKeySelectionRevision` is for keys and the store + `generation` is credential lineage): the preference is dropped only by an + OPERATOR-driven change of the active account, meaning another + `resetCodexRoutingForManualSelection` naming a different account, or an explicit + clear. A POOL-driven move must not drop it. + + That distinction is load-bearing and was missed twice. An earlier draft said + "drop it whenever the accountId no longer equals the persisted active account", + which contradicts the guarantee below: `promoteActiveCodexAccount` (`:1677`) + calls `releaseCodexAccountPinFor` and then `setActiveCodexAccount` (`:1660`, + which clears `runtimeActiveCodexAccountId` at `:1661`) BEFORE it would reach the + guarded `remember`. Under the old rule a failover promote would move the + persisted active, look like a mismatch, and silently spend the operator's + one-shot. Keying invalidation to the operator path instead of to value equality + is what keeps F1 and F4 from cancelling each other. 2. `resetCodexRoutingForManualSelection` (`:870`) additionally seeds `manualPreference` from `config.activeCodexAccountId`, mirroring `anthropic-routing.ts:810`. It keeps clearing thread affinity, clearing the runtime cursor and seeding round-robin, and keeps preserving cooldown. -3. `pickUnboundStrategyAccount` (declared `:1446`, commit sites `:1470` and - `:1481`) returns early while a preference is live, so round-robin and - fill-first cannot call `rememberActiveCodexAccount` over the operator choice. +3. The guard sits on BOTH writers, not only on `remember`. + `rememberActiveCodexAccount` (`:1644`) becomes a no-op while a live preference + names a different account, which closes its four call sites `:1470`, `:1481`, + `:1678` and `:2286` at once. That alone is still insufficient, because + `promoteActiveCodexAccount` (`:1677`) releases the pin and calls + `setActiveCodexAccount` (`:1660`) before it ever reaches `remember`. So + `promoteActiveCodexAccount` and `setActiveCodexAccount` also check for a live + preference and leave the operator's account in place for the pool-driven paths + (failover `:1878`, model detour `:2213`, exclusion `:1704`, cooldown `:2534` + and `:2584`). An operator PUT still moves them, because that path seeds a new + preference first. 4. `getEffectiveActiveCodexAccountId` (`:1625`) returns the preference account while one is live, ahead of the runtime cursor. 5. `resolveCodexAccountForThreadDetailed` (`:2069`) checks the preference before - `pickUnboundStrategyAccount` (`:2194`). If it names the persisted active - account and that account is selectable and not exhausted, return it with a - `manual` reason and do not call `rememberActiveCodexAccount`. + `pickUnboundStrategyAccount` (`:2194`). If the preference account is selectable + and not exhausted, return it with a `manual` reason and do not call + `rememberActiveCodexAccount`. Honouring does NOT require the preference to still + equal `config.activeCodexAccountId`: a pool-driven promote may legitimately have + moved that value, and treating the difference as staleness is the mistake the + audit rejected twice. 6. `previewCodexAccountForRequest` (`:1987`) peeks the preference without consuming it. -7. NEW consume-on-success, mirroring `anthropic-routing.ts:799-800`: after a - successful token and admission, set `manualPreference = null` and confirm - `config.activeCodexAccountId`. A failed lookup must not spend the preference. +7. NEW consume-on-success, mirroring `anthropic-routing.ts:799-800`. Codex has no + equivalent of the Anthropic admission commit, so the hook must be named + explicitly: consume at the same point that already records a successful upstream + outcome for the resolved account, `recordCodexUpstreamOutcome`, and only for a + non-quota success. Consuming must call `setActiveCodexAccount` rather than only + nulling the entry, because nulling alone leaves `runtimeActiveCodexAccountId` + pointing at the pool's earlier pick and the next dispatch would silently return + to it. A failed lookup must not spend the preference. MODIFY `src/codex/auth-api.ts` PUT `/api/codex-auth/active` (`:2412-2444`): no contract change. It keeps `setCodexAccountPin` and @@ -73,7 +116,9 @@ preference carries the one-shot. A null body still clears the pin (`:2440`). Explicitly NOT changed: `applyQuotaAutoSwitch` (`:1784`). It only moves at `autoSwitchThreshold`, and `releaseDrainedCodexAccountPin` (`:1757`) already treats that drain as the end of a pin. An earlier draft named it as the cause and -the audit rejected that. +the audit rejected that. Goalplan criterion c-2 therefore already holds on `dev`; +what is missing is not behaviour but proof, so this layer adds the test rather +than the code. ## Tests @@ -94,8 +139,28 @@ new `codex-*.test.ts` would need entries in both `scripts/test-layout/layout.jso Semantic oracle: `tests/adapters/anthropic/anthropic-account-pool.test.ts` `:144`, `:209`, `:234`. +Added after the A-phase audit, because the three files above prove the ceiling and +the drain but not these: + +- a live preference survives `promoteActiveCodexAccount` reached through failover + and through a model detour, and survives a priority preemption +- an independent quota scope neither applies nor consumes the shared preference +- an operator selecting a different account replaces the previous preference, while + a pool-driven promote that moves the persisted active account does not spend it +- criterion c-2 directly: with a pinned account that is selectable and under + `autoSwitchThreshold`, auto-switch holds, under both the quota strategy and + round-robin or fill-first + ## Out of scope +## Audit record + +The A-phase reviewer returned FAIL with one blocker and four majors, all folded +above: the overwrite hole at `promoteActiveCodexAccount` and preemption, the +singleton-versus-scope-keyed state, the missing invalidation rule in the absence +of an account-side revision, the pin-versus-preference disagreement after a +released pin, and the test gap against criterion c-2. + The generic OAuth kind gets no preference in this layer; that arrives with the kernel in phase 2. No management or GUI change. From e395a87d8dce83d0eb38fdfcf90e74a7214fa1f8 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:32:16 +0900 Subject: [PATCH 046/231] feat(providers): pick a warm API key before the first attempt --- src/providers/key-failover.ts | 78 +++++++++++++++++++++++++++++ src/server/auth-cors.ts | 2 + src/types/provider.ts | 10 ++++ tests/adapters/key-failover.test.ts | 69 +++++++++++++++++++++++++ 4 files changed, 159 insertions(+) diff --git a/src/providers/key-failover.ts b/src/providers/key-failover.ts index ad4d61ba8d..bce8f3368d 100644 --- a/src/providers/key-failover.ts +++ b/src/providers/key-failover.ts @@ -100,6 +100,84 @@ export function hasKeyPoolFailover(provider: OcxProviderConfig): boolean { return (provider.apiKeyPool?.length ?? 0) >= 2; } +/** + * Process-local round-robin cursor per provider, deliberately parallel to `keyCooldowns` + * rather than borrowing the Codex pool-rotation state: an API key is not an OAuth account + * and must not share a quota scope key. Multi-process desync is the same accepted limit + * the cooldown map already carries. + */ +const keyRotationCursor = new Map(); + +/** Forget a provider's cursor so an operator's manual key selection is not second-guessed. */ +export function forgetApiKeyRotationCursor(providerName: string): void { + keyRotationCursor.delete(providerName); +} + +/** + * Pick a better key BEFORE the first attempt when the committed one is already cooling. + * + * This is intentionally narrow. It never overrides a healthy key: if the committed + * `apiKey` is not in cooldown it returns null, so an operator's manual selection stands + * and no config write happens. It only acts when the committed key is known-cooled (or + * missing from the pool), which is exactly the case where the first request would + * otherwise be spent earning a 429 the runtime could already predict. + * + * Returning null is the common path, so the persisted-selection transaction is not on + * the per-request hot path. + */ +export function selectProactiveApiKey( + config: OcxConfig, + providerName: string, + now = Date.now(), +): OcxProviderConfig | null { + const provider = config.providers?.[providerName]; + if (!provider) return null; + const strategy = provider.apiKeyPoolStrategy; + if (!strategy) return null; + if (!hasKeyPoolFailover(provider)) return null; + const pool = provider.apiKeyPool ?? []; + + const activeEntry = pool.find(entry => entry.key === provider.apiKey); + // A healthy committed key wins, whether the operator chose it or a previous rotation did. + if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) return null; + + const eligible = pool.filter(entry => !isKeyInCooldown(providerName, entry.id, now)); + if (eligible.length === 0) return null; + + let chosen = eligible[0]!; + if (strategy === "round-robin") { + const lastId = keyRotationCursor.get(providerName); + const lastIndex = lastId ? pool.findIndex(entry => entry.id === lastId) : -1; + for (let offset = 1; offset <= pool.length; offset += 1) { + const candidate = pool[(lastIndex + offset) % pool.length]!; + if (isKeyInCooldown(providerName, candidate.id, now)) continue; + chosen = candidate; + break; + } + } + if (chosen.key === provider.apiKey) return null; + + const outcome = commitProviderApiKeySelection(config, providerName, freshProvider => { + const freshPool = freshProvider.apiKeyPool ?? []; + const target = freshPool.find(entry => entry.id === chosen.id); + if (!target) return { changed: false, value: null }; + if (freshProvider.apiKey === target.key) return { changed: false, value: null }; + const freshActive = freshPool.find(entry => entry.key === freshProvider.apiKey); + // Re-check under the lock: a concurrent manual selection may have landed a healthy key. + if (freshActive && !isKeyInCooldown(providerName, freshActive.id, now)) { + return { changed: false, value: null }; + } + freshProvider.apiKey = target.key; + return { changed: true, value: target.id }; + }); + if (outcome.status !== "committed" || outcome.value === null) return null; + + keyRotationCursor.set(providerName, outcome.value); + const committed = structuredClone(outcome.provider); + config.providers[providerName] = committed; + return structuredClone(committed); +} + /** * Normalize a provider's `retryOn429` policy, or return null when the knob is absent, * explicitly disabled, or the provider is not key-auth (OAuth/forward credentials must not be diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index cad0acbd33..3a93246cd0 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -817,6 +817,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = { apiKey: "redacted", apiKeyTransport: "editor", apiKeyPool: "redacted", + // Ordering preference only; it names no key material, so an editor may read and set it. + apiKeyPoolStrategy: "editor", apiKeySelectionRevision: "runtime", _apiKeyAttempt: "runtime", defaultModel: "editor", diff --git a/src/types/provider.ts b/src/types/provider.ts index e14b89abe6..e65130a4fa 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -387,6 +387,16 @@ export interface OcxProviderConfig { * `apiKey` seeds a one-entry pool on first management touch. */ apiKeyPool?: Array<{ id: string; key: string; label?: string; addedAt?: number }>; + /** + * Optional proactive ordering for `apiKeyPool` when the committed key is already + * cooling. Deliberately NOT named like the OAuth `accountPoolStrategy`: an API key + * is a different identity from an OAuth account set, and key rotation is a + * rate-limit scheduling problem rather than a prompt-cache one. + * + * Absent means today's behaviour: no pre-dispatch pick at all, only the reactive + * 429/401 walk in `key-failover`. + */ + apiKeyPoolStrategy?: "round-robin" | "fill-first"; /** Changes on manual selection (including re-selection) and committed automatic allocation. */ apiKeySelectionRevision?: string; /** Runtime only. Never expose in management responses or persist a routed provider. */ diff --git a/tests/adapters/key-failover.test.ts b/tests/adapters/key-failover.test.ts index 8efb505aa6..1bd89af487 100644 --- a/tests/adapters/key-failover.test.ts +++ b/tests/adapters/key-failover.test.ts @@ -18,6 +18,10 @@ import { rotateProviderTransportOn429, rotateProviderTransportOn401, } from "../../src/providers/key-failover"; +import { + forgetApiKeyRotationCursor, + selectProactiveApiKey, +} from "../../src/providers/key-failover"; import { resolveOpenCodeGoTransport } from "../../src/providers/opencode-go-transport"; import { deriveXaiConvId } from "../../src/providers/xai-transport"; import { routeModel, routedProviderConfig } from "../../src/router"; @@ -449,4 +453,69 @@ describe("rotateKeyOn401", () => { expect(rotated?.apiKey).toBe("key-beta-444555666777"); expect(getKeyCooldownUntil("p", "k1", now)).toBe(now + 10 * 60_000); }); + + describe("proactive key selection", () => { + const now = 2_000_000; + + test("does nothing without a configured strategy", () => { + const config = makeConfig({ apiKey: "key-alpha-000111222333", apiKeyPool: pool3() }); + forgetApiKeyRotationCursor("p"); + rotateKeyOn429(config, "p", null, now); + expect(selectProactiveApiKey(config, "p", now)).toBeNull(); + }); + + test("keeps a healthy committed key instead of rotating off an operator choice", () => { + const config = makeConfig({ + apiKey: "key-alpha-000111222333", + apiKeyPool: pool3(), + apiKeyPoolStrategy: "round-robin", + }); + forgetApiKeyRotationCursor("p"); + // No cooldown recorded, so the committed key is healthy and must survive untouched. + expect(selectProactiveApiKey(config, "p", now)).toBeNull(); + expect(config.providers.p.apiKey).toBe("key-alpha-000111222333"); + }); + + test("moves off a committed key that is already cooling", () => { + const config = makeConfig({ + apiKey: "key-alpha-000111222333", + apiKeyPool: pool3(), + apiKeyPoolStrategy: "round-robin", + }); + forgetApiKeyRotationCursor("p"); + // Cool the committed key, then persist it back as active so the next request starts on + // it. Writing only the in-memory copy is not enough: the selector re-reads under the + // persistence lock, which is the guard that stops it clobbering a healthy choice. + rotateKeyOn429(config, "p", null, now); + setActiveProviderApiKey(config, "p", "k1"); + const picked = selectProactiveApiKey(config, "p", now); + expect(picked).not.toBeNull(); + expect(picked?.apiKey).not.toBe("key-alpha-000111222333"); + expect(getKeyCooldownUntil("p", "k1", now)).toBeGreaterThan(now); + }); + + test("returns null when every key is cooling", () => { + const config = makeConfig({ + apiKey: "key-alpha-000111222333", + apiKeyPool: pool3(), + apiKeyPoolStrategy: "round-robin", + }); + forgetApiKeyRotationCursor("p"); + rotateKeyOn429(config, "p", null, now); + rotateKeyOn429(config, "p", null, now); + rotateKeyOn429(config, "p", null, now); + config.providers.p.apiKey = "key-alpha-000111222333"; + expect(selectProactiveApiKey(config, "p", now)).toBeNull(); + }); + + test("a single-key pool is a no-op", () => { + const config = makeConfig({ + apiKey: "key-alpha-000111222333", + apiKeyPool: [{ id: "k1", key: "key-alpha-000111222333" }], + apiKeyPoolStrategy: "round-robin", + }); + forgetApiKeyRotationCursor("p"); + expect(selectProactiveApiKey(config, "p", now)).toBeNull(); + }); + }); }); From 2e6841be692695536353a45d7a5a305dd69e6fe2 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:34:36 +0900 Subject: [PATCH 047/231] fix(config): reject an unknown apiKeyPoolStrategy instead of loading it silently --- src/config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/config.ts b/src/config.ts index a5a75565ea..106dadd2b6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -580,6 +580,10 @@ const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => { const providerConfigSchema = z.object({ pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(), modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(), + // Validated rather than left to passthrough: an unrecognized strategy would otherwise + // load silently and then be ignored at selection time, which reads as a broken feature + // rather than a rejected setting. + apiKeyPoolStrategy: z.enum(["round-robin", "fill-first"]).optional(), adapter: z.string().min(1), baseUrl: z.string().min(1), alias: z.string().optional(), From e1cc65548c91137df280ffc1b70f4b6d3d804f85 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:37:08 +0900 Subject: [PATCH 048/231] docs(devlog): scope phase 2 to the files no lane owns --- .../020_phase2_shared_kernel.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md index c86c01a9ec..e019583ad7 100644 --- a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md +++ b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md @@ -9,6 +9,30 @@ Extract the rotation primitives into a credential-neutral kernel, then make the generic OAuth kind actually consume the `strategy` and `autoSwitchThreshold` it already persists. +## Availability and the slice this cycle can actually take + +Re-verified at the wp2 P entry against `origin/dev`. The lane partition for the +round in flight does not list `src/oauth/generic-account-failover.ts`, +`src/oauth/pool-settings-capability.ts` or `src/codex/pool-rotation.ts`, so the +kernel extraction and the generic-kind strategy work are available now. Two things +are not: + +- `src/codex/routing.ts` is owned by lane L3, so the Codex-side import swap waits. +- `src/server/responses/core.ts` is owned by lane L1 and is the most contended + file in the round with four open PRs, which is also why the wp4b call-site + wiring could not follow #4277 immediately. + +This cycle therefore takes the kernel plus the generic consumer and leaves the +Codex and Anthropic import swaps to a later layer. That ordering is not a +concession: a kernel that nothing imports yet is still verifiable through the +generic kind, and it keeps the contended files out of this PR entirely. + +Anchors confirmed present on `origin/dev`: `selectPriorityTier` :86, +`pickRoundRobinAccount` :189, `notePoolRotationSuccess` :213, +`seedPoolRotationAccount` :245, `reconcilePoolRotationState` :260 in +`pool-rotation.ts`; `preferredInitialAccount` :246 and the +`rankAccountsByHeadroom` import :19 in `generic-account-failover.ts`. + ## Current behaviour (verified on dd9a2906b) The primitives already take an opaque `poolKey`, so a third key is addable: From fe2b76364ab93607cebae770a60468d9acbde00a Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:46:10 +0900 Subject: [PATCH 049/231] docs(devlog): fold two audit rounds into the phase 2 kernel plan --- .../020_phase2_shared_kernel.md | 103 ++++++++++++++---- 1 file changed, 83 insertions(+), 20 deletions(-) diff --git a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md index e019583ad7..b9ead490fd 100644 --- a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md +++ b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md @@ -22,10 +22,22 @@ are not: file in the round with four open PRs, which is also why the wp4b call-site wiring could not follow #4277 immediately. -This cycle therefore takes the kernel plus the generic consumer and leaves the -Codex and Anthropic import swaps to a later layer. That ordering is not a -concession: a kernel that nothing imports yet is still verifiable through the -generic kind, and it keeps the contended files out of this PR entirely. +This cycle takes the kernel, the Anthropic import swap and the generic consumer. +Only the CODEX import swap is deferred, and it is deferred for free: once +`pool-rotation.ts` re-exports the kernel, `src/codex/` keeps its existing import +path and needs no edit at all. So the contended files stay out of this PR without +the kernel being an orphan. + +Two kinds of change are moving here and they carry different risk, which is why +only one of them is behind the flag: + +- **Relocation** is behaviour-preserving. Moving the state and primitives into + `pool-kernel.ts` and re-exporting them changes no selection outcome, so it is + not flagged. `git` history and a green existing suite are its proof. +- **Behaviour** is flagged. The generic kind consuming `strategy` and + `autoSwitchThreshold`, and the DTO reporting `inert: false`, only happen when + `pool.kernel` is on. Flag off restores today's outcomes exactly, because the + pre-kernel path is the same code reached through the shim. Anchors confirmed present on `origin/dev`: `selectPriorityTier` :86, `pickRoundRobinAccount` :189, `notePoolRotationSuccess` :213, @@ -69,29 +81,73 @@ and stamps `inert: true` on the generic DTO (`:40-54`, `:57-67`). ## Change surface NEW `src/oauth/pool-kernel.ts` -- move `SelectionState`, `pickRoundRobinAccount`, `peekRoundRobinAccount`, - `seedPoolRotationAccount`, `notePoolRotationSuccess`, `notePoolRotationFailure`, - `selectPriorityTier`, and the strategy/sticky normalizers +- move the WHOLE private `selectionState` map together with + `pickRoundRobinAccount`, `peekRoundRobinAccount`, `seedPoolRotationAccount`, + `notePoolRotationSuccess`, `notePoolRotationFailure`, `clearPoolRotationState`, + `selectPriorityTier`, the priority parsers, `POOL_KEY_*` and the strategy and + sticky normalizers. Moving a function subset while leaving the map behind would + split one piece of state across two modules. +- the move is safe: `pool-rotation.ts` imports only two TYPES, + `OcxAccountPoolRotationStrategy` from `../types` and `GenerationContext` from + `../lib/state-store-sweeper`. Neither creates a cycle into `src/oauth`. - add `genericPoolKey(provider) => \`generic:\${provider}\`` -- lift fill-first to `pickFillFirst(ids, afterId, hasHeadroom)` so both existing - copies call one implementation -- extend the reconcile sweep to `generic:*` keys, which `:270-276` currently skips +- add a fill-first helper with the signature + `pickFillFirst(ids, afterId, hasHeadroom, stableAll)`. The earlier three-argument + shape was rejected by the audit: both existing copies walk a STABLE FULL roster + and not the eligible subset, so dropping `stableAll` changes the wrap order + whenever an ineligible id sits between two eligible ones. +- extend the reconcile sweep to `generic:*`. `buildGenerationContext` already fills + `oauthAccountKeys` from `listLiveOAuthAccountKeys` as `provider\0id` for every + live OAuth provider, so the sweep needs no new field and no Codex dependency; + today those keys are simply skipped as `valid === null`. + +NOT moved, deliberately: the Codex fill-first copy in `src/codex/routing.ts` stays +where it is. Deleting it is the only thing that would force an edit to a file lane +L3 owns, and the audit flagged that as a blocker against this unit's own freeze. +Only `anthropic-routing.ts` and the generic kind switch to the kernel helper, and +the Anthropic caller keeps its weekly `exhausted5h` pre-filter rather than pushing +that rule into the shared helper. MODIFY `src/codex/pool-rotation.ts` — re-export the kernel so existing importers and `tests/codex-integration/codex-pool-rotation.test.ts` keep working unchanged. -MODIFY `src/oauth/generic-account-failover.ts` — route selection through the kernel -by strategy: `quota` keeps `rankAccountsByHeadroom`, `round-robin` calls -`pickRoundRobinAccount(genericPoolKey(name), ...)`, `fill-first` calls the lifted -helper; seed on manual selection; note success and failure. Keep the presence +MODIFY `src/oauth/generic-account-failover.ts` — branch BOTH paths on strategy, not +just the proactive one. `preferredInitialAccount` currently no-ops when the active +account is healthy and requires `hasHeadroomEvidence`, and the 429 path always ends +in `rankAccountsByHeadroom`; leaving either unbranched keeps the strategy inert in +practice even after the DTO says otherwise. `quota` keeps +`rankAccountsByHeadroom`, `round-robin` calls +`pickRoundRobinAccount(genericPoolKey(name), ...)`, and `fill-first` uses the +kernel helper with `autoSwitchThreshold` as its headroom test. Keep the presence quorum, the `EXCLUDED_PROVIDERS` guard and the per-provider `health` cooldown. -MODIFY `src/oauth/pool-settings-capability.ts` — drop `inert: true`, add -`stickyLimit`. MODIFY `src/types/provider.ts:512-518` comments and -`oauth-account-routes.ts:395` to accept `stickyLimit`. - -MODIFY `src/codex/routing.ts` and `src/oauth/anthropic-routing.ts` — import from -the kernel instead of holding their own copies. +MODIFY `src/server/management/oauth-account-routes.ts` — a manual account selection +must seed the cursor, or the operator's pick immediately loses to sticky +round-robin. Today that PUT calls only `forgetGenericFailoverRoster`, which clears +the presence cache and not the rotation state. Add +`seedPoolRotationAccount(genericPoolKey(provider), accountId)` beside it, mirroring +what `resetAnthropicRoutingForManualSelection` already does for Anthropic. +`clearGenericFailoverHealth` is the wrong map and `clearPoolRotationState` wipes +where seeding is wanted. + +MODIFY `src/oauth/pool-settings-capability.ts` — report `inert` from the flag rather +than as a type literal. While `pool.kernel` is off the generic DTO must keep saying +`inert: true`, because nothing consumes the strategy yet and the reversibility rule +below requires the old behaviour to be exactly restorable. The literal becomes a +computed field and only turns false once the kernel is on. + +Known readers of that field, all of which move in the same PR: +`src/cli/account-extended.ts` (forces generic auto-switch inactive), +`tests/server/account-pool-management-api.test.ts` and +`tests/cli/cli-account-pool-verbs.test.ts`. The GUI does not read it. +Also lift the `stickyLimit` rejection at `oauth-account-routes.ts:395` and update +`src/types/provider.ts:512-518` comments. + +MODIFY `src/oauth/anthropic-routing.ts` — import from the kernel. `src/codex/` +keeps importing `./pool-rotation`, which is now a re-export, so this layer needs +no edit inside lane L3's files at all. The audit confirmed the shim is sufficient: +`routing.ts`, `auth-api.ts`, `account-priority.ts` and +`state-store-registrations.ts` all keep their existing import path. ## Reversibility (audit blocker, mandatory) @@ -109,6 +165,13 @@ the kernel instead of holding their own copies. ## Tests +Audit record: the A-phase reviewer returned PASS-WITH-FINDINGS with two blockers, +both folded above. The first was that lifting fill-first out of its Codex copy +would have forced an edit inside lane L3's freeze. The second was that dropping +`inert: true` unconditionally contradicts this document's own reversibility rule, +which requires `pool.kernel` to default off and the old behaviour to be exactly +restorable. + - `tests/codex-integration/codex-pool-rotation.test.ts` — unchanged behaviour through the re-export (`pickRoundRobinAccount` `:270`, `selectPriorityTier` `:111`) - `tests/oauth/generic-oauth-failover.test.ts` — a configured strategy changes the From 80d4a3125540d7cf84cc7e18739be31cae33a752 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:50:03 +0900 Subject: [PATCH 050/231] refactor(oauth): move the pool rotation kernel out of the Codex namespace --- scripts/test-layout/layout.json | 1 + src/codex/pool-rotation.ts | 300 +--------------- src/oauth/pool-kernel.ts | 321 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/oauth/pool-kernel-generic-sweep.test.ts | 76 +++++ 5 files changed, 407 insertions(+), 292 deletions(-) create mode 100644 src/oauth/pool-kernel.ts create mode 100644 tests/oauth/pool-kernel-generic-sweep.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7b6e068d60..7cef263dd1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -665,6 +665,7 @@ "gemini-inline.test.ts": "images", "gemini-web-search.test.ts": "adapters/google", "generic-oauth-failover.test.ts": "oauth", + "pool-kernel-generic-sweep.test.ts": "oauth", "github-copilot-account-origin.test.ts": "providers/github-copilot", "github-copilot-oauth.test.ts": "providers/github-copilot", "github-copilot-sse-rewrite.test.ts": "providers/github-copilot", diff --git a/src/codex/pool-rotation.ts b/src/codex/pool-rotation.ts index d0d032be06..09936dc8c2 100644 --- a/src/codex/pool-rotation.ts +++ b/src/codex/pool-rotation.ts @@ -1,295 +1,11 @@ -import type { OcxAccountPoolRotationStrategy } from "../types"; -import type { GenerationContext } from "../lib/state-store-sweeper"; - -export const POOL_KEY_CODEX = "codex"; -export const POOL_KEY_ANTHROPIC = "anthropic"; - -interface SelectionState { - activeKey?: string; - successes: number; - currentWeights: Map; -} - -const selectionState = new Map(); -let lastReconciledGeneration = 0; - -const DEFAULT_STICKY_LIMIT = 1; -const MIN_STICKY_LIMIT = 1; -const MAX_STICKY_LIMIT = 100; -const DEFAULT_STRATEGY: OcxAccountPoolRotationStrategy = "quota"; -const VALID_STRATEGIES = new Set(["quota", "round-robin", "fill-first"]); - -/** Selection order for an account with no stored preference: one flat tier. */ -export const DEFAULT_ACCOUNT_PRIORITY = 0; -export const MIN_ACCOUNT_PRIORITY = -100; -export const MAX_ACCOUNT_PRIORITY = 100; - -/** Strict parse for management APIs — returns null instead of defaulting. */ -export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | null { - if (typeof raw === "string" && VALID_STRATEGIES.has(raw as OcxAccountPoolRotationStrategy)) { - return raw as OcxAccountPoolRotationStrategy; - } - return null; -} - -/** Strict parse for management APIs — returns null instead of defaulting. */ -export function parseAccountPoolStickyLimit(raw: unknown): number | null { - if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) { - return raw; - } - return null; -} - -export function normalizeAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy { - return parseAccountPoolStrategy(raw) ?? DEFAULT_STRATEGY; -} - -export function normalizeAccountPoolStickyLimit(raw: unknown): number { - return parseAccountPoolStickyLimit(raw) ?? DEFAULT_STICKY_LIMIT; -} - -/** Strict parse for management APIs — returns null instead of defaulting. */ -export function parseAccountPriority(raw: unknown): number | null { - if ( - typeof raw === "number" - && Number.isInteger(raw) - && raw >= MIN_ACCOUNT_PRIORITY - && raw <= MAX_ACCOUNT_PRIORITY - ) { - return raw; - } - return null; -} - -export function normalizeAccountPriority(raw: unknown): number { - return parseAccountPriority(raw) ?? DEFAULT_ACCOUNT_PRIORITY; -} - /** - * Narrow an already-eligible account list to the highest selection-order tier that - * still has usable quota. Priority is an *ordering* boundary layered on top of - * eligibility: it never admits an account the caller already filtered out, and it - * never keeps the pool on a tier whose every member is drained. + * Compatibility shim. The rotation primitives moved to `src/oauth/pool-kernel.ts` + * so every credential kind can share them, not only Codex and Anthropic. * - * Contract (each clause is load-bearing for "no behavior change when unconfigured"): - * - one distinct priority across `ids` (the unconfigured case) returns `ids` unchanged, - * so today's pick sequence is preserved byte for byte; - * - input order is preserved inside the returned tier, which keeps the `__main__` - * head-of-list bias and the first-index tie-break used by SWRR/lowest-usage; - * - every tier drained returns `ids` unchanged, reproducing today's - * stay-put-until-429 behavior rather than inventing a pick; - * - a `pinnedId` that is present *and* has headroom lowers the ceiling to its own - * tier, which is what makes a manual "use this now" survive round-robin and - * fill-first without any mutable selection state. A drained or absent pin is - * ignored, so the pin expires on its own once the account crosses the threshold. + * This file stays because the move is behaviour-preserving and its importers are + * spread across files that other work owns right now. Re-exporting keeps + * `routing.ts`, `auth-api.ts`, `account-priority.ts` and + * `state-store-registrations.ts` on their existing import path, so the extraction + * lands without editing any of them. */ -export function selectPriorityTier( - ids: readonly string[], - priorityOf: (id: string) => number, - hasHeadroom: (id: string) => boolean, - pinnedId?: string, -): readonly string[] { - // Readonly out as well as in: the no-change cases return the caller's own array, so a - // mutating caller would corrupt its input in exactly the cases that must not change. - const list = ids; - if (list.length <= 1) return list; - - const priorities = list.map(priorityOf); - const firstPriority = priorities[0]!; - if (priorities.every(priority => priority === firstPriority)) return list; - - let ceiling = Number.POSITIVE_INFINITY; - if (pinnedId !== undefined) { - const pinnedIndex = list.indexOf(pinnedId); - if (pinnedIndex >= 0 && hasHeadroom(pinnedId)) ceiling = priorities[pinnedIndex]!; - } - - const tiers = [...new Set(priorities)].sort((a, b) => b - a); - for (const tier of tiers) { - if (tier > ceiling) continue; - const members = list.filter((_, index) => priorities[index] === tier); - if (members.some(hasHeadroom)) return members; - } - return list; -} - -function getOrCreateState(poolKey: string): SelectionState { - let state = selectionState.get(poolKey); - if (!state) { - state = { successes: 0, currentWeights: new Map() }; - selectionState.set(poolKey, state); - } - return state; -} - -function cloneSelectionState(state: SelectionState): SelectionState { - return { - activeKey: state.activeKey, - successes: state.successes, - currentWeights: new Map(state.currentWeights), - }; -} - -function smoothWeightedIndex(ids: readonly string[], state: SelectionState): number { - let best = -1; - let bestScore = Number.NEGATIVE_INFINITY; - let total = 0; - const weight = 1; - for (let i = 0; i < ids.length; i++) { - const id = ids[i]!; - const score = (state.currentWeights.get(id) ?? 0) + weight; - state.currentWeights.set(id, score); - total += weight; - if (score > bestScore) { - best = i; - bestScore = score; - } - } - if (best >= 0) { - const key = ids[best]!; - state.currentWeights.set(key, (state.currentWeights.get(key) ?? 0) - total); - } - return best; -} - -/** - * Shared pick core. Mutates `state` the same way live resolve does; callers pass - * either the live map entry or a scratch/clone for dry-run peek. - */ -function pickRoundRobinFromState( - eligibleIds: readonly string[], - stickyLimit: number, - state: SelectionState, - commitSticky: boolean, -): string | null { - if (eligibleIds.length === 0) return null; - - const limit = normalizeAccountPoolStickyLimit(stickyLimit); - - if (state.activeKey && eligibleIds.includes(state.activeKey)) { - return state.activeKey; - } - - if (state.activeKey) { - delete state.activeKey; - state.successes = 0; - } - - const index = smoothWeightedIndex(eligibleIds, state); - if (index < 0) return null; - - const picked = eligibleIds[index]!; - if (commitSticky && limit > 1) { - state.activeKey = picked; - state.successes = 0; - } - return picked; -} - -export function pickRoundRobinAccount( - poolKey: string, - eligibleIds: readonly string[], - stickyLimit: number, -): string | null { - return pickRoundRobinFromState(eligibleIds, stickyLimit, getOrCreateState(poolKey), true); -} - -/** - * Dry-run of {@link pickRoundRobinAccount}: returns the same account resolve would - * pick without advancing ring weights, activeKey, or successes. - */ -export function peekRoundRobinAccount( - poolKey: string, - eligibleIds: readonly string[], - stickyLimit: number, -): string | null { - const live = selectionState.get(poolKey); - const scratch = live - ? cloneSelectionState(live) - : { successes: 0, currentWeights: new Map() }; - return pickRoundRobinFromState(eligibleIds, stickyLimit, scratch, false); -} - -export function notePoolRotationSuccess( - poolKey: string, - accountId: string, - stickyLimit: number, -): void { - const limit = normalizeAccountPoolStickyLimit(stickyLimit); - const state = selectionState.get(poolKey); - if (!state) return; - if (state.activeKey !== accountId) { - state.activeKey = accountId; - state.successes = 0; - } - state.successes += 1; - if (state.successes >= limit) { - delete state.activeKey; - state.successes = 0; - } -} - -export function notePoolRotationFailure(poolKey: string, accountId: string): void { - const state = selectionState.get(poolKey); - if (state?.activeKey === accountId) { - delete state.activeKey; - state.successes = 0; - } -} - -/** - * Force the next sticky/RR pick onto `accountId` (manual dashboard selection). - * Clears sticky success counters and ring weights so the seeded account is held - * for the next new-session pick before ordinary rotation resumes. - */ -export function seedPoolRotationAccount(poolKey: string, accountId: string): void { - const state = getOrCreateState(poolKey); - state.activeKey = accountId; - state.successes = 0; - state.currentWeights.clear(); -} - -export function clearPoolRotationState(poolKey?: string): void { - if (poolKey === undefined) { - selectionState.clear(); - return; - } - selectionState.delete(poolKey); -} - -export function reconcilePoolRotationState(context: GenerationContext): number { - if (context.generation <= lastReconciledGeneration) return 0; - const anthropicIds = new Set(); - for (const key of context.oauthAccountKeys) { - const separator = key.indexOf("\0"); - if (separator > 0 && key.slice(0, separator) === "anthropic") { - anthropicIds.add(key.slice(separator + 1)); - } - } - let removed = 0; - for (const [poolKey, state] of selectionState) { - const valid = poolKey === POOL_KEY_ANTHROPIC - ? anthropicIds - : poolKey === POOL_KEY_CODEX || poolKey.startsWith(`${POOL_KEY_CODEX}:`) - ? context.codexAccountIds - : null; - if (!valid) continue; - if (valid.size === 0) { - selectionState.delete(poolKey); - removed += 1; - continue; - } - if (state.activeKey && !valid.has(state.activeKey)) { - delete state.activeKey; - state.successes = 0; - removed += 1; - } - for (const accountId of state.currentWeights.keys()) { - if (valid.has(accountId)) continue; - state.currentWeights.delete(accountId); - removed += 1; - } - } - lastReconciledGeneration = context.generation; - return removed; -} +export * from "../oauth/pool-kernel"; diff --git a/src/oauth/pool-kernel.ts b/src/oauth/pool-kernel.ts new file mode 100644 index 0000000000..b36ac88e03 --- /dev/null +++ b/src/oauth/pool-kernel.ts @@ -0,0 +1,321 @@ +import type { OcxAccountPoolRotationStrategy } from "../types"; +import type { GenerationContext } from "../lib/state-store-sweeper"; + +export const POOL_KEY_CODEX = "codex"; +export const POOL_KEY_ANTHROPIC = "anthropic"; + +/** + * Pool key for a generic OAuth provider. Namespaced so a provider can never collide + * with the two dedicated kinds, and so `reconcilePoolRotationState` can recognise + * the entry as sweepable rather than skipping it as unknown. + */ +export function genericPoolKey(providerName: string): string { + return `generic:${providerName}`; +} + +interface SelectionState { + activeKey?: string; + successes: number; + currentWeights: Map; +} + +const selectionState = new Map(); +let lastReconciledGeneration = 0; + +const DEFAULT_STICKY_LIMIT = 1; +const MIN_STICKY_LIMIT = 1; +const MAX_STICKY_LIMIT = 100; +const DEFAULT_STRATEGY: OcxAccountPoolRotationStrategy = "quota"; +const VALID_STRATEGIES = new Set(["quota", "round-robin", "fill-first"]); + +/** Selection order for an account with no stored preference: one flat tier. */ +export const DEFAULT_ACCOUNT_PRIORITY = 0; +export const MIN_ACCOUNT_PRIORITY = -100; +export const MAX_ACCOUNT_PRIORITY = 100; + +/** Strict parse for management APIs — returns null instead of defaulting. */ +export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | null { + if (typeof raw === "string" && VALID_STRATEGIES.has(raw as OcxAccountPoolRotationStrategy)) { + return raw as OcxAccountPoolRotationStrategy; + } + return null; +} + +/** Strict parse for management APIs — returns null instead of defaulting. */ +export function parseAccountPoolStickyLimit(raw: unknown): number | null { + if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) { + return raw; + } + return null; +} + +export function normalizeAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy { + return parseAccountPoolStrategy(raw) ?? DEFAULT_STRATEGY; +} + +export function normalizeAccountPoolStickyLimit(raw: unknown): number { + return parseAccountPoolStickyLimit(raw) ?? DEFAULT_STICKY_LIMIT; +} + +/** Strict parse for management APIs — returns null instead of defaulting. */ +export function parseAccountPriority(raw: unknown): number | null { + if ( + typeof raw === "number" + && Number.isInteger(raw) + && raw >= MIN_ACCOUNT_PRIORITY + && raw <= MAX_ACCOUNT_PRIORITY + ) { + return raw; + } + return null; +} + +export function normalizeAccountPriority(raw: unknown): number { + return parseAccountPriority(raw) ?? DEFAULT_ACCOUNT_PRIORITY; +} + +/** + * Narrow an already-eligible account list to the highest selection-order tier that + * still has usable quota. Priority is an *ordering* boundary layered on top of + * eligibility: it never admits an account the caller already filtered out, and it + * never keeps the pool on a tier whose every member is drained. + * + * Contract (each clause is load-bearing for "no behavior change when unconfigured"): + * - one distinct priority across `ids` (the unconfigured case) returns `ids` unchanged, + * so today's pick sequence is preserved byte for byte; + * - input order is preserved inside the returned tier, which keeps the `__main__` + * head-of-list bias and the first-index tie-break used by SWRR/lowest-usage; + * - every tier drained returns `ids` unchanged, reproducing today's + * stay-put-until-429 behavior rather than inventing a pick; + * - a `pinnedId` that is present *and* has headroom lowers the ceiling to its own + * tier, which is what makes a manual "use this now" survive round-robin and + * fill-first without any mutable selection state. A drained or absent pin is + * ignored, so the pin expires on its own once the account crosses the threshold. + */ +export function selectPriorityTier( + ids: readonly string[], + priorityOf: (id: string) => number, + hasHeadroom: (id: string) => boolean, + pinnedId?: string, +): readonly string[] { + // Readonly out as well as in: the no-change cases return the caller's own array, so a + // mutating caller would corrupt its input in exactly the cases that must not change. + const list = ids; + if (list.length <= 1) return list; + + const priorities = list.map(priorityOf); + const firstPriority = priorities[0]!; + if (priorities.every(priority => priority === firstPriority)) return list; + + let ceiling = Number.POSITIVE_INFINITY; + if (pinnedId !== undefined) { + const pinnedIndex = list.indexOf(pinnedId); + if (pinnedIndex >= 0 && hasHeadroom(pinnedId)) ceiling = priorities[pinnedIndex]!; + } + + const tiers = [...new Set(priorities)].sort((a, b) => b - a); + for (const tier of tiers) { + if (tier > ceiling) continue; + const members = list.filter((_, index) => priorities[index] === tier); + if (members.some(hasHeadroom)) return members; + } + return list; +} + +function getOrCreateState(poolKey: string): SelectionState { + let state = selectionState.get(poolKey); + if (!state) { + state = { successes: 0, currentWeights: new Map() }; + selectionState.set(poolKey, state); + } + return state; +} + +function cloneSelectionState(state: SelectionState): SelectionState { + return { + activeKey: state.activeKey, + successes: state.successes, + currentWeights: new Map(state.currentWeights), + }; +} + +function smoothWeightedIndex(ids: readonly string[], state: SelectionState): number { + let best = -1; + let bestScore = Number.NEGATIVE_INFINITY; + let total = 0; + const weight = 1; + for (let i = 0; i < ids.length; i++) { + const id = ids[i]!; + const score = (state.currentWeights.get(id) ?? 0) + weight; + state.currentWeights.set(id, score); + total += weight; + if (score > bestScore) { + best = i; + bestScore = score; + } + } + if (best >= 0) { + const key = ids[best]!; + state.currentWeights.set(key, (state.currentWeights.get(key) ?? 0) - total); + } + return best; +} + +/** + * Shared pick core. Mutates `state` the same way live resolve does; callers pass + * either the live map entry or a scratch/clone for dry-run peek. + */ +function pickRoundRobinFromState( + eligibleIds: readonly string[], + stickyLimit: number, + state: SelectionState, + commitSticky: boolean, +): string | null { + if (eligibleIds.length === 0) return null; + + const limit = normalizeAccountPoolStickyLimit(stickyLimit); + + if (state.activeKey && eligibleIds.includes(state.activeKey)) { + return state.activeKey; + } + + if (state.activeKey) { + delete state.activeKey; + state.successes = 0; + } + + const index = smoothWeightedIndex(eligibleIds, state); + if (index < 0) return null; + + const picked = eligibleIds[index]!; + if (commitSticky && limit > 1) { + state.activeKey = picked; + state.successes = 0; + } + return picked; +} + +export function pickRoundRobinAccount( + poolKey: string, + eligibleIds: readonly string[], + stickyLimit: number, +): string | null { + return pickRoundRobinFromState(eligibleIds, stickyLimit, getOrCreateState(poolKey), true); +} + +/** + * Dry-run of {@link pickRoundRobinAccount}: returns the same account resolve would + * pick without advancing ring weights, activeKey, or successes. + */ +export function peekRoundRobinAccount( + poolKey: string, + eligibleIds: readonly string[], + stickyLimit: number, +): string | null { + const live = selectionState.get(poolKey); + const scratch = live + ? cloneSelectionState(live) + : { successes: 0, currentWeights: new Map() }; + return pickRoundRobinFromState(eligibleIds, stickyLimit, scratch, false); +} + +export function notePoolRotationSuccess( + poolKey: string, + accountId: string, + stickyLimit: number, +): void { + const limit = normalizeAccountPoolStickyLimit(stickyLimit); + const state = selectionState.get(poolKey); + if (!state) return; + if (state.activeKey !== accountId) { + state.activeKey = accountId; + state.successes = 0; + } + state.successes += 1; + if (state.successes >= limit) { + delete state.activeKey; + state.successes = 0; + } +} + +export function notePoolRotationFailure(poolKey: string, accountId: string): void { + const state = selectionState.get(poolKey); + if (state?.activeKey === accountId) { + delete state.activeKey; + state.successes = 0; + } +} + +/** + * Force the next sticky/RR pick onto `accountId` (manual dashboard selection). + * Clears sticky success counters and ring weights so the seeded account is held + * for the next new-session pick before ordinary rotation resumes. + */ +export function seedPoolRotationAccount(poolKey: string, accountId: string): void { + const state = getOrCreateState(poolKey); + state.activeKey = accountId; + state.successes = 0; + state.currentWeights.clear(); +} + +export function clearPoolRotationState(poolKey?: string): void { + if (poolKey === undefined) { + selectionState.clear(); + return; + } + selectionState.delete(poolKey); +} + +export function reconcilePoolRotationState(context: GenerationContext): number { + if (context.generation <= lastReconciledGeneration) return 0; + const anthropicIds = new Set(); + // Live account ids per generic OAuth provider, built from the same + // `provider\0id` roster the Anthropic pass already walks. Without this the + // `generic:*` entries fall through as unknown and are never swept, so a removed + // account would keep its rotation weight forever. + const genericIds = new Map>(); + for (const key of context.oauthAccountKeys) { + const separator = key.indexOf("\0"); + if (separator <= 0) continue; + const provider = key.slice(0, separator); + const accountId = key.slice(separator + 1); + if (provider === "anthropic") { + anthropicIds.add(accountId); + continue; + } + let bucket = genericIds.get(provider); + if (!bucket) { + bucket = new Set(); + genericIds.set(provider, bucket); + } + bucket.add(accountId); + } + let removed = 0; + for (const [poolKey, state] of selectionState) { + const valid = poolKey === POOL_KEY_ANTHROPIC + ? anthropicIds + : poolKey === POOL_KEY_CODEX || poolKey.startsWith(`${POOL_KEY_CODEX}:`) + ? context.codexAccountIds + : poolKey.startsWith("generic:") + ? genericIds.get(poolKey.slice("generic:".length)) ?? new Set() + : null; + if (!valid) continue; + if (valid.size === 0) { + selectionState.delete(poolKey); + removed += 1; + continue; + } + if (state.activeKey && !valid.has(state.activeKey)) { + delete state.activeKey; + state.successes = 0; + removed += 1; + } + for (const accountId of state.currentWeights.keys()) { + if (valid.has(accountId)) continue; + state.currentWeights.delete(accountId); + removed += 1; + } + } + lastReconciledGeneration = context.generation; + return removed; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 36b7a2e1ae..39bcb31f19 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -809,6 +809,7 @@ "plan-video.test.ts": "videos", "plan.test.ts": "images", "policy-execution.test.ts": "routing", + "pool-kernel-generic-sweep.test.ts": "oauth", "port-reclaim.test.ts": "server", "ports.test.ts": "server", "prime-client.test.ts": "clients", diff --git a/tests/oauth/pool-kernel-generic-sweep.test.ts b/tests/oauth/pool-kernel-generic-sweep.test.ts new file mode 100644 index 0000000000..400fc6af25 --- /dev/null +++ b/tests/oauth/pool-kernel-generic-sweep.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { + clearPoolRotationState, + genericPoolKey, + peekRoundRobinAccount, + reconcilePoolRotationState, + seedPoolRotationAccount, +} from "../../src/oauth/pool-kernel"; +import type { GenerationContext } from "../../src/lib/state-store-sweeper"; + +/** + * The rotation primitives moved out of src/codex/pool-rotation.ts so every credential + * kind can share them. Before the move, reconcilePoolRotationState recognised only the + * two dedicated pool keys and skipped everything else, so a generic OAuth provider's + * rotation state would have survived the removal of the very account it points at. + */ +function generation(n: number, oauthAccountKeys: string[]): GenerationContext { + return { + generation: n, + providerNames: new Set(), + comboIds: new Set(), + comboTargets: new Set(), + codexAccountIds: new Set(), + oauthAccountKeys: new Set(oauthAccountKeys), + configRoots: new Set(), + } as GenerationContext; +} + +describe("generic pool keys are swept", () => { + test("genericPoolKey namespaces a provider so it cannot collide with the dedicated kinds", () => { + expect(genericPoolKey("cursor")).toBe("generic:cursor"); + expect(genericPoolKey("cursor")).not.toBe("codex"); + expect(genericPoolKey("anthropic")).not.toBe("anthropic"); + }); + + test("a generic entry survives while its account is still live", () => { + const key = genericPoolKey("cursor"); + clearPoolRotationState(key); + seedPoolRotationAccount(key, "acct-1"); + // Seeding pins the sticky account, so a peek over both candidates returns it. + expect(peekRoundRobinAccount(key, ["acct-1", "acct-2"], 5)).toBe("acct-1"); + + // Nothing was removed, so the sweep must report no change and leave the pin. + expect(reconcilePoolRotationState(generation(9001, ["cursor\u0000acct-1"]))).toBe(0); + expect(peekRoundRobinAccount(key, ["acct-1", "acct-2"], 5)).toBe("acct-1"); + clearPoolRotationState(key); + }); + + test("a generic entry is dropped once its account leaves the roster", () => { + const key = genericPoolKey("kimi"); + clearPoolRotationState(key); + seedPoolRotationAccount(key, "gone"); + expect(peekRoundRobinAccount(key, ["gone", "still-here"], 5)).toBe("gone"); + + // The account is absent from this generation. Before the generic branch existed + // this key fell through as unknown and the stale pin survived forever. + expect(reconcilePoolRotationState(generation(9002, ["kimi\u0000still-here"]))).toBeGreaterThan(0); + expect(peekRoundRobinAccount(key, ["still-here"], 5)).toBe("still-here"); + clearPoolRotationState(key); + }); + + test("one provider's roster does not sweep another provider's entry", () => { + const cursor = genericPoolKey("cursor"); + const kimi = genericPoolKey("kimi"); + clearPoolRotationState(cursor); + clearPoolRotationState(kimi); + seedPoolRotationAccount(cursor, "c1"); + seedPoolRotationAccount(kimi, "k1"); + + expect(reconcilePoolRotationState(generation(9003, ["cursor\u0000c1", "kimi\u0000k1"]))).toBe(0); + expect(peekRoundRobinAccount(cursor, ["c1", "c2"], 5)).toBe("c1"); + expect(peekRoundRobinAccount(kimi, ["k1", "k2"], 5)).toBe("k1"); + clearPoolRotationState(cursor); + clearPoolRotationState(kimi); + }); +}); From 3015be811665feccc1eddde5756486e52bebd9f9 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:57:08 +0900 Subject: [PATCH 051/231] docs(devlog): record the second-half audit findings for the pool kernel --- .../020_phase2_shared_kernel.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md index b9ead490fd..3b0fc74ba3 100644 --- a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md +++ b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md @@ -172,6 +172,42 @@ would have forced an edit inside lane L3's freeze. The second was that dropping which requires `pool.kernel` to default off and the old behaviour to be exactly restorable. +## Second-half audit (the flagged behaviour change) + +The extraction shipped as PR #4279. A separate audit of the remaining half returned +FAIL, and its findings change that half materially. Recorded here so the next cycle +starts from them rather than rediscovering them. + +1. **BLOCKER. Branching the final ranking expression is not enough.** + `preferredInitialAccount` encodes the quota strategy BEFORE its tail: the + healthy-active early return tests `isAccountQuotaExhausted` (:262) and the + roster-wide `hasHeadroomEvidence` check (:272) returns null when a provider has + no quota data at all. Leave those untouched and round-robin can never run for a + provider without quota evidence, and fill-first never reaches + `autoSwitchThreshold` because the healthy active account already returned. Both + guards have to be strategy-gated: skip the evidence requirement for round-robin, + and use the threshold rather than exhaustion for fill-first. +2. **BLOCKER. The preference must peek, not pick.** + `pickRoundRobinAccount` mutates live ring state, but + `preferredInitialAccount` is explicitly a discardable proposal that the caller + drops on a resolver throw or a missing project. Mutating there desyncs the + cursor against requests that never happened. Use `peekRoundRobinAccount` and + mutate with `pickRoundRobinAccount` plus `notePoolRotationSuccess` only after + the selection is admitted, which is what Anthropic already does. +3. **The 429 path is safe to branch but fill-first must still move.** That tail has + no evidence guard, so a strategy branch is structurally fine. Fill-first there + cannot mean keep-active: the account that just returned 429 is already cooled, + so staying put would skip rotation entirely. +4. **`stickyLimit` does not exist for the generic kind yet.** The + `oauthAccountFailover` type carries only `enabled`, `strategy` and + `autoSwitchThreshold`. Lifting the 400 at `oauth-account-routes.ts:395` before + adding the field to the type, the DTO, GET and the PUT writer would accept a + value and then drop it. The kernel default is 1. +5. **The flag lands in a lane-owned file.** `OcxConfig` has no `pool` key today, + so `pool.kernel` belongs in `src/types/config.ts` (around :363) - which lane L3 + owns. This half therefore inherits the same freeze as work-phases 1 and 2 until + that ownership clears, or the flag needs a different home. + - `tests/codex-integration/codex-pool-rotation.test.ts` — unchanged behaviour through the re-export (`pickRoundRobinAccount` `:270`, `selectPriorityTier` `:111`) - `tests/oauth/generic-oauth-failover.test.ts` — a configured strategy changes the From e1e8b250a7488e40a869370dd6c780478a8f085e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 21:58:25 +0900 Subject: [PATCH 052/231] docs(devlog): re-verify phase 3 anchors and record what blocks it --- .../030_phase3_cache_affinity.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md index bee0f768df..393c498934 100644 --- a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md +++ b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md @@ -69,3 +69,35 @@ passing the threshold, because a threshold rebind throws away a warm cache. A cache-affine account is chosen over a higher-headroom one; an exhausted affine account still yields; concurrent distinct sessions keep distinct accounts; a shared-cohort cache key does not collapse every session onto one account. + +## Staleness re-verification and why this phase is not open yet + +Re-verified at the wp3 P entry against `origin/dev` `1da8dae96`. Every anchor this +document relies on is unchanged from the original reading: + +| Symbol | File | Line | +|---|---|---| +| `CODEX_THREAD_AFFINITY_MAX_ENTRIES` | `src/codex/routing.ts` | 135 | +| `pruneLruThreadAffinities` | `src/codex/routing.ts` | 1212 | +| `reevaluateAffinityQuota` | `src/codex/routing.ts` | 1942 | +| `MAX_AFFINITY_ENTRIES` | `src/oauth/anthropic-routing.ts` | 48 | +| `anthropicSessionKeyFromParts` | `src/oauth/anthropic-routing.ts` | 877 | +| `promptCacheKeyIsSharedCohort` | `src/oauth/anthropic-routing.ts` | 883 | +| `MAX_CACHE_BREAKPOINTS` | `src/adapters/anthropic.ts` | 60 | + +The design is therefore current. Two things still stop this phase from opening, +and neither is a documentation gap: + +1. **Its three open assumptions are genuine product decisions, not research gaps.** + The affinity key shape, what to do when a `prompt_cache_key` looks like a shared + cohort, and whether to add a minimum-token cache gate all change observable + behaviour and none is settled by reading the code. They need a human answer. + Under an active goal the Interview is suppressed, so this phase cannot resolve + them from inside the loop. +2. **The Codex half is frozen.** `src/codex/routing.ts` carries three of the seven + anchors above and is owned by lane L3 for the dispatch round in flight. + +The Anthropic and generic halves are not frozen, so a narrower first slice exists: +unify the affinity key for those two kinds only, leaving the Codex thread-affinity +map on its current key until the freeze lifts. That slice still needs assumption 1 +answered, which is why this phase stays closed rather than being re-scoped now. From 09d0e7a13765db61be2be1a283e92a4961c9e2a3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 23:14:24 +0900 Subject: [PATCH 053/231] docs(devlog): fold the implementation-entry audit into the phase 1 design --- .../010_phase1_manual_selection.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md index bfb1a2b1b0..5a2907aa68 100644 --- a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md +++ b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md @@ -161,6 +161,48 @@ singleton-versus-scope-keyed state, the missing invalidation rule in the absence of an account-side revision, the pin-versus-preference disagreement after a released pin, and the test gap against criterion c-2. +## Implementation-entry audit, after the lane freeze lifted + +Lane L3 PR #4230 and lane L1 PR #4226 merged, so this work became writable. A +fresh audit against the post-merge file returned FAIL with three more blockers. +All anchors survived the merge (`codexPoolKeyForScope` 225, +`resetCodexRoutingForManualSelection` 870, `pickUnboundStrategyAccount` 1446, +`getEffectiveActiveCodexAccountId` 1625, `rememberActiveCodexAccount` 1644, +`setActiveCodexAccount` 1660, `promoteActiveCodexAccount` 1669), but L3 added +independent-scope cursor isolation and runtime-only preemption, which changes what +the design may assume. + +1. **BLOCKER. The preference is scope-keyed but `getEffectiveActiveCodexAccountId` + is not.** It takes only a config and has no `quotaScope`, so it can read the + shared `POOL_KEY_CODEX` entry and nothing else. `resolveCodexAccountForThreadDetailed` + and `previewCodexAccountForRequest` look up `codexPoolKeyForScope(quotaScope)` + themselves. A scope with no entry means NO preference; it must never fall back to + the shared key, or an independent scope would consume a one-shot it was not given. +2. **BLOCKER. Consuming inside `setActiveCodexAccount` is wrong.** That function is + also the persist path for pool-driven moves: quota auto-switch (1807), affinity + re-evaluation (2166), unbound persist (2231 and 2252) and the quota promote + (1671) all call it. Consuming there would let the pool spend the operator's + one-shot. Consume only on the path where the preference was actually honoured + and the dispatch succeeded, plus on an operator reset. +3. **BLOCKER. An unconditional honour traps a cooled account.** With + `rememberActiveCodexAccount` a no-op, a 429 or failover on the preferred account + (2527, 2576, 1878) could not move `getEffectiveActiveCodexAccountId` away from + it. Honour the preference only while that account is selectable and not cooling; + otherwise treat it as absent for this dispatch without spending it. +4. **The preview path must mirror resolve.** The check belongs immediately before + BOTH `pickUnboundStrategyAccount` calls, at 2020 and 2193, after affinity and + model-detour handling, not at function entry. +5. **Pause and exclusion never route through the reset.** `reconcileCodexActiveAfterExclusion` + (1692) and the health-clear path (317-320) bypass it, so a preference would + outlive an excluded or paused account. Drop the key when the preferred account is + excluded or paused. +6. **Minor, but decide it deliberately.** `isEffectiveCodexAccountPinned` (1637) + would read true while the preference equals the pin, and L3 now documents that + `getEffectiveActiveCodexAccountId` is what surfaces automatic picks to the API + and dashboard. Either keep the pin check reading persisted and runtime only, or + accept and document that `GET /api/codex-auth/active` is manual-sticky until the + preference is consumed. + The generic OAuth kind gets no preference in this layer; that arrives with the kernel in phase 2. No management or GUI change. From 683384a2bb9214f34fc49a29252436d57ebc7a83 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 23:17:41 +0900 Subject: [PATCH 054/231] docs(devlog): record why the consume call site must be built first --- .../010_phase1_manual_selection.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md index 5a2907aa68..1cc952b6b3 100644 --- a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md +++ b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md @@ -203,6 +203,36 @@ the design may assume. accept and document that `GET /api/codex-auth/active` is manual-sticky until the preference is consumed. +## Measured: the consume call site is the whole design, not a detail + +A first implementation pass built the preference map, the seeding inside +`resetCodexRoutingForManualSelection`, the `rememberActiveCodexAccount` guard, the +`getEffectiveActiveCodexAccountId` overlay and the exclusion revoke, and left the +consume call site unwired. It typechecked, and then +`tests/codex-integration/codex-pool-rotation.test.ts` went from green to **15 failures +out of 69**, including "fill-first picks the same sequence with no stored order as +before the feature". + +That is the correct result, and it is worth recording rather than repeating. Without a +consume site the one-shot is permanent: the first operator selection freezes the +automatic cursor forever, because `rememberActiveCodexAccount` stays a no-op and no +code path ever clears the entry. Every rotation-strategy test that expects the pool to +keep moving after a manual selection fails, and they are right to. + +So the implementation order matters. Build the consume path FIRST, not last: + +1. Find the point that already records a successful upstream outcome for the resolved + account and call `consumeCodexManualPreference(poolKey)` there, for a non-quota + success only. This is the Codex analogue of `commitAnthropicSelectionRouting` + (`anthropic-routing.ts` :799-800), which Codex has no direct equivalent of. +2. Only then add the `rememberActiveCodexAccount` guard, so the suite never passes + through a state where the cursor can freeze. +3. Gate honouring on the account being selectable, per blocker 3 above, so a cooled + preferred account is skipped for that dispatch without being spent. + +The pass was reverted rather than pushed. The branch `codex/manual-selection-wins` +carries this document and no source change. + The generic OAuth kind gets no preference in this layer; that arrives with the kernel in phase 2. No management or GUI change. From afb5db9456a5f800be26170d97d02801ea978c2e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 23:21:17 +0900 Subject: [PATCH 055/231] docs(devlog): measure why the failover promote needs a guard exemption --- .../010_phase1_manual_selection.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md index 1cc952b6b3..02f4cb045c 100644 --- a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md +++ b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md @@ -233,6 +233,40 @@ So the implementation order matters. Build the consume path FIRST, not last: The pass was reverted rather than pushed. The branch `codex/manual-selection-wins` carries this document and no source change. +## Measured again: guarding the writer is right, but the 429 path needs an exemption + +A second pass followed the order above. The consume site went in first, at the +`outcomeClass === "success"` branch of `recordCodexUpstreamOutcome` (:2356), keyed by +`codexPoolKeyForScope(quotaScope)` which that function already computes. Seeding and the +exclusion revoke followed. At each of those two steps +`tests/codex-integration/codex-pool-rotation.test.ts` stayed **69 pass, 0 fail**, which +confirms the ordering advice above is correct. + +Adding the `rememberActiveCodexAccount` guard then produced **6 failures out of 69**, down +from 15, and every single one is a 429 promotion case: + +- fill-first 429 advances to next stable account, not lowest usage +- RR 429 promotes via ring, not lowest usage +- 429 retry reuse promoteAccountId avoids a second RR ring advance +- fill-first transient failover advances stable order, not lowest usage +- scoped reset 429s retain strategy while excluding only the affected native quota +- fill-first preserves its pre-feature fallback when every ordered tier is drained + +That is exactly the hazard blocker 3 named, and it is sharper than the blocker stated it. +Gating the guard on `isCodexAccountSelectable(preferred)` is NOT sufficient: at the moment +`promoteActiveCodexAccount` runs, the preferred account can still read as selectable +because the 429 cooldown is recorded on a different path, so the guard holds and the +promotion cannot land. + +The conclusion for the next pass: guarding the writer closes all four call sites at once, +which is still the right shape, but the failover promote needs an explicit exemption. It +only ever runs because the account in use just failed, so it is never an automatic pick +competing with the operator. Either pass an explicit "this is a failover promote" flag +through `rememberActiveCodexAccount`, or leave the writer unguarded and guard the two +strategy commit sites plus preemption instead, accepting three guards rather than one. + +Reverted again rather than pushed. The measurement is the deliverable. + The generic OAuth kind gets no preference in this layer; that arrives with the kernel in phase 2. No management or GUI change. From 63217d1614b67f825563c1bbbad8e30273b96e92 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 23:27:13 +0900 Subject: [PATCH 056/231] fix(codex): let an operator account selection outrank the pool cursor --- src/codex/routing.ts | 81 ++++++++++++++++++- .../codex-pool-rotation.test.ts | 67 +++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index a1f1f4fbd9..98e24e2b18 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -315,6 +315,10 @@ export function clearThreadAccountMapForAccount(accountId: string): void { } export function clearCodexUpstreamHealth(): void { + // Operator preferences are routing state, not health, but they live and die with the same + // reset points. Leaving them behind lets a selection from one context suppress the + // automatic cursor in the next one. + manualPreference.clear(); upstreamHealth.clear(); quotaScopedHealth.clear(); runtimeActiveCodexAccountId = undefined; @@ -871,12 +875,20 @@ export function resetCodexRoutingForManualSelection(accountId: string): void { clearThreadAccountMap(); // Manual selection is the operator source of truth — drop any automatic runtime cursor. runtimeActiveCodexAccountId = undefined; + // Record the pick as an unspent one-shot, over the same scope set the rotation ring is + // seeded for. An absent key means NO preference for that scope: an independent scope must + // never inherit the shared entry, or it would consume intent it was not given. + // + // Seeding happens ONLY here. A pool-driven promote must never create or move a preference, + // or the pool would manufacture an operator intent nobody expressed. + manualPreference.set(POOL_KEY_CODEX, accountId); // Seed the RR ring so the next unbound new session honors the manually selected account // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows // config.activeCodexAccountId, which the caller persists before invoking this. seedPoolRotationAccount(POOL_KEY_CODEX, accountId); for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) { if (isIndependentCodexQuotaScope(scope)) { + manualPreference.set(codexPoolKeyForScope(scope), accountId); seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId); } } @@ -1467,7 +1479,10 @@ function pickUnboundStrategyAccount( picked = pickRoundRobinAccount(poolKey, eligible, limit); if (!picked) return null; if (commitSharedActive) { - if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked); + if (!isIndependentCodexQuotaScope(quotaScope) + && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) { + rememberActiveCodexAccount(config, picked); + } } if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope); notePoolRotationSuccess(poolKey, picked, limit); @@ -1478,7 +1493,10 @@ function pickUnboundStrategyAccount( picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions); if (!picked) return null; if (commitSharedActive) { - if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked); + if (!isIndependentCodexQuotaScope(quotaScope) + && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) { + rememberActiveCodexAccount(config, picked); + } } if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope); return picked; @@ -1622,6 +1640,53 @@ export function pickAlternateCodexAccount( } /** Effective active: automatic runtime cursor, else operator/persisted selection. */ +/** + * Unspent operator selections, keyed by pool scope. + * + * Codex has no account-side equivalent of the Anthropic `selectionRevision`, so staleness + * cannot be detected by comparing values: a pool-driven promote legitimately moves the + * persisted active account, and reading that as staleness would silently spend the + * operator's one-shot. Invalidation is keyed to the OPERATOR path instead — another manual + * selection, the account leaving the pool, or a successful dispatch on it. + */ +const manualPreference = new Map(); + +/** + * Spend the one-shot for a pool scope once a dispatch on that account actually succeeded. + * This is the Codex analogue of `commitAnthropicSelectionRouting`, which Codex lacks. + * + * Wiring this BEFORE the guard below is not a style choice. Measured: with the guard in + * place and no consume site, the first manual selection freezes the automatic cursor + * permanently and 15 of 69 rotation tests fail. + */ +function consumeManualPreference(accountId: string, poolKey: string): void { + if (manualPreference.get(poolKey) === accountId) manualPreference.delete(poolKey); +} + +/** + * Drop an account's preference in every scope. Pause and exclusion do not route through + * `resetCodexRoutingForManualSelection`, so without this a preference could outlive the + * account it names and keep suppressing the automatic cursor. + */ +function forgetManualPreference(accountId: string): void { + for (const [poolKey, preferred] of manualPreference) { + if (preferred === accountId) manualPreference.delete(poolKey); + } +} + +/** + * True while an unspent operator selection for this scope names a DIFFERENT account than + * the automatic pick about to be recorded. + * + * Callers pass their own scope: an independent quota scope keeps its own entry and must + * never read the shared one. The failover promote does NOT consult this — see its call + * site for why. + */ +function manualPreferenceBlocks(poolKey: string, accountId: string): boolean { + const preferred = manualPreference.get(poolKey); + return preferred !== undefined && preferred !== accountId; +} + export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined { return runtimeActiveCodexAccountId ?? config.activeCodexAccountId; } @@ -1690,6 +1755,10 @@ export function reconcileCodexActiveAfterExclusion( now = Date.now(), ): string | null { const wasEffective = (getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID) === excludedAccountId; + // Exclusion does not route through resetCodexRoutingForManualSelection, so the one-shot is + // revoked here too. A preference naming an account that can no longer serve would keep + // suppressing the automatic cursor with no way to clear it. + forgetManualPreference(excludedAccountId); if (config.activeCodexAccountId === excludedAccountId) { config.activeCodexAccountId = undefined; } @@ -2283,7 +2352,10 @@ export function resolveCodexAccountForThreadDetailed( !preserveSharedSelectionForModelDetour && !isIndependentCodexQuotaScope(quotaScope) ) { - rememberActiveCodexAccount(config, preempted); + // Preemption is an automatic pick competing with the operator, so it yields. + if (!manualPreferenceBlocks(POOL_KEY_CODEX, preempted)) { + rememberActiveCodexAccount(config, preempted); + } } active = preempted; } @@ -2354,6 +2426,9 @@ export function recordCodexUpstreamOutcome( */ dropSpentCredentialFailure(accountId); if (outcomeClass === "success") { + // The operator's one-shot is spent by a dispatch that actually worked, and only by that. + // A failed lookup leaves it unspent so the intent survives the failure. + consumeManualPreference(accountId, codexPoolKeyForScope(quotaScope)); const scopedProbe = meta.probeQuotaScope ? scopedHealthFor(accountId, meta.probeQuotaScope) : undefined; diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index 1a69a0e269..6f70c97d12 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -1021,4 +1021,71 @@ describe("selection order across rotation strategies", () => { expect(pickAlternateCodexAccount(config, "a", Date.now(), "shared", selectionOptions)) .toBe(MAIN_CODEX_ACCOUNT_ID); }); + + describe("an operator selection outranks the pool cursor", () => { + test("the pool moves, then a manual pick wins the next unbound dispatch", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "a", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + updateAccountQuota("c", 30); + + // Let the pool move the runtime cursor off the operator account. + const first = resolveCodexAccountForThread(null, config)!; + recordCodexUpstreamOutcome(config, first, 429); + const promoted = getEffectiveActiveCodexAccountId(config); + expect(promoted).not.toBe(first); + + // The operator now selects the third account, one the pool did not choose and that + // carries no cooldown. Before this feature the runtime cursor kept winning and the + // next dispatch still served the pool account, which is the defect this phase fixes. + const chosen = ["a", "b", "c"].find(id => id !== first && id !== promoted)!; + config.activeCodexAccountId = chosen; + resetCodexRoutingForManualSelection(chosen); + + expect(getEffectiveActiveCodexAccountId(config)).toBe(chosen); + expect(resolveCodexAccountForThread(null, config)).toBe(chosen); + }); + + test("a successful dispatch spends the one-shot so the pool may move again", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "a", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + updateAccountQuota("c", 30); + resetCodexRoutingForManualSelection("a"); + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + + // Success commits the operator choice and releases the hold. Without a consume site + // the preference would be permanent and the automatic cursor could never move again. + recordCodexUpstreamOutcome(config, "a", 200); + recordCodexUpstreamOutcome(config, "a", 429); + expect(getEffectiveActiveCodexAccountId(config)).not.toBe("a"); + }); + + test("a 429 on the preferred account still promotes away from it", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 1, + activeCodexAccountId: "a", + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + updateAccountQuota("c", 30); + resetCodexRoutingForManualSelection("a"); + + // The failover promote is exempt from the preference guard on purpose: it only runs + // because the account in use just failed, so it is never an automatic pick competing + // with the operator. Guarding it would trap routing on a cooled account. + recordCodexUpstreamOutcome(config, "a", 429); + expect(isCodexAccountInCooldown("a")).toBe(true); + expect(getEffectiveActiveCodexAccountId(config)).not.toBe("a"); + }); + }); }); From e99db1d7258f4436d630473b3dd79005ee189aa1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 00:18:51 +0900 Subject: [PATCH 057/231] fix(codex): revoke the operator one-shot when its account is deleted The audit drove the first version of these tests red on the parent branch: all three passed with the production change reverted, because they only re-asserted what resetCodexRoutingForManualSelection and the deliberately exempt failover promote already did. Three defects fall out of that. Deletion reaches routing through clearCodexUpstreamHealthForAccount, which did not revoke the preference, so a preference could outlive its account and suppress every later write. The generation sweep had the same hole. The model-detour promote wrote over the operator's selection while preemption next to it yielded. The independent-scope preference entries were written and consumed but never read by any guard. Co-authored-by: Heisenberg --- src/codex/routing.ts | 29 ++++++-- .../codex-pool-rotation.test.ts | 70 +++++++++++++++++-- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 98e24e2b18..adc4d3b42d 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -327,6 +327,11 @@ export function clearCodexUpstreamHealth(): void { export function clearCodexUpstreamHealthForAccount(accountId: string): void { upstreamHealth.delete(accountId); quotaScopedHealth.delete(accountId); + // Deletion is the third operator exit, next to pause and exclusion, and it is the one + // with no reconcile path behind it: once the account is gone nothing can succeed on it, + // so an unspent preference naming it would suppress the automatic cursor for every other + // account until the process restarts. + forgetManualPreference(accountId); } export function reconcileCodexRoutingHealth(context: GenerationContext): number { @@ -342,6 +347,14 @@ export function reconcileCodexRoutingHealth(context: GenerationContext): number quotaScopedHealth.delete(accountId); removed += 1; } + // Sweep preferences the same way, for the account set this generation actually has. The + // delete path above is the direct route; this is the one that catches an account removed + // by an edit the runtime never saw. Deliberately not counted in `removed`, which reports + // health rows. + for (const [poolKey, preferred] of manualPreference) { + if (context.codexAccountIds.has(preferred)) continue; + manualPreference.delete(poolKey); + } liveHealthAccountIds = new Set(context.codexAccountIds); lastReconciledGeneration = context.generation; return removed; @@ -875,9 +888,10 @@ export function resetCodexRoutingForManualSelection(accountId: string): void { clearThreadAccountMap(); // Manual selection is the operator source of truth — drop any automatic runtime cursor. runtimeActiveCodexAccountId = undefined; - // Record the pick as an unspent one-shot, over the same scope set the rotation ring is - // seeded for. An absent key means NO preference for that scope: an independent scope must - // never inherit the shared entry, or it would consume intent it was not given. + // Record the pick as an unspent one-shot on the SHARED scope only. An independent scope + // gets no entry on purpose: every write site the guard protects is already skipped for + // independent scopes, so an entry there would be state nothing reads — and state nothing + // reads is what the next reader mistakes for a rule. // // Seeding happens ONLY here. A pool-driven promote must never create or move a preference, // or the pool would manufacture an operator intent nobody expressed. @@ -888,7 +902,6 @@ export function resetCodexRoutingForManualSelection(accountId: string): void { seedPoolRotationAccount(POOL_KEY_CODEX, accountId); for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) { if (isIndependentCodexQuotaScope(scope)) { - manualPreference.set(codexPoolKeyForScope(scope), accountId); seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId); } } @@ -2279,7 +2292,13 @@ export function resolveCodexAccountForThreadDetailed( && !preserveSharedSelectionForModelDetour && !isIndependentCodexQuotaScope(quotaScope) ) { - promoteActiveCodexAccount(config, strategyPick); + // Same rule as preemption below: a model detour that lands on another account is + // still an automatic pick, so it may serve this request without overwriting the + // operator's selection. Only the failover promote is exempt, because that one runs + // precisely because the account in use just failed. + if (!manualPreferenceBlocks(POOL_KEY_CODEX, strategyPick)) { + promoteActiveCodexAccount(config, strategyPick); + } } return { status: "selected", accountId: strategyPick }; } diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index 6f70c97d12..a6985c174e 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -17,6 +17,7 @@ import { } from "../../src/codex/account-priority"; import { clearCodexUpstreamHealth, + clearCodexUpstreamHealthForAccount, clearThreadAccountMap, CODEX_TRANSIENT_SOFT_AVOID_MS, previewCodexAccountForRequest, @@ -1050,7 +1051,58 @@ describe("selection order across rotation strategies", () => { expect(resolveCodexAccountForThread(null, config)).toBe(chosen); }); + // The three tests below are the ones that carry the feature. Each was driven red against + // the parent branch first: an assertion that passes with the production change reverted + // proves nothing, and the first draft of this block was exactly that — three tests that + // all passed without the guard, because they only re-asserted what + // resetCodexRoutingForManualSelection and the exempt failover promote already did. + test("an over-threshold operator account is served around, not replaced", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + }); + // The operator's account is past the switch threshold, so fill-first advances off it. + // This is the ordinary case the report was about: the account the operator chose is + // temporarily spent, not wrong. + updateAccountQuota("a", 90); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + resetCodexRoutingForManualSelection("a"); + + const served = resolveCodexAccountForThread(null, config)!; + expect(served).not.toBe("a"); + + // Serving the request from another account is the pool doing its job. Writing that + // account over the operator's selection is not: when a's window rolls over there + // would be nothing left pointing back at it. Without the guard this reads `served`. + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + }); + test("a successful dispatch spends the one-shot so the pool may move again", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + resetCodexRoutingForManualSelection("a"); + expect(resolveCodexAccountForThread(null, config)).toBe("a"); + + // The operator got what they asked for, so the hold is released. Without a consume + // site the preference is permanent and the cursor could never move again — measured: + // guard without consume fails 15 of the 69 rotation tests in this file. + recordCodexUpstreamOutcome(config, "a", 200); + + updateAccountQuota("a", 90); + const served = resolveCodexAccountForThread(null, config)!; + expect(served).not.toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe(served); + }); + + test("deleting the preferred account releases the hold", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin", accountPoolStickyLimit: 1, @@ -1060,13 +1112,19 @@ describe("selection order across rotation strategies", () => { updateAccountQuota("b", 20); updateAccountQuota("c", 30); resetCodexRoutingForManualSelection("a"); - expect(resolveCodexAccountForThread(null, config)).toBe("a"); - // Success commits the operator choice and releases the hold. Without a consume site - // the preference would be permanent and the automatic cursor could never move again. - recordCodexUpstreamOutcome(config, "a", 200); - recordCodexUpstreamOutcome(config, "a", 429); - expect(getEffectiveActiveCodexAccountId(config)).not.toBe("a"); + // Delete is the operator exit with no reconcile behind it: the account can never + // succeed again, so nothing else would ever spend the one-shot. The account-lifecycle + // delete path reaches routing through exactly this call. + config.codexAccounts = config.codexAccounts!.filter(account => account.id !== "a"); + config.activeCodexAccountId = undefined; + clearCodexUpstreamHealthForAccount("a"); + + const served = resolveCodexAccountForThread(null, config)!; + expect(served).not.toBe("a"); + // Without the revocation the preference outlives its account and blocks every write, + // so the effective active stays empty and the pool can never commit a replacement. + expect(getEffectiveActiveCodexAccountId(config)).toBe(served); }); test("a 429 on the preferred account still promotes away from it", () => { From f725ef89374dcd3efc4f11bf8e0fc807f3814f73 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 00:20:19 +0900 Subject: [PATCH 058/231] docs(devlog): record the audit round that found the tests proved nothing --- .../010_phase1_manual_selection.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md index 02f4cb045c..3dbe90ab56 100644 --- a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md +++ b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md @@ -289,3 +289,58 @@ The design therefore survives the lane's landings. What does not change is the coordination risk: L3 still owns these files for the dispatch round, so the B phase of this work-phase must not open until that ownership clears. Re-run this table at that point, because the guarantee above is a snapshot of `16f18d654`. + +## Audit round 4 — the shipped tests proved nothing + +The first implementation landed as PR #4284 with three new cases under +`an operator selection outranks the pool cursor`, and a reviewer was asked one +question the earlier rounds never asked: does each test fail without the production +change? It does not. Measured by reverting only `src/codex/routing.ts` to the parent +branch and keeping the new tests: + +``` +bun test tests/codex-integration/codex-pool-rotation.test.ts \ + -t "an operator selection outranks the pool cursor" +3 pass, 0 fail # production change reverted +``` + +All three passed against a tree with no guard, no preference map and no consume site. +They were re-assertions of things that already held: case 1 of +`resetCodexRoutingForManualSelection` clearing the runtime cursor and seeding the ring, +cases 2 and 3 of the failover promote, which this design deliberately leaves exempt. A +test that cannot fail is not weak coverage, it is an empty claim, and criteria c-1 and +c-2 had been recorded `met` against it. + +Three real defects were behind that blind spot. + +**Deletion never revoked the preference.** Pause and exclusion both route through +`reconcileCodexActiveAfterExclusion`, which forgets it. Delete does not: the +account-lifecycle path reaches routing through `clearCodexUpstreamHealthForAccount` +(`routing.ts:327`, called from `account-lifecycle.ts:43`), which cleared two health maps +and left the preference behind. Once the named account is gone nothing can ever succeed +on it, so the one-shot can never be spent, and every later automatic write is suppressed +until the process restarts. The generation sweep in `reconcileCodexRoutingHealth` had the +same hole for an account removed by an edit the runtime never observed. + +**The model-detour promote was not guarded.** `promoteActiveCodexAccount` at the +model-detour site sits twelve lines above the preemption site that this design already +guards, and both are automatic picks competing with the operator. Only the failover +promote earns the exemption, and for the stated reason: it runs because the account in +use just failed. + +**The independent-scope entries were dead state.** Every write site the guard protects is +already skipped for independent scopes, so those keys were seeded and consumed but never +read. Removed: state nothing reads is what the next reader mistakes for a rule. + +The replacement cases are each red against the variant that removes the piece they cover: + +| Case | Red against | +|---|---| +| an over-threshold operator account is served around, not replaced | parent branch: reads `b`, expected `a` | +| deleting the preferred account releases the hold | pre-fix head `63217d161`: reads `undefined`, expected `b` | +| a successful dispatch spends the one-shot so the pool may move again | guard without consume: 15 of 69 rotation tests fail | + +The over-threshold case is also the one that states the user-facing rule plainly. An +account past its switch threshold is temporarily spent, not wrong: the pool serves the +request from elsewhere, and the operator's selection stays pointed where the operator put +it, so the window rolling over returns routing to it without a second manual pick. From e3b61b342d9908d5ae694946e50cf0ddfa089cf3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 00:36:32 +0900 Subject: [PATCH 059/231] fix(codex): keep the model detour outside the operator preference guard Guarding it failed 8 cases in codex-routing.test.ts. A model detour runs because the operator account cannot serve the model at all, and under a rotating strategy the promote moves only the process-local cursor, never the persisted selection. --- src/codex/routing.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index adc4d3b42d..9c044bd6de 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2292,13 +2292,14 @@ export function resolveCodexAccountForThreadDetailed( && !preserveSharedSelectionForModelDetour && !isIndependentCodexQuotaScope(quotaScope) ) { - // Same rule as preemption below: a model detour that lands on another account is - // still an automatic pick, so it may serve this request without overwriting the - // operator's selection. Only the failover promote is exempt, because that one runs - // precisely because the account in use just failed. - if (!manualPreferenceBlocks(POOL_KEY_CODEX, strategyPick)) { - promoteActiveCodexAccount(config, strategyPick); - } + // NOT guarded by manualPreferenceBlocks, unlike preemption below. Measured: guarding + // it fails 8 cases in tests/codex-integration/codex-routing.test.ts, because a model + // detour is not the pool exercising discretion — the operator's account cannot serve + // this model at all. Under a rotating strategy this promote only moves the + // process-local cursor to whoever is actually serving and releases the pin; the + // operator's persisted activeCodexAccountId is left untouched either way, which is + // the thing the preference exists to protect. + promoteActiveCodexAccount(config, strategyPick); } return { status: "selected", accountId: strategyPick }; } From 2da37d7eb2ccff666e19ccdaf15d5eb2cc37c6da Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 00:36:53 +0900 Subject: [PATCH 060/231] docs(devlog): record the rebuttal of the model-detour finding --- .../010_phase1_manual_selection.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md index 3dbe90ab56..949ee387b1 100644 --- a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md +++ b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md @@ -322,11 +322,18 @@ on it, so the one-shot can never be spent, and every later automatic write is su until the process restarts. The generation sweep in `reconcileCodexRoutingHealth` had the same hole for an account removed by an edit the runtime never observed. -**The model-detour promote was not guarded.** `promoteActiveCodexAccount` at the -model-detour site sits twelve lines above the preemption site that this design already -guards, and both are automatic picks competing with the operator. Only the failover -promote earns the exemption, and for the stated reason: it runs because the account in -use just failed. +**The model-detour promote was reported as unguarded — REBUTTED.** `promoteActiveCodexAccount` +at the model-detour site sits twelve lines above the preemption site this design guards, so +the symmetry argument is tempting. It is wrong, and the measurement says so: guarding it +fails 8 cases in `tests/codex-integration/codex-routing.test.ts`, the +`cannot re-pick a quota-drained shared account that remains model-eligible` family and its +siblings. Those encode an older contract. A model detour is not the pool exercising +discretion — it runs because the operator's account cannot serve the requested model at +all — and under a rotating strategy that promote moves only the process-local cursor to +whoever is actually serving, then releases the pin. `config.activeCodexAccountId`, the +operator's persisted selection and the thing this preference exists to protect, is +untouched either way. The guard was written, measured red, and reverted with the reason +recorded at the call site. **The independent-scope entries were dead state.** Every write site the guard protects is already skipped for independent scopes, so those keys were seeded and consumed but never From 7235d25f67d2ae25dd540c688e62779c15f6991c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 00:40:04 +0900 Subject: [PATCH 061/231] test(codex): cover the generation sweep of operator preferences reconcileCodexRoutingHealth had no test at all, so the preference sweep added for the delete-path blocker was verified by reading rather than by running. Both halves are covered now: an account the generation no longer lists loses its preference, and one that is still listed keeps it. Red control: removing the four sweep lines makes the first case read undefined. --- .../codex-pool-rotation.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index a6985c174e..f5a5c38c49 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -25,6 +25,7 @@ import { isCodexAccountInCooldown, pickAlternateCodexAccount, recordCodexUpstreamOutcome, + reconcileCodexRoutingHealth, resetCodexRoutingForManualSelection, resolveCodexAccountForThread, } from "../../src/codex/routing"; @@ -61,6 +62,24 @@ function saveTestCredential(id: string): void { }); } +/** + * `reconcileCodexRoutingHealth` ignores a generation it has already seen, and the counter is + * module state shared by every test in this file, so each call needs a strictly higher one. + */ +let sweepGeneration = 9_000_000; +function generationContext(codexAccountIds: ReadonlySet) { + sweepGeneration += 1; + return { + generation: sweepGeneration, + providerNames: new Set(), + comboIds: new Set(), + comboTargets: new Set(), + codexAccountIds, + oauthAccountKeys: new Set(), + configRoots: new Set(), + }; +} + function makeThreeAccountConfig(overrides: Partial = {}): OcxConfig { const ids = ["a", "b", "c"]; for (const id of ids) saveTestCredential(id); @@ -1127,6 +1146,50 @@ describe("selection order across rotation strategies", () => { expect(getEffectiveActiveCodexAccountId(config)).toBe(served); }); + test("the generation sweep drops a preference whose account is gone", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + resetCodexRoutingForManualSelection("a"); + + // The other removal path: an account edited out of the config by something the runtime + // never observed, so no delete call ever reached routing. The sweep is the only thing + // standing between that and a preference that can never be spent. + reconcileCodexRoutingHealth(generationContext(new Set(["b", "c"]))); + + config.codexAccounts = config.codexAccounts!.filter(account => account.id !== "a"); + config.activeCodexAccountId = undefined; + const served = resolveCodexAccountForThread(null, config)!; + expect(served).not.toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe(served); + }); + + test("the generation sweep keeps a preference whose account is still live", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "fill-first", + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + }); + updateAccountQuota("a", 90); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + resetCodexRoutingForManualSelection("a"); + + // The half that makes the sweep a sweep rather than a reset: "a" is over threshold and + // is about to be routed around, but it is still in the roster, so the operator's + // selection has to survive. + reconcileCodexRoutingHealth(generationContext(new Set(["a", "b", "c"]))); + + const served = resolveCodexAccountForThread(null, config)!; + expect(served).not.toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + }); + test("a 429 on the preferred account still promotes away from it", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin", From eec1f9bb60262bcfab29ec4d28b9ff23eb359a1f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 00:44:33 +0900 Subject: [PATCH 062/231] fix(codex): reset the reconcile watermark with the health state it guards clearCodexUpstreamHealth dropped every health map but kept lastReconciledGeneration and liveHealthAccountIds, so after a full reset recordCodexUpstreamOutcome would still discard a writer whose generation predated the watermark for any account missing from the equally stale live set. Found because the watermark leaked between test files: the new generation-sweep coverage failed two unrelated cases in codex-routing.test.ts when both files ran in one process. --- src/codex/routing.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 9c044bd6de..95c99e5d15 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -322,6 +322,13 @@ export function clearCodexUpstreamHealth(): void { upstreamHealth.clear(); quotaScopedHealth.clear(); runtimeActiveCodexAccountId = undefined; + // The reconcile watermark is part of this state, not something that outlives it. Keeping + // it across a full reset is incoherent: there is no health left to protect, yet + // recordCodexUpstreamOutcome would still drop a writer whose generation predates the + // watermark for any account missing from the equally stale live set. Left behind, it also + // leaks between test files, which is how it was found. + lastReconciledGeneration = 0; + liveHealthAccountIds = new Set(); } export function clearCodexUpstreamHealthForAccount(accountId: string): void { From b7328d7424617fd78926071c6769e4734f582bdc Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 01:02:34 +0900 Subject: [PATCH 063/231] docs(devlog): plan the generic pool kernel layer at diff level --- .../020_phase2_shared_kernel.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md index 3b0fc74ba3..8eda084c1c 100644 --- a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md +++ b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md @@ -217,3 +217,111 @@ starts from them rather than rediscovering them. - `tests/adapters/anthropic/anthropic-account-pool.test.ts` — parity - `tests/providers/kiro/kiro-pool-rank.test.ts` — the kiro exhaustion special case in `account-quota-rank.ts:84-108` survives + +## wp2b implementation plan (re-verified against `dev` 29d632ff2) + +Every anchor below was re-read on the post-merge tree, after #4275/#4277/#4279/#4284 landed. + +| Symbol | File | Line | +|---|---|---| +| `isProactivePreferenceEnabled` | `src/oauth/generic-account-failover.ts` | 150 | +| `rotateGenericOAuthAccountOn429` | `src/oauth/generic-account-failover.ts` | 178 | +| `preferredInitialAccount` | `src/oauth/generic-account-failover.ts` | 246 | +| `forgetGenericFailoverRoster` | `src/oauth/generic-account-failover.ts` | 308 | +| `GenericPoolSettingsDto` / `inert: true` | `src/oauth/pool-settings-capability.ts` | 40 / 54, 65 | +| `PUT /api/oauth/accounts/active` | `src/server/management/oauth-account-routes.ts` | 325 | +| generic GET / PUT DTO | `src/server/management/oauth-account-routes.ts` | 360 / 422 | +| `stickyLimit` 400 | `src/server/management/oauth-account-routes.ts` | 396 | +| `genericPoolKey` / `pickRoundRobinAccount` / `peekRoundRobinAccount` / `notePoolRotationSuccess` | `src/oauth/pool-kernel.ts` | 12 / 198 / 210 / 222 | +| `genericFailoverAccountId = resolved.accountId` | `src/server/responses/core.ts` | 4407 | +| per-provider `oauthAccountFailover` | `src/types/provider.ts` | 520 | + +### The question 020 left open: where does a round-robin proposal commit? + +`peekRoundRobinAccount` exists and does not advance the ring, which is correct for +`preferredInitialAccount` — that answer is discardable, and the resolver drops it when the +account turns out to be removed, reauth-flagged, or missing a Cloud Code Assist project. But +a peek that never commits is a ring that never turns: every request would propose the same +account forever, and "round-robin" would be a label on a constant. + +So a commit site is mandatory, and it has to be the admission point, not the proposal. That +point already exists and already has a generic-only branch: + +``` +src/server/responses/core.ts:4405-4408 + if (isGenericFailoverProvider(route.providerName, route.provider)) { + genericFailoverAccountId = resolved.accountId; + } +``` + +One line joins it: `noteGenericPoolSelection(config, route.providerName, resolved.accountId)`. +The function lives in `generic-account-failover.ts` and does the flag read, the strategy read +and the `notePoolRotationSuccess(genericPoolKey(name), id, stickyLimit)` call itself. No policy +moves into `core.ts`, the import comes from a module `core.ts` already imports from, and the +core-path Lab boundary is untouched — `pool-kernel.ts` pulls only two types. + +This is the one file in the unit that sits on every user's request path, so it takes exactly +one statement and no branching of its own. + +### Change surface + +**`src/types/config.ts`** — add `pool?: { kernel?: boolean }` beside the existing optional flag +objects (`resetCreditAutoRedeem` at :833 is the nearest shape). **`src/config.ts`** — add +`pool: z.object({ kernel: z.boolean().optional() }).optional().catch(undefined)` next to +`resetCreditAutoRedeem` at :1304. `.catch(undefined)` matches the house rule: a malformed hand +edit turns the feature off rather than costing the operator their providers. + +**`src/types/provider.ts`** — add `stickyLimit?: number` to the per-provider +`oauthAccountFailover` block at :520, with the same 1..100 range the Anthropic pool documents. + +**`src/oauth/generic-account-failover.ts`** — branch BOTH paths on strategy, because branching +one leaves the setting inert in practice: + +| Strategy | `preferredInitialAccount` | `rotateGenericOAuthAccountOn429` | +|---|---|---| +| flag off, or absent/`quota` | unchanged: healthy-active return :262, `hasHeadroomEvidence` :267, `rankAccountsByHeadroom` | unchanged: ring after the failed id, then `rankAccountsByHeadroom` | +| `round-robin` | skip BOTH guards, `peekRoundRobinAccount(genericPoolKey(name), eligible, stickyLimit)` | `pickRoundRobinAccount` over the eligible ring | +| `fill-first` | skip the healthy-active return; keep active while its usage is under `autoSwitchThreshold`, else advance to the next eligible account | must NOT keep the failed account: advance to the next eligible one | + +The two guards are skipped deliberately and for different reasons, both measured in 020's audit: +`hasHeadroomEvidence` returns false for any provider with no quota data, so leaving it in front +of round-robin makes round-robin unreachable exactly where it is most useful; and the +healthy-active early return fires before `autoSwitchThreshold` can ever be read, so fill-first +would never reach its own threshold test. Keep the presence quorum, the `EXCLUDED_PROVIDERS` +guard and the per-provider `health` cooldown on every branch. + +**`src/oauth/pool-settings-capability.ts`** — `inert` becomes `boolean` computed from the flag +instead of the literal `true`. `genericPoolSettingsDto` takes the flag as a third argument +rather than reading config itself, so the DTO stays a pure projection. + +**`src/server/management/oauth-account-routes.ts`** — three edits. The active PUT at :325 gains +`seedPoolRotationAccount(genericPoolKey(provider), accountId)` beside `forgetGenericFailoverRoster`, +or the operator's pick immediately loses to sticky rotation — the same defect wp1b just fixed on +the Codex side, and `forgetGenericFailoverRoster` only drops the presence count, never the +cursor. The 400 at :396 narrows to `quotaWindow` alone. The pool PUT accepts and persists +`stickyLimit` with the 1..100 validation. + +**`src/cli/account-extended.ts`** — the generic branch at :395 currently hardcodes +`const enabled = false`. With the kernel on it reports the real state. + +### Acceptance + +Criterion c-3: a test asserts a configured strategy actually changes the selected account, and +the DTO stops reporting `inert` once the flag is on. + +- `tests/oauth/generic-oauth-failover.test.ts` — round-robin rotates across dispatches for a + provider with NO quota data (the case the evidence guard blocks today); fill-first holds the + active account under threshold and advances over it; quota is byte-identical to today; every + one of them is a no-op with `pool.kernel` off. +- `tests/server/account-pool-management-api.test.ts` — `inert` follows the flag, `stickyLimit` + round-trips, `quotaWindow` still 400s. The existing marker test at :435 reads the source for + the literal `inert: true;` and moves with the type. +- `tests/cli/cli-account-pool-verbs.test.ts` — the CLI reports the live threshold when on. +- Red control for each new case, as in wp1b: the assertion must fail with its production branch + removed. A test that passes either way is not coverage. + +### Reversibility + +`pool.kernel` defaults off, and off means the pre-kernel code path byte for byte: the guards +stay, the DTO still says `inert: true`, and `noteGenericPoolSelection` returns before touching +the ring. No migration writes on upgrade; the kernel reads keys that are already persisted. From 82253a36908cf92f80de371a4e8d1962549612c8 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 01:15:34 +0900 Subject: [PATCH 064/231] docs(devlog): fold the plan-audit findings into the generic kernel plan --- .../020_phase2_shared_kernel.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md index 8eda084c1c..c1d7423bf9 100644 --- a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md +++ b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md @@ -325,3 +325,43 @@ the DTO stops reporting `inert` once the flag is on. `pool.kernel` defaults off, and off means the pre-kernel code path byte for byte: the guards stay, the DTO still says `inert: true`, and `noteGenericPoolSelection` returns before touching the ring. No migration writes on upgrade; the kernel reads keys that are already persisted. + +### A-phase findings folded into this plan + +Verified while auditing the plan above, before any code was written. + +**The `inert` contract is published, in seven languages.** Turning `inert` into a computed +field makes live documentation false, and AGENTS.md requires docs-site to stay in sync and +translated locales not to contradict the English source. The statements that change: +`docs-site/src/content/docs/reference/configuration/providers.md` :568 ("the generic selector +does not act on it yet, so omitted and set behave the same today"), :569 ("inert until the +selector consumes it") and :590 ("`inert: true` for those two fields only"); and +`reference/cli/providers-accounts.md` :351 ("Generic pool thresholds are currently inert") and +:355, whose signature literally reads `inert: true | null`. The same page exists under +`ko`, `ja`, `fr`, `ru`, `tr`, `zh-cn` and `zh-tw`. All of it moves in this PR: a flag-gated +feature still has to describe both states, not the old one. + +**The DTO marker test fails OPEN, which is worse than failing.** +`tests/server/account-pool-management-api.test.ts:435` locates its slice with +`source.indexOf("inert: true;", start)`. Once the type reads `inert: boolean;` that returns +`-1`, and `source.slice(start, -1)` happily returns almost the whole file — which still +contains "strategy", "autoSwitchThreshold" and "enabled", so all three assertions pass while +the test has stopped checking anything. It must be rewritten against the new literal, not +merely allowed to keep passing. This is the same failure mode wp1b was built on, so it gets +named rather than discovered later. + +**`src/server/management/provider-routes.ts`:1023-1024 is a reader the plan did not name.** +It carries `oauthAccountFailover` forward when a provider is overwritten, to stop an edit +silently enabling rotation. It copies the whole object, so a new `stickyLimit` rides along +with no change — verified, listed here so the next reader does not have to re-derive it. + +**The core-path import edge is already there.** `src/server/responses/core.ts` imports from +`../../oauth/generic-account-failover` at :150, so adding `noteGenericPoolSelection` to that +existing import creates no new module edge at all, and `pool-kernel.ts` imports only two +types. `bun test tests/lab/core-lab-boundary.test.ts` is green at 17 pass / 0 fail on this +branch and is re-run at Check. + +**Fill-first's stable order is `eligibleFailoverAccounts`:164**, which preserves +`set.accounts` order from the store and filters out reauth-flagged and cooled accounts. That +is the order the 429 ring already walks, so fill-first advances through the same sequence +rather than inventing a second one. From 5b282ba0c537158911d77ede64bb5172f5ae4a33 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 01:23:23 +0900 Subject: [PATCH 065/231] docs(devlog): fold three plan blockers, including a wrong fill-first order --- .../020_phase2_shared_kernel.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md index c1d7423bf9..fd0b21c651 100644 --- a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md +++ b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md @@ -365,3 +365,57 @@ branch and is re-run at Check. `set.accounts` order from the store and filters out reauth-flagged and cooled accounts. That is the order the 429 ring already walks, so fill-first advances through the same sequence rather than inventing a second one. + +### Plan audit round 2 — FAIL, three blockers folded + +A dispatched reviewer returned FAIL on the plan above. All three blockers are real and two of +them contradict what this document said one revision earlier. Recorded rather than quietly +edited, because the corrections are the useful part. + +**Blocker 1 — fill-first must walk the SORTED FULL roster, not the eligible subset.** +The "A-phase findings" note above claimed `eligibleFailoverAccounts`:164 is the order +fill-first advances through. That is wrong, and it is the exact bug 020's own earlier audit +already rejected when it added the `stableAll` argument to `pickFillFirst`. Both shipped +copies walk a stable roster sorted with `localeCompare` — `src/codex/routing.ts`:1443 and +`src/oauth/anthropic-routing.ts`:427 — and dropping to the eligible subset changes the wrap +order whenever an ineligible id sits between two eligible ones. The generic roster is worse +than unsorted-by-accident: `getAccountSet().accounts` is in LOGIN order, so two operators who +added the same accounts in a different sequence would get different rotation. The generic +fill-first sorts the full roster the same way, then skips ineligible ids while walking it. +Supersedes the paragraph above. + +**Blocker 2 — the commit site fires on every generic dispatch, so it must gate on +round-robin specifically.** `core.ts`:4407 is reached on every generic first dispatch, +including the preferred-null quota path and the fallback after a preferred account is dropped +at :4368-4388. The plan said `noteGenericPoolSelection` "does the flag read and the strategy +read" without saying what it does with them, which is not precise enough to implement: an +ungated call would advance round-robin sticky state for quota and fill-first pools too. +It returns immediately unless `pool.kernel` is on AND the resolved strategy is +`round-robin`. Anthropic already draws exactly this line — `anthropic-routing.ts`:791 notes +rotation only on its round-robin branch — so this is matching an existing contract, not +inventing one. + +**Blocker 3 — the CLI has three states, not two.** `src/cli/account-extended.ts`:402-409 +prints "unavailable" and "threshold support is unknown" whenever `inert !== true`, so a +kernel-on `inert: false` would render the live feature as an unknown capability — the +opposite of the truth. `tests/cli/cli-account-pool-verbs.test.ts`:393-403 also feeds +`inert: false` through a malformed-capability loop that expects `enabled: false`. The CLI +needs `true` (stored, not applied), `false` (applied) and `null`/absent (unknown) as three +distinct renderings, and that test's fixture must stop conflating the middle one with +malformed input. + +**Major folded — an exact-equality DTO assertion.** +`tests/server/account-pool-management-api.test.ts`:477 asserts the generic GET body with +`toEqual`, so adding `stickyLimit` breaks it. :484 uses `toMatchObject` and is safe. The PUT +round-trip at :486 breaks only once PUT actually persists the field. All three move with the +change. + +**Major folded — `src/cli/capabilities.ts`:331** also publishes the inert contract, alongside +the docs-site pages already listed. + +**Correction — anchor.** This document cited `hasHeadroomEvidence` at :267; that is where its +comment begins. The call is at :272. The anchor table itself was verified correct. + +**Confirmed, no action —** the reviewer independently reached the same conclusion on the Lab +boundary: `core.ts` already imports `generic-account-failover`, and `pool-kernel.ts` is +`import type` only, which the boundary walker skips. No new edge. From 6d211927e398fe24263ffb4e04cca1759d6215ee Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 01:37:04 +0900 Subject: [PATCH 066/231] feat(oauth): let the generic pool consume its strategy behind pool.kernel round-robin and fill-first now actually select an account for a generic OAuth provider, on both the initial-preference and the 429 path. Both quota guards are skipped for them deliberately: hasHeadroomEvidence refuses every provider with no quota data, which is exactly where round-robin is the point, and the healthy-active early return fires before autoSwitchThreshold can be read. quota, and the flag off, keep the pre-kernel path unchanged. The live round-robin pick commits at admission rather than at proposal, matching commitAnthropicSelectionRouting: peek never creates the pool state and notePoolRotationSuccess no-ops without it, so a peek-only path would never turn the ring. --- src/cli/account-extended.ts | 16 +- src/cli/capabilities.ts | 2 +- src/config.ts | 3 + src/oauth/account-quota-rank.ts | 11 ++ src/oauth/generic-account-failover.ts | 170 +++++++++++++++++- src/oauth/pool-settings-capability.ts | 24 ++- src/server/management/oauth-account-routes.ts | 23 ++- src/server/responses/core.ts | 5 + src/types/config.ts | 9 + src/types/provider.ts | 14 +- tests/cli/cli-account-pool-verbs.test.ts | 36 +++- tests/oauth/generic-oauth-failover.test.ts | 119 ++++++++++++ .../account-pool-management-api.test.ts | 16 +- 13 files changed, 426 insertions(+), 22 deletions(-) diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index a6fa3b707c..a8484f8b73 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -399,14 +399,22 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise< const storedThreshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100 ? stored : null; const poolEnabled = typeof settings.enabled === "boolean" ? settings.enabled : null; - const inert = settings.inert === true ? true : null; - // This CLI understands only the current inert generic threshold contract. - const enabled = false; + // Three states, not two. `true` is stored-but-not-applied, `false` is applied by the + // shared kernel, and absent is a server that does not speak this field at all. Collapsing + // false into absent would render the live feature as an unknown capability. + const inert = typeof settings.inert === "boolean" ? settings.inert : null; + // A stored threshold only steers selection once the pool consumes it, which is exactly + // what `inert: false` reports. + const enabled = inert === false && storedThreshold !== null; if (wantsJson) { console.log(JSON.stringify({ provider: name, autoSwitchThreshold: storedThreshold, enabled, poolEnabled, inert }, null, 2)); } else { const value = storedThreshold === null ? "unset" : `${storedThreshold}%`; - console.log(`auto-switch: ${inert === true ? "inactive" : "unavailable"} (stored threshold ${value}; ${inert === true ? "not applied by this pool" : "threshold support is unknown"})`); + const state = inert === false ? (enabled ? "on" : "off") : inert === true ? "inactive" : "unavailable"; + const why = inert === false + ? (enabled ? "applied by this pool" : "no threshold stored") + : inert === true ? "not applied by this pool" : "threshold support is unknown"; + console.log(`auto-switch: ${state} (stored threshold ${value}; ${why})`); } return 0; } diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index d9aa8d0402..60dfc67380 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -328,7 +328,7 @@ export const CAPABILITIES: readonly Capability[] = [ "A bare invocation reads and never writes.", "The APPLIED value is echoed, not the requested one, so a server-side normalization stays visible.", "Values are not re-validated in the CLI: the server owns the strategy names and the 1-100 sticky bound.", - "`anthropic` owns the full pool contract. Other OAuth providers reach the same endpoint with a generic subset (enabled/strategy/autoSwitchThreshold) whose settings persist but do not yet steer selection; `sticky` and `quotaWindow` are refused for them.", + "`anthropic` owns the full pool contract. Other OAuth providers reach the same endpoint with a generic subset (enabled/strategy/autoSwitchThreshold/sticky); those settings steer selection only while `pool.kernel` is on, which is what the `inert` field reports. `quotaWindow` is still refused for them.", ], }, { diff --git a/src/config.ts b/src/config.ts index 106dadd2b6..3cdd699549 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1305,6 +1305,9 @@ const configSchema = z.object({ enabled: z.boolean().optional(), leadTimeMinutes: z.number().int().min(1).max(60).optional(), }).optional().catch(undefined), + // Same degrade-to-off rule as the flags above: a hand-edited typo in an opt-in pool + // feature must never cost the operator their providers. + pool: z.object({ kernel: z.boolean().optional() }).optional().catch(undefined), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole diff --git a/src/oauth/account-quota-rank.ts b/src/oauth/account-quota-rank.ts index 978f73de9d..0cae484fa5 100644 --- a/src/oauth/account-quota-rank.ts +++ b/src/oauth/account-quota-rank.ts @@ -67,6 +67,17 @@ function headroomOf(provider: string, accountId: string): number | null { return 100 - Math.max(...percents); } +/** + * Remaining headroom percent for one account, or null when nothing has measured it. + * + * Exported for the generic fill-first threshold, which needs the measurement itself rather + * than an ordering. Null stays null all the way out: a caller must decide what "unmeasured" + * means for its own rule instead of being handed a fabricated 0 or 100. + */ +export function accountHeadroomPercent(provider: string, accountId: string): number | null { + return headroomOf(provider, accountId); +} + /** Unknown usage is not exhaustion; Kiro's explicit overage verdict is authoritative. */ export function isAccountQuotaExhausted(provider: string, accountId: string): boolean { const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${accountId}`) : null; diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index 1ccfaf71df..6b444ea435 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -16,7 +16,21 @@ */ import { getAccountSet } from "./store"; import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index"; -import { exhaustedCooldownMs, hasHeadroomEvidence, isAccountQuotaExhausted, rankAccountsByHeadroom } from "./account-quota-rank"; +import { + accountHeadroomPercent, + exhaustedCooldownMs, + hasHeadroomEvidence, + isAccountQuotaExhausted, + rankAccountsByHeadroom, +} from "./account-quota-rank"; +import { + genericPoolKey, + normalizeAccountPoolStickyLimit, + notePoolRotationSuccess, + peekRoundRobinAccount, + pickRoundRobinAccount, + seedPoolRotationAccount, +} from "./pool-kernel"; import { parseRetryAfterMs } from "../combos/failover"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import type { OcxConfig, OcxProviderConfig } from "../types"; @@ -169,6 +183,113 @@ export function eligibleFailoverAccounts(providerName: string, now = Date.now()) .map(account => account.id); } +/** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */ +type ActiveGenericStrategy = "round-robin" | "fill-first"; + +/** Matches the Codex and Anthropic pools; the DTO still reports `null` for "not stored". */ +const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80; + +/** + * The strategy this provider's pool actually runs, or null for today's behaviour. + * + * Three different inputs answer null and they all mean the same thing to a caller: the flag is + * off, no strategy is stored, or the stored strategy is `quota` — which is precisely what the + * unflagged code already does. Collapsing them here is what keeps every call site a two-way + * branch instead of a four-way one. + */ +function activeGenericStrategy(config: OcxConfig, providerName: string): ActiveGenericStrategy | null { + if (config.pool?.kernel !== true) return null; + const raw = config.providers?.[providerName]?.oauthAccountFailover?.strategy; + return raw === "round-robin" || raw === "fill-first" ? raw : null; +} + +function genericStickyLimit(config: OcxConfig, providerName: string): number { + return normalizeAccountPoolStickyLimit(config.providers?.[providerName]?.oauthAccountFailover?.stickyLimit); +} + +/** + * The FULL roster in a stable order, not the eligible subset. + * + * Two load-bearing reasons. The store holds accounts in LOGIN order, so two operators who added + * the same accounts in a different sequence would otherwise rotate differently; sorting makes + * the ring a property of the accounts rather than of the history. And walking the eligible + * subset instead of the full roster changes the wrap order whenever an ineligible id sits + * between two eligible ones — the bug the Codex and Anthropic copies carry a `stableAll` + * argument to avoid. + */ +function stableGenericRoster(providerName: string): string[] { + const set = getAccountSet(providerName); + if (!set) return []; + return set.accounts.map(account => account.id).sort((left, right) => left.localeCompare(right)); +} + +/** + * Has this account spent enough of its allowance for fill-first to move on? + * + * An unmeasured account reads as UNDER the threshold, matching the Codex pool: a threshold is a + * statement about observed usage, and treating "no observation" as "spent" would evacuate every + * quota-less provider off its active account on the very first request. + */ +function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number): boolean { + const headroom = accountHeadroomPercent(providerName, accountId); + if (headroom === null) return false; + return 100 - headroom >= threshold; +} + +/** + * Fill-first: stay on the active account until it crosses its threshold, then take the next + * eligible account in the stable ring. Null means "keep the active account". + */ +function pickFillFirstGenericAccount( + config: OcxConfig, + providerName: string, + activeId: string | undefined, + now: number, +): string | null { + const stableAll = stableGenericRoster(providerName); + if (stableAll.length < 2) return null; + const eligible = new Set(eligibleFailoverAccounts(providerName, now)); + const stored = config.providers?.[providerName]?.oauthAccountFailover?.autoSwitchThreshold; + const threshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100 + ? stored + : DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD; + if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold)) { + return null; + } + const start = activeId ? stableAll.indexOf(activeId) : -1; + const ring = start >= 0 ? [...stableAll.slice(start + 1), ...stableAll.slice(0, start)] : stableAll; + for (const id of ring) { + if (id !== activeId && eligible.has(id)) return id; + } + return null; +} + +/** + * Advance the round-robin cursor once a dispatch has actually been admitted on this account. + * + * The early return is the whole safety story for the core path: this is reached on EVERY + * generic first dispatch, including quota pools and the fallback after a preferred account was + * dropped, so anything but round-robin must leave the cursor untouched. + * + * The live pick belongs here rather than in the proposal, and that is not stylistic. + * `peekRoundRobinAccount` never creates the pool state and `notePoolRotationSuccess` returns + * immediately when there is none, so a peek-only path would leave the ring with nothing to + * advance and round-robin would propose the same account forever. This is the same shape + * `commitAnthropicSelectionRouting` already commits with. + */ +export function noteGenericPoolSelection(config: OcxConfig, providerName: string, accountId: string): void { + if (activeGenericStrategy(config, providerName) !== "round-robin") return; + const poolKey = genericPoolKey(providerName); + const limit = genericStickyLimit(config, providerName); + const picked = pickRoundRobinAccount(poolKey, eligibleFailoverAccounts(providerName), limit); + // The resolver may have admitted a different account than the ring proposed: a removal, a + // reauth verdict or a manual selection can land during credential resolution. Realign the + // cursor onto what actually served rather than leaving it on a road not taken. + if (picked !== accountId) seedPoolRotationAccount(poolKey, accountId); + notePoolRotationSuccess(poolKey, accountId, limit); +} + + /** * Cool the account that actually 429'd and name the next eligible one, or null. * @@ -212,6 +333,30 @@ export function rotateGenericOAuthAccountOn429( const ring = start >= 0 ? [...order.slice(start + 1), ...order.slice(0, start)] : order; const candidates = ring.filter(id => id !== failedAccountId && eligible.includes(id)); if (candidates.length === 0) return null; + // The 429 path branches too. Leaving it on the quota ranking would make a configured + // strategy inert in practice the moment anything actually failed, which is the case the + // operator chose the strategy for. + const strategy = activeGenericStrategy(config, providerName); + if (strategy === "round-robin") { + // PICK here, not peek: the failure already happened and this answer is the one being used, + // so the ring genuinely advances. + return pickRoundRobinAccount( + genericPoolKey(providerName), + candidates, + genericStickyLimit(config, providerName), + ); + } + if (strategy === "fill-first") { + // Not "keep the active account": the one that just 429'd is cooled, so fill-first takes + // the next eligible account in the stable ring rather than its usual hold. + const stableAll = stableGenericRoster(providerName); + const from = stableAll.indexOf(failedAccountId); + const walk = from >= 0 ? [...stableAll.slice(from + 1), ...stableAll.slice(0, from)] : stableAll; + for (const id of walk) { + if (id !== failedAccountId && candidates.includes(id)) return id; + } + return null; + } // With no quota evidence this returns the ring untouched, so providers without // per-account quota keep exactly the traversal they have today. return rankAccountsByHeadroom(providerName, candidates)[0] ?? null; @@ -259,6 +404,29 @@ export function preferredInitialAccount( const order = selected.accounts.filter(account => account.needsReauth !== true).map(account => account.id); if (order.length < 2) return null; + // A configured strategy answers this question itself. Both guards below exist to protect the + // QUOTA answer, and both are fatal to the other two: hasHeadroomEvidence refuses every + // provider with no quota data, which is exactly where round-robin is the point, and the + // healthy-active return fires before autoSwitchThreshold can ever be read, so fill-first + // would never reach its own test. Cooldowns and reauth are still honoured inside each pick. + const strategy = activeGenericStrategy(config, providerName); + if (strategy === "round-robin") { + const eligibleNow = eligibleFailoverAccounts(providerName, now); + if (eligibleNow.length === 0) return null; + // PEEK, not pick: this proposal is discardable, and advancing the ring for an account the + // resolver then rejects would skip a turn for nothing. noteGenericPoolSelection commits. + const picked = peekRoundRobinAccount( + genericPoolKey(providerName), + eligibleNow, + genericStickyLimit(config, providerName), + ); + return picked && picked !== active ? picked : null; + } + if (strategy === "fill-first") { + const picked = pickFillFirstGenericAccount(config, providerName, active, now); + return picked && picked !== active ? picked : null; + } + const activeRow = selected.accounts.find(account => account.id === active); if (activeRow && activeRow.needsReauth !== true && !isCooled(providerName, activeRow.id, now) diff --git a/src/oauth/pool-settings-capability.ts b/src/oauth/pool-settings-capability.ts index 210870b718..eb98a0d4dc 100644 --- a/src/oauth/pool-settings-capability.ts +++ b/src/oauth/pool-settings-capability.ts @@ -37,24 +37,37 @@ export function parseGenericAutoSwitchThreshold(value: unknown): number | null { return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100 ? value : null; } +export function parseGenericStickyLimit(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100 ? value : null; +} + export interface GenericPoolSettingsDto { provider: string; kind: "generic"; enabled: boolean | null; strategy: GenericPoolStrategy | null; autoSwitchThreshold: number | null; + stickyLimit: number | null; /** - * Slice-1 marker for `strategy` and `autoSwitchThreshold` only: persisted, not yet consumed - * by the selector. + * Marker for `strategy`, `autoSwitchThreshold` and `stickyLimit` only: true while they are + * persisted but not consumed by the selector, false once `pool.kernel` is on and they + * actually choose an account. * * It deliberately does NOT describe `enabled`, which governs the pre-dispatch preference. * Widening it to the whole DTO would tell a dashboard that `enabled` changes nothing, which * has been false since reactive and proactive activation were split. + * + * Computed, never a literal: the flag is reversible, so a DTO that hard-codes either answer + * would be lying in one of the two states. */ - inert: true; + inert: boolean; } -export function genericPoolSettingsDto(name: string, provider: OcxProviderConfig): GenericPoolSettingsDto { +export function genericPoolSettingsDto( + name: string, + provider: OcxProviderConfig, + kernelEnabled = false, +): GenericPoolSettingsDto { const failover = provider.oauthAccountFailover ?? {}; return { provider: name, @@ -62,6 +75,7 @@ export function genericPoolSettingsDto(name: string, provider: OcxProviderConfig enabled: typeof failover.enabled === "boolean" ? failover.enabled : null, strategy: parseGenericPoolStrategy(failover.strategy), autoSwitchThreshold: parseGenericAutoSwitchThreshold(failover.autoSwitchThreshold), - inert: true, + stickyLimit: parseGenericStickyLimit(failover.stickyLimit), + inert: kernelEnabled !== true, }; } diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index e2ba5a2029..6a1a1ae35b 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -331,6 +331,12 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404); const { forgetGenericFailoverRoster } = await import("../../oauth/generic-account-failover"); forgetGenericFailoverRoster(provider); + // Seed the rotation cursor on the operator's pick, or a sticky round-robin ring hands the + // very next dispatch back to whatever the pool had chosen. forgetGenericFailoverRoster + // only drops the presence count; it has never touched the cursor. Same defect the Codex + // side carries resetCodexRoutingForManualSelection for. + const { genericPoolKey, seedPoolRotationAccount } = await import("../../oauth/pool-kernel"); + seedPoolRotationAccount(genericPoolKey(provider), body.accountId); if (provider === "anthropic") { const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing"); resetAnthropicRoutingForManualSelection(body.accountId); @@ -357,7 +363,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") { return jsonResponse({ error: "pool config is only supported for anthropic and generic OAuth providers" }, 400); } - return jsonResponse(genericPoolSettingsDto(provider, prov)); + return jsonResponse(genericPoolSettingsDto(provider, prov, config.pool?.kernel === true)); } const pool = config.anthropicAccountPool ?? {}; return jsonResponse({ @@ -387,13 +393,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (provider !== "anthropic") { const { poolSettingsCapability, genericPoolSettingsDto, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold, + parseGenericStickyLimit, } = await import("../../oauth/pool-settings-capability"); const prov = config.providers[provider]; if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") { return jsonResponse({ error: "pool config is only supported for anthropic and generic OAuth providers" }, 400); } - if (body.stickyLimit !== undefined || body.quotaWindow !== undefined) { - return jsonResponse({ error: "stickyLimit and quotaWindow are not part of the generic pool contract yet" }, 400); + if (body.quotaWindow !== undefined) { + return jsonResponse({ error: "quotaWindow is not part of the generic pool contract yet" }, 400); } const next = { ...(prov.oauthAccountFailover ?? {}) }; if (body.enabled !== undefined) { @@ -416,10 +423,18 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< next.autoSwitchThreshold = parsed; } } + if (body.stickyLimit !== undefined) { + if (body.stickyLimit === null) delete next.stickyLimit; + else { + const parsed = parseGenericStickyLimit(body.stickyLimit); + if (parsed === null) return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400); + next.stickyLimit = parsed; + } + } if (Object.keys(next).length > 0) prov.oauthAccountFailover = next; else delete prov.oauthAccountFailover; saveConfigPreservingClaudeCode(config); - return jsonResponse({ ok: true, ...genericPoolSettingsDto(provider, prov) }); + return jsonResponse({ ok: true, ...genericPoolSettingsDto(provider, prov, config.pool?.kernel === true) }); } let enabled = config.anthropicAccountPool?.enabled === true; if (body.enabled !== undefined) { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index cccd942026..4ca744264f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -145,6 +145,7 @@ import { GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST, isGenericFailoverProvider, isGenericOAuthFailoverEnabled, + noteGenericPoolSelection, preferredInitialAccount, rotateGenericOAuthAccountOn429, } from "../../oauth/generic-account-failover"; @@ -4405,6 +4406,10 @@ async function handleResponsesInner( // whichever account is active by the time the response comes back (#2568). if (isGenericFailoverProvider(route.providerName, route.provider)) { genericFailoverAccountId = resolved.accountId; + // Advance the pool cursor only now that this account is actually admitted. The + // helper returns immediately unless the kernel is on AND the strategy is + // round-robin, so quota and fill-first pools reach it without being touched. + noteGenericPoolSelection(config, route.providerName, resolved.accountId); } // Anthropic is excluded from isGenericFailoverProvider -- its own pool owns affinity and // a fail-closed local-cli credential rule -- so without this stamp its identity is diff --git a/src/types/config.ts b/src/types/config.ts index 499ca59eb1..979310f80c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -831,6 +831,15 @@ export interface OcxConfig { * spends a second credit. A malformed value reads as off. */ resetCreditAutoRedeem?: { enabled?: boolean; leadTimeMinutes?: number }; + /** + * Shared account-pool kernel, opt-in and off by default. + * + * `kernel: true` is what makes a generic OAuth provider's stored `strategy` and + * `autoSwitchThreshold` actually select an account instead of merely being persisted. + * Off restores the pre-kernel path exactly, which is why the DTO keeps reporting + * `inert: true` until this is on. A malformed value reads as off. + */ + pool?: { kernel?: boolean }; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ diff --git a/src/types/provider.ts b/src/types/provider.ts index e65130a4fa..fc56c88e19 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -521,11 +521,21 @@ export interface OcxProviderConfig { enabled?: boolean; /** * Generic OAuth pool selection strategy (#695). Persisted through the pool-settings - * contract; the selector does not consume it yet, so omitted keeps today's behavior. + * contract. Consumed by the selector only while `pool.kernel` is on; with the flag off + * it is still merely persisted, so omitted and set behave the same. */ strategy?: "quota" | "round-robin" | "fill-first"; - /** 0-100 usage percent at which a proactive switch may be considered (#695); inert today. */ + /** + * 0-100 usage percent at which fill-first advances off the active account (#695). + * Read only under `pool.kernel` with `strategy: "fill-first"`; 80 when unset, matching + * the Codex and Anthropic pools. + */ autoSwitchThreshold?: number; + /** + * Successful dispatches retained on one round-robin selection. Default 1; range 1..100. + * Read only under `pool.kernel` with `strategy: "round-robin"`. + */ + stickyLimit?: number; }; /** Allow an explicitly key/oauth provider to run without a credential (for keyless local proxies). */ keyOptional?: boolean; diff --git a/tests/cli/cli-account-pool-verbs.test.ts b/tests/cli/cli-account-pool-verbs.test.ts index 04be2e4fa6..0eacd8e1bf 100644 --- a/tests/cli/cli-account-pool-verbs.test.ts +++ b/tests/cli/cli-account-pool-verbs.test.ts @@ -391,8 +391,7 @@ describe("generic OAuth pool-settings contract (#695)", () => { test("generic missing or malformed capability stays unknown rather than enabled", async () => { for (const json of [null, [], {}, { enabled: "true", autoSwitchThreshold: "90", inert: "false" }, - { enabled: true, autoSwitchThreshold: 90 }, { enabled: true, autoSwitchThreshold: 101, inert: false }, - { enabled: true, autoSwitchThreshold: 90, inert: false }]) { + { enabled: true, autoSwitchThreshold: 90 }, { enabled: true, autoSwitchThreshold: 101, inert: false }]) { const out = capture(); try { expect(await cmdAutoSwitch(["google-antigravity", "status", "--json"], genericDeps(() => ({ json }), []))).toBe(0); @@ -403,6 +402,39 @@ describe("generic OAuth pool-settings contract (#695)", () => { } }); + test("a generic pool that reports inert false is live, not unknown", async () => { + // `inert: false` used to be lumped in with the malformed bodies above, which made the CLI + // render a threshold the kernel is actually applying as "threshold support is unknown" -- + // the opposite of the truth. The three states are distinct: true is stored-but-not-applied, + // false is applied, absent is a server that does not speak the field at all. + const out = capture(); + try { + expect(await cmdAutoSwitch( + ["google-antigravity", "status", "--json"], + genericDeps(() => ({ json: { enabled: true, autoSwitchThreshold: 90, inert: false } }), []), + )).toBe(0); + } finally { out.restore(); } + expect(JSON.parse(out.lines.join("\n"))).toEqual({ + provider: "google-antigravity", autoSwitchThreshold: 90, enabled: true, poolEnabled: true, inert: false, + }); + }); + + test("a live generic pool with no stored threshold reports off, not on", async () => { + // `inert: false` alone is not enablement: the kernel is consuming settings, but there is + // no threshold to consume. Reporting "on" here would invent a value nobody set. + const out = capture(); + try { + expect(await cmdAutoSwitch( + ["google-antigravity", "status", "--json"], + genericDeps(() => ({ json: { enabled: true, inert: false } }), []), + )).toBe(0); + } finally { out.restore(); } + const result = JSON.parse(out.lines.join("\n")); + expect(result.enabled).toBe(false); + expect(result.inert).toBe(false); + expect(result.autoSwitchThreshold).toBeNull(); + }); + test("a successful generic write with a null body reports unknown settings", async () => { const calls: Captured[] = []; const out = capture(); diff --git a/tests/oauth/generic-oauth-failover.test.ts b/tests/oauth/generic-oauth-failover.test.ts index 48fb2782ec..f1b3db303c 100644 --- a/tests/oauth/generic-oauth-failover.test.ts +++ b/tests/oauth/generic-oauth-failover.test.ts @@ -9,6 +9,7 @@ import { hasFailoverAccountQuorum, isGenericFailoverProvider, isGenericOAuthFailoverEnabled, + noteGenericPoolSelection, preferredInitialAccount, rotateGenericOAuthAccountOn429, } from "../../src/oauth/generic-account-failover"; @@ -525,3 +526,121 @@ describe("#2807 a 429 rotation pairs the bearer with its OWN origin", () => { expect(rotated.baseUrl).toBe(CANONICAL); }); }); + +describe("#695 the generic pool consumes its persisted strategy behind pool.kernel", () => { + /** Proactive preference on, plus whichever strategy this case is about. */ + function kernelConfig(strategy?: "quota" | "round-robin" | "fill-first", extra: Record = {}): OcxConfig { + return { + pool: { kernel: true }, + providers: { + xai: { + ...OAUTH_PROVIDER, + oauthAccountFailover: { enabled: true, ...(strategy ? { strategy } : {}), ...extra }, + }, + }, + } as unknown as OcxConfig; + } + + test("round-robin rotates a provider with no quota data at all", async () => { + const ids = await seed(3); + await setActiveAccount("xai", ids[0]!); + // Deliberately NO quota is cached. This is the case the evidence guard refuses outright, + // and it is exactly where round-robin is the point: with nothing measured there is no + // ranking to make, only a turn to take. + const cfg = kernelConfig("round-robin"); + + const served: string[] = []; + for (let i = 0; i < 4; i += 1) { + const preferred = preferredInitialAccount(cfg, "xai"); + const account = preferred ?? getAccountSet("xai")!.activeAccountId!; + served.push(account); + // Admission is what advances the ring; the proposal above only peeks. + noteGenericPoolSelection(cfg, "xai", account); + } + expect(new Set(served).size).toBeGreaterThan(1); + }); + + test("round-robin is a no-op while pool.kernel is off", async () => { + const ids = await seed(3); + await setActiveAccount("xai", ids[0]!); + const off = { + providers: { xai: { ...OAUTH_PROVIDER, oauthAccountFailover: { enabled: true, strategy: "round-robin" } } }, + } as unknown as OcxConfig; + + for (let i = 0; i < 4; i += 1) { + // Same roster, same strategy, flag off: the pre-kernel answer is null every time, + // because no quota was ever measured. Reversibility is the whole point of the flag. + expect(preferredInitialAccount(off, "xai")).toBeNull(); + noteGenericPoolSelection(off, "xai", ids[0]!); + } + }); + + test("fill-first holds the active account under its threshold and advances over it", async () => { + const ids = await seed(3); + const sorted = [...ids].sort((left, right) => left.localeCompare(right)); + const active = sorted[0]!; + await setActiveAccount("xai", active); + const cfg = kernelConfig("fill-first", { autoSwitchThreshold: 80 }); + + setCachedProviderAccountQuotaForTests("xai", active, { weeklyPercent: 40, updatedAt: Date.now() }); + // Under threshold: fill-first is supposed to keep filling this one. + expect(preferredInitialAccount(cfg, "xai")).toBeNull(); + + setCachedProviderAccountQuotaForTests("xai", active, { weeklyPercent: 90, updatedAt: Date.now() }); + // Over threshold: it advances, and to the NEXT account in the sorted roster rather than + // to whichever id the login order happened to put first. + expect(preferredInitialAccount(cfg, "xai")).toBe(sorted[1]!); + }); + + test("fill-first advances through the sorted roster, not the eligible subset", async () => { + const ids = await seed(3); + const sorted = [...ids].sort((left, right) => left.localeCompare(right)); + const active = sorted[0]!; + await setActiveAccount("xai", active); + const cfg = kernelConfig("fill-first", { autoSwitchThreshold: 80 }); + setCachedProviderAccountQuotaForTests("xai", active, { weeklyPercent: 95, updatedAt: Date.now() }); + + // The successor is out of service, so the walk has to step OVER it and land on the third + // account. Walking the eligible subset instead would wrap from a shorter list and pick a + // different account -- the bug the shared kernel carries a stableAll argument to avoid. + await markAccountNeedsReauth("xai", sorted[1]!, true); + expect(preferredInitialAccount(cfg, "xai")).toBe(sorted[2]!); + }); + + test("quota keeps its pre-kernel answer with the flag on", async () => { + const ids = await seed(2); + await setActiveAccount("xai", ids[0]!); + // 100, not 99: the quota path only leaves an active account once it is exhausted or + // cooled. A merely busy account keeps serving, and that is the pre-kernel rule this case + // is here to pin. + setCachedProviderAccountQuotaForTests("xai", ids[0]!, { weeklyPercent: 100, updatedAt: Date.now() }); + setCachedProviderAccountQuotaForTests("xai", ids[1]!, { weeklyPercent: 10, updatedAt: Date.now() }); + + // An explicit "quota" and no strategy at all must answer identically: quota IS the + // pre-kernel path, so the flag must not change it. + expect(preferredInitialAccount(kernelConfig("quota"), "xai")).toBe(ids[1]!); + expect(preferredInitialAccount(kernelConfig(), "xai")).toBe(ids[1]!); + }); + + test("a 429 under round-robin rotates instead of ranking", async () => { + const ids = await seed(3); + await setActiveAccount("xai", ids[0]!); + // No quota evidence anywhere, so the quota path would hand back the ring untouched. + const next = rotateGenericOAuthAccountOn429(kernelConfig("round-robin"), "xai", ids[0]!, null); + expect(next).not.toBeNull(); + expect(next).not.toBe(ids[0]!); + }); + + test("a 429 under fill-first leaves the cooled account rather than holding it", async () => { + const ids = await seed(3); + const sorted = [...ids].sort((left, right) => left.localeCompare(right)); + await setActiveAccount("xai", sorted[0]!); + const cfg = kernelConfig("fill-first", { autoSwitchThreshold: 80 }); + // Well under threshold: the initial-preference rule would keep this account. The 429 path + // must not, because the account it would hold is the one that just failed. + setCachedProviderAccountQuotaForTests("xai", sorted[0]!, { weeklyPercent: 10, updatedAt: Date.now() }); + + const next = rotateGenericOAuthAccountOn429(cfg, "xai", sorted[0]!, null); + expect(next).toBe(sorted[1]!); + }); +}); diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index e8eed3c05d..502062c1a5 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -439,7 +439,13 @@ describe("Anthropic account pool strategy management API", () => { // reading `inert` as covering `enabled` would render a live control as decorative. const source = await Bun.file("src/oauth/pool-settings-capability.ts").text(); const start = source.indexOf("autoSwitchThreshold: number | null;"); - const marker = source.slice(start, source.indexOf("inert: true;", start)); + // Anchored on the CURRENT literal. When this type read `inert: true;` and the field became + // `inert: boolean;`, indexOf returned -1 and slice(start, -1) handed back almost the whole + // file -- which still contains all three words, so every assertion below passed while the + // test had stopped checking anything. Fail closed on a missing anchor instead. + const end = source.indexOf("inert: boolean;", start); + expect(end).toBeGreaterThan(start); + const marker = source.slice(start, end); expect(marker).toContain("strategy"); expect(marker).toContain("autoSwitchThreshold"); expect(marker).toContain("enabled"); @@ -474,7 +480,10 @@ describe("generic OAuth pool-settings contract (#695)", () => { try { const absent = await fetch(new URL("/api/oauth/accounts/pool?provider=google-antigravity", server.url)); expect(absent.status).toBe(200); - expect(await absent.json()).toEqual({ provider: "google-antigravity", kind: "generic", enabled: null, strategy: null, autoSwitchThreshold: null, inert: true }); + expect(await absent.json()).toEqual({ + provider: "google-antigravity", kind: "generic", enabled: null, strategy: null, + autoSwitchThreshold: null, stickyLimit: null, inert: true, + }); const put = await fetch(new URL("/api/oauth/accounts/pool", server.url), { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -488,7 +497,8 @@ describe("generic OAuth pool-settings contract (#695)", () => { for (const body of [ { provider: "google-antigravity", strategy: "weighted" }, { provider: "google-antigravity", autoSwitchThreshold: 101 }, - { provider: "google-antigravity", stickyLimit: 3 }, + { provider: "google-antigravity", stickyLimit: 0 }, + { provider: "google-antigravity", quotaWindow: "weekly" }, { provider: "deepseek", strategy: "quota" }, ]) { const bad = await fetch(new URL("/api/oauth/accounts/pool", server.url), { From 2122732136441689857dd7d5db7fdcee4ce61ca8 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 01:37:37 +0900 Subject: [PATCH 067/231] test(oauth): make the two 429 strategy cases discriminate --- tests/oauth/generic-oauth-failover.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/oauth/generic-oauth-failover.test.ts b/tests/oauth/generic-oauth-failover.test.ts index f1b3db303c..fbf47c776a 100644 --- a/tests/oauth/generic-oauth-failover.test.ts +++ b/tests/oauth/generic-oauth-failover.test.ts @@ -625,10 +625,16 @@ describe("#695 the generic pool consumes its persisted strategy behind pool.kern test("a 429 under round-robin rotates instead of ranking", async () => { const ids = await seed(3); await setActiveAccount("xai", ids[0]!); - // No quota evidence anywhere, so the quota path would hand back the ring untouched. + // Quota evidence pointing SOMEWHERE ELSE is what makes this case mean anything. With no + // evidence the pre-kernel path hands the ring back untouched and lands on the same + // account round-robin would, so the test would pass whether or not the branch exists. + setCachedProviderAccountQuotaForTests("xai", ids[1]!, { weeklyPercent: 80, updatedAt: Date.now() }); + setCachedProviderAccountQuotaForTests("xai", ids[2]!, { weeklyPercent: 5, updatedAt: Date.now() }); const next = rotateGenericOAuthAccountOn429(kernelConfig("round-robin"), "xai", ids[0]!, null); expect(next).not.toBeNull(); expect(next).not.toBe(ids[0]!); + // Round-robin takes its turn. Quota would have chased the roomier third account. + expect(next).toBe(ids[1]!); }); test("a 429 under fill-first leaves the cooled account rather than holding it", async () => { @@ -639,6 +645,10 @@ describe("#695 the generic pool consumes its persisted strategy behind pool.kern // Well under threshold: the initial-preference rule would keep this account. The 429 path // must not, because the account it would hold is the one that just failed. setCachedProviderAccountQuotaForTests("xai", sorted[0]!, { weeklyPercent: 10, updatedAt: Date.now() }); + // The successor is the BUSIER of the two survivors, so quota ranking would skip past it. + // Fill-first still takes it: filling one account before opening the next is the point. + setCachedProviderAccountQuotaForTests("xai", sorted[1]!, { weeklyPercent: 70, updatedAt: Date.now() }); + setCachedProviderAccountQuotaForTests("xai", sorted[2]!, { weeklyPercent: 5, updatedAt: Date.now() }); const next = rotateGenericOAuthAccountOn429(cfg, "xai", sorted[0]!, null); expect(next).toBe(sorted[1]!); From 5567cc863c4feb2e63ba3677ec4ac850d5a2c1b4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 01:40:16 +0900 Subject: [PATCH 068/231] docs: describe the generic pool contract in both flag states The inert contract was published in the English reference and in seven translated locales, all of which said a generic strategy and threshold never steer selection. That is now true only with pool.kernel off, so each page describes both states rather than the old one. --- .../docs/fr/reference/cli/providers-accounts.md | 4 ++-- .../docs/ja/reference/cli/providers-accounts.md | 4 ++-- .../docs/ko/reference/cli/providers-accounts.md | 4 ++-- .../docs/reference/cli/providers-accounts.md | 4 ++-- .../docs/reference/configuration/providers.md | 13 +++++++------ .../docs/ru/reference/cli/providers-accounts.md | 4 ++-- .../docs/tr/reference/cli/providers-accounts.md | 4 ++-- .../docs/zh-cn/reference/cli/providers-accounts.md | 4 ++-- .../docs/zh-tw/reference/cli/providers-accounts.md | 4 ++-- 9 files changed, 23 insertions(+), 22 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index c41c2ee7cd..184923941f 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -208,11 +208,11 @@ renvoient 1 ; une sonde de quota en amont qui échoue ou expire produit plutôt ### `ocx account auto-switch > [--json]` -Contrôle le seuil du pool Codex `openai`, ou enregistre celui d’un pool OAuth générique. `on` enregistre 80 %, `off` 0 % et `threshold ` accepte 0–100. Les seuils génériques sont actuellement inactifs : leur sauvegarde ne change ni le basculement par seuil, ni l’activation du fournisseur, ni la rotation réactive après une erreur 429. Pour les pools génériques, les sorties utilisent la réponse confirmée du serveur. Pour un pool générique, `poolEnabled` est le réglage enregistré (`null` signifie non spécifié), pas l’état effectif hérité. `inert: true` indique que le seuil ne s’applique pas ; une capacité inconnue ne produit jamais `enabled: true`. Les fournisseurs à clé API, Anthropic et les valeurs invalides sont refusés. +Contrôle le seuil du pool Codex `openai`, ou enregistre celui d’un pool OAuth générique. `on` enregistre 80 %, `off` 0 % et `threshold ` accepte 0–100. Un seuil générique n’oriente la sélection que si `pool.kernel` est activé avec `strategy: "fill-first"` ; le drapeau désactivé, sa sauvegarde n’active pas le basculement par seuil. Dans les deux cas, elle ne change ni l’activation du fournisseur, ni la rotation réactive après une erreur 429. Pour les pools génériques, les sorties utilisent la réponse confirmée du serveur. Pour un pool générique, `poolEnabled` est le réglage enregistré (`null` signifie non spécifié), pas l’état effectif hérité. `inert: true` indique un seuil enregistré mais non appliqué, `inert: false` un seuil que le pool applique réellement. L’absence d’`inert` signale une capacité inconnue, qui ne produit jamais `enabled: true`. Les fournisseurs à clé API, Anthropic et les valeurs invalides sont refusés. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index 3557cd5ea7..dc5fd0dccf 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -152,11 +152,11 @@ OAuth プロバイダーと API キー プロバイダーの場合、これに ### `ocx account auto-switch > [--json]` -`openai` Codex プールのしきい値を制御するか、汎用 OAuth プールのしきい値を保存します。`on` は 80%、`off` は 0%、`threshold ` は 0–100 を保存します。汎用プールのしきい値は現在適用されません。保存しても、しきい値による切り替え、プロバイダーの有効化設定、429 エラー時のローテーションは変更されません。汎用プールの照会と変更の結果はサーバーの確認値を使用します。汎用プールの `poolEnabled` は保存された設定で、`null` は未指定です。継承後の実効状態ではありません。`inert: true` は未適用を示し、機能が不明な場合も `enabled: true` とは表示しません。API キープロバイダー、Anthropic、不正な値は拒否されます。 +`openai` Codex プールのしきい値を制御するか、汎用 OAuth プールのしきい値を保存します。`on` は 80%、`off` は 0%、`threshold ` は 0–100 を保存します。汎用プールのしきい値は `pool.kernel` が有効で `strategy: "fill-first"` の場合にのみ選択へ反映されます。フラグが無効なら、保存してもしきい値による切り替えは有効になりません。いずれの場合もプロバイダーの有効化設定と 429 エラー時のローテーションは変更されません。汎用プールの照会と変更の結果はサーバーの確認値を使用します。汎用プールの `poolEnabled` は保存された設定で、`null` は未指定です。継承後の実効状態ではありません。`inert: true` は保存済みで未適用、`inert: false` はプールが適用中であることを示します。`inert` が無い場合は機能が不明であり、その場合も `enabled: true` とは表示しません。API キープロバイダー、Anthropic、不正な値は拒否されます。 ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index c6eaeec910..5f691ae3ee 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -218,11 +218,11 @@ OAuth 및 API 키 제공자에는 제공자의 할당량 보고 엔드포인트 ### `ocx account auto-switch > [--json]` -`openai` Codex 풀의 임계값을 제어하거나 일반 OAuth 풀의 임계값을 저장합니다. `on`은 80%, `off`는 0%, `threshold `은 0–100을 저장합니다. 일반 풀의 임계값은 현재 동작에 적용되지 않습니다. 저장해도 임계값 기반 전환이나 제공자 활성화 설정이 바뀌지 않고, 429 오류에 따른 회전도 비활성화되지 않습니다. 일반 풀의 조회와 변경 결과는 서버가 확인한 값을 사용합니다. 일반 풀의 `poolEnabled`는 저장된 제공자별 설정이며 `null`은 미지정입니다. 전역 설정을 상속한 실제 상태를 뜻하지 않습니다. `inert: true`이면 임계값이 적용되지 않으며, 기능 지원을 알 수 없을 때도 `enabled: true`로 표시하지 않습니다. API 키 제공자, Anthropic 및 잘못된 값은 거부합니다. +`openai` Codex 풀의 임계값을 제어하거나 일반 OAuth 풀의 임계값을 저장합니다. `on`은 80%, `off`는 0%, `threshold `은 0–100을 저장합니다. 일반 풀의 임계값은 `pool.kernel`이 켜져 있고 `strategy: "fill-first"`일 때만 선택에 반영됩니다. 플래그가 꺼져 있으면 저장해도 임계값 기반 전환이 켜지지 않습니다. 어느 쪽이든 제공자 활성화 설정은 바뀌지 않고, 429 오류에 따른 회전도 비활성화되지 않습니다. 일반 풀의 조회와 변경 결과는 서버가 확인한 값을 사용합니다. 일반 풀의 `poolEnabled`는 저장된 제공자별 설정이며 `null`은 미지정입니다. 전역 설정을 상속한 실제 상태를 뜻하지 않습니다. `inert: true`는 임계값이 저장만 되고 적용되지 않는 상태, `inert: false`는 풀이 실제로 적용하고 있는 상태를 뜻합니다. `inert`가 아예 없으면 기능 지원을 알 수 없는 경우이며, 이때도 `enabled: true`로 표시하지 않습니다. API 키 제공자, Anthropic 및 잘못된 값은 거부합니다. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 7ec246a96f..18eab1a47d 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -348,11 +348,11 @@ instead (exit 0), matching the dashboard's quota bars. ### `ocx account auto-switch > [--json]` -Controls the `openai` Codex pool threshold, or stores a threshold for a generic OAuth pool. `on` stores 80%, `off` stores 0%, and `threshold ` accepts 0–100. Generic pool thresholds are currently inert: saving one does not enable threshold-based switching, change the provider enablement override, or disable reactive 429 rotation. `status` and mutation output for generic pools use the confirmed server response. For generic pools, `poolEnabled` is the stored provider override (`null` means unspecified), not inherited effective state; `inert: true` means the threshold is not applied, and unknown capability never reports `enabled: true`. API-key providers, Anthropic and invalid values are rejected. +Controls the `openai` Codex pool threshold, or stores a threshold for a generic OAuth pool. `on` stores 80%, `off` stores 0%, and `threshold ` accepts 0–100. A generic pool threshold steers selection only while `pool.kernel` is on with `strategy: "fill-first"`; with the flag off, saving one does not enable threshold-based switching. It never changes the provider enablement override or disables reactive 429 rotation. `status` and mutation output for generic pools use the confirmed server response. For generic pools, `poolEnabled` is the stored provider override (`null` means unspecified), not inherited effective state; `inert: true` means the threshold is stored but not applied, `inert: false` means the pool is applying it, and an absent `inert` is an unknown capability, which never reports `enabled: true`. API-key providers, Anthropic and invalid values are rejected. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 65e57f19ab..39b5bc7dfa 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -565,8 +565,9 @@ second account. | --- | --- | --- | --- | | `oauthAccountFailover.enabled?` | `boolean` | presence-driven | Global override for the **pre-dispatch account preference** only. `false` stops a healthy request being steered toward the account with more known headroom. It does **not** disable 429 rotation. | | `providers..oauthAccountFailover.enabled?` | `boolean` | inherits | Per-provider override for the same preference; beats the global setting in either direction. `false` declines the preference for this provider even when the global setting is `true`, and `true` opts this provider in even when the global setting is `false`. Reactive 429 rotation is unaffected either way. | -| `providers..oauthAccountFailover.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | — | Declared pool strategy for a generic OAuth provider (#695). Persisted through `ocx account strategy ` or `PUT /api/oauth/accounts/pool`; the generic selector does not act on it yet, so omitted and set behave the same today. | -| `providers..oauthAccountFailover.autoSwitchThreshold?` | `number` | — | Declared 0–100 usage percent for a proactive switch on a generic OAuth provider (#695). Set with `ocx account auto-switch threshold `; inert until the selector consumes it. | +| `providers..oauthAccountFailover.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | — | Pool strategy for a generic OAuth provider (#695). Persisted through `ocx account strategy ` or `PUT /api/oauth/accounts/pool`. The selector acts on it only while `pool.kernel` is on; with the flag off, omitted and set behave the same. `quota` is the pre-kernel behaviour either way. | +| `providers..oauthAccountFailover.autoSwitchThreshold?` | `number` | `80` | 0–100 usage percent at which `fill-first` advances off the active account (#695). Set with `ocx account auto-switch threshold `. Read only under `pool.kernel` with `strategy: "fill-first"`; an account with no measured usage counts as under the threshold. | +| `providers..oauthAccountFailover.stickyLimit?` | `number` | `1` | Successful dispatches retained on one `round-robin` selection, 1–100 (#695). Read only under `pool.kernel` with `strategy: "round-robin"`. | To decline proactive account steering for one provider whose terms you would rather not test, while still recovering from a rate limit: @@ -586,10 +587,10 @@ That setting survives logging in, adding an account, and reauthenticating. Generic OAuth providers (Google Antigravity, xAI, Cursor, Kimi, GitHub Copilot, Nous, and any other OAuth provider outside the Codex and Anthropic pools) also accept `strategy` and `autoSwitchThreshold` on the same key, through `GET`/`PUT /api/oauth/accounts/pool?provider=` -and the `ocx account strategy` / `ocx account auto-switch` verbs. The response carries -`"inert": true` for those two fields only — `enabled` is live and governs the pre-dispatch -preference. `stickyLimit` and -`quotaWindow` are not part of the generic contract. Codex (`/api/codex-auth`) and Anthropic +and the `ocx account strategy` / `ocx account auto-switch` / `ocx account sticky` verbs. The response carries +`"inert"` for those three fields only — `true` while they are stored but not consumed, +`false` once `pool.kernel` is on and they actually select an account — `enabled` is live and governs the pre-dispatch +preference. `quotaWindow` is not part of the generic contract. Codex (`/api/codex-auth`) and Anthropic (`anthropicAccountPool`) keep their own contracts unchanged. Deliberately narrower than `anthropicAccountPool`: no session affinity, no quota-ranked diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 9ee81cb626..e612ee0529 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -192,11 +192,11 @@ quota-bar'ов дашборда. ### `ocx account auto-switch > [--json]` -Управляет порогом пула Codex `openai` или сохраняет порог общего пула OAuth. `on` сохраняет 80 %, `off` — 0 %, а `threshold ` принимает 0–100. Пороги общих пулов пока не применяются: сохранение не включает переключение по порогу, не меняет настройку включения провайдера и не отключает ротацию после ошибки 429. Для общего пула результат чтения и изменения берётся из подтверждённого ответа сервера. Для общего пула `poolEnabled` — сохранённая настройка провайдера (`null` означает отсутствие настройки), а не итоговое унаследованное состояние. `inert: true` означает, что порог не применяется; неизвестная возможность также не даёт `enabled: true`. Провайдеры с ключом API, Anthropic и неверные значения отклоняются. +Управляет порогом пула Codex `openai` или сохраняет порог общего пула OAuth. `on` сохраняет 80 %, `off` — 0 %, а `threshold ` принимает 0–100. Порог общего пула влияет на выбор только при включённом `pool.kernel` и `strategy: "fill-first"`; при выключенном флаге сохранение не включает переключение по порогу. В обоих случаях оно не меняет настройку включения провайдера и не отключает ротацию после ошибки 429. Для общего пула результат чтения и изменения берётся из подтверждённого ответа сервера. Для общего пула `poolEnabled` — сохранённая настройка провайдера (`null` означает отсутствие настройки), а не итоговое унаследованное состояние. `inert: true` означает, что порог сохранён, но не применяется, а `inert: false` — что пул его применяет. Отсутствие `inert` означает неизвестную возможность, которая также не даёт `enabled: true`. Провайдеры с ключом API, Anthropic и неверные значения отклоняются. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index 729596215e..8ab2ca1647 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -234,11 +234,11 @@ eşleşen null veya eski bir rapora düşer (çıkış 0). ### `ocx account auto-switch > [--json]` -`openai` Codex havuzunun eşiğini yönetir veya genel OAuth havuzunun eşiğini kaydeder. `on` %80, `off` %0 kaydeder; `threshold ` 0–100 kabul eder. Genel havuz eşikleri şu anda uygulanmaz: kayıt işlemi eşik tabanlı geçişi, sağlayıcının etkinlik ayarını veya 429 hatasından sonraki otomatik hesap değişimini etkilemez. Genel havuz çıktısı sunucunun doğruladığı değerleri kullanır. Genel havuzlarda `poolEnabled`, kaydedilmiş sağlayıcı ayarıdır (`null` belirtilmemiş demektir); devralınmış etkin durumu göstermez. `inert: true`, eşiğin uygulanmadığını belirtir; yetenek bilinmiyorsa `enabled: true` bildirilmez. API anahtarlı sağlayıcılar, Anthropic ve geçersiz değerler reddedilir. +`openai` Codex havuzunun eşiğini yönetir veya genel OAuth havuzunun eşiğini kaydeder. `on` %80, `off` %0 kaydeder; `threshold ` 0–100 kabul eder. Genel havuz eşiği yalnızca `pool.kernel` açıkken ve `strategy: "fill-first"` seçiliyken seçimi yönlendirir; bayrak kapalıyken kayıt işlemi eşik tabanlı geçişi etkinleştirmez. Her iki durumda da sağlayıcının etkinlik ayarını veya 429 hatasından sonraki otomatik hesap değişimini etkilemez. Genel havuz çıktısı sunucunun doğruladığı değerleri kullanır. Genel havuzlarda `poolEnabled`, kaydedilmiş sağlayıcı ayarıdır (`null` belirtilmemiş demektir); devralınmış etkin durumu göstermez. `inert: true` eşiğin kaydedildiğini ama uygulanmadığını, `inert: false` ise havuzun onu uyguladığını belirtir. `inert` yoksa yetenek bilinmiyordur ve bu durumda da `enabled: true` bildirilmez. API anahtarlı sağlayıcılar, Anthropic ve geçersiz değerler reddedilir. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 6418792429..623c84996b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -173,11 +173,11 @@ token,也不是简单重读账号列表。`--json` 返回 ### `ocx account auto-switch > [--json]` -控制 `openai` Codex 账户池阈值,或保存通用 OAuth 账户池阈值。`on` 保存 80%,`off` 保存 0%,`threshold ` 接受 0–100。通用池的阈值目前不参与运行;保存阈值不会启用阈值切换、改变提供方启用设置或禁用 429 错误后的轮换。通用池的查询和修改结果使用服务器确认值。通用池的 `poolEnabled` 是已保存的提供方设置,`null` 表示未指定,并不代表继承后的实际状态。`inert: true` 表示阈值未应用;能力未知时也不会报告 `enabled: true`。API 密钥提供方、Anthropic 和无效值会被拒绝。 +控制 `openai` Codex 账户池阈值,或保存通用 OAuth 账户池阈值。`on` 保存 80%,`off` 保存 0%,`threshold ` 接受 0–100。通用池的阈值只有在 `pool.kernel` 打开且 `strategy: "fill-first"` 时才参与选择;标志关闭时,保存阈值不会启用阈值切换。两种情况下都不会改变提供方启用设置或禁用 429 错误后的轮换。通用池的查询和修改结果使用服务器确认值。通用池的 `poolEnabled` 是已保存的提供方设置,`null` 表示未指定,并不代表继承后的实际状态。`inert: true` 表示阈值已保存但未应用,`inert: false` 表示账户池正在应用它。没有 `inert` 字段表示能力未知,此时同样不会报告 `enabled: true`。API 密钥提供方、Anthropic 和无效值会被拒绝。 ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index 5677e79852..ad824e24e7 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -132,11 +132,11 @@ Codex 池選擇套用於清除既有親和性後的下一個請求;進行中 ### `ocx account auto-switch > [--json]` -控制 `openai` Codex 帳戶池閾值,或儲存通用 OAuth 帳戶池閾值。`on` 儲存 80%,`off` 儲存 0%,`threshold ` 接受 0–100。通用池的閾值目前不參與執行;儲存閾值不會啟用閾值切換、改變供應商啟用設定或停用 429 錯誤後的輪替。通用池的查詢與修改結果使用伺服器確認值。通用池的 `poolEnabled` 是已儲存的供應商設定,`null` 表示未指定,並不代表繼承後的實際狀態。`inert: true` 表示閾值未套用;能力未知時也不會回報 `enabled: true`。API 金鑰供應商、Anthropic 與無效值會被拒絕。 +控制 `openai` Codex 帳戶池閾值,或儲存通用 OAuth 帳戶池閾值。`on` 儲存 80%,`off` 儲存 0%,`threshold ` 接受 0–100。通用池的閾值只有在 `pool.kernel` 開啟且 `strategy: "fill-first"` 時才參與選擇;旗標關閉時,儲存閾值不會啟用閾值切換。兩種情況下都不會改變供應商啟用設定或停用 429 錯誤後的輪替。通用池的查詢與修改結果使用伺服器確認值。通用池的 `poolEnabled` 是已儲存的供應商設定,`null` 表示未指定,並不代表繼承後的實際狀態。`inert: true` 表示閾值已儲存但未套用,`inert: false` 表示帳戶池正在套用它。沒有 `inert` 欄位表示能力未知,此時同樣不會回報 `enabled: true`。API 金鑰供應商、Anthropic 與無效值會被拒絕。 ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account login|reauth|code|cancel ...` From f9bf31cc010d776de326459d4a050521e9f60f08 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 01:52:37 +0900 Subject: [PATCH 069/231] docs(devlog): plan the API-key pool call-site wiring --- .../040_phase4_key_pool_strategy.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md index f5d42333ff..6765182bf1 100644 --- a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md +++ b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md @@ -64,3 +64,69 @@ both paths; a single-key pool is a no-op. No operator-visible surface. Phase 5 owns the management route and GUI; adding fields there from this layer would collide with it. + +## wp4b wiring plan (re-verified against `codex/generic-pool-kernel`) + +#4277 shipped `selectProactiveApiKey` (`src/providers/key-failover.ts`:128) and deliberately +stopped there: the picker exists, is unit-tested, and is called from nowhere in production. So +does `forgetApiKeyRotationCursor` (:112). This unit connects both, and nothing else. + +| Symbol | File | Line | +|---|---|---| +| `selectProactiveApiKey` | `src/providers/key-failover.ts` | 128 | +| `forgetApiKeyRotationCursor` | `src/providers/key-failover.ts` | 112 | +| OAuth-only branch, skipped by key-auth | `src/server/responses/core.ts` | 4322 | +| transport pin, last `route.provider` write before the first send | `src/server/responses/core.ts` | 4450 | +| `activeProvider` bind | `src/server/chat-native.ts` | 238 | +| `PUT /api/providers/keys/active` | `src/server/management/oauth-account-routes.ts` | 674 | + +### Where the call goes, and why there + +`route.provider` is final for a key-auth request at the transport pin on `core.ts`:4450, and all +four first-send consumers read that same object — the image/video bridge (:6570), web search +(:6653), `runTurn` (:6739) and the generic HTTP path (:7174). One call placed after the OAuth +block and before the pin therefore serves every one of them, with no per-path duplication. That +is the exact position the OAuth side already occupies: "prefer the account with known headroom +BEFORE the first attempt" at :4344. + +`chat-native.ts` is a separate entry path and needs its own call, immediately before +`activeProvider` is bound at :238. + +Nothing competes with it. `resolveProviderTransport` never swaps keys, and +`applyCodexAuthContextToProvider` is a no-op outside `authMode: "forward"`. The one pre-send +`apiKey` rewrite that does exist (`core.ts`:4196) re-reads an already committed selection and +does not run on a current first attempt. + +**No new import edge on the core path.** `core.ts` already imports `hasKeyPoolFailover` from +`../../providers/key-failover` at :269, so the picker joins an existing import — which matters +because `core.ts` is one of the three files that must never reach `src/lab`. + +### Cursor invalidation + +`forgetApiKeyRotationCursor` has no production caller, so the round-robin cursor currently +outlives the pool it describes. It joins `clearKeyCooldowns(name)` at the three management +routes that already reset key state: the manual active-key PUT at :674, and the add/remove key +routes at :641 and :714. An operator who just chose a key should not be second-guessed by a +cursor that predates the choice — the same rule wp1b and wp2b applied to the account pools. + +### Scope boundary + +No change to `selectProactiveApiKey` itself, to the reactive 429/401 rotation, or to the +strategy semantics. The picker already refuses to override a healthy committed key and already +returns null when no strategy is configured, so an install that never set `apiKeyPoolStrategy` +executes one predicate and nothing else. + +### Acceptance + +Criterion c-5 is already met by #4277 for the selection logic; this unit adds the evidence that +it reaches a real dispatch. + +- `tests/server/server-key-failover-e2e.test.ts` is the only suite that drives a real + first-attempt key-auth dispatch with an `apiKeyPool`, so it takes the new case: a two-key pool + whose committed key is cooled, with `apiKeyPoolStrategy` set, must send the FIRST request on + the other key. Red control: without the wiring the first attempt goes out on the cooled key and + earns the 429 the runtime could already predict. +- A second case pins the no-op: with no `apiKeyPoolStrategy`, the committed key is used + unchanged even when cooled, because rotation stays reactive-only for that install. +- A cursor case: a manual key selection through `PUT /api/providers/keys/active` clears the + rotation cursor. From 6b28478ef1df94d2bb481927724c88900e4c3e09 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 01:58:39 +0900 Subject: [PATCH 070/231] feat(clients): add omo as an export and integration client omo-ai@beta is a launcher around @code-yeongyu/senpi, and senpi reads the same models.json contract Pi, Prime and Aside already use. That was verified rather than assumed: the document buildPiClientConfig emits validates true against senpi's own compiled validateModelsConfig, while an audio input modality and a keyed models object both validate false. Because senpi validates compat.sendSessionAffinityHeaders, omo opts into it the way pi does, and the flag is passed on both the build and the contribution path so ocx export and an enabled integration cannot emit different documents. Path resolution mirrors omo's own published precedence -- OMO_CODING_AGENT_DIR, then SENPI_CODING_AGENT_DIR, then PI_CODING_AGENT_DIR, then ~/.omo/agent -- with each variable reporting refusals under its own name. Detection is the agent directory rather than ~/.omo, because the older v4 launcher creates ~/.omo for its binary-runtime without ever creating agent/, and detecting the parent would report a v5 install that is not there. Loopback-only on OMP's and Prime's grounds rather than Pi's and Aside's: senpi's provider block does accept a headers map, but the shared Pi builder emits none, so remote credential wiring is deferred instead of generating a config that 401s. Registration is one change rather than a backend half and a GUI half, because tests/gui/integrations-invariants.test.ts binds the backend client list to three gui/src lists and no ordering of the halves leaves the tree green. Two repairs ride along. The seven translated destination tables in reference/cli/agents.md have been missing their aside row since 2026-08-31; they are restored so omo does not land beside a known hole. And the Gajae Code label becomes gjc across the nine locale catalogs and the docs, following that product's own rename -- the client id, config path, API route and OPENCODEX_GAJAE_API_KEY stay gajae, because the id keys the stored enable record and renaming it would orphan the state of anyone already connected. Design and evidence: devlog/_plan/260912_omo_client_integration/. --- .../260912_omo_client_integration/000_plan.md | 88 ++++++++ .../001_omo_contract.md | 95 ++++++++ .../002_registration_checklist.md | 132 +++++++++++ .../003_brand_mark_provenance.md | 43 ++++ .../010_wp2_backend.md | 205 ++++++++++++++++++ .../020_wp3_gui.md | 91 ++++++++ .../030_wp4_docs.md | 19 ++ .../040_wp5_verification.md | 25 +++ .../050_wp6_gjc_rename.md | 49 +++++ .../060_wp3_rendered_proof.md | 75 +++++++ .../content/docs/fr/guides/integrations.md | 15 +- .../content/docs/fr/reference/cli/agents.md | 10 +- .../src/content/docs/guides/integrations.md | 20 +- .../content/docs/ja/reference/cli/agents.md | 8 +- .../content/docs/ko/reference/cli/agents.md | 8 +- .../src/content/docs/reference/cli/agents.md | 9 +- .../content/docs/reference/configuration.md | 2 +- .../content/docs/ru/reference/cli/agents.md | 10 +- .../content/docs/tr/guides/integrations.md | 19 +- .../content/docs/tr/reference/cli/agents.md | 10 +- .../docs/zh-cn/reference/cli/agents.md | 8 +- .../content/docs/zh-tw/guides/integrations.md | 15 +- .../docs/zh-tw/reference/cli/agents.md | 8 +- gui/public/provider-icons/README.md | 19 ++ gui/public/provider-icons/omo.svg | 42 ++++ gui/src/app-routing.ts | 1 + .../client-config-clients.ts | 6 +- gui/src/components/integration-marks.ts | 1 + gui/src/i18n/de.ts | 7 +- gui/src/i18n/en.ts | 7 +- gui/src/i18n/fr.ts | 7 +- gui/src/i18n/ja.ts | 7 +- gui/src/i18n/ko.ts | 7 +- gui/src/i18n/ru.ts | 7 +- gui/src/i18n/tr.ts | 7 +- gui/src/i18n/zh-TW.ts | 7 +- gui/src/i18n/zh.ts | 7 +- .../integrations/FileIntegrationPage.tsx | 2 + gui/src/pages/integrations/integration-api.ts | 1 + .../pages/integrations/integration-tabs.ts | 2 + .../pages/integrations/overview-clients.ts | 1 + gui/tests/client-config-panel.test.tsx | 5 +- gui/tests/fr-localization.test.ts | 2 + gui/tests/integrations-api.test.ts | 4 +- gui/tests/integrations-overview-rows.test.ts | 2 +- gui/tests/locale-parity.test.ts | 3 + scripts/test-layout/layout.json | 1 + src/cli/dispatch.ts | 2 +- src/cli/help.ts | 2 +- src/cli/registry.ts | 4 +- src/clients/config-export.ts | 96 ++++++++ src/clients/config-export/contracts.ts | 3 +- src/integrations/catalog-refresh.ts | 2 +- src/integrations/registry.ts | 19 ++ src/server/management/config-routes.ts | 2 +- tests/clients/integrations-state.test.ts | 4 +- tests/clients/omo-client.test.ts | 161 ++++++++++++++ .../clients/sync-client-integrations.test.ts | 6 +- .../client-config-export-new-clients.test.ts | 6 +- tests/config/client-config-export.test.ts | 4 +- tests/fixtures/test-layout-expected.json | 1 + tests/gui/integrations-invariants.test.ts | 6 +- 62 files changed, 1334 insertions(+), 103 deletions(-) create mode 100644 devlog/_plan/260912_omo_client_integration/000_plan.md create mode 100644 devlog/_plan/260912_omo_client_integration/001_omo_contract.md create mode 100644 devlog/_plan/260912_omo_client_integration/002_registration_checklist.md create mode 100644 devlog/_plan/260912_omo_client_integration/003_brand_mark_provenance.md create mode 100644 devlog/_plan/260912_omo_client_integration/010_wp2_backend.md create mode 100644 devlog/_plan/260912_omo_client_integration/020_wp3_gui.md create mode 100644 devlog/_plan/260912_omo_client_integration/030_wp4_docs.md create mode 100644 devlog/_plan/260912_omo_client_integration/040_wp5_verification.md create mode 100644 devlog/_plan/260912_omo_client_integration/050_wp6_gjc_rename.md create mode 100644 devlog/_plan/260912_omo_client_integration/060_wp3_rendered_proof.md create mode 100644 gui/public/provider-icons/omo.svg create mode 100644 tests/clients/omo-client.test.ts diff --git a/devlog/_plan/260912_omo_client_integration/000_plan.md b/devlog/_plan/260912_omo_client_integration/000_plan.md new file mode 100644 index 0000000000..1dac5ccb34 --- /dev/null +++ b/devlog/_plan/260912_omo_client_integration/000_plan.md @@ -0,0 +1,88 @@ +# omo as an export and integration client + +## What this unit adds + +`omo-ai@beta` (5.0.0-0.beta.53, bin `omo`, repo `code-yeongyu/oh-my-openagent`) +describes itself as "omo native edition - the senpi-based OMO harness". It keeps +engine state under `~/.omo/agent` and its allowlist of carried-forward state +files names `models.json` — the same custom-provider catalog Pi, Prime and Aside +read. The user asked for its preset to appear on the Integrations page beside +the other thirteen clients, with a real brand mark. + +This unit registers `omo` as the fourteenth export client and the fourteenth +file integration, reusing the Pi builder the way `prime` and `aside` already do +rather than restating the document shape a fourth time. + +## Why the Pi family is the right precedent + +`prime` is documented in `src/clients/config-export.ts` as "the pi coding agent +shipped under a different brand": it derives its config directory and env prefix +from its own `piConfig` block, so `models.json` is the same contract. omo reaches +the same place by a different route — it is a harness around +`@code-yeongyu/senpi`, written by the same author as Pi — so the claim has to be +verified against senpi's parser rather than assumed from the family +resemblance. `001_omo_contract.md` carries that evidence; the builder is only +reused where the bytes are verified to be accepted. + +## Path resolution + +omo publishes its own precedence in `bin/lib/agent-dir.js`: +`OMO_CODING_AGENT_DIR`, then `SENPI_CODING_AGENT_DIR`, then +`PI_CODING_AGENT_DIR`, then `~/.omo/agent`. The third entry is Pi's variable and +is deliberately honored by omo itself, so mirroring the chain is reporting omo's +contract, not inventing a shared one. Relative overrides are refused for the +reason MCode, ZCode, Pi and Prime already refuse them: a background proxy and a +foreground client can have different working directories and would otherwise +disagree about which file is named. + +## Work phases + +| id | phase | contents | +|----|-------|----------| +| wp1 | roadmap | this unit: contract evidence, registration checklist, mark provenance, per-phase docs | +| wp2 | registration | the whole atomic change: path helpers, `EXPORT_CLIENTS.omo`, contribution, `INTEGRATION_CLIENTS.omo`, CLI help and count, catalog-refresh fan-out, every GUI list and record, the mark wiring, nine locales, and every test literal and allowlist that moves with them | +| wp3 | GUI verification | build and serve this worktree's GUI, confirm the row, tab and mark render, and copy-edit the semantics prose against what the page actually shows | +| wp4 | docs | `docs-site` agents reference and integrations guide, English plus translated locales | +| wp5 | verification | typecheck, focused tests, GUI build, and the rendered dashboard proving the row and tab exist | +| wp6 | gjc rename | the Gajae Code label becomes `gjc` on every user-visible surface, with the `gajae` id untouched | + +wp3, wp4 and wp6 each depend on wp2; wp5 depends on wp3 and wp4. + +**Why wp2 is one phase and not two.** The first plan split backend from GUI. +Two audit rounds failed it on the same ground. +`tests/gui/integrations-invariants.test.ts` asserts sorted equality between +`EXPORT_CLIENT_IDS` and five lists, three of which live in `gui/src`; the GUI +test literals and the two translation allowlists hang off the same edit; and CI +runs `cd gui && bun test` unconditionally. There is no ordering of the halves +that leaves the tree green at the boundary, so registration is one change. + +## Scope boundaries + +In scope: registration of one new client id across the surfaces +`002_registration_checklist.md` enumerates, plus its brand mark and its docs +rows. + +Out of scope: any change to how the Pi document is built for the existing +clients; any new remote-bind credential path; any change to the Integrations +page layout or to the journal/rollback machinery; publishing, releasing, or +pushing anything. + +Also out of scope, deliberately: renaming the `gajae` client **id**. wp6 changes +what the user reads, not what the system keys on. The id is the segment in +`/api/client-integrations/gajae` and the key an enable record is filed under, so +renaming it would orphan the stored state of anyone who already connected that +client and leave our ownership record unable to match the block it wrote. The +user asked for the short name and chose the label-only scope. + +## Terminal outcomes + +DONE requires all five criteria in the bound goalplan to hold with fresh proof: +the row and tab render on the running dashboard with a real mark, the exported +document matches the schema omo parses at the path omo resolves, `bun run +typecheck` is clean, every exact-list test passes with omo included plus a new +omo test, and an enable/disable round trip writes and removes only the owned +fragments. + +BLOCKED is the outcome if senpi turns out to reject the Pi document and no +honest mapping exists. NEEDS_HUMAN is the outcome if omo publishes no usable +first-party mark and the user wants something other than the monogram fallback. diff --git a/devlog/_plan/260912_omo_client_integration/001_omo_contract.md b/devlog/_plan/260912_omo_client_integration/001_omo_contract.md new file mode 100644 index 0000000000..9cb482980b --- /dev/null +++ b/devlog/_plan/260912_omo_client_integration/001_omo_contract.md @@ -0,0 +1,95 @@ +# What omo actually reads + +Evidence: `omo-ai@5.0.0-0.beta.53` unpacked at `/tmp/omoprobe2/package`, and +`@code-yeongyu/senpi@2026.9.10-2` unpacked at `/tmp/senpiprobe/package`. Schema +claims below were executed against that tarball's own TypeBox compiler +(`typebox@1.3.18`), not read off documentation. + +## omo parses nothing + +`bin/omo.js` either runs setup or `runLauncher()`, and the launcher brands senpi +and spawns it. The catalog is senpi's. Branded with `configDir: ".omo"` and +`flatLayout: false`, senpi resolves `getModelsPath() = join(getAgentDir(), +"models.json")` (`senpi dist/config.js:506-508`), which with omo's default agent +directory (`bin/lib/agent-dir.js:41-43`) is `~/.omo/agent/models.json`. + +omo's own setup only *inspects* that path — `setup-detect.js:139-143` lists it in +`detectedFilePaths`, and `setup-models.js:19` tells the user to define the +provider `baseUrl` in it. Setup writes `auth.json`, never `models.json`, so +opencodex is not fighting omo's installer for the file. + +## Path precedence + +The launcher resolves, first non-empty trimmed value winning: +`OMO_CODING_AGENT_DIR`, `SENPI_CODING_AGENT_DIR`, `PI_CODING_AGENT_DIR`, else +`~/.omo/agent` (`agent-dir.js:15,33-38`). It then *overwrites* the first two with +the resolved absolute path before spawning senpi, so senpi's own lookup — which +reads the same three names in the same order (`brand.js:126-140`, +`config.js:456-457`) — always finds the launcher's answer and never reaches its +project-local `.omo/agent` walk. + +Home is `env.HOME || env.USERPROFILE || os.homedir()` (`agent-dir.js:27-30`). + +That third variable is Pi's. omo honors it deliberately, so `omoAgentDir` reading +it is reporting omo's contract rather than asserting a shared one. + +## The provider block senpi validates + +`providers` is a keyed object; each provider's `models` is an **array** whose +identity is `id`, not a keyed object — a keyed object fails with `must be array` +(`model-config-schema.js:201-220`). That is Pi's shape, not OpenCode's. + +Accepted provider keys include `baseUrl`, `apiKey`, `api`, `headers` +(`Record`), `compat`, `models`, and `modelOverrides`. `api` is a +bare string at schema time rather than an enum; an unknown value loads and then +fails at stream time (`provider-composer.js:274-277`). `openai-completions` is a +known api (`types.d.ts:25`) and validates. + +`thinkingLevelMap` accepts exactly the keys the Pi builder emits — `off`, +`minimal`, `low`, `medium`, `high`, `xhigh`, `max` — each `string | null` +(`model-config-schema.js:65-72`), so emitting `max: "ultra"` as a *value* is +fine. + +`input` accepts `text`, `image`, `video` (`model-config-schema.js:170`). `audio` +is rejected — and rejection is not local: a schema failure empties the whole +`models.json` snapshot (`model-config.js:483-487`), so one bad row takes every +custom provider down. This is Pi's failure mode exactly, and it is why +`buildPiClientConfig` drops an audio-only row instead of claiming `text` for it. + +`cost` is optional, but a *partial* `cost` is a schema failure: all four rates +are required (`model-config-schema.js:141-155`). The Pi builder omits `cost` +entirely, which is the safe side of that line. + +## Verdict + +**The bytes `buildPiClientConfig` emits are accepted verbatim.** Nothing needs +renaming, adding, or removing. That was confirmed by running the document +through senpi's compiled validator, not inferred from the family resemblance. + +`compat.sendSessionAffinityHeaders` also validates +(`model-config-schema.js:111,210`), so omo takes the builder's +`sendSessionAffinityHeaders` flag as `true`, the way `pi` does and `prime` and +`aside` do not. + +## Why omo is still loopback-only + +senpi's provider block *does* accept `headers`, and it interpolates `$ENV` and +`${ENV}` in values (`provider-api-key-auth.js:107-117`), so unlike Aside there is +somewhere an `x-opencodex-api-key` could live. What does not exist is a builder +that emits it: `buildPiClientConfig` writes no `headers` at all +(`src/clients/config-export.ts:854-866`), which is exactly why `pi` itself is +loopback-only. + +So `loopbackOnly: true` for omo is OMP's and Prime's stance rather than Aside's: +the field exists, the remote credential wiring is deferred, and a non-loopback +bind refuses instead of generating a config that 401s. Adding `headers` to the +shared Pi builder would change four clients at once and is out of this unit's +scope. + +## Left unverified + +- Whether the dummy `apiKey` is copied into an `Authorization` header at stream + time (would need `pi-ai/dist/api/openai-completions.js`). Irrelevant for a + loopback bind, which admits without a key. +- `oauth: "radius"` appears in senpi's `docs/models.md:139` but not in the live + schema. Not used here. diff --git a/devlog/_plan/260912_omo_client_integration/002_registration_checklist.md b/devlog/_plan/260912_omo_client_integration/002_registration_checklist.md new file mode 100644 index 0000000000..dcf057d8b8 --- /dev/null +++ b/devlog/_plan/260912_omo_client_integration/002_registration_checklist.md @@ -0,0 +1,132 @@ +# Every surface a new export client must reach, as of 2026-09-12 + +The Aside unit wrote this list on 2026-08-31 +(`devlog/_fin/260831_aside_client_and_integrations_ux/002_registration_checklist.md`). +Two things have moved since: `tests/` was reorganised into domain directories, +and `raycast` landed as the thirteenth client, with its own app-side status block. +The table below is re-derived against the current tree by word-boundary search +for `raycast`, the freshest template. + +## Backend + +| file | what omo needs | how failure shows | +|---|---|---| +| `src/clients/config-export/contracts.ts` | `"omo"` in `ExportClientId` | typecheck, everywhere | +| `src/clients/config-export.ts` | `omoAgentDir`, `omoConfigPath`, `buildOmoContribution`, `EXPORT_CLIENTS.omo` | typecheck | +| `src/integrations/registry.ts` | `INTEGRATION_CLIENTS.omo` | typecheck | +| `src/cli/registry.ts` | the `export` usage union and the prose summary | no exact-list gate; `tests/cli/cli-help.test.ts` checks only a prefix of the union | +| `src/cli/help.ts:84` | the `(13 clients)` literal | `tests/cli/cli-help.test.ts:79` | + +The count literal lives in `help.ts`, not `registry.ts`, hand-written on purpose +so `ocx --help` does not import the export registry. +`tests/cli/cli-help.test.ts:79` asserts it in lockstep with +`EXPORT_CLIENT_IDS.length`, so it is a focused-test obligation rather than a +cosmetic edit. + +`/api/client-config` is served from `src/server/management/model-routes.ts:516`, +not `config-routes.ts` as the 2026-08-31 doc says; it reads the registry and +needs no per-client edit. `ExportClientId` likewise moved to +`src/clients/config-export/contracts.ts:84`. + +`src/integrations/ownership-policy.ts`, `state.ts` and `writer.ts` need nothing: +the first is a `zcode`-only exception, and the other two read the registry. +`bun run skill:surface` is not implicated — a new `--client` value creates no +capability. + +## GUI + +| file | what omo needs | how failure shows | +|---|---|---| +| `gui/src/components/apikeys-workspace/client-config-clients.ts` | `CLIENTS`, `CLIENT_LABEL_KEYS`, `CLIENT_MARKS`, possibly `MONOCHROME_CLIENT_MARKS` | invariant test + typecheck | +| `gui/src/components/integration-marks.ts` | `INTEGRATION_MARKS.omo` | typecheck (exhaustive record) | +| `gui/src/pages/integrations/integration-api.ts` | `INTEGRATION_CLIENT_IDS` | invariant test | +| `gui/src/pages/integrations/integration-tabs.ts` | `TABS` and `FILE_CLIENTS` | **silent** — only `gui/tests/integrations-tab-coverage.test.ts` | +| `gui/src/pages/integrations/overview-clients.ts` | `FILE_LABEL_KEY` | typecheck | +| `gui/src/pages/integrations/FileIntegrationPage.tsx` | `SEMANTICS_KEY`, `TAB_LABEL_KEY`, `FILE_INTEGRATION_CLIENTS` | typecheck + invariant test | +| `gui/src/app-routing.ts` | `integrations/omo` hash | **silent** | +| `gui/src/i18n/{en,de,fr,ja,ko,ru,tr,zh,zh-TW}.ts` | three keys each | `locale-parity` | + +## Docs + +`docs-site/src/content/docs/reference/cli/agents.md` plus `fr`, `ja`, `ko`, `ru`, +`tr`, `zh-cn`, `zh-tw`; `docs-site/src/content/docs/guides/integrations.md` plus +`fr`, `tr`, `zh-tw`. + +## Tests that fail until updated + +`tests/config/client-config-export.test.ts`, +`tests/config/client-config-export-new-clients.test.ts`, +`tests/gui/integrations-invariants.test.ts`, +`tests/clients/integrations-state.test.ts`, +`tests/clients/sync-client-integrations.test.ts`, +`tests/clients/integrations-merge.test.ts`, +`tests/cli/cli-export-command.test.ts`, +`tests/cli/cli-headless-parity.test.ts`, +`tests/server/management-integration-routes.test.ts`, +`tests/server/management-client-config-route.test.ts`, +`gui/tests/{client-config-panel.test.tsx,integrations-api.test.ts,integrations-overview-rows.test.ts,integration-marks.test.ts,client-marks-assets.test.ts,locale-parity.test.ts,fr-localization.test.ts,integrations-tab-coverage.test.ts}`. + +Of these, the ones verified to hardcode a list or a count are: +`tests/config/client-config-export.test.ts:812` (ordered thirteen ids), +`tests/config/client-config-export-new-clients.test.ts:68` and +`tests/clients/integrations-state.test.ts:799` (loopback-only set), +`tests/gui/integrations-invariants.test.ts:94` (`toHaveLength(13)`, plus the +typechecked `SEED` record), `tests/cli/cli-help.test.ts:79` (the client count), +`gui/tests/integrations-api.test.ts:20` and +`gui/tests/client-config-panel.test.tsx:174` (GUI literals), and +`gui/tests/integrations-overview-rows.test.ts:293` (row count 18 to 19 — the +overview carries five non-file rows on top of the clients). + +`gui/tests/integrations-tab-coverage.test.ts` still exists and still reads +`TABS`, `FILE_CLIENTS` and the routable hashes, so the 2026-08-31 silent hole is +covered — conditional on `FILE_INTEGRATION_CLIENTS` being updated, since that is +what the coverage test compares against. + +## New files + +`tests/clients/omo-client.test.ts` needs an entry in both +`scripts/test-layout/layout.json` `explicit` and +`tests/fixtures/test-layout-expected.json`, or `tests/test-layout-tooling.test.ts` +names the missing one. + +The explicit entries are not optional here: the `clients` domain regex does not +match an `omo-` prefix, so the seed cannot place the file. Model the test on +`tests/clients/prime-client.test.ts` rather than the Aside one — Aside's test +lives under `providers` because of that same regex, which would be the wrong +precedent to copy. + +## Deliberately not copied from the neighbours + +Aside's profile machinery (`aside-profile-*`, the per-profile journal routes) and +Raycast's app-side install block and live-server export branch are client-specific +surfaces, not part of registration. omo has neither. + +## One list that is a judgement, not a checklist item + +There is not one owned-catalog fan-out list, there are four, and they disagree: + +| call site | list | what triggers it | +|---|---|---| +| `src/integrations/catalog-refresh.ts` default, used by `model-routes.ts` | `pi, aside, raycast` | model visibility changed from the dashboard | +| `src/server/management/config-routes.ts:240` | `mcode, pi, aside, raycast` | the `/api/sync` route | +| `src/cli/dispatch.ts:455` | `mcode, pi, raycast` | `ocx sync` (Aside follows immediately through its own server owner) | +| `src/cli/index.ts` | `raycast` | proxy start, via a helper literally named `refreshOwnedRaycastCatalog` | + +`prime` is in none of them. Updating one and not the others is the failure +mode, so the decision is taken here rather than in passing. + +**Decision: omo joins the three general fan-outs — the `catalog-refresh` +default, `config-routes.ts`, and `dispatch.ts` — and not the fourth.** The +refresh only touches clients that are *already connected* +(`catalog-refresh.ts:10`), so this costs a user who never enables omo exactly +nothing, and it is what stops a connected omo catalog going stale the moment the +user changes model visibility. The fourth is not a general list at all: it is a +Raycast-specific startup helper, and adding an unrelated client to it would +write a file on every `ocx start` for no reason. + +`tests/clients/sync-client-integrations.test.ts:68` pins the `config-routes.ts` +list as source text and moves with it. + +That `prime` is in no list looks like an oversight from when it landed. Fixing +it is not this unit's business; it is recorded here so the next person does not +read prime's absence as a deliberate pattern to copy. diff --git a/devlog/_plan/260912_omo_client_integration/003_brand_mark_provenance.md b/devlog/_plan/260912_omo_client_integration/003_brand_mark_provenance.md new file mode 100644 index 0000000000..02cf7a9cfa --- /dev/null +++ b/devlog/_plan/260912_omo_client_integration/003_brand_mark_provenance.md @@ -0,0 +1,43 @@ +# The omo mark + +## Source + +`gui/public/provider-icons/omo.svg` is `https://omo.dev/brand/omo-mark.svg` +unmodified: 4021 bytes, MD5 `c33f72d7c4612c290834ba860f644557`, +`viewBox="0 0 1024 1024"`. The identical file is committed as +`.github/assets/omo-icon-light.svg` in `code-yeongyu/oh-my-openagent` and +rendered as that README's logo, so the same artwork is both the site header mark +and the repository logo. First-party either way. + +**Correction.** The first draft of this doc cited the GitHub raw path on `main`. +That URL 404s: the repository's default branch is `dev`. The asset is real and +the bytes match — verified by downloading both and comparing MD5 — but the +branch in the citation was wrong, which is the kind of unreproducible provenance +the README exists to prevent. Both citations now name a URL that resolves. + +The project's SUL-1.0 licence says trademark use is "subject to applicable law" +and imposes no distribution ban of the kind that disqualified an earlier +candidate elsewhere in this directory. + +## Rejected candidates + +- `https://omo.dev/icon.svg` — a single `O` glyph. The client-mark + asset test refuses ``, the same rule that sent Hermes to a trace. +- `omo-logo.png` — a superseded 3D rock illustration, and a raster. +- `omo.png` — a landscape screenshot, not a mark. +- The npm tarball carries no `.svg`, `.png`, or `.ico` at all. + +## Which maps it joins + +`CLIENT_MARKS.omo` — yes. + +`MONOCHROME_CLIENT_MARKS` — no. The artwork is two inks: an `#F4F4F4` rounded +plate with an `#041617` face on it. Masking a plated mark discards the plate and +the face together and renders a filled square at 20px, which is the failure the +monochrome set exists to avoid rather than an instance of it. + +Provenance is recorded alongside the file in +`gui/public/provider-icons/README.md`, in both the per-file source list and the +masked/not-masked ledger. That was done in wp1 rather than wp3: the asset landed +in this cycle, and an uncited file in that directory is exactly what the README +exists to prevent. diff --git a/devlog/_plan/260912_omo_client_integration/010_wp2_backend.md b/devlog/_plan/260912_omo_client_integration/010_wp2_backend.md new file mode 100644 index 0000000000..3fc5ccf671 --- /dev/null +++ b/devlog/_plan/260912_omo_client_integration/010_wp2_backend.md @@ -0,0 +1,205 @@ +# wp2 — registration, as one atomic change + +## Why this phase is not backend-only + +The first draft split backend from GUI. An audit round failed it, correctly: +`tests/gui/integrations-invariants.test.ts` asserts sorted equality between +`EXPORT_CLIENT_IDS` and five GUI lists, so the moment the backend knows about a +fourteenth client and the GUI does not, that test is red — and leaving it at +thirteen is red against the backend instead. There is no ordering of the two +halves that keeps the tree green. + +A further binding runs the same way, though not where it first looks. +`CLIENT_LABEL_KEYS` is an `as const` map, not an annotated +`Record<…, TKey>`; the type pressure comes from its use sites — +`t(CLIENT_LABEL_KEYS[client])` and the exhaustive +`Record` maps — where `TKey` is +`keyof typeof en` and every other catalog is a `Record`. So a new +label key has to exist in `en` to compile and in all nine catalogs to keep +parity. i18n is part of the same atomic change rather than a follow-up. + +So wp2 is the whole registration: backend, GUI source lists, marks wiring, the +nine locale catalogs, **and every test literal and allowlist that moves with +them** — including `gui/tests/client-config-panel.test.tsx`, +`gui/tests/integrations-api.test.ts`, the row count in +`gui/tests/integrations-overview-rows.test.ts`, `ZH_TW_KEEP_ENGLISH` in +`gui/tests/locale-parity.test.ts` and `INTENTIONAL_ENGLISH` in +`gui/tests/fr-localization.test.ts`. A second audit round caught the earlier +version leaving those in wp3: CI runs `cd gui && bun test` unconditionally, so a +finished wp2 would have been red on five GUI suites. + +The `integrations.semantics.omo` string is therefore written in wp2 too, in all +nine locales — a placeholder would fail locale parity just as an absent key +would. wp3 copy-edits it against the rendered page rather than creating it. + +**wp2's closing condition** is that all three are green together: `bun run +typecheck`, the focused root tests, and `cd gui && bun test`. + +## Files and what each gains + +`src/clients/config-export/contracts.ts` +: `"omo"` in the `ExportClientId` union. Every exhaustive `Record` + in the tree becomes a typecheck error until it is filled, which is the point. + +`src/clients/config-export.ts` +: `omoAgentDir` and `omoConfigPath` helpers implementing omo's published + precedence (`OMO_CODING_AGENT_DIR`, `SENPI_CODING_AGENT_DIR`, + `PI_CODING_AGENT_DIR`, `~/.omo/agent`), a `buildOmoContribution` that stamps + omo's ownership on the shared Pi fragment, and the `EXPORT_CLIENTS.omo` spec + with all nine fields. `filename` is `omo-models.json` rather than a bare + `models.json`, for the Downloads-folder collision reason `prime-models.json` + and `aside-models.json` already record. + + Each of the three variables is resolved separately and reports `ClientPathError` + under **its own name**, so a user who set `OMO_CODING_AGENT_DIR` is not told + that `PI_CODING_AGENT_DIR` is wrong. An empty or whitespace value falls through + to the next name, which is what `agent-dir.js` does. + + One consequence is worth stating rather than discovering: a user who has set + `PI_CODING_AGENT_DIR` and neither of the other two now has Pi and omo + resolving the **same** `models.json`. Both write the same `providers.opencodex` + block through the same builder, so the bytes agree; what does not agree is + ownership, since two enable records would claim one file. That is omo's own + contract — it reads Pi's variable by design — and the honest response is to + document it, not to silently diverge from the client we are configuring. + + One divergence is deliberate and worth naming: omo `resolve()`s its override + against the process cwd and does not expand `~`. We refuse a relative override + and do expand `~`, exactly as Pi, Prime, MCode and ZCode already do, because a + background proxy and a foreground client have different working directories. + + `buildOmoContribution` calls `buildPiClientConfig(ctx, true)` — with the flag, + not the default. Prime and Aside pass the default in their contribution while + their `build` also passes the default, so they are consistent; splitting the + flag across `build` and `buildContribution` would make `ocx export` emit + `compat` while enable and refresh wrote a file without it. + + **Slot: last, after `raycast`,** in both the union and the `EXPORT_CLIENTS` + object. `EXPORT_CLIENT_IDS` is `Object.keys(EXPORT_CLIENTS)`, so the object's + insertion order is the public order that three ordered assertions compare + against. The existing order is append-only landing order — `pi` is second and + `prime` eleventh — not a family grouping, so appending is the edit that leaves + the other thirteen positions untouched. + +`src/integrations/registry.ts` +: `INTEGRATION_CLIENTS.omo` with `configPath` and `detectDir`. JSON, so no + `sourcePreservingYaml`; single-writer, so no `writerLock`; paths are a pure + function of env and home, so no `resolvePaths` and no `unresolvedPathHint`. + This matches `pi` and `prime` exactly. + +`src/cli/registry.ts` +: the `export` entry's static usage string. Acceptance comes from + `EXPORT_CLIENT_IDS` through `isExportClientId`, so this is help text only — + but `tests/cli/cli-headless-parity.test.ts` reads it. + +`gui/src/…` and `gui/src/i18n/*` +: the five lists the invariant compares, the two lists only the tab-coverage + test compares, the three exhaustive records the compiler forces, the mark + entry, the routing hash, and three keys in each of nine locales. `020` + enumerates them; they land here because of the binding above, not because wp3 + was abandoned. + +`src/cli/help.ts` +: the `(13 clients)` literal on line 84. Hand-written so `ocx --help` does not + import the export registry, and asserted in lockstep with + `EXPORT_CLIENT_IDS.length` by `tests/cli/cli-help.test.ts:79`. + +`src/integrations/catalog-refresh.ts`, `src/server/management/config-routes.ts`, +`src/cli/dispatch.ts` +: omo added to the three general owned-catalog fan-out lists, per the decision in + `002`. Not `src/cli/index.ts`, which is a Raycast-specific startup helper. + `tests/clients/sync-client-integrations.test.ts:68` pins one of those lists as + source text. + +## The detect directory question + +`detectDir` is the cheap "is this client installed at all" signal. For omo the +honest directory is the agent directory itself rather than `~/.omo`: `~/.omo` +exists on this machine carrying only `binary-runtime`, written by the v4 launcher +wrapper, while `~/.omo/agent` does not exist yet. Detecting on `~/.omo` would +report a v5 install that is not there. `agent-dir.js` creates the agent directory +on first launch, so its presence is the fact we want. + +## Loopback-only + +`loopbackOnly: true`, on OMP's and Prime's grounds rather than Pi's and Aside's. + +The flag is a policy bit, not a schema observation: `isLoopbackOnly` is read by +the writer, which refuses apply and refresh when the proxy is bound +non-loopback. It does not block `ocx export` or `/api/client-config` for a +Pi-family client. + +senpi's provider block *does* accept a `headers` map, so unlike Aside there is a +place an `x-opencodex-api-key` could go. What does not exist is a builder that +emits one — `buildPiClientConfig` writes no headers at all, which is why `pi` +is loopback-only too. So the honest wording is deferred remote wiring, and +`010` must not repeat Pi's "no header field" line, which `001` disproves. + +## Session affinity + +`buildPiClientConfig` takes a `sendSessionAffinityHeaders` flag. `pi` passes +`true`; `prime` and `aside` leave it false. omo's value follows the same +evidence rule: true only if senpi is verified to read `compat.sendSessionAffinityHeaders`. + +## Tests + +New: `tests/clients/omo-client.test.ts`, modeled on +`tests/clients/raycast-client.test.ts` and the prime test before it — path +precedence including the two inherited variables, relative-override refusal, +emitted document shape, and the ownership stamp on the contribution. + +One assertion must **not** be copied from Prime. +`tests/clients/prime-client.test.ts` asserts Prime's document carries no +`compat` block. omo's asserts the opposite, the way the Pi case in +`tests/config/client-config-export.test.ts` does, and it asserts it on both the +`build` output and the contribution so the two cannot drift apart. + +Updated because they assert exact lists: +`tests/config/client-config-export.test.ts` (ordered `EXPORT_CLIENT_IDS`), +`tests/config/client-config-export-new-clients.test.ts` (loopback-only set), +`tests/gui/integrations-invariants.test.ts` (client count and the +`Record` seed, which forces an omo fixture in omo's +own JSON shape), `tests/clients/integrations-state.test.ts` (loopback-only set), +and the layout guards `scripts/test-layout/layout.json` plus +`tests/fixtures/test-layout-expected.json` for the new file. + +## Focused test set for this phase + +`tests/clients/omo-client.test.ts`, `tests/config/client-config-export.test.ts`, +`tests/config/client-config-export-new-clients.test.ts`, +`tests/clients/integrations-state.test.ts`, +`tests/clients/sync-client-integrations.test.ts`, +`tests/cli/cli-help.test.ts`, `tests/gui/integrations-invariants.test.ts`, +and the two layout guards. + +## Execution notes fixed at P + +**`omoAgentDir` semantics.** Three variables, in omo's order, each resolved the +way `piAgentDir` resolves its one: `env.NAME?.trim()`, and a falsy result falls +through to the next name. That reproduces `agent-dir.js`, which trims and then +tests truthiness, so a variable set to the empty string or to whitespace is +skipped rather than treated as a path. Each variable reports `ClientPathError` +under **its own name** when it holds a relative path, because telling a user +`PI_CODING_AGENT_DIR must be an absolute path` when they set +`OMO_CODING_AGENT_DIR` is worse than no message. + +**The order matters and it is not cosmetic.** omo resolves its own variable +first, so an opencodex that checked `PI_CODING_AGENT_DIR` first would write to a +Pi directory omo will never read whenever a user has both set. + +**`sendSessionAffinityHeaders: true`.** The flag is not decoration: the +generated provider tells the client to supply a stable session identity, from +which opencodex derives canonical OpenCode Go affinity +(`docs-site/.../guides/pi.md`). senpi validates the same `compat` key +(`001`), so omo gets the same benefit pi does. `prime` and `aside` are left +false because nobody verified their engines read it — that is an absence of +evidence, not a decision to copy. + +**Slot.** `omo` goes last in `ExportClientId` and in `EXPORT_CLIENTS`, after +`raycast`. The ordered assertion in `tests/config/client-config-export.test.ts` +reads `Object.keys` order, and appending is the only edit that leaves the other +thirteen positions untouched. + +**Layout.** The `clients` domain regex seeds only `^aside-profile(?!s-routes)` +and `^(?:desktop|omp|pi|prime|remote|sync)-`, so `omo-client.test.ts` cannot be +placed by pattern and needs the explicit entry in both tables. diff --git a/devlog/_plan/260912_omo_client_integration/020_wp3_gui.md b/devlog/_plan/260912_omo_client_integration/020_wp3_gui.md new file mode 100644 index 0000000000..74706af780 --- /dev/null +++ b/devlog/_plan/260912_omo_client_integration/020_wp3_gui.md @@ -0,0 +1,91 @@ +# wp3 — the Integrations page + +**Scope note after two audit rounds.** Everything above the line lands in wp2, +not here. `tests/gui/integrations-invariants.test.ts` binds the GUI lists to the +backend list; the locale keys are reached through `TKey` (`keyof typeof en`) at +their use sites and every other catalog is a `Record`, so a new key +must exist in all nine locales to compile; and the GUI test literals and the two +translation allowlists fail the moment the source lists move. This document is +the map of those surfaces; wp2 executes all of it, including the test edits. + +wp3 keeps what genuinely cannot be done before the code exists: looking at it. + +The page the user named, `#integrations/`, reads five lists that are compared +against `EXPORT_CLIENT_IDS` by `tests/gui/integrations-invariants.test.ts`, three +exhaustive records the compiler forces, and two lists that are neither. + +## Compared against the backend list + +- `FILE_INTEGRATION_CLIENTS` in `gui/src/pages/integrations/integration-api.ts` + (the GUI's own list; `INTEGRATION_CLIENT_IDS` is the backend's, in + `src/integrations/registry.ts`) +- `CLIENTS` in `gui/src/components/apikeys-workspace/client-config-clients.ts` +- the keys of `CLIENT_LABEL_KEYS` in the same file +- `FILE_INTEGRATION_CLIENTS` +- the hashes in `INTEGRATION_TAB_HASHES` + +## Forced by typecheck + +- `FILE_LABEL_KEY` in `gui/src/pages/integrations/overview-clients.ts` +- `SEMANTICS_KEY` and `TAB_LABEL_KEY` in `FileIntegrationPage.tsx` +- `INTEGRATION_MARKS` in `gui/src/components/integration-marks.ts`, which is an + exhaustive `Record` — so a new client cannot + reach the page without an explicit asset decision + +## The silent hazards + +`TABS` and `FILE_CLIENTS` in `gui/src/pages/integrations/integration-tabs.ts` are +neither exhaustive records nor covered by the invariant test. Omitting omo from +either leaves typecheck and the invariants green while the tab simply does not +render — exactly the failure the user would see and we would not. +`gui/tests/integrations-tab-coverage.test.ts` stands in that gap and must be +confirmed still to do so. + +`gui/src/app-routing.ts` is not a third. `tests/gui/integrations-invariants.test.ts` +already requires a routable hash per `EXPORT_CLIENT_IDS` entry, and the tab +coverage test re-checks that every tab hash is routable. It still has to be +edited; it just fails loudly rather than silently. + +## Mark + +`CLIENT_MARKS.omo` points at a first-party asset in +`gui/public/provider-icons/`, with provenance recorded in that directory's +`README.md` and in `003_brand_mark_provenance.md`. Membership in +`MONOCHROME_CLIENT_MARKS` depends on the artwork having a single neutral ink: +a single-ink mark is drawn as a themed mask so it does not vanish against one of +the two themes, and a multi-ink or brand-colored mark stays an image so masking +cannot flatten a palette or repaint a trademark. + +If omo publishes nothing usable, the honest outcome is `null` and the monogram +tile — `integration-marks.test.ts` currently pins that no client is in that +state, so the pin changes rather than a lookalike logo being invented. + +## i18n + +Three keys across nine locales (`en`, `de`, `fr`, `ja`, `ko`, `ru`, `tr`, `zh`, +`zh-TW`): `integrations.tab.omo`, `integrations.semantics.omo`, +`api.clientConfig.clientOmo`. + +"omo" is a product name and stays untranslated, which means adding the tab and +client keys to `ZH_TW_KEEP_ENGLISH` in `gui/tests/locale-parity.test.ts` and +`INTENTIONAL_ENGLISH` in `gui/tests/fr-localization.test.ts`. The semantics +string is prose and is translated in all nine. + +## GUI tests to update + +`gui/tests/client-config-panel.test.tsx`, `gui/tests/integrations-api.test.ts`, +`gui/tests/integrations-overview-rows.test.ts`, `gui/tests/integration-marks.test.ts`, +`gui/tests/client-marks-assets.test.ts`, `gui/tests/locale-parity.test.ts`, +`gui/tests/fr-localization.test.ts`, `gui/tests/integrations-tab-coverage.test.ts`. + +--- + +## What wp3 still owns + +- Building this worktree's GUI and serving it, rather than reading whatever build + the long-running proxy already has. +- Confirming the row, the tab and the mark actually render — the mark as the omo + face rather than a filled plate, at the size the row draws it. +- Copy-editing `integrations.semantics.omo` against what the page shows. wp2 + writes the string; wp3 is where it gets read in place and fixed if it reads + badly beside its thirteen neighbours. diff --git a/devlog/_plan/260912_omo_client_integration/030_wp4_docs.md b/devlog/_plan/260912_omo_client_integration/030_wp4_docs.md new file mode 100644 index 0000000000..48b4b0a3ae --- /dev/null +++ b/devlog/_plan/260912_omo_client_integration/030_wp4_docs.md @@ -0,0 +1,19 @@ +# wp4 — documentation + +Two pages carry the client list, and both have translated copies that commit +`42adf4996` established are synchronized rather than allowed to drift. + +`docs-site/src/content/docs/reference/cli/agents.md` +: the `--client` union, the flag table, and the destination table. Translated + copies exist under `fr`, `ja`, `ko`, `ru`, `tr`, `zh-cn`, `zh-tw`. + +`docs-site/src/content/docs/guides/integrations.md` +: the client table. Translated copies exist under `fr`, `tr`, `zh-tw`. + +The omo row names the destination `~/.omo/agent/models.json`, the download +filename `omo-models.json`, and the loopback-only stance with its reason, in the +same voice the neighbouring rows use. + +The Aside unit found the integrations guide missing a `zcode` row. wp4 re-checks +that every registered client is present in both tables before adding omo, so a +new row does not land next to a known hole. diff --git a/devlog/_plan/260912_omo_client_integration/040_wp5_verification.md b/devlog/_plan/260912_omo_client_integration/040_wp5_verification.md new file mode 100644 index 0000000000..e9a1956048 --- /dev/null +++ b/devlog/_plan/260912_omo_client_integration/040_wp5_verification.md @@ -0,0 +1,25 @@ +# wp5 — what counts as proof + +The goalplan's five criteria, and the evidence each one accepts. + +1. **The page shows it.** Not "the list contains omo" — a dashboard rendering an + omo row in the overview grid and an omo tab that opens `integrations/omo`, + captured from **this worktree's** built GUI rather than whatever build the + long-running proxy on `:10100` happens to be serving. The silent hazard in + `020` is precisely the failure that passes every other check, so a + source-level assertion cannot discharge this one. +2. **The bytes are right.** `ocx export --client omo` emitting a document whose + provider block matches what senpi parses, naming the path + `omoConfigPath` resolves. Evidence is the emitted text plus the senpi parser + citation in `001`. +3. **`bun run typecheck`** clean. +4. **Focused tests.** The new omo test plus every exact-list test named in + `010` and `020`, run by path — including `tests/cli/cli-help.test.ts`, which + is the only thing that catches a stale `(13 clients)` literal. The full suite + is the PR gate, not this one. +5. **Round trip.** Enable then disable omo through the integrations API against + a temporary home, and show the config file returns to its prior bytes — the + ownership claim, not just the write. + +Anything short of these is reported as it is. A rendered page is not inferred +from a green test, and a green test is not inferred from a compiling record. diff --git a/devlog/_plan/260912_omo_client_integration/050_wp6_gjc_rename.md b/devlog/_plan/260912_omo_client_integration/050_wp6_gjc_rename.md new file mode 100644 index 0000000000..4ddceebc44 --- /dev/null +++ b/devlog/_plan/260912_omo_client_integration/050_wp6_gjc_rename.md @@ -0,0 +1,49 @@ +# wp6 — Gajae Code reads as gjc + +The product shortened its name. The repository is `Yeachan-Heo/gajae-code`, the +published package is `@gajae-code/coding-agent`, the command is `gjc`, and the +config path this repo already writes is `~/.gjc/agent/models.yml` — the path +rebranded before the label did, which is why "Gajae Code" now sits next to a +`.gjc` directory in the same docs table. + +## The line this phase does not cross + +The client **id** stays `gajae`. It is not a caption: it is the key in +`EXPORT_CLIENTS` and `INTEGRATION_CLIENTS`, the segment in +`/api/client-integrations/gajae`, and the key an enable record is stored under. +Renaming it orphans the stored state of every user who already connected the +client — the integration silently reads "not applied", and the ownership record +no longer matches the block we wrote into their config, so a later disable can +no longer remove it cleanly. The user was asked and chose label-only. + +`OPENCODEX_GAJAE_API_KEY` stays for the same reason: it is a variable a user has +already exported, and renaming it breaks a working setup silently. + +## Surfaces that change + +| file | from | to | +|---|---|---| +| `gui/src/i18n/*.ts` × 9, `integrations.tab.gajae` | `Gajae Code` | `gjc` | +| `gui/src/i18n/*.ts` × 9, `api.clientConfig.clientGajae` | `Gajae Code` | `gjc` | +| `src/cli/registry.ts` export summary | `Gajae Code` | `gjc` | +| `docs-site/.../guides/integrations.md` + translations | `Gajae Code` rows and prose | `gjc` | +| `docs-site/.../reference/cli/agents.md` + translations | `Gajae`/`Gajae Code` prose | `gjc` | +| `docs-site/.../reference/configuration.md` + translations | `Gajae` in the Fast-rows client list | `gjc` | + +`gjc` is a product name, so it stays untranslated in all nine locales, which +means the two keys keep their places in `ZH_TW_KEEP_ENGLISH` and +`INTENTIONAL_ENGLISH` — they were already there under the old spelling, so this +is a value change, not a list change. + +## What proves it + +No test asserts the literal `Gajae Code`, and the two translation allowlists key +on key *names*, not values, so changing the value needs no list edit. The label +is never derived from the id — every surface reaches it through an i18n key — so +the two can honestly disagree. + +The proof is `rg -n "Gajae"` restricted to **user-visible text**: the nine locale +catalogs, the CLI summary prose, and `docs-site`. A bare tree-wide search is the +wrong check and would report itself failing forever, because the internal +identifiers (`GajaeGeneratedConfig`, `gajaeConfigPath`, `buildGajaeClientConfig`, +`OPENCODEX_GAJAE_API_KEY`) are exactly what this phase is not touching. diff --git a/devlog/_plan/260912_omo_client_integration/060_wp3_rendered_proof.md b/devlog/_plan/260912_omo_client_integration/060_wp3_rendered_proof.md new file mode 100644 index 0000000000..a66b407c54 --- /dev/null +++ b/devlog/_plan/260912_omo_client_integration/060_wp3_rendered_proof.md @@ -0,0 +1,75 @@ +# wp3 — what the page actually showed + +Built `gui/dist` from this worktree and served it from a proxy on port 10177. + +## The environment mistake, recorded because it cost something + +The first attempt ran `ocx start` with only `OPENCODEX_HOME` redirected. That is +not isolation: `start` also syncs the Codex catalog and the Grok Build config, +both of which resolve from the real home. It rewrote +`~/.codex/opencodex-catalog.json` and pointed `~/.grok/config.toml` at port +10177, which is a dead port the moment the test proxy stops. + +Both were restored: `ocx sync` against the live installation rebuilt the Codex +catalog (29 models), and the Grok base URL was put back to 10100. A diff with +the port normalised on both sides confirmed the port was the only difference. + +The second attempt redirected `HOME` and `CODEX_HOME` as well. The reviewer +argued for keeping the real `HOME` instead, so the badge would act as an oracle +against the machine's real `~/.omo`. That is rebutted rather than ignored: the +condition under test is a `.omo` directory containing only `binary-runtime` and +no `agent/`, and that condition was reproduced exactly inside the isolated home. +It tests the same thing without a second chance to damage the user's setup. + +That the three agent-dir variables were unset is not asserted, it is visible: +the page printed `/tmp/omo-vh/.omo/agent/models.json`, which is the +home-relative default. Any of the three being set would have shown its value. + +## The oracle, pinned before the screenshot + +Muted **Not installed**, the literal config path, Apply **disabled**. "Not +applied" or an enabled switch would mean `detectDir` had matched the bare +`.omo` directory. + +## What was observed + +| check | result | +|---|---| +| omo tab in the strip, routing to `integrations/omo` | present, selectable, reachable | +| overview row, `data-client="omo"` | present, last in the grid | +| `.omo` holding only `binary-runtime`, no `agent/` | **Not installed**, Apply disabled — the v4 false positive is rejected | +| `.omo/agent` created | flips to **Not applied**, Apply enabled | +| Apply toggled | wrote `models.json`: `providers.opencodex` with `baseUrl`, `api: openai-completions`, the loopback placeholder, `compat.sendSessionAffinityHeaders: true`, and `models` as a 12-element array | +| the written file, through senpi's own compiled validator | **valid: true** | +| negative control, `input: ["audio"]` | valid: false | +| negative control, `models` as a keyed object | valid: false | +| Disable toggled | file returns to `{}` — only our block removed | +| mark | `, `mask-image: none`, at 14px (tab), 20px (row) and 24px (page); no monogram anywhere | +| mark in dark and light themes | the dark face carries it on both surfaces; it is not a blank plate | +| API Keys tab, the client-config row | `omo` label present, same unmodified 20px `` mark | +| both `integrations-tab-omo` and `integrations-tab-raycast` measured | non-zero box, top >= 0 — the tab is reachable in a twenty-tab strip, not merely in the DOM | + +The validator run is the one that matters most, because it is the difference +between "the bytes look like Pi's" and "the engine omo actually ships accepts +this file". The two negative controls are there so the check cannot be vacuous — +a validator that returns true for everything would have passed them too. + +The last two rows close the reviewer's remaining non-blocking notes. The API +Keys row matters because it is a second surface reading the same +`CLIENT_MARKS.omo`, and it is reached through `CLIENTS` rather than through +`FILE_INTEGRATION_CLIENTS` — a different list, so a different way to be missing. +Tab reachability was measured rather than eyeballed, because "present in the +DOM" and "the user can get to it" are not the same claim once a strip holds +twenty tabs. + +## Semantics copy, read in place + +`integrations.semantics.omo` wraps to three lines where Prime's wraps to two, +because it names three environment variables instead of one. Kept as is: the +three names are the fact a user needs when omo and Pi can resolve the same file, +and shortening it would mean dropping two of them. + +## Cleanup + +The test proxy is stopped and `/tmp/omo-vh`, `/tmp/omo-ocxhome` hold everything +it wrote. Nothing under the user's home carries state from this run. diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index 9dd4e5bc2e..b803c9f2c5 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Intégrations -description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside et Raycast depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. +description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast et omo depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. --- L'onglet **Intégrations** écrit le bloc fournisseur d'opencodex dans le fichier de configuration du client, -puis peut le retirer. Treize clients fonctionnent ainsi, chacun avec son propre commutateur : +puis peut le retirer. Quatorze clients fonctionnent ainsi, chacun avec son propre commutateur : | Client | Fichier de configuration | Format | Prise d'effet de la modification | Identifiant | |---|---|---|---|---| @@ -14,13 +14,14 @@ puis peut le retirer. Treize clients fonctionnent ainsi, chacun avec son propre | Hermes | `~/.hermes/config.yaml` | YAML | dans les nouvelles sessions | `OPENCODEX_HERMES_API_KEY` | | OpenClaw | `~/.openclaw/openclaw.json` | JSON5 | immédiatement, sur une passerelle en cours d'exécution | `OPENCODEX_OPENCLAW_API_KEY` | | Kimi Code | `~/.kimi-code/config.toml` | TOML | au redémarrage ou avec `/reload` | valeur fictive de bouclage | -| Gajae Code | `~/.gjc/agent/models.yml` | YAML | dans les nouvelles sessions ou à l'ouverture de `/model` |`OPENCODEX_GAJAE_API_KEY` | +| gjc | `~/.gjc/agent/models.yml` | YAML | dans les nouvelles sessions ou à l'ouverture de `/model` |`OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (`~/.dsh/settings.yaml` par défaut) | YAML | rechargement à chaud | jeton porteur fictif et non secret pour le bouclage | | MiniMax Code | `~/.minimax/config.yaml` | YAML | dans les nouvelles sessions ou après l’ouverture du sélecteur de modèles | valeur fictive de bouclage | | Prime Agent | `~/.prime/agent/models.json` | JSON | dans les nouvelles sessions | valeur fictive de bouclage | | ZCode | `~/.zcode/v2/config.json` | JSON | au redémarrage | valeur fictive de bouclage | | Aside | `~/.aside/u//models.json` | JSON | après avoir quitté complètement puis rouvert Aside | valeur fictive de bouclage | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immédiatement à l'enregistrement — Raycast surveille le fichier | aucun — bouclage uniquement | +| omo | `~/.omo/agent/models.json` | JSON | nouvelles sessions | espace réservé de bouclage | La prise en charge gérée de DSH exige au minimum **DSH 0.1.0-rc.6**. OpenCodex ne possède que le fragment `llm-pi-ai.providers.opencodex` : **Appliquer** et **Actualiser** remplacent ce fragment, **Désactiver** ne @@ -128,7 +129,7 @@ niveaux. Dans ces cas, le commutateur est verrouillé afin que rien ne soit modi **OMP, DSH et Hermes** ne sont pas affectés non plus par les modifications voisines, mais pour une autre raison : leurs outils d'écriture ne modifient, octet par octet, que leur propre plage `providers.opencodex` ; le reste du fichier n'est jamais réécrit. Pour les autres formats susceptibles de contenir des commentaires (OpenClaw, -Kimi Code, Gajae Code, MiniMax Code et Raycast — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées +Kimi Code, gjc, MiniMax Code et Raycast — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées d'opencodex ont été modifiées, le commutateur se verrouille et la désactivation est refusée plutôt que de deviner quelles modifications vous appartiennent. @@ -154,7 +155,7 @@ les convertirait en chaînes entre guillemets, y compris dans les tableaux et le tables en ligne. Les dates déjà écrites entre guillemets restent prises en charge. Pour conserver une date typée sans guillemets, modifiez manuellement la configuration. -**Pi, Kimi Code, Gajae Code, MiniMax Code et l'intégration DSH gérée fonctionnent uniquement avec une adresse de +**Pi, Kimi Code, gjc, MiniMax Code et l'intégration DSH gérée fonctionnent uniquement avec une adresse de bouclage.** Les quatre premiers n'ont aucun champ de configuration pour l'en-tête `x-opencodex-api-key` qu'exige une liaison hors bouclage. DSH possède une table d'en-têtes générique, mais rc.6 ne documente pas cet en-tête d'admission dédié comme contrat d'intégration pris en charge ; l'outil d'écriture géré échoue @@ -205,8 +206,8 @@ ocx mcode ``` Une fois l’intégration connectée, `ocx sync` et `POST /api/sync` actualisent les catalogues MCode, -Pi, Aside et Raycast gérés. Le démarrage du proxy actualise aussi le catalogue Raycast géré. -Les changements de visibilité, de fournisseur ou de préréglage actualisent Pi, Aside et Raycast. +Pi, Aside, Raycast et omo gérés. Le démarrage du proxy actualise aussi le catalogue Raycast géré. +Les changements de visibilité, de fournisseur ou de préréglage actualisent Pi, Aside, Raycast et omo. Les blocs absents, modifiés par un tiers, non sûrs ou supprimés manuellement restent intacts ; réactivez explicitement l’intégration lorsque vous souhaitez la reconnecter. diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 749119f70a..0d781042d9 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -164,7 +164,7 @@ Gérez et appliquez la clôture du modèle Grok Build. ## Exportation de la configuration client -### `ocx export --client ` +### `ocx export --client ` Imprimez une configuration client connectée au proxy en cours d'exécution. La commande sérialise le bloc fournisseur `opencodex` — URL de base, liste de modèles et référence d’identifiant du client @@ -175,7 +175,7 @@ les modèles Codex peuvent actuellement voir. | Option | Actions | | --- | --- | -| `--client ` | Requis. Sélectionne le dialecte de configuration client. | +| `--client ` | Requis. Sélectionne le dialecte de configuration client. | | `--json` | Imprimez le document généré en tant que JSON sur la sortie standard pour les scripts. Il s'agit de JSON même lorsque le format natif du client sélectionné est YAML, TOML ou JSON5. | | `--out ` | Écrivez le format de configuration natif du client dans ``. Refuse de remplacer un fichier existant. | | `--force` | Autoriser `--out` à remplacer un fichier existant. | @@ -205,7 +205,9 @@ propres valeurs par défaut à ces lignes. | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, puis l'ancien `MAVIS_DATA_DIR`, l'emportent une fois définis ; une valeur relative est refusée) | `mcode-config.yaml` | aucun — espace réservé de bouclage | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` l'emporte une fois défini ; une valeur relative est refusée) | `config.json` | aucun — espace réservé de bouclage | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` l'emporte une fois défini ; une valeur relative est refusée) | `prime-models.json` | aucun — espace réservé de bouclage | +| `aside` | `~/.aside/u//models.json` pour le compte que le fichier `accounts.json` d'Aside désigne comme courant ; un manifeste illisible est refusé plutôt que de retomber sur un compte | `aside-models.json` | aucun — espace réservé de bouclage | | `raycast` | `~/.config/raycast/ai/providers.yaml`, sur macOS comme sur Windows (Raycast n'honore pas `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | aucun — bouclage uniquement, aucune entrée `api_keys` n'est écrite | +| `omo` | `~/.omo/agent/models.json` (`OMO_CODING_AGENT_DIR`, puis `SENPI_CODING_AGENT_DIR`, puis `PI_CODING_AGENT_DIR` l'emportent dans cet ordre une fois définis ; une valeur relative est refusée) | `omo-models.json` | aucun — espace réservé de bouclage | L'exportation Raycast est un document `providers.yaml` autonome contenant un seul élément `id: opencodex` dans la séquence `providers` : `name: OpenCodex`, l'URL de base `/v1` du proxy et chaque modèle routé avec @@ -241,8 +243,8 @@ le proxy se lie au-delà du bouclage ; voir [Accès à distance](/fr/reference/configuration/server/#accès-à-distance) pour savoir comment les clés d'admission sont délivrées. Clés pour les fournisseurs en amont eux-mêmes sont une chose entièrement distincte, configurée par [Fournisseurs](/fr/guides/providers/). -Gajae est l'exception : `OPENCODEX_GAJAE_API_KEY` remplit ses informations d'identification de fournisseur à partir du -environnement, mais son schéma ne peut pas envoyer l'en-tête d'admission à distance, donc le Gajae généré +gjc est l'exception : `OPENCODEX_GAJAE_API_KEY` remplit ses informations d'identification de fournisseur à partir du +environnement, mais son schéma ne peut pas envoyer l'en-tête d'admission à distance, donc l'intégration gjc générée l'intégration reste uniquement en boucle. La même charge utile est servie par `GET /api/client-config` et rendue sur l'onglet API du tableau de bord, donc diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 166a848614..16242c9f77 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Integrations -description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside and Raycast from the dashboard — one switch per client, with a backup taken before every write. +description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast and omo from the dashboard — one switch per client, with a backup taken before every write. --- The **Integrations** tab writes opencodex's provider block into a client's own config -file, and removes it again. Thirteen clients work this way, each with a switch: +file, and removes it again. Fourteen clients work this way, each with a switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| @@ -14,13 +14,14 @@ file, and removes it again. Thirteen clients work this way, each with a switch: | Hermes | `~/.hermes/config.yaml` | YAML | new sessions | `OPENCODEX_HERMES_API_KEY` | | OpenClaw | `~/.openclaw/openclaw.json` | JSON5 | immediately, on a running gateway | `OPENCODEX_OPENCLAW_API_KEY` | | Kimi Code | `~/.kimi-code/config.toml` | TOML | on restart, or `/reload` | loopback placeholder | -| Gajae Code | `~/.gjc/agent/models.yml` | YAML | new sessions, or when you open `/model` |`OPENCODEX_GAJAE_API_KEY` | +| gjc | `~/.gjc/agent/models.yml` | YAML | new sessions, or when you open `/model` |`OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (default `~/.dsh/settings.yaml`) | YAML | hot reload | non-secret loopback bearer placeholder | | MiniMax Code | `~/.minimax/config.yaml` | YAML | new sessions, or after opening the model picker | loopback placeholder | | Prime Agent | `~/.prime/agent/models.json` | JSON | new sessions | loopback placeholder | | ZCode | `~/.zcode/v2/config.json` | JSON | on restart | loopback placeholder | | Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only | +| omo | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder | Generated catalogs include only enabled models from each provider selection. This applies to both downloads and managed integrations, including Pi and Aside. The management model list still shows @@ -175,7 +176,7 @@ than 1000 levels — which locks the switch instead, so nothing is silently chan **OMP, DSH and Hermes** are unaffected by sibling edits too, for a different reason: their writers patch only their own managed provider ranges byte-wise, so the rest of the file is never rewritten. For the remaining formats that can carry comments -(OpenClaw, Kimi Code, Gajae Code, MiniMax Code, Raycast — JSON5 and TOML +(OpenClaw, Kimi Code, gjc, MiniMax Code, Raycast — JSON5 and TOML written as whole documents, or generic YAML without source preservation), or whenever our own entries were edited, the switch locks and disable refuses rather than guessing which edits were yours. @@ -211,7 +212,7 @@ typed values into quoted strings. This includes values inside arrays and inline tables. Quoted date strings remain supported; an unquoted date must be preserved by editing the configuration manually. -**Pi, Kimi Code, Gajae Code, MiniMax Code, Prime Agent and the managed DSH integration only work against a loopback bind.** +**Pi, Kimi Code, gjc, MiniMax Code, Prime Agent and the managed DSH integration only work against a loopback bind.** The first four have no config field for the `x-opencodex-api-key` header a non-loopback bind requires. DSH has a generic headers map, but rc.6 does not document that dedicated admission header as a supported integration contract, so the managed writer fails closed instead of @@ -261,10 +262,11 @@ ocx integration client enable --client mcode ocx mcode ``` -Once connected, `ocx sync` and `POST /api/sync` refresh owned MCode, Pi, Aside, and -Raycast catalogs with the current model selection, context windows, and reasoning-effort -ladders. Proxy startup refreshes an owned Raycast catalog. Changes to model visibility, -provider selection, or presets also refresh connected Pi, Aside, and Raycast catalogs. +Once connected, `ocx sync` and `POST /api/sync` refresh owned MCode, Pi, Aside, +Raycast, and omo catalogs with the current model selection, context windows, and +reasoning-effort ladders. Proxy startup refreshes an owned Raycast catalog. Changes to +model visibility, provider selection, or presets also refresh connected Pi, Aside, +Raycast, and omo catalogs. Missing, foreign-edited, or unsafe blocks stay untouched, as do previously owned blocks you removed manually. An enabled Aside profile is an exception to the usual owned-only refresh: if its account diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index a223362a56..a74f20a817 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -125,7 +125,7 @@ Grok Build モデル フェンスを管理および適用します。 ## クライアント設定のエクスポート -### `ocx export --client ` +### `ocx export --client ` 実行中のプロキシに接続するクライアント設定を出力します。このコマンドは、ベース URL、モデル一覧、およびクライアントに応じた認証情報参照または `opencodex-loopback` プレースホルダーを含む `opencodex` プロバイダーブロックを、選択したクライアントのネイティブ形式でシリアル化します。 @@ -133,7 +133,7 @@ Grok Build モデル フェンスを管理および適用します。 |旗 |アクション | | --- | --- | -| `--client ` |必須。クライアントの設定形式を選択します。 | +| `--client ` |必須。クライアントの設定形式を選択します。 | | `--json` |構成 JSON のみを標準出力に出力するため、リダイレクトはバイト正確な出力をキャプチャします。 `--out` 書き込みメモを含むすべての診断は stderr に送られます。 | | `--out ` |設定を `` に書き込みます。既存のファイルの置き換えを拒否します。 | | `--force` | `--out` が既存のファイルを置き換えることを許可します。 | @@ -160,7 +160,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`、次に旧 `MAVIS_DATA_DIR` が設定時に優先。相対値は拒否されます) | `mcode-config.yaml` | なし — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` が設定時に優先。相対値は拒否されます) | `config.json` | なし — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` が設定時に優先。相対値は拒否されます) | `prime-models.json` | なし — loopback placeholder | +| `aside` | `~/.aside/u//models.json`。Aside 自身の `accounts.json` が現在のアカウントとして指す account を使います。マニフェストが読めない場合は、既定のアカウントに落とさず拒否します | `aside-models.json` | なし — loopback placeholder | | `raycast` | `~/.config/raycast/ai/providers.yaml` (macOS と Windows で同じ。Raycast は `XDG_CONFIG_HOME` を尊重しません) | `raycast-providers.yaml` | なし — loopback のみ。`api_keys` エントリは書き込まれません | +| `omo` | `~/.omo/agent/models.json` (`OMO_CODING_AGENT_DIR`、次に `SENPI_CODING_AGENT_DIR`、次に `PI_CODING_AGENT_DIR` の順で設定時に優先。相対値は拒否されます) | `omo-models.json` | なし — loopback placeholder | Raycast のエクスポートは、`providers` シーケンスに `id: opencodex` 要素を 1 つだけ持つ独立した `providers.yaml` 文書です。内容は `name: OpenCodex`、プロキシの `/v1` ベース URL、および `abilities` 付きのルーティング済み全モデルです (`tools` と `system_message` は常にサポート、`vision` はカタログの入力モダリティから、`reasoning_effort` はモデルに effort ラダーがある場合、`temperature` は推論モデルではオフ)。Custom Providers は Raycast Pro の機能で、Raycast はこのファイルを監視しているため、保存した変更は再起動なしで反映されます。形式は [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) に記載されています。`api_keys` エントリは書き込まれないため、このエクスポートは loopback 専用で、loopback 以外のバインドは拒否されます。 @@ -170,7 +172,7 @@ opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex `ocx export` は実際のクライアント設定を書き込むことはありません。宛先は手動でマージできるように出力されます。`--out` は、`--force` なしで既存のファイルを上書きすることを拒否します。これは、設定を置き換えると、その中にすでに含まれている他のプロバイダー、エージェント、および MCP エントリが破壊されるためです。 ::: -キーはシリアル化されません。生成される設定には、文書化された環境参照か、秘密ではないループバック用プレースホルダーのいずれかが入ります。ループバック プロキシ (`127.0.0.1`、デフォルト) にはアドミッション キーはまったく必要ありません。プロキシがループバックを超えてバインドする場合は、対応する `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY`、または `OPENCODEX_OPENCLAW_API_KEY` を設定します。`OPENCODEX_GAJAE_API_KEY` は Gajae の provider 認証値を環境から渡しますが、remote admission header は送れないため、生成される Gajae 統合はループバック専用のままです。アドミッションキーの発行方法については、[リモートアクセス](/reference/configuration/#remote-access) を参照してください。上流プロバイダー自体のキーは完全に別のものであり、[プロバイダー](/guides/providers/) ごとに構成されます。 +キーはシリアル化されません。生成される設定には、文書化された環境参照か、秘密ではないループバック用プレースホルダーのいずれかが入ります。ループバック プロキシ (`127.0.0.1`、デフォルト) にはアドミッション キーはまったく必要ありません。プロキシがループバックを超えてバインドする場合は、対応する `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY`、または `OPENCODEX_OPENCLAW_API_KEY` を設定します。`OPENCODEX_GAJAE_API_KEY` は gjc の provider 認証値を環境から渡しますが、remote admission header は送れないため、生成される gjc 統合はループバック専用のままです。アドミッションキーの発行方法については、[リモートアクセス](/reference/configuration/#remote-access) を参照してください。上流プロバイダー自体のキーは完全に別のものであり、[プロバイダー](/guides/providers/) ごとに構成されます。 同じペイロードが `GET /api/client-config` によって提供され、ダッシュボードの [API] タブにレンダリングされるため、CLI、API、および GUI は同じバイトを使用します。 diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 76cef4bda7..82f776b782 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -152,7 +152,7 @@ Grok Build model fence를 관리하고 적용합니다. ## 클라이언트 설정 내보내기 -### `ocx export --client ` +### `ocx export --client ` 실행 중인 프록시에 연결할 client config를 출력합니다. 이 명령은 base URL, model list, 그리고 client에 따라 credential reference 또는 `opencodex-loopback` placeholder를 포함한 `opencodex` provider block을 선택한 client의 네이티브 형식으로 직렬화합니다. @@ -160,7 +160,7 @@ Grok Build model fence를 관리하고 적용합니다. | 플래그 | 동작 | | --- | --- | -| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | +| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | | `--json` | config JSON만 stdout에 출력하므로, redirect가 byte-exact 출력을 캡처합니다. `--out` write note를 포함한 모든 진단 메시지는 stderr로 갑니다. | | `--out ` | config를 ``에 씁니다. 기존 파일이 있으면 덮어쓰지 않습니다. | | `--force` | `--out`이 기존 파일을 덮어쓰도록 허용합니다. | @@ -187,7 +187,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, 그다음 레거시 `MAVIS_DATA_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `mcode-config.yaml` | 없음 — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `config.json` | 없음 — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `prime-models.json` | 없음 — loopback placeholder | +| `aside` | `~/.aside/u//models.json`. Aside의 `accounts.json`이 현재 계정으로 지정한 account를 사용합니다. 매니페스트를 읽을 수 없으면 임의의 계정으로 넘어가지 않고 거부합니다 | `aside-models.json` | 없음 — loopback placeholder | | `raycast` | `~/.config/raycast/ai/providers.yaml` (macOS와 Windows 모두 동일. Raycast는 `XDG_CONFIG_HOME`을 따르지 않습니다) | `raycast-providers.yaml` | 없음 — loopback 전용. `api_keys` 항목은 쓰지 않습니다 | +| `omo` | `~/.omo/agent/models.json` (`OMO_CODING_AGENT_DIR`, `SENPI_CODING_AGENT_DIR`, `PI_CODING_AGENT_DIR` 순서로 설정된 값이 우선. 상대 경로는 거부됩니다) | `omo-models.json` | 없음 — loopback placeholder | Raycast 내보내기는 `providers` 시퀀스에 `id: opencodex` 요소 하나만 담은 독립 `providers.yaml` 문서입니다. 내용은 `name: OpenCodex`, proxy의 `/v1` base URL, 그리고 `abilities`가 붙은 라우팅된 모든 모델입니다(`tools`와 `system_message`는 항상 지원, `vision`은 카탈로그의 입력 모달리티를 따름, `reasoning_effort`는 모델에 effort 사다리가 있을 때, `temperature`는 추론 모델에서 꺼짐). Custom Providers는 Raycast Pro 기능이며, Raycast가 이 파일을 감시하므로 저장한 변경은 재시작 없이 적용됩니다. 형식은 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)에 문서화되어 있습니다. `api_keys` 항목은 쓰지 않으므로 이 내보내기는 loopback 전용이며, loopback이 아닌 bind는 거부됩니다. @@ -197,7 +199,7 @@ opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 `ocx export`는 실제 client config를 절대 쓰지 않습니다. 대상 경로는 손으로 병합하라고 출력되며, `--out`은 `--force` 없이 기존 파일을 덮어쓰지 않습니다. config를 바꾸어 덮어쓰면 이미 들어 있던 다른 provider, agent, MCP entry가 사라지기 때문입니다. ::: -어떤 key도 직렬화되지 않습니다. 생성되는 config에는 문서화된 env reference 또는 비밀이 아닌 loopback placeholder 중 하나가 들어갑니다. loopback proxy(`127.0.0.1`, 기본값)는 admission key가 전혀 필요하지 않습니다. proxy가 loopback을 넘어 바인딩할 때는 해당하는 `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY`, `OPENCODEX_OPENCLAW_API_KEY`를 설정하십시오. `OPENCODEX_GAJAE_API_KEY`는 Gajae provider 인증 값을 환경에서 전달하지만 remote admission header를 보낼 수는 없으므로, 생성되는 Gajae 통합은 loopback 전용으로 남습니다. admission key가 어떻게 발급되는지는 [Remote access](/reference/configuration/#remote-access)를 보십시오. upstream provider 자체의 key는 완전히 별개의 것으로, 각 [Providers](/guides/providers/)에 맞게 설정합니다. +어떤 key도 직렬화되지 않습니다. 생성되는 config에는 문서화된 env reference 또는 비밀이 아닌 loopback placeholder 중 하나가 들어갑니다. loopback proxy(`127.0.0.1`, 기본값)는 admission key가 전혀 필요하지 않습니다. proxy가 loopback을 넘어 바인딩할 때는 해당하는 `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY`, `OPENCODEX_OPENCLAW_API_KEY`를 설정하십시오. `OPENCODEX_GAJAE_API_KEY`는 gjc provider 인증 값을 환경에서 전달하지만 remote admission header를 보낼 수는 없으므로, 생성되는 gjc 통합은 loopback 전용으로 남습니다. admission key가 어떻게 발급되는지는 [Remote access](/reference/configuration/#remote-access)를 보십시오. upstream provider 자체의 key는 완전히 별개의 것으로, 각 [Providers](/guides/providers/)에 맞게 설정합니다. 같은 payload는 `GET /api/client-config`로 제공되고 dashboard의 API 탭에도 렌더링되므로, CLI, API, GUI가 모두 같은 바이트를 사용합니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index ab96881d11..32399c59df 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -235,7 +235,7 @@ Manage and apply the Grok Build model fence. ## Client config export -### `ocx export --client ` +### `ocx export --client ` Print a client config wired to the running proxy. The command serializes the `opencodex` provider block — base URL, model list, and the client's credential @@ -246,7 +246,7 @@ models Codex can currently see. | Flag | Action | | --- | --- | -| `--client ` | Required. Selects the client config dialect. | +| `--client ` | Required. Selects the client config dialect. | | `--json` | Print the generated document as JSON on stdout for scripts. This is JSON even when the selected client's native format is YAML, TOML, or JSON5. | | `--out ` | Write the client's native config format to ``. Refuses to replace an existing file. | | `--force` | Allow `--out` to replace an existing file. | @@ -277,6 +277,7 @@ client applies its own defaults for those). | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` wins when set; a relative value is refused) | `prime-models.json` | none — loopback placeholder | | `aside` | `~/.aside/u//models.json` for the account Aside's own `accounts.json` names as current; an unreadable manifest is refused rather than defaulting to an account | `aside-models.json` | none — loopback placeholder | | `raycast` | `~/.config/raycast/ai/providers.yaml` on macOS and Windows alike (Raycast does not honor `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | none — loopback only, no `api_keys` entry is written | +| `omo` | `~/.omo/agent/models.json` (`OMO_CODING_AGENT_DIR`, then `SENPI_CODING_AGENT_DIR`, then `PI_CODING_AGENT_DIR` win in that order when set; a relative value is refused) | `omo-models.json` | none — loopback placeholder | The managed DSH export requires DSH 0.1.0-rc.6 or newer and owns only `llm-pi-ai.providers.opencodex`. DSH hot reloads that provider; the user's default model and @@ -328,8 +329,8 @@ the proxy binds beyond loopback; see [Remote access](/reference/configuration/#remote-access) for how admission keys are issued. Keys for the upstream providers themselves are a separate thing entirely, configured per [Providers](/guides/providers/). -Gajae is the exception: `OPENCODEX_GAJAE_API_KEY` fills its provider credential from the -environment, but its schema cannot send the remote admission header, so the generated Gajae +gjc is the exception: `OPENCODEX_GAJAE_API_KEY` fills its provider credential from the +environment, but its schema cannot send the remote admission header, so the generated gjc integration remains loopback-only. The same payload is served by `GET /api/client-config` and rendered on the dashboard's API tab, so diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index b1136ee86e..8ed7b97e69 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -61,7 +61,7 @@ a known configured model id. Cursor may require a model-list refresh or restart `fastRows` is an optional boolean and defaults to `true`. The raw OpenAI-style `/v1/models` list, Claude Code discovery, and client config exports (including pi, OpenCode, -OMP, Hermes, OpenClaw, Kimi, Gajae, DSH, MCode, ZCode, Prime, and Aside) add a `--fast` selector for every model whose +OMP, Hermes, OpenClaw, Kimi, gjc, DSH, MCode, ZCode, Prime, Aside, Raycast, and omo) add a `--fast` selector for every model whose resolved Fast policy is eligible. Selecting one routes the base model and requests the `priority` service tier — the same Fast the Codex app exposes through its picker toggle. The base row stays listed, so the row is an addition rather than a replacement. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 8df8175173..eadeb03d61 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -152,7 +152,7 @@ override, но файлы на диске никогда не меняются. ## Экспорт client config -### `ocx export --client ` +### `ocx export --client ` Печатает client config, направленный на работающий прокси. Команда сериализует блок провайдера `opencodex` в нативном формате выбранного клиента: base URL, список моделей и, @@ -163,7 +163,7 @@ override, но файлы на диске никогда не меняются. | Флаг | Действие | | --- | --- | -| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | +| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | | `--json` | Печатать только JSON-конфиг в stdout, чтобы redirect сохранял побайтно точный вывод. Вся диагностика, включая заметку о записи через `--out`, идёт в stderr. | | `--out ` | Записать конфиг в ``. Перезаписывать существующий файл не позволит. | | `--force` | Разрешить `--out` заменить существующий файл. | @@ -193,7 +193,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, затем устаревшая `MAVIS_DATA_DIR`, имеют приоритет, если заданы; относительное значение отклоняется) | `mcode-config.yaml` | нет — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` имеет приоритет, если задана; относительное значение отклоняется) | `config.json` | нет — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` имеет приоритет, если задана; относительное значение отклоняется) | `prime-models.json` | нет — loopback placeholder | +| `aside` | `~/.aside/u//models.json` для аккаунта, который `accounts.json` самого Aside называет текущим; нечитаемый манифест отклоняется, а не подменяется произвольным аккаунтом | `aside-models.json` | нет — loopback placeholder | | `raycast` | `~/.config/raycast/ai/providers.yaml` одинаково на macOS и Windows (Raycast не учитывает `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | нет — только loopback, запись `api_keys` не создаётся | +| `omo` | `~/.omo/agent/models.json` (`OMO_CODING_AGENT_DIR`, затем `SENPI_CODING_AGENT_DIR`, затем `PI_CODING_AGENT_DIR` имеют приоритет в этом порядке, если заданы; относительное значение отклоняется) | `omo-models.json` | нет — loopback placeholder | Экспорт для Raycast — это отдельный документ `providers.yaml` с одним элементом `id: opencodex` в последовательности `providers`: `name: OpenCodex`, базовый URL прокси с `/v1` и каждая маршрутизируемая @@ -222,8 +224,8 @@ MCP-записи. env-reference, либо несекретную loopback-заглушку. Loopback-прокси (`127.0.0.1`, по умолчанию) вообще не требует admission key. Если прокси слушает не на loopback, задайте соответствующую переменную `OPENCODEX_OPENCODE_API_KEY`, `OPENCODEX_HERMES_API_KEY` или `OPENCODEX_OPENCLAW_API_KEY`. -`OPENCODEX_GAJAE_API_KEY` передаёт provider credential Gajae через окружение, но не позволяет -отправить remote admission header, поэтому сгенерированная интеграция Gajae +`OPENCODEX_GAJAE_API_KEY` передаёт provider credential gjc через окружение, но не позволяет +отправить remote admission header, поэтому сгенерированная интеграция gjc работает только через loopback. Как выдаются admission key, описано в [Удалённом доступе](/reference/configuration/#remote-access). Ключи upstream-провайдеров — это совсем отдельная история и настраиваются в [Провайдерах](/guides/providers/). diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index f068b0233f..03f64377f2 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Entegrasyonlar -description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside ve Raycast'i opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. +description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast ve omo'yu opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. --- **Entegrasyonlar** sekmesi, opencodex'in sağlayıcı bloğunu istemcinin kendi -yapılandırma dosyasına yazar ve tekrar kaldırır. On üç istemci bu şekilde +yapılandırma dosyasına yazar ve tekrar kaldırır. On dört istemci bu şekilde çalışır, her biri bir anahtarla: | İstemci | Yapılandırma dosyası | Format | Değişiklik ne zaman geçerli olur? | Kimlik bilgisi | @@ -15,13 +15,14 @@ yapılandırma dosyasına yazar ve tekrar kaldırır. On üç istemci bu şekild | Hermes | `~/.hermes/config.yaml` | YAML | yeni oturumlarda | `OPENCODEX_HERMES_API_KEY` | | OpenClaw | `~/.openclaw/openclaw.json` | JSON5 | hemen, çalışan bir ağ geçidinde | `OPENCODEX_OPENCLAW_API_KEY` | | Kimi Code | `~/.kimi-code/config.toml` | TOML | yeniden başlatmada veya `/reload` ile | geri döngü (loopback) yer tutucusu | -| Gajae Code | `~/.gjc/agent/models.yml` | YAML | yeni oturumlarda veya `/model` açtığınızda | `OPENCODEX_GAJAE_API_KEY` | +| gjc | `~/.gjc/agent/models.yml` | YAML | yeni oturumlarda veya `/model` açtığınızda | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (varsayılan `~/.dsh/settings.yaml`) | YAML | çalışırken yeniden yükleme | gizli olmayan geri döngü bearer yer tutucusu | | MiniMax Code | `~/.minimax/config.yaml` | YAML | yeni oturumlarda veya model seçici açıldıktan sonra | geri döngü (loopback) yer tutucusu | | Prime Agent | `~/.prime/agent/models.json` | JSON | yeni oturumlarda | geri döngü yer tutucusu | | ZCode | `~/.zcode/v2/config.json` | JSON | yeniden başlatmada | geri döngü yer tutucusu | | Aside | `~/.aside/u//models.json` | JSON | Aside tamamen kapatılıp yeniden açıldıktan sonra | geri döngü yer tutucusu | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | kaydedildiği anda — Raycast dosyayı izler | yok — yalnızca geri döngü | +| omo | `~/.omo/agent/models.json` | JSON | yeni oturumlarda | geri döngü yer tutucusu | Yönetilen DSH desteğinin en düşük uyumlu sürümü **DSH 0.1.0-rc.6**'dır. OpenCodex yalnızca `llm-pi-ai.providers.opencodex` bölümünü yönetir: Uygula ve Yenile bu bölümü değiştirir, Devre Dışı @@ -149,7 +150,7 @@ hiçbir şey sessizce değiştirilmez veya düşürülmez. **OMP** de yanındaki düzenlemelerden etkilenmez, ama başka bir nedenle: writer'ı yalnızca kendi `providers.opencodex` aralığını bayt bayt yamalar, dosyanın geri kalanı hiçbir zaman yeniden yazılmaz. Yorum taşıyabilen diğer biçimlerde (Hermes, OpenClaw, -Kimi Code, Gajae Code, MiniMax Code, Raycast — bütün belge olarak yazılan YAML, JSON5 ve TOML) veya +Kimi Code, gjc, MiniMax Code, Raycast — bütün belge olarak yazılan YAML, JSON5 ve TOML) veya kendi girdilerimiz düzenlenmişse, anahtar kilitlenir ve hangi düzenlemelerin size ait olduğunu tahmin etmek yerine devre dışı bırakmayı reddeder. @@ -178,7 +179,7 @@ diziler ve satır içi tablolar dahil bu türlenmiş değerleri tırnaklı metne Zaten tırnak içinde yazılmış tarihler desteklenir. Tırnaksız tarih türünü korumak için yapılandırmayı elle düzenleyin. -**Pi, Kimi Code, Gajae Code, MiniMax Code ve yönetilen DSH entegrasyonu yalnızca geri döngü (loopback) bağlantısına karşı +**Pi, Kimi Code, gjc, MiniMax Code ve yönetilen DSH entegrasyonu yalnızca geri döngü (loopback) bağlantısına karşı çalışır.** İlk dördünün yapılandırmasında geri döngü olmayan bir bağlantının gerektirdiği `x-opencodex-api-key` başlığı için alan yoktur. DSH genel bir headers haritası sunar, ancak rc.6 bu özel kabul başlığını desteklenen bir entegrasyon sözleşmesi olarak belgelememektedir; bu nedenle @@ -229,10 +230,10 @@ ocx integration client enable --client mcode ocx mcode ``` -Bağlandıktan sonra `ocx sync` ve `POST /api/sync`, yönetilen MCode, Pi, Aside ve -Raycast kataloglarını yeniler. Proxy başlangıcı da yönetilen Raycast kataloğunu -yeniler. Model görünürlüğü, sağlayıcı veya ön ayar değişiklikleri Pi, Aside ve -Raycast kataloglarını günceller. Eksik, dışarıdan düzenlenmiş, güvenli olmayan +Bağlandıktan sonra `ocx sync` ve `POST /api/sync`, yönetilen MCode, Pi, Aside, +Raycast ve omo kataloglarını yeniler. Proxy başlangıcı da yönetilen Raycast +kataloğunu yeniler. Model görünürlüğü, sağlayıcı veya ön ayar değişiklikleri Pi, +Aside, Raycast ve omo kataloglarını günceller. Eksik, dışarıdan düzenlenmiş, güvenli olmayan veya elle kaldırılmış bloklara dokunmaz; yeniden bağlamak istediğinizde entegrasyonu açıkça etkinleştirin. diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 04e72a766c..9f38ae6495 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -191,7 +191,7 @@ Grok Build model çitini yönetin ve uygulayın. ## İstemci yapılandırma dışa aktarma -### `ocx export --client ` +### `ocx export --client ` Çalışan proxy'ye bağlı bir istemci yapılandırmasını yazdırın. Komut, `opencodex` sağlayıcı bloğunu — temel URL, model listesi ve istemcinin kimlik bilgisi @@ -203,7 +203,7 @@ yalnızca Codex'in şu anda görebildiği modelleri yayınlar. | Bayrak | Eylem | | --- | --- | -| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | +| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | | `--json` | Betikler için stdout üzerinde oluşturulan belgeyi JSON olarak yazdırın. Bu, seçilen istemcinin yerel formatı YAML, TOML veya JSON5 olsa bile JSON'dur. | | `--out ` | İstemcinin yerel yapılandırma formatını `` konumuna yazın. Mevcut bir dosyanın üzerine yazmayı reddeder. | | `--force` | `--out`'un mevcut bir dosyanın üzerine yazmasına izin verin. | @@ -233,7 +233,9 @@ için kendi varsayılanlarını uygular) gelir. | `mcode` | `~/.minimax/config.yaml` (ayarlandığında `MINIMAX_DATA_DIR`, ardından eski `MAVIS_DATA_DIR` öncelikli; göreli değer reddedilir) | `mcode-config.yaml` | yok — geri döngü yer tutucusu | | `zcode` | `~/.zcode/v2/config.json` (ayarlandığında `ZCODE_DATA_DIR` öncelikli; göreli değer reddedilir) | `config.json` | yok — geri döngü yer tutucusu | | `prime` | `~/.prime/agent/models.json` (ayarlandığında `PRIME_AGENT_CODING_AGENT_DIR` öncelikli; göreli değer reddedilir) | `prime-models.json` | yok — geri döngü yer tutucusu | +| `aside` | Aside'ın kendi `accounts.json` dosyasının güncel olarak gösterdiği hesap için `~/.aside/u//models.json`; okunamayan bir manifest, gelişigüzel bir hesaba düşmek yerine reddedilir | `aside-models.json` | yok — geri döngü yer tutucusu | | `raycast` | `~/.config/raycast/ai/providers.yaml`, macOS ve Windows'ta aynı (Raycast `XDG_CONFIG_HOME` değerini dikkate almaz) | `raycast-providers.yaml` | yok — yalnızca geri döngü, `api_keys` girdisi yazılmaz | +| `omo` | `~/.omo/agent/models.json` (ayarlandığında sırasıyla `OMO_CODING_AGENT_DIR`, `SENPI_CODING_AGENT_DIR`, `PI_CODING_AGENT_DIR` öncelikli; göreli değer reddedilir) | `omo-models.json` | yok — geri döngü yer tutucusu | Raycast dışa aktarımı, `providers` dizisinde tek bir `id: opencodex` öğesi içeren bağımsız bir `providers.yaml` belgesidir: `name: OpenCodex`, proxy'nin `/v1` temel URL'si ve @@ -270,8 +272,8 @@ döngünün ötesine bağlandığında ayarlayın; kabul anahtarlarının nasıl görmek için [Uzaktan erişim](/tr/reference/configuration/#remote-access) bölümüne bakın. Yukarı akış sağlayıcılarının kendi anahtarları tamamen ayrı bir şeydir ve [Sağlayıcılar](/tr/guides/providers/) bölümüne göre yapılandırılır. -Gajae istisnadır: `OPENCODEX_GAJAE_API_KEY` provider kimlik bilgisini ortamdan -sağlar, ancak şeması uzaktan kabul başlığını gönderemediği için üretilen Gajae +gjc istisnadır: `OPENCODEX_GAJAE_API_KEY` provider kimlik bilgisini ortamdan +sağlar, ancak şeması uzaktan kabul başlığını gönderemediği için üretilen gjc entegrasyonu yalnızca geri döngüde çalışır. Aynı yük `GET /api/client-config` tarafından sunulur ve kontrol panelinin API diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 89203420e9..f141dee649 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -132,7 +132,7 @@ ocx claude desktop import [--apply] Validate and import JSON ## Client config export -### `ocx export --client ` +### `ocx export --client ` 输出连接到正在运行代理的客户端配置。此命令会以所选客户端的原生格式序列化 `opencodex` provider 块,其中包含基础 URL、模型列表,以及该客户端适用的凭据引用或 `opencodex-loopback` 占位值。 @@ -140,7 +140,7 @@ ocx claude desktop import [--apply] Validate and import JSON | 标志 | 动作 | | --- | --- | -| `--client ` | 必需。选择客户端配置格式。 | +| `--client ` | 必需。选择客户端配置格式。 | | `--json` | 仅在 stdout 打印配置 JSON,这样重定向即可捕获字节级精确输出。包括 `--out` 写入提示在内的所有诊断信息都会输出到 stderr。 | | `--out ` | 将配置写入 ``。拒绝替换已存在的文件。 | | `--force` | 允许 `--out` 替换已存在的文件。 | @@ -167,7 +167,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (设置后 `MINIMAX_DATA_DIR` 优先,其次是旧的 `MAVIS_DATA_DIR`;相对路径会被拒绝) | `mcode-config.yaml` | 无 — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (设置后 `ZCODE_DATA_DIR` 优先;相对路径会被拒绝) | `config.json` | 无 — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (设置后 `PRIME_AGENT_CODING_AGENT_DIR` 优先;相对路径会被拒绝) | `prime-models.json` | 无 — loopback placeholder | +| `aside` | `~/.aside/u//models.json`,对应 Aside 自己的 `accounts.json` 指明的当前账户;清单不可读时会被拒绝,而不是退回到某个账户 | `aside-models.json` | 无 — loopback placeholder | | `raycast` | `~/.config/raycast/ai/providers.yaml`(macOS 与 Windows 相同;Raycast 不遵循 `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | 无 — 仅限回环,不会写入 `api_keys` 条目 | +| `omo` | `~/.omo/agent/models.json`(设置后依次由 `OMO_CODING_AGENT_DIR`、`SENPI_CODING_AGENT_DIR`、`PI_CODING_AGENT_DIR` 优先;相对路径会被拒绝) | `omo-models.json` | 无 — loopback placeholder | Raycast 导出是一份独立的 `providers.yaml` 文档,在 `providers` 序列中只有一个 `id: opencodex` 元素:`name: OpenCodex`、代理的 `/v1` 基础 URL,以及每个已路由模型及其 `abilities`(`tools` 与 `system_message` 始终支持,`vision` 取自目录的输入模态,`reasoning_effort` 在模型有 effort 阶梯时设置,`temperature` 对推理模型关闭)。Custom Providers 是 Raycast Pro 功能,且 Raycast 会监视该文件,因此保存后的更改无需重启即可生效。格式见 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。不会写入任何 `api_keys` 条目,所以该导出仅限回环,非回环绑定会被拒绝。 @@ -177,7 +179,7 @@ opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi `ocx export` 从不写入你的真实客户端配置。该命令只会打印目标路径供你手动合并,而 `--out` 在没有 `--force` 的情况下拒绝覆盖已有文件,因为替换配置会破坏其中已有的其他 providers、agents 和 MCP 条目。 ::: -任何密钥都不会被序列化。生成的配置里携带的要么是有文档记录的环境引用,要么是非机密的环回占位值。环回代理(`127.0.0.1`,默认值)根本不需要准入密钥。当代理绑定到环回地址之外时,请设置对应的 `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY` 或 `OPENCODEX_OPENCLAW_API_KEY`。`OPENCODEX_GAJAE_API_KEY` 只会从环境中提供 Gajae provider 凭据,不能发送远程准入 header,因此生成的 Gajae 集成仍仅支持环回。关于准入密钥如何签发,请参见 [远程访问](/reference/configuration/#remote-access)。上游 providers 自身的密钥则完全是另一回事,需要按 [Providers](/guides/providers/) 单独配置。 +任何密钥都不会被序列化。生成的配置里携带的要么是有文档记录的环境引用,要么是非机密的环回占位值。环回代理(`127.0.0.1`,默认值)根本不需要准入密钥。当代理绑定到环回地址之外时,请设置对应的 `OPENCODEX_OPENCODE_API_KEY`、`OPENCODEX_HERMES_API_KEY` 或 `OPENCODEX_OPENCLAW_API_KEY`。`OPENCODEX_GAJAE_API_KEY` 只会从环境中提供 gjc provider 凭据,不能发送远程准入 header,因此生成的 gjc 集成仍仅支持环回。关于准入密钥如何签发,请参见 [远程访问](/reference/configuration/#remote-access)。上游 providers 自身的密钥则完全是另一回事,需要按 [Providers](/guides/providers/) 单独配置。 同一份负载会通过 `GET /api/client-config` 提供,并在仪表盘的 API 选项卡中渲染,因此 CLI、API 和 GUI 使用的是同一字节内容。 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index cd767e0a9b..bc9277656f 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -1,9 +1,9 @@ --- title: 整合 -description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside 與 Raycast——每個客戶端一個開關,每次寫入前都會先備份。 +description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast 與 omo——每個客戶端一個開關,每次寫入前都會先備份。 --- -**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十三個客戶端以這種方式運作,每個都有一個開關: +**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十四個客戶端以這種方式運作,每個都有一個開關: | 客戶端 | 設定檔 | 格式 | 變更生效時機 | 憑證 | |---|---|---|---|---| @@ -13,13 +13,14 @@ description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、 | Hermes | `~/.hermes/config.yaml` | YAML | 新 sessions | `OPENCODEX_HERMES_API_KEY` | | OpenClaw | `~/.openclaw/openclaw.json` | JSON5 | 立即,在執行中的 gateway 上 | `OPENCODEX_OPENCLAW_API_KEY` | | Kimi Code | `~/.kimi-code/config.toml` | TOML | 重新啟動時,或 `/reload` | loopback 佔位符 | -| Gajae Code | `~/.gjc/agent/models.yml` | YAML | 新 sessions,或當你開啟 `/model` 時 | `OPENCODEX_GAJAE_API_KEY` | +| gjc | `~/.gjc/agent/models.yml` | YAML | 新 sessions,或當你開啟 `/model` 時 | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml`(預設 `~/.dsh/settings.yaml`) | YAML | 熱重載 | 非秘密的 loopback bearer 佔位符 | | MiniMax Code | `~/.minimax/config.yaml` | YAML | 新 sessions,或開啟模型選擇器後 | loopback 佔位符 | | Prime Agent | `~/.prime/agent/models.json` | JSON | 新 sessions | loopback 佔位符 | | ZCode | `~/.zcode/v2/config.json` | JSON | 重新啟動時 | loopback 佔位符 | | Aside | `~/.aside/u//models.json` | JSON | 完全結束並重新開啟 Aside 後 | loopback 佔位符 | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 儲存後立即生效——Raycast 會監看該檔案 | 無——僅限 loopback | +| omo | `~/.omo/agent/models.json` | JSON | 新工作階段 | loopback 佔位符 | 受管理 DSH 支援的相容性下限是 **DSH 0.1.0-rc.6**。OpenCodex 只擁有 `llm-pi-ai.providers.opencodex`:Apply 與 Refresh 會取代該片段,Disable 只移除該片段, @@ -80,7 +81,7 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil - **Restore this point…** 會出現在較舊的操作上,或當檔案在那次操作之後有變更時。跨過這樣的變更做回復會再詢問一次,才覆蓋你的較新編輯——並且也會備份它們,所以那次的回復本身也可以復原。 - 每個客戶端保留十份備份。超過之後,最舊的快照檔案會被移除,其歷史列顯示為 **Backup expired**。 -停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP、DSH 與 Hermes** 同樣不受旁邊編輯影響,但原因不同:它們的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(OpenClaw、Kimi Code、Gajae Code、MiniMax Code、Raycast——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 +停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP、DSH 與 Hermes** 同樣不受旁邊編輯影響,但原因不同:它們的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(OpenClaw、Kimi Code、gjc、MiniMax Code、Raycast——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 ## 誠實的預期 @@ -90,7 +91,7 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil TOML 日期與時間值也會阻止自動重寫:合併步驟會將這些帶有型別的值轉成加引號的字串,陣列和行內表格中的值也一樣。原本就加引號的日期字串仍受支援;若要保留不加引號的日期型別,請手動編輯設定。 -**Pi、Kimi Code、Gajae Code、MiniMax Code 與受管理 DSH 整合只能對 loopback bind 運作。** 前四者的設定沒有非 loopback bind 所需的 `x-opencodex-api-key` header 欄位。DSH 雖然提供通用 headers map,但 rc.6 並未把這個專用准入 header 記錄為受支援的整合契約,因此受管理 writer 會選擇安全拒絕,而不自行猜測。請改用 SSH tunnel,或由本機 forwarder 加上該 header 後再以 loopback 存取。 +**Pi、Kimi Code、gjc、MiniMax Code 與受管理 DSH 整合只能對 loopback bind 運作。** 前四者的設定沒有非 loopback bind 所需的 `x-opencodex-api-key` header 欄位。DSH 雖然提供通用 headers map,但 rc.6 並未把這個專用准入 header 記錄為受支援的整合契約,因此受管理 writer 會選擇安全拒絕,而不自行猜測。請改用 SSH tunnel,或由本機 forwarder 加上該 header 後再以 loopback 存取。 **產生的 OMP 整合也刻意只支援 loopback。** OMP 確實支援 provider 層級的 headers,但這個最初的整合不會發出遠端 `x-opencodex-api-key` 憑證連線。手動的遠端 OMP 設定目前不在受管理的整合範圍內。 @@ -127,8 +128,8 @@ ocx mcode ``` 完成一次連接後,`ocx sync` 與 `POST /api/sync` 會更新 OpenCodex 已擁有的 -MCode、Pi、Aside 與 Raycast 目錄。proxy 啟動也會更新已擁有的 Raycast 目錄。 -模型可見性、provider 或 preset 變更會更新 Pi、Aside 與 Raycast。若區塊已刪除、 +MCode、Pi、Aside、Raycast 與 omo 目錄。proxy 啟動也會更新已擁有的 Raycast 目錄。 +模型可見性、provider 或 preset 變更會更新 Pi、Aside、Raycast 與 omo。若區塊已刪除、 遭外部修改、不安全或由你手動移除,sync 會保持原檔不動;只有在你確定要重新 連接時才再次執行 enable。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index d04c099ebf..d9585b2520 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -130,7 +130,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ## 客戶端設定匯出 -### `ocx export --client ` +### `ocx export --client ` 印出連接到執行中代理的客戶端設定。此指令會用所選客戶端的原生格式,序列化含有 base URL、模型清單,以及適用的環境變數參考或 loopback 佔位符的 `opencodex` provider 區塊。 @@ -138,7 +138,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON | 旗標 | 動作 | | --- | --- | -| `--client ` | 必填。選擇客戶端設定格式。 | +| `--client ` | 必填。選擇客戶端設定格式。 | | `--json` | 僅在 stdout 印出設定 JSON,使重導向能擷取逐位元組輸出。所有診斷訊息(含 `--out` 寫入提示)皆送至 stderr。 | | `--out ` | 將設定寫入 ``。拒絕覆寫既有檔案。 | | `--force` | 允許 `--out` 覆寫既有檔案。 | @@ -165,7 +165,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (設定後 `MINIMAX_DATA_DIR` 優先,其次為舊的 `MAVIS_DATA_DIR`;相對路徑會被拒絕) | `mcode-config.yaml` | 無——loopback 佔位符 | | `zcode` | `~/.zcode/v2/config.json` (設定後 `ZCODE_DATA_DIR` 優先;相對路徑會被拒絕) | `config.json` | 無——loopback 佔位符 | | `prime` | `~/.prime/agent/models.json` (設定後 `PRIME_AGENT_CODING_AGENT_DIR` 優先;相對路徑會被拒絕) | `prime-models.json` | 無——loopback 佔位符 | +| `aside` | `~/.aside/u//models.json`,對應 Aside 自己的 `accounts.json` 指定的目前帳戶;資訊清單無法讀取時會被拒絕,而不是退回任一帳戶 | `aside-models.json` | 無——loopback 佔位符 | | `raycast` | `~/.config/raycast/ai/providers.yaml`(macOS 與 Windows 相同;Raycast 不遵循 `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | 無——僅限 loopback,不會寫入 `api_keys` 項目 | +| `omo` | `~/.omo/agent/models.json`(設定後依序由 `OMO_CODING_AGENT_DIR`、`SENPI_CODING_AGENT_DIR`、`PI_CODING_AGENT_DIR` 優先;相對路徑會被拒絕) | `omo-models.json` | 無——loopback 佔位符 | Raycast 匯出是一份獨立的 `providers.yaml` 文件,在 `providers` 序列中只有一個 `id: opencodex` 元素:`name: OpenCodex`、proxy 的 `/v1` base URL,以及每個路由模型及其 `abilities`(`tools` 與 `system_message` 一律支援,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort 階梯時設定,`temperature` 對推理模型關閉)。Custom Providers 是 Raycast Pro 功能,且 Raycast 會監看該檔案,因此儲存後的變更不需重新啟動即可生效。格式說明見 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。不會寫入任何 `api_keys` 項目,所以此匯出僅限 loopback,非 loopback 的 bind 會被拒絕。 @@ -180,7 +182,7 @@ opencode 會插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。Pi 與 OMP 的匯出不 金鑰永不被序列化。設定只帶有文件化的環境變數參考,或非秘密的 loopback 佔位符。loopback 代理(`127.0.0.1`,預設值)完全不需要准入金鑰。只有客戶端 schema 支援、且代理綁定超出 loopback 時,才設定被引用的變數;關於准入金鑰的簽發方式,請見[遠端存取](/zh-tw/reference/configuration/#remote-access)。上游 provider 本身的金鑰是完全不同的事,依[供應商](/zh-tw/guides/providers/)個別設定。 -Gajae 是例外:`OPENCODEX_GAJAE_API_KEY` 只會從環境提供 provider 憑證,但其 schema 無法傳送遠端准入 header,因此產生的 Gajae 整合仍僅支援 loopback。 +gjc 是例外:`OPENCODEX_GAJAE_API_KEY` 只會從環境提供 provider 憑證,但其 schema 無法傳送遠端准入 header,因此產生的 gjc 整合仍僅支援 loopback。 相同的 payload 亦由 `GET /api/client-config` 提供,並在儀表板的 API 分頁渲染,因此 CLI、API 與 GUI 使用相同的位元組。 diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md index 1aab8d651c..235fa7bf27 100644 --- a/gui/public/provider-icons/README.md +++ b/gui/public/provider-icons/README.md @@ -57,6 +57,19 @@ Export-client marks (used by the API tab's connect rows, not the provider list): behind — is removed because the path never leaves the frame and the rect would read as a second ink to the mark tooling here. +- `omo.svg` — fetched 2026-09-12 from `https://omo.dev/brand/omo-mark.svg`, the + 24px header mark on omo's own site. The identical file (4021 bytes, MD5 + `c33f72d7c4612c290834ba860f644557`) is committed as + `.github/assets/omo-icon-light.svg` in `code-yeongyu/oh-my-openagent` and + rendered as that README's logo, which is what identifies it as the intended + square lockup rather than an incidental asset. Note the branch: that repository's + default branch is `dev`, so the `main` raw path 404s. Copied unmodified, + `viewBox="0 0 1024 1024"`. Used for the `omo` client (`omo-ai@beta`). + `omo.dev/icon.svg` was rejected for being a single `O` glyph, the + same rule that sent Hermes to a trace; `omo-logo.png` is a superseded 3D + illustration and `omo.png` a landscape screenshot. The npm tarball ships no + image at all. + - `minimax.svg` — fetched 2026-08-31 from `https://raw.githubusercontent.com/MiniMax-AI/MiniMax-01/main/figures/minimax.svg`, MiniMax's own symbol as committed in their own model repository. The API-docs @@ -148,6 +161,12 @@ Decisions that are not obvious from looking at the file: - `raycast.svg` **is not masked.** One ink, but that ink is #FF6363 — Raycast red, the same case as `openai.svg` and `deepseek-harness.svg`. Legible on both surfaces as an image. +- `omo.svg` **is not masked.** Two inks: an `#F4F4F4` rounded plate carrying an + `#041617` face. The plate is opaque and covers most of the canvas, so masking + — which reads alpha, not color — would paint a filled rounded tile and discard + the face entirely. This is the plated case `qoder.svg` already established, and + it is why a plated mark is not a candidate for the monochrome set however + neutral its inks look. Both directions are enforced in `gui/tests/integration-marks.test.ts`, including a luminance check that fails any single-ink near-neutral mark left as an image. That diff --git a/gui/public/provider-icons/omo.svg b/gui/public/provider-icons/omo.svg new file mode 100644 index 0000000000..21c1b35c6e --- /dev/null +++ b/gui/public/provider-icons/omo.svg @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index c5971ffb6b..cf9762ab71 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -101,6 +101,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/prime", "integrations/aside", "integrations/raycast", + "integrations/omo", ] as const; export function hashBelongsToPage(rawHash: string, page: Page): boolean { diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index afd4484551..2c6e198600 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,7 +8,7 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"] as const; +export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { @@ -25,6 +25,7 @@ export const CLIENT_LABEL_KEYS = { prime: "api.clientConfig.clientPrime", aside: "api.clientConfig.clientAside", raycast: "api.clientConfig.clientRaycast", + omo: "api.clientConfig.clientOmo", } as const; /** @@ -73,6 +74,9 @@ export const CLIENT_MARKS: Partial> = { aside: "/provider-icons/aside.svg", // Raycast red (#FF6363) is the brand, so like `dsh` it stays an image. raycast: "/provider-icons/raycast.svg", + // Two inks: an #F4F4F4 plate carrying an #041617 face. Masking reads alpha, + // so it would paint the plate and throw the face away — see the README. + omo: "/provider-icons/omo.svg", }; /** diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts index eca38510bd..f345866336 100644 --- a/gui/src/components/integration-marks.ts +++ b/gui/src/components/integration-marks.ts @@ -58,6 +58,7 @@ export const INTEGRATION_MARKS: Record = { prime: CLIENT_MARKS.prime ?? null, aside: CLIENT_MARKS.aside ?? null, raycast: CLIENT_MARKS.raycast ?? null, + omo: CLIENT_MARKS.omo ?? null, }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 68363152b5..5a6c9d3872 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1097,13 +1097,14 @@ export const de: Record = { "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", - "integrations.tab.gajae": "Gajae Code", + "integrations.tab.gajae": "gjc", "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", + "integrations.tab.omo": "omo", "integrations.aside.profilesTitle": "Aside-Profile", "integrations.aside.profilesHint": "Wähle, welche Profile die ausgewählten Modelle erhalten. Das aktive Aside-Profil bleibt unverändert.", "integrations.aside.all": "Alle Profile synchronisieren", @@ -1264,6 +1265,7 @@ export const de: Record = { "integrations.semantics.prime": "Verwaltet nur providers.opencodex in der models.json von Prime Agent — ~/.prime/agent, sofern PRIME_AGENT_CODING_AGENT_DIR sie nicht umleitet. Andere Provider und Modell-Overrides bleiben unverändert. Gilt für neue Sitzungen.", "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der ~/.aside/u//models.json dieses Profils. Andere Provider bleiben unverändert. Beende Aside nach dem Anwenden vollständig und öffne es erneut.", "integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.", + "integrations.semantics.omo": "Verwaltet ausschließlich providers.opencodex in der models.json von omo — ~/.omo/agent, sofern nicht OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR oder PI_CODING_AGENT_DIR sie umleitet. Ihre übrigen Provider bleiben unverändert. Gilt ab neuen Sitzungen.", "integrations.raycast.proRequired": "Custom Providers ist eine Funktion von Raycast Pro. Die Datei wird geschrieben, aber Raycast ignoriert sie, bis ein Pro-Abonnement aktiv ist.", "integrations.raycast.planUnknown": "Es konnte nicht festgestellt werden, ob Raycast Pro aktiv ist; Custom Providers erfordert Raycast Pro.", "integrations.raycast.revealConfig": "Öffnen Sie Raycast → Einstellungen → AI und klicken Sie einmal auf „Reveal Providers Config“, damit der Providers-Ordner existiert.", @@ -1589,13 +1591,14 @@ export const de: Record = { "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", - "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientGajae": "gjc", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", + "api.clientConfig.clientOmo": "omo", "api.clientConfig.copy": "Konfiguration kopieren", "api.clientConfig.download": "Herunterladen", "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 89fc5ec0d7..9007a52761 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1630,13 +1630,14 @@ export const en = { "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", - "integrations.tab.gajae": "Gajae Code", + "integrations.tab.gajae": "gjc", "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", + "integrations.tab.omo": "omo", "integrations.aside.profilesTitle": "Aside profiles", "integrations.aside.profilesHint": "Choose which profiles receive the selected models. Aside’s active profile stays unchanged.", "integrations.aside.all": "Sync all profiles", @@ -1837,6 +1838,7 @@ export const en = { "integrations.semantics.prime": "Manages only providers.opencodex in Prime Agent's models.json — ~/.prime/agent unless PRIME_AGENT_CODING_AGENT_DIR redirects it. Your other providers and model overrides stay unchanged. Applies to new sessions.", "integrations.semantics.aside": "Manages only providers.opencodex in this profile’s ~/.aside/u//models.json. Your other providers stay unchanged. Fully quit and reopen Aside after applying.", "integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.", + "integrations.semantics.omo": "Manages only providers.opencodex in omo's models.json — ~/.omo/agent unless OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR or PI_CODING_AGENT_DIR redirects it. Your other providers stay unchanged. Applies to new sessions.", "integrations.raycast.proRequired": "Custom Providers is a Raycast Pro feature. The file will be written, but Raycast ignores it until a Pro subscription is active.", "integrations.raycast.planUnknown": "Could not determine whether Raycast Pro is active; Custom Providers requires Raycast Pro.", "integrations.raycast.revealConfig": "Open Raycast → Settings → AI and click Reveal Providers Config once so the providers folder exists.", @@ -2173,13 +2175,14 @@ export const en = { "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", - "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientGajae": "gjc", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", + "api.clientConfig.clientOmo": "omo", "api.clientConfig.copy": "Copy config", "api.clientConfig.download": "Download", "api.clientConfig.loading": "Building client config…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4cf6818072..c5e3c54c5a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1602,13 +1602,14 @@ export const fr: Record = { "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", - "integrations.tab.gajae": "Gajae Code", + "integrations.tab.gajae": "gjc", "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", + "integrations.tab.omo": "omo", "integrations.aside.profilesTitle": "Profils Aside", "integrations.aside.profilesHint": "Choisissez les profils qui recevront les modèles sélectionnés. Le profil actif dans Aside reste inchangé.", "integrations.aside.all": "Synchroniser tous les profils", @@ -1769,6 +1770,7 @@ export const fr: Record = { "integrations.semantics.prime": "Gère uniquement providers.opencodex dans le models.json de Prime Agent — ~/.prime/agent, sauf si PRIME_AGENT_CODING_AGENT_DIR le redirige. Vos autres fournisseurs et surcharges de modèles restent inchangés. S'applique aux nouvelles sessions.", "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le fichier ~/.aside/u//models.json de ce profil. Vos autres fournisseurs restent inchangés. Quittez complètement Aside et relancez-le après application.", "integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.", + "integrations.semantics.omo": "Gère uniquement providers.opencodex dans le models.json d'omo — ~/.omo/agent, sauf redirection par OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR ou PI_CODING_AGENT_DIR. Vos autres fournisseurs restent inchangés. S'applique aux nouvelles sessions.", "integrations.raycast.proRequired": "Custom Providers est une fonctionnalité Raycast Pro. Le fichier sera écrit, mais Raycast l'ignore tant qu'un abonnement Pro n'est pas actif.", "integrations.raycast.planUnknown": "Impossible de déterminer si Raycast Pro est actif ; Custom Providers nécessite Raycast Pro.", "integrations.raycast.revealConfig": "Ouvrez Raycast → Réglages → AI et cliquez une fois sur « Reveal Providers Config » pour que le dossier des fournisseurs existe.", @@ -2092,13 +2094,14 @@ export const fr: Record = { "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", - "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientGajae": "gjc", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", + "api.clientConfig.clientOmo": "omo", "api.clientConfig.copy": "Copier la configuration", "api.clientConfig.download": "Télécharger", "api.clientConfig.loading": "Génération de la configuration du client…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c77edbe2c7..ca358a950b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1527,13 +1527,14 @@ export const ja: Record = { "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", - "integrations.tab.gajae": "Gajae Code", + "integrations.tab.gajae": "gjc", "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", + "integrations.tab.omo": "omo", "integrations.aside.profilesTitle": "Asideのプロファイル", "integrations.aside.profilesHint": "選択したモデルを同期するプロファイルを選んでください。Asideで使用中のプロファイルは変わりません。", "integrations.aside.all": "すべてのプロファイルを同期", @@ -1694,6 +1695,7 @@ export const ja: Record = { "integrations.semantics.prime": "Prime Agent の models.json 内の providers.opencodex のみを管理します。場所は ~/.prime/agent ですが、PRIME_AGENT_CODING_AGENT_DIR が設定されている場合はそちらが優先されます。他のプロバイダーとモデルオーバーライドは変更しません。新しいセッションから適用されます。", "integrations.semantics.aside": "このプロファイルの ~/.aside/u//models.json 内の providers.opencodex のみを管理します。他のプロバイダーは変更しません。適用後は Aside を完全に終了してから開き直してください。", "integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。", + "integrations.semantics.omo": "omo の models.json にある providers.opencodex のみを管理します。場所は ~/.omo/agent で、OMO_CODING_AGENT_DIR・SENPI_CODING_AGENT_DIR・PI_CODING_AGENT_DIR のいずれかが設定されている場合はそちらが優先されます。他のプロバイダーは変更しません。新しいセッションから適用されます。", "integrations.raycast.proRequired": "Custom Providers は Raycast Pro の機能です。ファイルは書き込まれますが、Pro サブスクリプションが有効になるまで Raycast はこれを無視します。", "integrations.raycast.planUnknown": "Raycast Pro が有効かどうか確認できませんでした。Custom Providers には Raycast Pro が必要です。", "integrations.raycast.revealConfig": "Raycast → 設定 → AI を開き、「Reveal Providers Config」を一度クリックして providers フォルダを作成してください。", @@ -2024,13 +2026,14 @@ export const ja: Record = { "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", - "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientGajae": "gjc", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", + "api.clientConfig.clientOmo": "omo", "api.clientConfig.copy": "設定をコピー", "api.clientConfig.download": "ダウンロード", "api.clientConfig.loading": "クライアント設定を生成中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 34d5ceae87..5eb49f8235 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1133,13 +1133,14 @@ export const ko: Record = { "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", - "integrations.tab.gajae": "Gajae Code", + "integrations.tab.gajae": "gjc", "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", + "integrations.tab.omo": "omo", "integrations.aside.profilesTitle": "Aside 프로필", "integrations.aside.profilesHint": "선택한 모델을 동기화할 프로필을 고르세요. Aside에서 사용 중인 프로필은 바뀌지 않습니다.", "integrations.aside.all": "모든 프로필 동기화", @@ -1300,6 +1301,7 @@ export const ko: Record = { "integrations.semantics.prime": "Prime Agent의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.prime/agent이며 PRIME_AGENT_CODING_AGENT_DIR가 설정되면 그쪽이 우선합니다. 다른 프로바이더와 모델 오버라이드는 변경하지 않습니다. 새 세션부터 적용됩니다.", "integrations.semantics.aside": "이 프로필의 ~/.aside/u//models.json에서 providers.opencodex만 관리합니다. 다른 프로바이더는 그대로 유지됩니다. 적용 후 Aside를 완전히 종료하고 다시 여세요.", "integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.", + "integrations.semantics.omo": "omo의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.omo/agent이며 OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR, PI_CODING_AGENT_DIR 중 설정된 값이 있으면 그쪽이 우선합니다. 다른 프로바이더는 그대로 유지됩니다. 새 세션부터 적용됩니다.", "integrations.raycast.proRequired": "Custom Providers는 Raycast Pro 기능입니다. 파일은 기록되지만 Pro 구독이 활성화될 때까지 Raycast는 이를 무시합니다.", "integrations.raycast.planUnknown": "Raycast Pro 활성 여부를 확인할 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.", "integrations.raycast.revealConfig": "Raycast → 설정 → AI를 열고 「Reveal Providers Config」를 한 번 클릭해 providers 폴더를 만드세요.", @@ -1628,13 +1630,14 @@ export const ko: Record = { "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", - "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientGajae": "gjc", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", + "api.clientConfig.clientOmo": "omo", "api.clientConfig.copy": "설정 복사", "api.clientConfig.download": "다운로드", "api.clientConfig.loading": "클라이언트 설정 생성 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 488f87d55b..621b3e11a1 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1597,13 +1597,14 @@ export const ru: Record = { "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", - "integrations.tab.gajae": "Gajae Code", + "integrations.tab.gajae": "gjc", "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", + "integrations.tab.omo": "omo", "integrations.aside.profilesTitle": "Профили Aside", "integrations.aside.profilesHint": "Выберите профили, в которые будут добавлены выбранные модели. Активный профиль Aside не изменится.", "integrations.aside.all": "Синхронизировать все профили", @@ -1764,6 +1765,7 @@ export const ru: Record = { "integrations.semantics.prime": "Управляет только providers.opencodex в models.json Prime Agent — ~/.prime/agent, если PRIME_AGENT_CODING_AGENT_DIR не переопределяет путь. Другие провайдеры и переопределения моделей не меняются. Применяется к новым сессиям.", "integrations.semantics.aside": "Управляет только providers.opencodex в файле ~/.aside/u//models.json этого профиля. Другие провайдеры остаются без изменений. После применения полностью закройте и снова откройте Aside.", "integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.", + "integrations.semantics.omo": "Управляет только providers.opencodex в models.json omo — ~/.omo/agent, если только OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR или PI_CODING_AGENT_DIR не перенаправляет путь. Остальные провайдеры остаются без изменений. Применяется к новым сессиям.", "integrations.raycast.proRequired": "Custom Providers — функция Raycast Pro. Файл будет записан, но Raycast игнорирует его, пока не активна подписка Pro.", "integrations.raycast.planUnknown": "Не удалось определить, активен ли Raycast Pro; для Custom Providers требуется Raycast Pro.", "integrations.raycast.revealConfig": "Откройте Raycast → Настройки → AI и один раз нажмите «Reveal Providers Config», чтобы папка провайдеров появилась.", @@ -2094,13 +2096,14 @@ export const ru: Record = { "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", - "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientGajae": "gjc", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", + "api.clientConfig.clientOmo": "omo", "api.clientConfig.copy": "Копировать конфигурацию", "api.clientConfig.download": "Скачать", "api.clientConfig.loading": "Формируется конфигурация клиента…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 2e46e2792f..2c4f1a57cf 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1616,13 +1616,14 @@ export const tr: Record = { "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", - "integrations.tab.gajae": "Gajae Code", + "integrations.tab.gajae": "gjc", "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", + "integrations.tab.omo": "omo", "integrations.aside.profilesTitle": "Aside profilleri", "integrations.aside.profilesHint": "Seçili modellerin hangi profillere aktarılacağını seçin. Aside’ın etkin profili değişmez.", "integrations.aside.all": "Tüm profilleri eşitle", @@ -1782,6 +1783,7 @@ export const tr: Record = { "integrations.semantics.prime": "Yalnızca Prime Agent'ın models.json dosyasındaki providers.opencodex bölümünü yönetir — PRIME_AGENT_CODING_AGENT_DIR ayarlı değilse ~/.prime/agent. Diğer sağlayıcılar ve model geçersiz kılmaları değişmez. Yeni oturumlarda geçerli olur.", "integrations.semantics.aside": "Yalnızca bu profilin ~/.aside/u//models.json dosyasındaki providers.opencodex bölümünü yönetir. Diğer sağlayıcılarınız değişmez. Uyguladıktan sonra Aside’ı tamamen kapatıp yeniden açın.", "integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.", + "integrations.semantics.omo": "Yalnızca omo'nun models.json dosyasındaki providers.opencodex girdisini yönetir — OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR veya PI_CODING_AGENT_DIR yönlendirmediği sürece ~/.omo/agent. Diğer sağlayıcılarınız değişmeden kalır. Yeni oturumlardan itibaren geçerlidir.", "integrations.raycast.proRequired": "Custom Providers bir Raycast Pro özelliğidir. Dosya yazılır, ancak bir Pro aboneliği etkin olana kadar Raycast bunu yok sayar.", "integrations.raycast.planUnknown": "Raycast Pro’nun etkin olup olmadığı belirlenemedi; Custom Providers için Raycast Pro gerekir.", "integrations.raycast.revealConfig": "Raycast → Ayarlar → AI bölümünü açıp sağlayıcı klasörünün oluşması için „Reveal Providers Config“ seçeneğine bir kez tıklayın.", @@ -2113,13 +2115,14 @@ export const tr: Record = { "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", - "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientGajae": "gjc", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", + "api.clientConfig.clientOmo": "omo", "api.clientConfig.copy": "JSON Kopyala", "api.clientConfig.download": "İndir", "api.clientConfig.loading": "İstemci konfigürasyonu oluşturuluyor…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index b9a26da41c..797011c886 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2204,13 +2204,14 @@ export const zhTW: Record = { "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", - "integrations.tab.gajae": "Gajae Code", + "integrations.tab.gajae": "gjc", "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", + "integrations.tab.omo": "omo", "integrations.aside.profilesTitle": "Aside 設定檔", "integrations.aside.profilesHint": "選擇要接收所選模型的設定檔。Aside 目前使用的設定檔不會改變。", "integrations.aside.all": "同步所有設定檔", @@ -2371,6 +2372,7 @@ export const zhTW: Record = { "integrations.semantics.prime": "僅管理 Prime Agent 的 models.json 中的 providers.opencodex;預設位於 ~/.prime/agent,若設定 PRIME_AGENT_CODING_AGENT_DIR 則以其為準。不會變更其他供應商或模型覆寫設定。對新工作階段生效。", "integrations.semantics.aside": "僅管理此設定檔的 ~/.aside/u//models.json 中的 providers.opencodex。其他供應商維持不變。套用後請完全結束並重新開啟 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。", + "integrations.semantics.omo": "僅管理 omo 的 models.json 中的 providers.opencodex,路徑為 ~/.omo/agent,若設定了 OMO_CODING_AGENT_DIR、SENPI_CODING_AGENT_DIR 或 PI_CODING_AGENT_DIR 則以其為準。你的其他供應商維持不變。對新工作階段生效。", "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。檔案會被寫入,但在 Pro 訂閱生效之前 Raycast 會忽略它。", "integrations.raycast.planUnknown": "無法確認 Raycast Pro 是否已啟用;Custom Providers 需要 Raycast Pro。", "integrations.raycast.revealConfig": "開啟 Raycast → 設定 → AI,點一次「Reveal Providers Config」,以便建立 providers 資料夾。", @@ -2409,13 +2411,14 @@ export const zhTW: Record = { "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", - "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientGajae": "gjc", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", + "api.clientConfig.clientOmo": "omo", "cws.tabsLabel": "Combo 詳細區段", "cws.field.nativeAlias": "原生 OpenAI 別名", "cws.field.nativeAliasHint": "讓此 combo 擁有受支援的未限定原生 OpenAI 模型 ID。帶有帳號或供應商限定的 OpenAI 路由仍保持獨立。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 46866680b0..3bdf1e7630 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1114,13 +1114,14 @@ export const zh: Record = { "integrations.tab.hermes": "Hermes", "integrations.tab.openclaw": "OpenClaw", "integrations.tab.kimi": "Kimi Code", - "integrations.tab.gajae": "Gajae Code", + "integrations.tab.gajae": "gjc", "integrations.tab.dsh": "DSH", "integrations.tab.mcode": "MiniMax Code", "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", "integrations.tab.raycast": "Raycast", + "integrations.tab.omo": "omo", "integrations.aside.profilesTitle": "Aside 配置文件", "integrations.aside.profilesHint": "选择要接收所选模型的配置文件。Aside 当前使用的配置文件不会改变。", "integrations.aside.all": "同步所有配置文件", @@ -1281,6 +1282,7 @@ export const zh: Record = { "integrations.semantics.prime": "仅管理 Prime Agent 的 models.json 中的 providers.opencodex;默认位于 ~/.prime/agent,若设置 PRIME_AGENT_CODING_AGENT_DIR 则以其为准。不会更改其他提供商或模型覆盖设置。对新会话生效。", "integrations.semantics.aside": "仅管理此配置文件的 ~/.aside/u//models.json 中的 providers.opencodex。其他提供商保持不变。应用后请完全退出并重新打开 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。", + "integrations.semantics.omo": "仅管理 omo 的 models.json 中的 providers.opencodex,路径为 ~/.omo/agent,若设置了 OMO_CODING_AGENT_DIR、SENPI_CODING_AGENT_DIR 或 PI_CODING_AGENT_DIR 则以其为准。你的其他提供商保持不变。对新会话生效。", "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。文件会被写入,但在 Pro 订阅生效之前 Raycast 会忽略它。", "integrations.raycast.planUnknown": "无法确定 Raycast Pro 是否已激活;Custom Providers 需要 Raycast Pro。", "integrations.raycast.revealConfig": "打开 Raycast → 设置 → AI,点击一次“Reveal Providers Config”,以便创建 providers 文件夹。", @@ -1609,13 +1611,14 @@ export const zh: Record = { "api.clientConfig.clientHermes": "Hermes", "api.clientConfig.clientOpenclaw": "OpenClaw", "api.clientConfig.clientKimi": "Kimi Code", - "api.clientConfig.clientGajae": "Gajae Code", + "api.clientConfig.clientGajae": "gjc", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", "api.clientConfig.clientRaycast": "Raycast", + "api.clientConfig.clientOmo": "omo", "api.clientConfig.copy": "复制配置", "api.clientConfig.download": "下载", "api.clientConfig.loading": "正在生成客户端配置…", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 2eef2c5cf0..52e1c97733 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -59,6 +59,7 @@ const SEMANTICS_KEY: Record = { prime: "integrations.semantics.prime", aside: "integrations.semantics.aside", raycast: "integrations.semantics.raycast", + omo: "integrations.semantics.omo", }; const TAB_LABEL_KEY: Record = { @@ -75,6 +76,7 @@ const TAB_LABEL_KEY: Record = { prime: "integrations.tab.prime", aside: "integrations.tab.aside", raycast: "integrations.tab.raycast", + omo: "integrations.tab.omo", }; export default function FileIntegrationPage({ diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 85ffdc7be4..bd72c61dfe 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -15,6 +15,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "prime", "aside", "raycast", + "omo", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts index 99502bde87..b37ceab85f 100644 --- a/gui/src/pages/integrations/integration-tabs.ts +++ b/gui/src/pages/integrations/integration-tabs.ts @@ -47,6 +47,7 @@ export const TABS: readonly TabDefinition[] = [ { id: "prime", hash: "integrations/prime", labelKey: "integrations.tab.prime" }, { id: "aside", hash: "integrations/aside", labelKey: "integrations.tab.aside" }, { id: "raycast", hash: "integrations/raycast", labelKey: "integrations.tab.raycast" }, + { id: "omo", hash: "integrations/omo", labelKey: "integrations.tab.omo" }, ] as const; export const FILE_CLIENTS = new Set([ @@ -63,4 +64,5 @@ export const FILE_CLIENTS = new Set([ "prime", "aside", "raycast", + "omo", ]); diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 7932cf5648..2d4770f3d5 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -153,6 +153,7 @@ const FILE_LABEL_KEY: Record = { prime: "integrations.tab.prime", aside: "integrations.tab.aside", raycast: "integrations.tab.raycast", + omo: "integrations.tab.omo", }; /** A file client's block is in the file for both `current` and `stale`. */ diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index ea8210e7e4..37f2df193b 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -170,12 +170,13 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut .find(el => el.textContent?.trim() === label)!; } -test("the API download surface includes DSH, MiniMax Code, Aside and Raycast as clients", () => { - expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); +test("the API download surface includes DSH, MiniMax Code, Aside, Raycast and omo as clients", () => { + expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo"]); expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode"); expect(CLIENT_LABEL_KEYS.zcode).toBe("api.clientConfig.clientZcode"); expect(CLIENT_LABEL_KEYS.aside).toBe("api.clientConfig.clientAside"); + expect(CLIENT_LABEL_KEYS.omo).toBe("api.clientConfig.clientOmo"); }); test("each row fetches its own client and its dialog renders that client's exact bytes", async () => { diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 87250bb74b..8a1f34390f 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -121,6 +121,8 @@ const INTENTIONAL_ENGLISH = new Set([ "api.clientConfig.clientAside", "integrations.tab.raycast", "api.clientConfig.clientRaycast", + "integrations.tab.omo", + "api.clientConfig.clientOmo", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "pws.pacingRpmUnit", diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index eea7dcfa0c..7b540be78f 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -16,9 +16,9 @@ import { const originalFetch = globalThis.fetch; -test("DSH, Aside and Raycast are file integration clients", () => { +test("DSH, Aside, Raycast and omo are file integration clients", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", ]); }); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 54809a4422..777ae3e8ec 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -290,7 +290,7 @@ test("every client counts toward the summary, not just the file clients", () => test("an unsettled file list renders unknown rows instead of dropping them", () => { const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); - expect(built.rows).toHaveLength(18); + expect(built.rows).toHaveLength(19); expect(rowById(built, "omp").state).toBe("unknown"); expect(rowById(built, "mcode").state).toBe("unknown"); expect(rowById(built, "zcode").state).toBe("unknown"); diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 9754b98051..883827baa7 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -132,6 +132,9 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "api.clientConfig.clientAside", "integrations.tab.raycast", "api.clientConfig.clientRaycast", + // "omo" is the product's own lowercase spelling, identical in every locale. + "integrations.tab.omo", + "api.clientConfig.clientOmo", "integrations.codex.title", // Provider proper nouns kept in English "provider.name.commandCodeAuth", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7b6e068d60..8ade65ef45 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -926,6 +926,7 @@ "ollama-show-enrichment-v7.test.ts": "providers/ollama", "ollama-show-enrichment.test.ts": "providers/ollama", "ollama-show-ignore-abort.test.ts": "providers/ollama", + "omo-client.test.ts": "clients", "omp-path-contract.test.ts": "clients", "omp-yaml-source-inline-comments.test.ts": "clients", "openai-api-virtual-models.test.ts": "adapters/openai", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 2cd69d59ed..94e6fe7b3f 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -452,7 +452,7 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi", "raycast"])); + }, ["mcode", "pi", "raycast", "omo"])); } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/cli/help.ts b/src/cli/help.ts index c8f71cdbbd..70d53f9eb2 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -81,7 +81,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (13 clients) + ocx export --client Print a client config wired to the running proxy (14 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 929aa7f1fe..6a693780b8 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -336,8 +336,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 6a94b74d70..8551f190a9 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -458,6 +458,51 @@ export function primeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(primeAgentDir(env, home), "models.json"); } +/** + * omo resolves its agent directory from THREE variables, in its own order: + * `OMO_CODING_AGENT_DIR`, then `SENPI_CODING_AGENT_DIR`, then + * `PI_CODING_AGENT_DIR`, falling back to `~/.omo/agent`. That is not an + * inference from the family resemblance — `bin/lib/agent-dir.js` publishes the + * list as `AGENT_DIR_ENV_NAMES` and the launcher pins the first two to whatever + * it resolves before spawning senpi, so the engine can never disagree with it. + * + * The order is load-bearing rather than cosmetic. A user with both + * `PI_CODING_AGENT_DIR` and `OMO_CODING_AGENT_DIR` set runs omo out of the omo + * one; checking Pi's first would have us write a catalog omo never reads. + * + * Each variable reports under its OWN name, because telling someone that + * `PI_CODING_AGENT_DIR` is relative when they set `OMO_CODING_AGENT_DIR` sends + * them to the wrong line of their shell profile. An empty or whitespace value + * falls through to the next name, which is what omo's own `.trim()`-then-test + * loop does. + * + * One divergence is deliberate: omo `resolve()`s a relative override against its + * own cwd and does not expand `~`. We refuse the relative form and do expand + * `~`, exactly as Pi, Prime, MCode and ZCode already do, because a background + * proxy and a foreground client have different working directories and would + * otherwise disagree about which file is named. + * + * Consequence worth knowing: a user who sets only `PI_CODING_AGENT_DIR` has Pi + * and omo reading ONE `models.json`. Both clients emit the same provider block + * through the same builder so the bytes agree; what cannot be shared is the + * ownership record, since two enabled clients would claim one file. That is + * omo's contract, not ours to paper over. + */ +export function omoAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const omo = env.OMO_CODING_AGENT_DIR?.trim(); + if (omo) return absoluteClientPath(omo, home, "OMO_CODING_AGENT_DIR"); + const senpi = env.SENPI_CODING_AGENT_DIR?.trim(); + if (senpi) return absoluteClientPath(senpi, home, "SENPI_CODING_AGENT_DIR"); + const pi = env.PI_CODING_AGENT_DIR?.trim(); + if (pi) return absoluteClientPath(pi, home, "PI_CODING_AGENT_DIR"); + return join(home, ".omo", "agent"); +} + +/** omo's canonical custom-provider catalog, read by the senpi engine it wraps. */ +export function omoConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(omoAgentDir(env, home), "models.json"); +} + /** * Aside's state root. Unlike every other client here, Aside ships NO variable * that relocates it: its CLI carries `ASIDE_DAEMON_BASE_URL`, @@ -1109,6 +1154,26 @@ function buildAsideContribution(ctx: ExportContext): ManagedContribution { return singleFragment("aside", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } +/** + * omo is the Pi document again, and this time the engine was checked rather + * than inferred: `omo-ai@beta` is a launcher around `@code-yeongyu/senpi`, and + * senpi's compiled validator accepts what `buildPiClientConfig` emits verbatim — + * the keyed `providers`, the `models` ARRAY whose identity is `id`, the + * `openai-completions` dialect, the loopback placeholder, and the + * `thinkingLevelMap` levels. Evidence: + * `devlog/_plan/260912_omo_client_integration/001_omo_contract.md`. + * + * The flag is passed HERE as well as in the spec's `build`, which is the one + * thing Prime and Aside do not do. They pass the default on both paths, so they + * are consistent; passing it on only one would make `ocx export --client omo` + * emit a `compat` block while enable and refresh wrote a file without it, and + * the two would drift apart at the first refresh. + */ +function buildOmoContribution(ctx: ExportContext): ManagedContribution { + const doc = buildPiClientConfig(ctx, true); + return singleFragment("omo", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); +} + export const EXPORT_CLIENTS: Record = { opencode: { id: "opencode", @@ -1297,6 +1362,37 @@ export const EXPORT_CLIENTS: Record = { // remote bind would be a plaintext secret on disk. Refuse instead. loopbackOnly: true, }, + /* + * Appended rather than filed beside the other Pi-family clients on purpose. + * `EXPORT_CLIENT_IDS` is `Object.keys(EXPORT_CLIENTS)`, so this object's + * insertion order IS the public order, and three tests assert it exactly. The + * existing sequence is landing order — `pi` second, `prime` eleventh — not a + * grouping, so appending is the edit that leaves the other thirteen alone. + */ + omo: { + id: "omo", + // Not a bare `models.json`: same Downloads-folder collision argument as + // `prime-models.json` and `aside-models.json`. + filename: "omo-models.json", + destination: env => omoConfigPath(env), + apiKeyEnv: "", + exportHint: "omo reads a non-secret placeholder from models.json; loopback needs no key.", + build: ctx => buildPiClientConfig(ctx, true), + format: "json", + summarize: summarizePi, + buildContribution: buildOmoContribution, + /* + * Loopback-only for OMP's and Prime's reason, NOT Pi's and Aside's. senpi's + * provider block does accept a `headers` map and does interpolate `$ENV` in + * its values, so unlike Aside there is somewhere the dedicated admission + * header could live. What does not exist is a builder that emits one: + * `buildPiClientConfig` writes no headers at all, which is why `pi` is + * loopback-only too. Teaching the shared builder to emit them would change + * four clients at once, so remote wiring is deferred and a non-loopback bind + * refuses rather than generating a config that 401s. + */ + loopbackOnly: true, + }, }; export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index c888a4c257..dc33732fe2 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -94,7 +94,8 @@ export type ExportClientId = | "zcode" | "prime" | "aside" - | "raycast"; + | "raycast" + | "omo"; export interface ExportClientSpec { id: ExportClientId; diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 8b89762f30..45e9b7a97b 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -10,7 +10,7 @@ import { /** Refresh only previously connected clients; a refused file never blocks its peers. */ export async function refreshOwnedCatalogIntegrations( input: Omit, - clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast"], + clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo"], ): Promise { let models: Promise | undefined; const loadModels = () => models ??= Promise.resolve().then(() => diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 8d67ac83a1..493a347819 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -26,6 +26,8 @@ import { kimiHomeDir, mcodeConfigPath, mcodeHomeDir, + omoAgentDir, + omoConfigPath, ompAgentDir, ompModelsConfigPath, opencodeGlobalConfigPath, @@ -280,6 +282,23 @@ export const INTEGRATION_CLIENTS: Record raycastAiDir(env, home), }, + omo: { + id: "omo", + configPath: (env = process.env, home = homedir()) => omoConfigPath(env, home), + /* + * The AGENT directory, not `~/.omo`. The v4 launcher wrapper creates + * `~/.omo` to hold `binary-runtime` without ever creating `agent/`, so + * detecting on the parent reports an omo v5 install that is not there -- + * and `installed` is what stops apply from writing a catalog for an engine + * that will never read it. Prime's agent directory and Aside's account + * directory are the same shape; Pi's parent-directory check is the odd one. + * + * No `sourcePreservingYaml` (JSON), no `writerLock` (single writer), and no + * `resolvePaths` -- unlike Aside, both omo paths are a pure function of env + * and home, so reading them in sequence cannot straddle a state change. + */ + detectDir: (env = process.env, home = homedir()) => omoAgentDir(env, home), + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 1faa056c8d..dfc369102c 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -237,7 +237,7 @@ export async function syncEnabledClientIntegrations( }, config, port, - }, ["mcode", "pi", "aside", "raycast"])); + }, ["mcode", "pi", "aside", "raycast", "omo"])); return out; } diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index b2d4f530cc..6b49ba79ee 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -794,9 +794,9 @@ describe("installation detection is independent of config state", () => { * from. Rationale and the per-client table: 020 §1 amendment. */ describe("the loopback-only set is one fact, read through one seam", () => { - test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime, aside and raycast are loopback-only and nobody else is", () => { + test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime, aside, raycast and omo are loopback-only and nobody else is", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/clients/omo-client.test.ts b/tests/clients/omo-client.test.ts new file mode 100644 index 0000000000..aac1cffbcb --- /dev/null +++ b/tests/clients/omo-client.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { + ClientPathError, + EXPORT_CLIENTS, + LOOPBACK_API_KEY_PLACEHOLDER, + OPENCODE_PROVIDER_ID, + buildClientConfig, + buildClientConfigText, + buildClientContribution, + omoAgentDir, + omoConfigPath, + type ExportContext, + type PiGeneratedConfig, +} from "../../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import type { OcxConfig } from "../../src/types"; + +const CONFIG = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +function context(): ExportContext { + return { + baseUrl: "http://127.0.0.1:10100/v1", + config: CONFIG, + models: [ + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000, inputModalities: ["text", "image"] }, + { namespaced: "openai/gpt-5.6-sol", provider: "openai", id: "gpt-5.6-sol", contextWindow: 922_000, reasoningEfforts: ["low", "medium", "high"] }, + // No authoritative context window: ships without limits rather than guessing. + { namespaced: "mystery/model", provider: "mystery", id: "model" }, + ], + }; +} + +describe("omo client config", () => { + /** + * The opposite of Prime's assertion, deliberately. + * + * Prime and Aside reuse Pi's builder with the session-affinity flag left at + * its default, because nobody has verified that their engines read it. omo's + * engine WAS verified: senpi's compiled validator accepts `compat` with + * `sendSessionAffinityHeaders`, so omo opts in and the generated provider is + * byte-identical to Pi's. + */ + test("is Pi's document including the session-affinity opt-in", () => { + const omo = buildClientConfig("omo", context()) as PiGeneratedConfig; + const pi = buildClientConfig("pi", context()) as PiGeneratedConfig; + expect(omo).toEqual(pi); + expect(omo.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true }); + }); + + /** + * The flag has to be on BOTH paths or they drift: `build` feeds `ocx export` + * and `/api/client-config`, while `buildContribution` is what the writer + * actually puts on disk when the integration is enabled or refreshed. One + * carrying `compat` and the other not would look correct in every unit test + * that only reads one of them. + */ + test("the exported document and the written fragment are the same bytes", () => { + const document = buildClientConfig("omo", context()) as PiGeneratedConfig; + expect(buildClientContribution("omo", context()).fragments[0]!.value) + .toEqual(document.providers[OPENCODE_PROVIDER_ID]); + }); + + test("adds only providers.opencodex, wired to the loopback proxy", () => { + const document = buildClientConfig("omo", context()) as PiGeneratedConfig; + expect(Object.keys(document)).toEqual(["providers"]); + expect(Object.keys(document.providers)).toEqual([OPENCODE_PROVIDER_ID]); + const provider = document.providers[OPENCODE_PROVIDER_ID]!; + expect(provider.baseUrl).toBe("http://127.0.0.1:10100/v1"); + expect(provider.api).toBe("openai-completions"); + expect(provider.apiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER); + }); + + test("native JSON round-trips and never carries a credential", () => { + const sentinel = ["sk", "live", "omo", "sentinel"].join("-"); + const withKey = { ...CONFIG, apiKeys: [{ key: sentinel }] } as OcxConfig; + const built = buildClientConfigText("omo", { ...context(), config: withKey }); + expect(JSON.parse(built.text)).toEqual(built.document as never); + expect(built.text).not.toContain(sentinel); + expect(built.text).toContain(LOOPBACK_API_KEY_PLACEHOLDER); + }); + + test("the contribution owns exactly the providers.opencodex path under its own id", () => { + const contribution = buildClientContribution("omo", context()); + // Reusing Pi's builder must not leak Pi's id into the ownership record, or + // the writer would stamp one client's block with the other's name. + expect(contribution.clientId).toBe("omo"); + expect(contribution.fragments.map(f => f.path)).toEqual([["providers", OPENCODE_PROVIDER_ID]]); + }); + + /** + * omo publishes three variables and reads them in this order, then pins the + * first two for the senpi process it spawns. Checking them in any other order + * would write a catalog omo never opens. + */ + test("resolves omo's own three-variable precedence", () => { + expect(omoAgentDir({}, "/home/u")).toBe(join("/home/u", ".omo", "agent")); + expect(omoConfigPath({}, "/home/u")).toBe(join("/home/u", ".omo", "agent", "models.json")); + + expect(omoConfigPath({ OMO_CODING_AGENT_DIR: "/from-omo" }, "/home/u")).toBe(join("/from-omo", "models.json")); + expect(omoConfigPath({ SENPI_CODING_AGENT_DIR: "/from-senpi" }, "/home/u")).toBe(join("/from-senpi", "models.json")); + expect(omoConfigPath({ PI_CODING_AGENT_DIR: "/from-pi" }, "/home/u")).toBe(join("/from-pi", "models.json")); + + // Precedence, not merely recognition. + expect(omoConfigPath({ + OMO_CODING_AGENT_DIR: "/from-omo", + SENPI_CODING_AGENT_DIR: "/from-senpi", + PI_CODING_AGENT_DIR: "/from-pi", + }, "/home/u")).toBe(join("/from-omo", "models.json")); + expect(omoConfigPath({ + SENPI_CODING_AGENT_DIR: "/from-senpi", + PI_CODING_AGENT_DIR: "/from-pi", + }, "/home/u")).toBe(join("/from-senpi", "models.json")); + + // omo trims and then tests truthiness, so a blank value is not a path. + expect(omoConfigPath({ OMO_CODING_AGENT_DIR: " ", PI_CODING_AGENT_DIR: "/from-pi" }, "/home/u")) + .toBe(join("/from-pi", "models.json")); + expect(omoConfigPath({ OMO_CODING_AGENT_DIR: "" }, "/home/u")) + .toBe(join("/home/u", ".omo", "agent", "models.json")); + + expect(omoConfigPath({ OMO_CODING_AGENT_DIR: "~/alt" }, "/home/u")).toBe(join("/home/u", "alt", "models.json")); + }); + + /** + * Each variable reports under its own name. Telling someone their + * `PI_CODING_AGENT_DIR` is relative when they set `OMO_CODING_AGENT_DIR` + * sends them to the wrong line of their shell profile. + */ + test("refuses a relative override and names the variable that carried it", () => { + expect(() => omoConfigPath({ OMO_CODING_AGENT_DIR: "relative" }, "/home/u")).toThrow(ClientPathError); + expect(() => omoConfigPath({ OMO_CODING_AGENT_DIR: "relative" }, "/home/u")).toThrow(/OMO_CODING_AGENT_DIR/); + expect(() => omoConfigPath({ SENPI_CODING_AGENT_DIR: "relative" }, "/home/u")).toThrow(/SENPI_CODING_AGENT_DIR/); + expect(() => omoConfigPath({ PI_CODING_AGENT_DIR: "relative" }, "/home/u")).toThrow(/PI_CODING_AGENT_DIR/); + }); + + /** + * The AGENT directory, not `~/.omo`. The v4 launcher wrapper creates + * `~/.omo` for its `binary-runtime` without ever creating `agent/`, so + * detecting on the parent reports a v5 install that is not there. + */ + test("detects installation by the agent directory, not the brand directory", () => { + const spec = INTEGRATION_CLIENTS.omo; + expect(spec.detectDir({}, "/home/u")).toBe(join("/home/u", ".omo", "agent")); + expect(spec.detectDir({}, "/home/u")).not.toBe(join("/home/u", ".omo")); + expect(spec.detectDir({ OMO_CODING_AGENT_DIR: "/elsewhere" } as NodeJS.ProcessEnv, "/home/u")).toBe("/elsewhere"); + }); + + test("ships as a loopback-only integration with no env var to export", () => { + const spec = EXPORT_CLIENTS.omo; + expect(spec.loopbackOnly).toBe(true); + expect(spec.apiKeyEnv).toBe(""); + // Not a bare models.json: a download would collide with pi's, prime's and + // aside's in the user's Downloads folder. + expect(spec.filename).toBe("omo-models.json"); + }); +}); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index e051afb2f4..3a8cd9123d 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -65,7 +65,7 @@ describe("ocx sync fans out to enabled native clients and owned file integration expect(fn).toContain("grokIntegrationEnabled(config)"); expect(fn).toContain("claudeDesktopIntegrationEnabled(config)"); - expect(fn).toContain('["mcode", "pi", "aside", "raycast"]'); + expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo"]'); expect(fn).toContain("refreshOwnedCatalogIntegrations"); // Native clients keep their catches; the owned catalog helper isolates file clients. expect(fn.match(/catch \(error\)/g)?.length).toBe(2); @@ -651,12 +651,12 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { }); }); -test("the direct ocx sync command refreshes MCode, Pi, Raycast and server-owned Aside", async () => { +test("the direct ocx sync command refreshes MCode, Pi, Raycast, omo and server-owned Aside", async () => { const src = await Bun.file(new URL("../../src/cli/dispatch.ts", import.meta.url)).text(); const start = src.indexOf("sync: async deps =>"); const command = src.slice(start, src.indexOf("v2: async deps =>", start)); expect(command).toContain("refreshOwnedCatalogIntegrations"); - expect(command).toContain('["mcode", "pi", "raycast"]'); + expect(command).toContain('["mcode", "pi", "raycast", "omo"]'); expect(command).toContain("refreshAsideProfilesThroughServer"); expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts index 5a381d6237..025718dfb4 100644 --- a/tests/config/client-config-export-new-clients.test.ts +++ b/tests/config/client-config-export-new-clients.test.ts @@ -61,11 +61,11 @@ describe("no secret reaches a client config", () => { // Pi, Kimi, Gajae, Aside and Raycast cannot emit the dedicated admission // header -- Aside's observed provider block has four keys and none is // `headers`; Raycast's `api_keys` is read literally with no env - // interpolation. OMP and Prime can carry provider headers, but remote + // interpolation. OMP, Prime and omo can carry provider headers, but remote // credential wiring is deliberately deferred from those initial generated - // integrations. + // integrations -- omo reuses Pi's builder, which emits no headers at all. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 17b3ce9c93..5524b1c338 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -808,8 +808,8 @@ describe("hub-resolved Fast exports", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the thirteen file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); + test("covers exactly the fourteen file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 36b7a2e1ae..826e44d13d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -761,6 +761,7 @@ "ollama-show-enrichment-v7.test.ts": "providers/ollama", "ollama-show-enrichment.test.ts": "providers/ollama", "ollama-show-ignore-abort.test.ts": "providers/ollama", + "omo-client.test.ts": "clients", "omp-path-contract.test.ts": "clients", "omp-yaml-source-inline-comments.test.ts": "clients", "openai-api-virtual-models.test.ts": "adapters/openai", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 47b196b688..3ab941e508 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -91,7 +91,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(13); + expect(expected).toHaveLength(14); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -174,6 +174,10 @@ describe("every client survives a full lifecycle", () => { // Raycast's `providers` is a SEQUENCE keyed by `id`, so the user's entry is // a sibling element rather than a sibling map key. raycast: "providers:\n - id: lmstudio\n name: LM Studio\n base_url: http://localhost:1234/v1\n models: []\n", + // omo is senpi under an omo brand, and senpi reads Pi's models.json + // contract -- verified against senpi's own compiled validator, not assumed + // from the family resemblance (260912 plan unit, 001). + omo: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', }; /** Where the seed's user-owned entry lives when the seed is a sequence. */ const USER_ELEMENT: Partial> = { From 1c93a6fac0f7d1e047e1e09d83e89ad1c6cde4a1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 01:59:50 +0900 Subject: [PATCH 071/231] docs(devlog): fold the wiring plan blockers --- .../040_phase4_key_pool_strategy.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md index 6765182bf1..2b13254ae5 100644 --- a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md +++ b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md @@ -130,3 +130,49 @@ it reaches a real dispatch. unchanged even when cooled, because rotation stays reactive-only for that install. - A cursor case: a manual key selection through `PUT /api/providers/keys/active` clears the rotation cursor. + +### Plan audit — FAIL, folded + +**Blocker 1 — the picker does not mutate the route.** `selectProactiveApiKey` writes +`config.providers[name]` and RETURNS a clone; it never touches `route.provider`. The plan said +"wire the call" without saying what to do with the return, which is not implementable: a literal +reading leaves the live route on the cooled key and the whole unit is a no-op that still writes +config. The call site is: + +``` +const picked = selectProactiveApiKey(config, route.providerName, now); +if (picked) route.provider = picked; +``` + +**Blocker 2 — the assignment must land before the copies, not merely before the send.** +"One call serves all four consumers" is true only because nothing reassigns `route.provider` +between the pin and each consumer — but they do not all read it late. `adapterProvider` is +copied at `core.ts`:4458 and the adapter is bound at :4477, and the HTTP path captures +`builtInitialRequest` at :7139. So the assignment goes BEFORE :4450, ahead of every copy. The +audit also showed why this cannot be left to self-healing: the HTTP and `runTurn` paths can +re-read a stale selection through `refreshDispatchAdapter` (:4197), but the image bridge +(:6570) and web search (:6655) call `providerFetch(route.provider)` directly and have no such +second chance. Ordering is the entire correctness argument here. + +**Major 1 accepted, with the reason recorded.** Putting the picker on the first-attempt path +means an ordinary request can now perform a persisted config write. It is bounded: the picker +returns null unless a strategy is configured AND the committed key is already cooled, so a +healthy install does one predicate and stops. The write goes through the same +`commitProviderApiKeySelection` / `mutatePersistedConfig` lock the reactive rotation uses, and a +later same-request 429 rotation serializes behind that lock rather than racing it. The cost is +paid exactly once per cooldown, replacing a request that was otherwise spent earning a 429 the +runtime could already predict. + +**Major 2 — two first-send paths this unit does NOT cover, named rather than silently dropped.** +Native compact for `openai-apikey` (`src/server/responses/compact.ts`:669, dispatch at :745-883) +never enters `core.ts`, and the keyed `/v1/images` path (`src/server/images.ts`:701) reads +`candidates.keyed.apiKey` directly rather than a provider object. Each has a different +provider-resolution shape and needs its own dispatch harness, so they become their own +work-phase instead of riding along untested here. `collaboration.ts` and +`encrypted-payload.ts` are NOT affected: they import `rotateProviderTransportOn429` and +dispatch no first attempt. + +**Minors folded.** The web-search fetch is `core.ts`:6655, not :6653 (that line is a comment). +The stale-selection re-read is :4197, not :4196. `src/server/management/provider-routes.ts`:832 +and :931 also `clearKeyCooldowns` on key replace and delete, so the cursor reset belongs there +too — five routes, not three. From 085fa6fbb5f2bf4fdf023d0fe7251014b8921890 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:05:28 +0900 Subject: [PATCH 072/231] feat(providers): use the warm API key on the first attempt selectProactiveApiKey and forgetApiKeyRotationCursor shipped in #4277 with no production caller. Both are wired now: the picker runs on the Responses core and native chat first-send paths, assigned before the transport pin and every copy taken from it, and the cursor is forgotten at the five routes that already reset key cooldowns. --- src/server/chat-native.ts | 7 +++++++ src/server/management/oauth-account-routes.ts | 15 ++++++++++++--- src/server/management/provider-routes.ts | 5 ++++- src/server/responses/core.ts | 12 ++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index d08582249e..a4bb684252 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -33,6 +33,7 @@ import { } from "../lib/translator-budget"; import { hasKeyPoolFailover, + selectProactiveApiKey, rateLimitRetryDelayMs, rateLimitRetryPolicyFor, rotateProviderTransportOn429, @@ -235,6 +236,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio unregisterTurn(upstream); }; const connectMs = config.connectTimeoutMs ?? 200_000; + // Native chat is its own entry path -- chat-completions.ts routes here directly and never + // through the Responses core -- so the pre-dispatch key preference is applied again here + // rather than inherited. Assigned before the adapter binds below, for the same reason it is + // assigned before the transport pin in core.ts. + const proactiveKeyProvider = selectProactiveApiKey(config, route.providerName); + if (proactiveKeyProvider) route.provider = proactiveKeyProvider; let activeProvider: OcxProviderConfig = route.provider; let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider); let activeRequest: AdapterRequest; diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 6a1a1ae35b..4e9f0ffef4 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -638,8 +638,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< clearModelCache(name); const { clearProviderQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); - const { clearKeyCooldowns } = await import("../../providers/key-failover"); + const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover"); clearKeyCooldowns(name); // manual key management resets 429 cooldown state + // ...and the rotation cursor with it. A cursor that predates the operator's choice would + // hand the next proactive pick straight back to whichever key the pool had reached. + forgetApiKeyRotationCursor(name); return jsonResponse({ ok: true, id: result.id }, 201); } // Opt-in OS keychain storage (#1221): move the active key and pool into the OS credential @@ -682,8 +685,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< clearModelCache(name); const { clearProviderQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); - const { clearKeyCooldowns } = await import("../../providers/key-failover"); + const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover"); clearKeyCooldowns(name); // manual key management resets 429 cooldown state + // ...and the rotation cursor with it. A cursor that predates the operator's choice would + // hand the next proactive pick straight back to whichever key the pool had reached. + forgetApiKeyRotationCursor(name); return jsonResponse({ ok: true, name, activeId: body.id }); } if (url.pathname === "/api/providers/keys/alias" && req.method === "PUT") { @@ -711,8 +717,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< clearModelCache(name); const { clearProviderQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); - const { clearKeyCooldowns } = await import("../../providers/key-failover"); + const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover"); clearKeyCooldowns(name); // manual key management resets 429 cooldown state + // ...and the rotation cursor with it. A cursor that predates the operator's choice would + // hand the next proactive pick straight back to whichever key the pool had reached. + forgetApiKeyRotationCursor(name); return jsonResponse({ ok: true }); } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index d2cb40291a..448771224b 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -57,7 +57,7 @@ import { import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { clearKeyCooldowns } from "../../providers/key-failover"; +import { clearKeyCooldowns, forgetApiKeyRotationCursor } from "../../providers/key-failover"; import { providerRequestPacingStatus } from "../../providers/request-pacing"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; @@ -830,6 +830,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise Date: Sat, 12 Sep 2026 02:06:03 +0900 Subject: [PATCH 073/231] docs(devlog): record the rendered Integrations evidence for omo The Integrations page is the surface this change exists for, and it is the one thing no source test can prove: the two lists a client can be missing from leave typecheck and the invariants green while the tab silently does not render. This is the captured page -- the omo tab and mark, the ownership sentence, and the gjc label on its neighbour. --- .../evidence/integrations-omo-tab.png | Bin 0 -> 129453 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 devlog/_plan/260912_omo_client_integration/evidence/integrations-omo-tab.png diff --git a/devlog/_plan/260912_omo_client_integration/evidence/integrations-omo-tab.png b/devlog/_plan/260912_omo_client_integration/evidence/integrations-omo-tab.png new file mode 100644 index 0000000000000000000000000000000000000000..36f9809e12e52610657fb464947c3753e5e5177b GIT binary patch literal 129453 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¼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 Date: Sat, 12 Sep 2026 02:06:55 +0900 Subject: [PATCH 074/231] test(server): cover the pre-dispatch API key pick end to end --- tests/server/server-key-failover-e2e.test.ts | 67 +++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index d23bf848dd..267c687ac8 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig, saveConfig } from "../../src/config"; -import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearKeyCooldowns, rotateKeyOn429 } from "../../src/providers/key-failover"; import { deriveXaiConvId } from "../../src/providers/xai-transport"; import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; import { startServer } from "../../src/server"; @@ -664,3 +664,68 @@ describe("server 429 key failover (end-to-end)", () => { } }); }); + + /** + * Both cases land on the same state: the committed key is already cooling when a request + * arrives. That is not exotic -- it is what an operator has after the pool rotated and a + * restart, a manual edit or a config reload pointed `apiKey` back at the spent key. + */ + async function cooledCommittedKeySetup(strategy?: "round-robin" | "fill-first") { + const seen: string[] = []; + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + seen.push(req.headers.get("authorization") ?? ""); + return Response.json({ id: "chatcmpl-warm", object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "warm" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + } }); + saveConfig({ port: 0, hostname: "127.0.0.1", defaultProvider: "pooled", providers: { pooled: { + adapter: "openai-chat", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, + authMode: "key", apiKey: "synthetic-first", + ...(strategy ? { apiKeyPoolStrategy: strategy } : {}), + apiKeyPool: [{ id: "first", key: "synthetic-first" }, { id: "second", key: "synthetic-second" }], + } } } as OcxConfig); + // Cool the committed key exactly the way a real 429 does, then point the stored selection + // back at it. Cooldowns are process-local, so the server started below shares this state. + const live = loadConfig(); + rotateKeyOn429(live, "pooled", null, Date.now(), "synthetic-first"); + const restored = loadConfig(); + restored.providers.pooled!.apiKey = "synthetic-first"; + saveConfig(restored); + return seen; + } + + test("a cooled committed key is replaced before the first attempt", async () => { + const seen = await cooledCommittedKeySetup("round-robin"); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pooled/test", stream: false, messages: [{ role: "user", content: "hello" }] }), + }); + expect(response.status).toBe(200); + // ONE attempt, on the warm key. Reactive rotation alone cannot produce this: it needs a + // 429 first, so without the pre-dispatch pick the upstream would see the cooled key here + // and the request would be spent earning a refusal the runtime could already predict. + expect(seen).toEqual(["Bearer synthetic-second"]); + } finally { + await server.stop(true); + } + }); + + test("without a configured strategy the cooled key is still used", async () => { + const seen = await cooledCommittedKeySetup(); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pooled/test", stream: false, messages: [{ role: "user", content: "hello" }] }), + }); + expect(response.status).toBe(200); + // The other half of the contract: rotation stays reactive-only for an install that never + // asked for a strategy, so the committed key is honoured even when it is cooling. + expect(seen).toEqual(["Bearer synthetic-first"]); + } finally { + await server.stop(true); + } + }); From 9b067c2934ed1562ba497957675d8cd2bd4b8d2c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:10:55 +0900 Subject: [PATCH 075/231] docs(devlog): plan quota-aware API key selection --- .../040_phase4_key_pool_strategy.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md index 2b13254ae5..adc3e7ff6c 100644 --- a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md +++ b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md @@ -176,3 +176,77 @@ dispatch no first attempt. The stale-selection re-read is :4197, not :4196. `src/server/management/provider-routes.ts`:832 and :931 also `clearKeyCooldowns` on key replace and delete, so the cursor reset belongs there too — five routes, not three. + +## wp4 plan — quota-aware API key selection + +wp4b wired the picker in; this gives it the third strategy. Today +`apiKeyPoolStrategy` accepts only `round-robin` and `fill-first` +(`src/config.ts`:586, `src/types/provider.ts`:399), so an API key pool cannot do what every +other pool in this codebase already does: prefer the credential with the most room left. + +| Symbol | File | Line | +|---|---|---| +| `apiKeyPoolStrategy` schema | `src/config.ts` | 586 | +| `apiKeyPoolStrategy` type | `src/types/provider.ts` | 399 | +| `selectProactiveApiKey` strategy read | `src/providers/key-failover.ts` | 135 | +| per-key quota cache (private) | `src/providers/quota-key-accounts.ts` | 22 | +| `identity()` cache key | `src/providers/quota-key-accounts.ts` | 50 | +| `readProviderApiKeyQuotas` | `src/providers/quota-key-accounts.ts` | 101 | +| `keyQuotaReaderForProvider` | `src/providers/quota.ts` | 2897 | +| editor field list | `src/server/auth-cors.ts` | 821 | + +### The one real obstacle: the selector is synchronous, the quota reader is not + +Per-key quota already exists — `keyQuotaReaderForProvider` serves seventeen providers — but it +is reached only through `readProviderApiKeyQuotas`, which is `async` and probes the network on a +miss. `selectProactiveApiKey` is synchronous and sits on the first-attempt path, where it must +not await anything. + +So `quota-key-accounts.ts` grows one cache-only, synchronous reader: + +``` +export function cachedApiKeyQuota(name, provider, keyId, key): ProviderQuota | null +``` + +It recomputes the same `identity()` the async path stores under, reads `cache`, and returns +null on a miss. It never probes, never awaits and never schedules one — a selector that could +trigger a network read on the request path would be a worse defect than the one this unit +fixes. A miss is simply "no evidence", which is the same word the OAuth side uses. + +Env-placeholder keys resolve through `resolveProviderApiKey` exactly as the async path does, +inside a try/catch: an unresolvable key is a miss, not a throw on the dispatch path. + +### Ranking, and what happens without evidence + +`quota` ranks the eligible keys by remaining headroom and takes the roomiest. When NO eligible +key has a cached row, it falls back to the first eligible key — which is what `fill-first` +already does, and therefore exactly today's behaviour for a provider whose quota reader does not +exist or has never run. + +That is deliberately NOT the OAuth rule. `preferredInitialAccount` returns null without +evidence because its active account is still perfectly usable. Here the function has already +established that the committed key is cooling, so returning null would mean deliberately +dispatching on a spent key. There is no no-op available; the only question is which replacement. + +### Change surface + +`src/providers/quota-key-accounts.ts` — add `cachedApiKeyQuota` and a +`setCachedProviderApiKeyQuotaForTests` seam mirroring the account-side +`setCachedProviderAccountQuotaForTests`, because a synchronous reader of a private cache is +otherwise untestable without a live probe. + +`src/types/provider.ts`:399 and `src/config.ts`:586 — widen the union to include `quota`. +`src/server/auth-cors.ts`:821 already lists the field as editor-visible and needs no change. + +`src/providers/key-failover.ts` — a third branch in `selectProactiveApiKey`. `round-robin` and +`fill-first` keep their current code paths byte for byte. + +### Acceptance + +- `tests/adapters/key-failover.test.ts`: the roomiest eligible key wins; a cooled roomier key is + skipped; with no cached rows the first eligible key is taken; an unknown strategy value still + degrades to no-op. Red control for each: with the `quota` branch removed the ranking cases must + fail. +- `apiKeyPoolStrategy` is currently undocumented in `docs-site` — no row exists anywhere. It + gains one in `reference/configuration/providers.md` describing all three values, since shipping + a third undocumented value is how the generic pool ended up inert and unexplained. From 9689ee8ceb0d868faa0643b036f8e8d9be4bd03c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:13:13 +0900 Subject: [PATCH 076/231] fix(devlog): stop the omo contract note tripping the privacy scan `omo-ai@5.0.0-0.beta.53` is an npm spec, but it is also shaped exactly like an email address, and privacy:scan reads it as one. The local run missed it because the file was still untracked when that check ran; CI caught it on all three jobs that invoke the scan. Same two facts, written so the version is not glued to the package name with an @. --- .../_plan/260912_omo_client_integration/001_omo_contract.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260912_omo_client_integration/001_omo_contract.md b/devlog/_plan/260912_omo_client_integration/001_omo_contract.md index 9cb482980b..663ec0dd37 100644 --- a/devlog/_plan/260912_omo_client_integration/001_omo_contract.md +++ b/devlog/_plan/260912_omo_client_integration/001_omo_contract.md @@ -1,7 +1,8 @@ # What omo actually reads -Evidence: `omo-ai@5.0.0-0.beta.53` unpacked at `/tmp/omoprobe2/package`, and -`@code-yeongyu/senpi@2026.9.10-2` unpacked at `/tmp/senpiprobe/package`. Schema +Evidence: `omo-ai` version `5.0.0-0.beta.53` unpacked at `/tmp/omoprobe2/package`, +and `@code-yeongyu/senpi` version `2026.9.10-2` unpacked at +`/tmp/senpiprobe/package`. Schema claims below were executed against that tarball's own TypeBox compiler (`typebox@1.3.18`), not read off documentation. From f5f87e88224b8dcb71e90042e5f8696780aba662 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:18:54 +0900 Subject: [PATCH 077/231] docs(devlog): fold the quota-selection plan blockers --- .../040_phase4_key_pool_strategy.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md index adc3e7ff6c..668a9a72e1 100644 --- a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md +++ b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md @@ -250,3 +250,53 @@ otherwise untestable without a live probe. - `apiKeyPoolStrategy` is currently undocumented in `docs-site` — no row exists anywhere. It gains one in `reference/configuration/providers.md` describing all three values, since shipping a third undocumented value is how the generic pool ended up inert and unexplained. + +### wp4 plan audit — FAIL, folded + +**Blocker 1 — a cache hit is not evidence.** `readEntry` stores `{ unavailable: true, quota: +lastGood }` for up to `LAST_GOOD_MS` (30 minutes) when a probe fails, so the row survives with a +stale measurement attached. A reader that returns `entry.quota` on any hit would rank on a +number taken up to half an hour ago from a probe that has since been failing — and rank it +ABOVE a key with no row at all. `cachedApiKeyQuota` returns null whenever `entry.unavailable` +is set or `entry.quota` is null. Last-good is a display value; it is not a selection input. + +**Blocker 2 — the ranking was not specified, and the obvious formula does not work.** +"Remaining headroom" is undefined for `ProviderQuota`, which carries `fiveHourPercent`, +`weeklyPercent`, `monthlyPercent`, `customWindows[].percent` and `creditsUsd`. The definition +this unit uses, matching `headroomOf` on the OAuth side so the two pools cannot disagree: + +`headroom = 100 - max(fiveHourPercent, weeklyPercent, monthlyPercent, ...customWindows.percent)`, +and null when none of those is a number. `creditsUsd` is deliberately excluded: it is a +currency amount, not a percentage, and mixing the two scales produces an ordering that means +nothing. + +**Mixed evidence needs a rule and now has one**, borrowed from +`rankAccountsByHeadroom`'s three buckets rather than invented: measured-with-headroom first +(most headroom wins), then unmeasured, then measured-and-exhausted, with the stable roster order +breaking ties. An unmeasured key is not assumed spent, and it is not assumed fresh either. + +**Recorded, not fixed — providers whose rows cannot discriminate.** DeepSeek reports every key +at `customWindows.percent: 0`, so all headrooms tie at 100 and the pick falls through to the +stable order, which is exactly today's behaviour. That is the correct outcome for a provider +that publishes no per-key differentiation, and it is why the fallback has to be a real ordering +rather than an error. + +**Major 1 — "unknown strategy is a no-op" was wrong.** Today any truthy value that is not +`round-robin` takes the `eligible[0]` default, which IS fill-first; zod is the only thing +rejecting junk. So the new branch is `else if (strategy === "quota")` placed after the +round-robin block and BEFORE that default. Replacing the default would silently retarget +fill-first. The acceptance bullet claiming a no-op is struck. + +**Major 2 — the test seam cannot mirror the account-side signature.** The key cache is keyed on +`identity(name, provider, id, resolvedKey)`, so the seam takes the provider name, the provider +config, the key id and the raw key, not `(provider, accountId, quota)`. + +**Minors folded.** `keyQuotaReaderForProvider` is at `quota.ts`:2898, not :2897. The e2e helper +added in wp4b types its strategy parameter as `"round-robin" | "fill-first"` and widens with the +union. The provider count is approximate and the claim is dropped. `resolveProviderApiKey` is +synchronous and swallows its own failures, so the try/catch is belt-and-braces rather than +required — kept, and labelled as such. + +**Deliberate:** a `quota` pick still records `keyRotationCursor`. The cursor is where the pool +last was, not a round-robin private; leaving it accurate means switching an operator to +`round-robin` later resumes from the key actually in use instead of the start of the ring. From d9849942a58895da9571bcf89e3eaf6d79a6431c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:22:17 +0900 Subject: [PATCH 078/231] feat(devin-cli): local Devin CLI provider over ACP stdio (#4288) * feat(devin-cli): add the local Devin CLI provider over ACP stdio Drives the installed devin CLI rather than a hosted endpoint. The CLI carries its own credentials from devin auth login, so this provider takes no key and the proxy never holds one; authKind is local and the destination is a stdio scheme. A turn is one ACP session: initialize, session/new, session/prompt, with session/update notifications streaming in between and a unary reply carrying the stop reason and usage. src/adapters/devin-cli/acp.ts holds that framing as pure functions with no spawn and no network, so the projection and the event mapping are testable against captured lines the way the coding-agent protocol module is. Three decisions are worth naming for review. The CLI's own tool calls stay internal. Devin executes them inside its session, so forwarding them as client tools would either fail the turn - the Responses bridge rejects a tool Codex never declared - or ask Codex to run something the agent had already run. Permission requests are refused by default. This provider runs an agent in the operator's own tree, so session/request_permission is answered with cancelled unless OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1 is set, and the child gets a scoped environment rather than the proxy's. The terminal event is genuinely last and a crash is not success. A close without a prompt reply reports the exit code and bounded stderr instead of an empty done, a trailing frame without a newline is flushed on stream end, and the child is reaped with SIGTERM, a grace period and SIGKILL rather than merely signalled. Tests drive a fake ACP child and assert exactly one terminal event with usage on a reply that arrives without a trailing newline, an error when the child exits before replying, and the CLI's own reason on a session/new failure. * fix(devin-cli): drop preserveCustomDestination from a local provider The flag is for fixed API-key destinations; provider-model-discovery-contract asserts that, and a local stdio provider claiming it failed CI on shard 2/4. * fix(devin-cli): make the provider configurable, reap the tree, keep the turn alive Four findings from the PR review, all real. The registry destination devin://acp/stdio could never be configured: providerBaseUrlConfigError accepts only http(s), so selecting the preset - including through ocx init - produced a config the loader rejects before routing could reach runTurn. It is now https://cli.devin.ai, a canonical identity URL rather than a transport, which is the shape the other CLI-backed providers already use. Signalling the direct pid left Devin's own subprocesses running. Once OPENCODEX_DEVIN_CLI_ALLOW_TOOLS lets it launch a shell, neither SIGTERM nor the later SIGKILL reached the descendants, so an abort could return while a tool kept writing in the operator's tree. The child now gets its own process group on POSIX and the reap signals the group, falling back to the single process. Internal ACP tool notifications became heartbeats instead of nothing. Dropping them entirely meant a Devin tool operation longer than the bridge's stall timeout looked like upstream silence, and the still-working turn was aborted. They are still not client tools. A malformed frame after the protocol starts now fails the turn. Treating every parse failure as banner noise silently lost a session/update, or waited out the ten-minute timeout for a reply that had already arrived damaged. Plain text is still accepted before the first valid frame. structure/adapters/registry.md records why devin-cli is a direct registry entry and why its baseUrl is an identity rather than a destination. --- .../src/content/docs/fr/guides/providers.md | 1 + .../src/content/docs/guides/providers.md | 1 + .../src/content/docs/ja/guides/providers.md | 1 + .../src/content/docs/ko/guides/providers.md | 1 + .../src/content/docs/reference/adapters.md | 24 ++ .../src/content/docs/ru/guides/providers.md | 1 + .../src/content/docs/tr/guides/providers.md | 1 + .../content/docs/zh-cn/guides/providers.md | 1 + .../content/docs/zh-tw/guides/providers.md | 1 + scripts/test-layout/layout.json | 1 + src/adapters/devin-cli/acp.ts | 204 +++++++++++ src/adapters/devin-cli/adapter.ts | 334 ++++++++++++++++++ src/adapters/devin-cli/binary.ts | 69 ++++ src/adapters/devin-cli/models.ts | 23 ++ src/adapters/registry.ts | 9 +- src/providers/registry.ts | 22 ++ src/routing/compatibility/behavior.ts | 1 + structure/adapters/registry.md | 5 + .../adapter-registry-authority.test.ts | 1 + .../adapters/adapter-tool-conformance.test.ts | 18 +- tests/fixtures/test-layout-expected.json | 1 + tests/providers/devin-cli-adapter.test.ts | 293 +++++++++++++++ 22 files changed, 1008 insertions(+), 5 deletions(-) create mode 100644 src/adapters/devin-cli/acp.ts create mode 100644 src/adapters/devin-cli/adapter.ts create mode 100644 src/adapters/devin-cli/binary.ts create mode 100644 src/adapters/devin-cli/models.ts create mode 100644 tests/providers/devin-cli-adapter.test.ts diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 7c9f3fc682..eb4f4a0718 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -125,6 +125,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | La connexion initiale importe la session de l'installation locale de `kiro-cli`, déjà authentifiée (sous Unix, installez avec `curl -fsSL https://cli.kiro.dev/install` | `bash`; sous Windows PowerShell, utilisez `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; puis exécutez `kiro-cli login`). **Ajouter un compte** déconnecte `kiro-cli`, lance une nouvelle connexion dans le navigateur qui change le compte utilisé par `kiro-cli`, puis enregistre les métadonnées propres au profil. Les comptes OpenCodex existants sont préservés ; une annulation ou un échec restaure la session `kiro-cli` précédente. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Connexion PKCE expérimentale, transport HTTP/2 en direct et découverte de modèles filtrés par compte. | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Pilote la CLI Devin installée localement via l'Agent Client Protocol (`devin acp`, JSON-RPC sur stdio). La CLI détient ses propres identifiants issus de `devin auth login`, donc opencodex ne stocke aucune clé. `OPENCODEX_DEVIN_CLI_BIN` désigne l'exécutable ; pour autoriser la CLI à lire et écrire des fichiers, il faut définir explicitement `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`, le refus étant la valeur par défaut. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Expérimental. Flux d'appareil GitHub et échange `copilot_internal` (client OAuth de VS Code). Nécessite un abonnement Copilot actif ; il ne s'agit pas d'une API tierce officielle. | Les vérifications de quota Google Antigravity utilisent des points de terminaison Google fixes, y compris le repli vers la liste des modèles. Elles prennent en charge le DNS Fake-IP transparent pour ces destinations en conservant la vérification TLS, le refus des redirections et les contrôles des adresses privées. Une URL de base personnalisée ne modifie que les requêtes de modèles ; `NO_PROXY` conserve la politique de connexion directe. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7119be8643..2196f1288e 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -191,6 +191,7 @@ ocx logout | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | Browser consent and key exchange use `https://www.orcarouter.ai` with S256 PKCE. The returned user-owned `sk-orca-…` API key is stored in the existing credential store and reused until revoked. | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Drives the locally installed Devin CLI over the Agent Client Protocol (`devin acp`, newline-delimited JSON-RPC on stdio). The CLI holds its own credentials from `devin auth login`, so opencodex stores no key for it. Point `OPENCODEX_DEVIN_CLI_BIN` at a specific build; letting the CLI read and write files requires setting `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` explicitly, because the default is to refuse. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | Google Antigravity account and provider quota probes use fixed Google accounting endpoints, including the models fallback. They support transparent Fake-IP DNS for those destinations while retaining TLS verification, redirect rejection and private-address checks. A custom provider base URL changes model requests, not quota destinations; `NO_PROXY` continues to select the direct-route policy. diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index d0025981aa..6b5bd881e6 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -114,6 +114,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは、インストール済みでサインインした `kiro-cli` セッションを取り込みます(Unix では `curl -fsSL https://cli.kiro.dev/install` | `bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1'` | `iex` でインストールしてから `kiro-cli login` を実行)。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | ローカルにインストールされた Devin CLI を Agent Client Protocol(`devin acp`、stdio 上の JSON-RPC)で駆動します。CLI が `devin auth login` の資格情報を保持するため、opencodex 側はキーを保存しません。実行ファイルは `OPENCODEX_DEVIN_CLI_BIN` で指定でき、CLI にファイル操作を許可するには `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` の明示が必要です(既定は拒否)。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | Google Antigravity のアカウント・プロバイダーのクォータ確認は、モデル一覧へのフォールバックも含め、固定の Google エンドポイントを使用します。その宛先では透過 Fake-IP DNS に対応し、TLS 検証、リダイレクト拒否、プライベートアドレス検査を維持します。カスタム base URL はモデル要求にのみ適用されます。`NO_PROXY` は直接接続のポリシーを維持します。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 9fb1bf9ed1..6a470c2d89 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -112,6 +112,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 설치하고 로그인한 `kiro-cli` 세션을 가져옵니다(Unix에서는 `curl -fsSL https://cli.kiro.dev/install` | `bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`로 설치한 뒤 `kiro-cli login` 실행). **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | 로컬에 설치된 Devin CLI를 Agent Client Protocol(`devin acp`, stdio 위 JSON-RPC)로 구동합니다. CLI가 `devin auth login` 자격증명을 직접 들고 있어 opencodex는 키를 저장하지 않습니다. 실행 파일은 `OPENCODEX_DEVIN_CLI_BIN`으로 지정할 수 있고, CLI가 파일을 읽고 쓰도록 허용하려면 `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`을 명시해야 합니다. 기본값은 거부입니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | Google Antigravity 계정·제공자 할당량 확인은 모델 목록 폴백을 포함해 고정된 Google 회계 엔드포인트를 사용합니다. 해당 목적지의 투명 Fake-IP DNS를 지원하며 TLS 검증, 리다이렉트 거부, 사설 주소 검사는 유지합니다. 사용자 지정 base URL은 모델 요청에만 적용되며 할당량 목적지는 바꾸지 않습니다. `NO_PROXY`는 기존 직접 연결 정책을 유지합니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 596fc2255f..3afecaa230 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -422,6 +422,30 @@ bare `exec_command` and `shell_command` names are reserved for non-freeform shel bridges. Namespace a custom freeform tool that uses either name. These schema declarations do not grant approval or change execution policy. +## `devin-cli` + +**Targets:** the locally installed Devin CLI, over the Agent Client Protocol — `devin acp` speaking +newline-delimited JSON-RPC on stdin and stdout. +**Auth:** none held by opencodex. The CLI carries its own credentials from `devin auth login`, so +this provider stores no key and asks for none. + +- Uses `runTurn`; a handshake over a child process has no fetch-shaped request for the generic wire + path, so `buildRequest` / `parseStream` are disabled. +- One turn is one ACP session: `initialize`, `session/new`, `session/prompt`, with `session/update` + notifications streaming in between and a unary reply carrying the stop reason and usage. The + conversation is flattened into the single prompt string a session takes, with role labels fenced + so a message body cannot forge one. +- The CLI's own tool calls stay internal. Devin executes them inside its session, so forwarding + them as client tools would either fail the turn — the bridge rejects a tool Codex never declared — + or ask Codex to run something the agent already ran. +- **Permission requests are refused by default.** This provider runs an agent in the operator's own + tree, so `session/request_permission` is answered with `cancelled` unless + `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` is set. The child also gets a scoped environment rather than + the proxy's, and `OPENCODEX_DEVIN_CLI_CWD` chooses where it runs. +- Binary discovery prefers `OPENCODEX_DEVIN_CLI_BIN`, then the paths the official installer and the + Homebrew cask use, then `PATH`. Install with `curl -fsSL https://cli.devin.ai/install.sh | bash` + or `brew install --cask devin-cli`. + ## `azure-openai` (alias: `azure`) **Targets:** **Azure OpenAI**. Wraps `openai-responses` (so also `passthrough: true`). diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 0811958d03..337dbfe8e4 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -123,6 +123,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (в Unix: `curl -fsSL https://cli.kiro.dev/install` | `bash`; в Windows PowerShell: `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; затем выполните `kiro-cli login`). **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Управляет локально установленным Devin CLI по Agent Client Protocol (`devin acp`, JSON-RPC поверх stdio). Учётные данные хранит сам CLI после `devin auth login`, поэтому opencodex не сохраняет ключ. Путь к исполняемому файлу задаётся через `OPENCODEX_DEVIN_CLI_BIN`; чтобы разрешить CLI читать и писать файлы, нужно явно выставить `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` — по умолчанию запрос отклоняется. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | Проверки квот аккаунтов и провайдера Google Antigravity используют фиксированные адреса Google, включая резервный запрос списка моделей. Для этих адресов поддерживается прозрачный Fake-IP DNS с сохранением проверки TLS, запрета перенаправлений и проверки частных адресов. Пользовательский base URL меняет только запросы моделей; `NO_PROXY` сохраняет политику прямого подключения. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 9b9b557d24..29ecc83a95 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -138,6 +138,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | İlk oturum açma, kurulu ve oturum açılmış `kiro-cli` oturumunu içe aktarır (Unix'te `curl -fsSL https://cli.kiro.dev/install` | `bash` ile kurun; Windows PowerShell'de `irm 'https://cli.kiro.dev/install.ps1'` | `iex` kullanın; ardından `kiro-cli login` çalıştırın). **Hesap ekle**, `kiro-cli` oturumunu kapatır, `kiro-cli` tarafından kullanılan hesabı değiştiren yeni bir tarayıcı girişi başlatır ve hesap kapsamlı profil meta verilerini saklar. Mevcut OpenCodex hesapları korunur ve iptal veya başarısızlık önceki `kiro-cli` oturumunu geri yükler. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Deneysel PKCE girişi, canlı HTTP/2 aktarımı ve hesap filtreli model keşfi. | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Yerelde kurulu Devin CLI'yi Agent Client Protocol ile (`devin acp`, stdio üzerinde JSON-RPC) çalıştırır. Kimlik bilgilerini `devin auth login` sonrası CLI'nin kendisi taşır, bu yüzden opencodex hiçbir anahtar saklamaz. Çalıştırılabilir dosya `OPENCODEX_DEVIN_CLI_BIN` ile belirtilir; CLI'nin dosya okuyup yazmasına izin vermek için `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` açıkça ayarlanmalıdır, varsayılan reddetmektir. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Deneysel. GitHub cihaz akışı + `copilot_internal` değişimi (VS Code OAuth istemcisi). Aktif bir Copilot aboneliği gerektirir; resmi bir üçüncü taraf API değildir. | Google Antigravity hesap ve sağlayıcı kota sorguları, model listesine geri dönüş dahil sabit Google uç noktalarını kullanır. Bu hedefler için şeffaf Fake-IP DNS desteklenirken TLS doğrulaması, yönlendirme reddi ve özel adres kontrolleri korunur. Özel base URL yalnızca model isteklerini değiştirir; `NO_PROXY` doğrudan bağlantı politikasını korur. diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 084f6cd38b..afdc1738a6 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -106,6 +106,7 @@ ocx logout | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | 浏览器授权与密钥交换走 `https://www.orcarouter.ai` + S256 PKCE。交换结果是用户自己的普通 `sk-orca-…` API key,保存在现有凭据库中并持续复用,直到被撤销。 | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | 通过 Agent Client Protocol(`devin acp`,stdio 上的 JSON-RPC)驱动本地安装的 Devin CLI。凭据由 CLI 自己通过 `devin auth login` 持有,opencodex 不保存密钥。可用 `OPENCODEX_DEVIN_CLI_BIN` 指定可执行文件;要允许 CLI 读写文件,必须显式设置 `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`,默认拒绝。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | Google Antigravity 账户和提供方的配额查询(包括模型列表回退)使用固定的 Google 计量端点。这些目标支持透明 Fake-IP DNS,同时保留 TLS 验证、重定向拒绝和私有地址检查。自定义 base URL 仅改变模型请求,不改变配额目标;`NO_PROXY` 仍使用直连策略。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 6dd562ec93..6a02d43546 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -111,6 +111,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初次登入會匯入已安裝且已登入的 `kiro-cli` session。Unix 可用 `curl -fsSL https://cli.kiro.dev/install` | `bash` 安裝;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`,再執行 `kiro-cli login`。**Add account** 會先登出 `kiro-cli`、啟動新的 browser login,切換 `kiro-cli` 所使用的帳號並保存 account-scoped profile metadata。既有 OpenCodex 帳號會保留;取消或失敗時會恢復先前的 `kiro-cli` session。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 實驗性 PKCE 登入、即時 HTTP/2 transport 與按帳號篩選的模型探索。 | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | 透過 Agent Client Protocol(`devin acp`,stdio 上的 JSON-RPC)驅動本機安裝的 Devin CLI。憑證由 CLI 以 `devin auth login` 自行保管,opencodex 不會儲存金鑰。可用 `OPENCODEX_DEVIN_CLI_BIN` 指定執行檔;要允許 CLI 讀寫檔案,必須明確設定 `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`,預設為拒絕。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 實驗性。GitHub device flow + `copilot_internal` exchange(VS Code OAuth client)。需要有效 Copilot 訂閱;不是官方第三方 API。 | Google Antigravity 帳戶與供應商的配額查詢(包括模型清單備援)使用固定的 Google 計量端點。這些目標支援透明 Fake-IP DNS,同時保留 TLS 驗證、重新導向拒絕與私有位址檢查。自訂 base URL 只改變模型請求,不改變配額目標;`NO_PROXY` 仍使用直連政策。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7cef263dd1..6f391d8a95 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -619,6 +619,7 @@ "desktop-profile.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", + "devin-cli-adapter.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", diff --git a/src/adapters/devin-cli/acp.ts b/src/adapters/devin-cli/acp.ts new file mode 100644 index 0000000000..28bd4fc6b0 --- /dev/null +++ b/src/adapters/devin-cli/acp.ts @@ -0,0 +1,204 @@ +/** + * Agent Client Protocol framing for the Devin CLI. + * + * `devin acp` speaks newline-delimited JSON-RPC on stdin/stdout. One ACP session + * answers one prompt, so a turn is: initialize -> session/new -> session/prompt, + * with session/update notifications streaming in between and a unary reply to + * the prompt carrying the stop reason and usage. + * + * This module is pure. It never spawns a process and never touches the network, + * so the framing and the event mapping are testable against captured lines, the + * same discipline src/adapters/coding-agent/protocol.ts follows for the + * stream-json CLIs. + */ +import type { AdapterEvent, OcxParsedRequest, OcxToolCall, OcxUsage } from "../../types"; + +/** Hard ceiling on a single buffered stdout line. */ +export const MAX_ACP_LINE_BYTES = 8 * 1024 * 1024; +/** Hard ceiling on total stdout bytes consumed for one turn. */ +export const MAX_ACP_TOTAL_BYTES = 64 * 1024 * 1024; + +export class AcpProtocolError extends Error { + readonly code = "protocol_error"; + readonly status = 502; + constructor(message: string) { + super(message); + this.name = "AcpProtocolError"; + } +} + +export const ACP_INITIALIZE_ID = 1; +export const ACP_SESSION_NEW_ID = 2; +export const ACP_SESSION_PROMPT_ID = 3; + +export function initializeFrame(clientVersion: string): Record { + return { + jsonrpc: "2.0", + id: ACP_INITIALIZE_ID, + method: "initialize", + params: { protocolVersion: 1, clientInfo: { name: "opencodex", version: clientVersion }, capabilities: {} }, + }; +} + +export function sessionNewFrame(cwd: string, modelId?: string): Record { + const params: Record = { cwd, mcpServers: [] }; + // The CLI picks its own default when no model is named, which is what an + // unset or vendor-default selection should do. + if (modelId) params.model = modelId; + return { jsonrpc: "2.0", id: ACP_SESSION_NEW_ID, method: "session/new", params }; +} + +export function sessionPromptFrame(sessionId: string, prompt: string): Record { + return { + jsonrpc: "2.0", + id: ACP_SESSION_PROMPT_ID, + method: "session/prompt", + params: { sessionId, prompt: [{ type: "text", text: prompt }] }, + }; +} + +/** + * Answer a permission request without a human. + * + * A headless turn has nobody to approve a tool call, and an unanswered + * `session/request_permission` stalls the agent until the turn times out. The + * answer is a refusal by default: this provider runs an agent in the operator's + * own tree, and auto-approving whatever it asks for would let any prompt that + * reaches the proxy read, write and execute there. Approval is an explicit + * operator decision, and only then is an allow-shaped option preferred over + * positional guessing — the first option in a real prompt is sometimes the + * rejection. + */ +export function permissionResponseFrame( + id: number | string, + options: Array<{ optionId?: string; name?: string; kind?: string }> | undefined, + allowed = false, +): Record { + if (!allowed) { + return { jsonrpc: "2.0", id, result: { outcome: { outcome: "cancelled" } } }; + } + const list = options ?? []; + const allow = + list.find((o) => typeof o.kind === "string" && /^allow/i.test(o.kind)) ?? + list.find((o) => /allow|accept|yes/i.test(`${o.optionId ?? ""} ${o.name ?? ""}`)); + if (!allow?.optionId) { + // Nothing offered says "allow". Guessing at `list[0]` here is how an + // auto-answer selects a rejection and calls it approval. + return { jsonrpc: "2.0", id, result: { outcome: { outcome: "cancelled" } } }; + } + return { jsonrpc: "2.0", id, result: { outcome: { outcome: "selected", optionId: allow.optionId } } }; +} + +/** + * Flatten an OcxContext into the single prompt string one ACP session takes. + * + * ACP has no multi-message history on session/prompt, so the conversation is + * projected into labelled blocks. Tool calls and results are rendered rather + * than dropped, because a turn that omits them loses the thread of a tool loop. + */ +export function buildAcpPrompt(parsed: OcxParsedRequest): string { + const blocks: string[] = []; + const system = parsed.context.systemPrompt?.filter((line) => line.trim().length > 0).join("\n"); + if (system) blocks.push(fence("System", system)); + for (const message of parsed.context.messages) { + if (message.role === "toolResult") { + const body = typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""); + blocks.push(fence("Tool", `[result id=${message.toolCallId}]\n${body}`)); + continue; + } + const parts = typeof message.content === "string" ? [] : message.content; + let text = typeof message.content === "string" + ? message.content + : parts.map((p) => (p.type === "text" ? p.text : "")).filter(Boolean).join("\n"); + if (message.role === "assistant" && Array.isArray(parts)) { + const calls = parts + .filter((p): p is OcxToolCall => p.type === "toolCall") + .map((c) => `[call ${c.name} id=${c.id}]\n${JSON.stringify(c.arguments ?? {})}`) + .join("\n\n"); + if (calls) text = text ? `${text}\n\n${calls}` : calls; + } + if (!text.trim()) continue; + const label = message.role === "assistant" ? "Assistant" : message.role === "developer" ? "System" : "User"; + blocks.push(fence(label, text)); + } + if (blocks.length === 0) return "(empty)"; + const joined = blocks.join("\n\n"); + // Keep the oldest turns rather than the newest when trimming: the tail is + // what the agent is answering. + return joined.length > MAX_ACP_PROMPT_CHARS + ? `[truncated]\n${joined.slice(joined.length - MAX_ACP_PROMPT_CHARS)}` + : joined; +} + +export type AcpTurnOutcome = { stopReason?: string; usage?: OcxUsage }; + +/** ACP stop reasons that mean the turn ended normally. */ +const NATURAL_STOP = new Set(["end_turn", "stop", "completed"]); + +export function mapAcpStopReason(reason: unknown): string | undefined { + if (typeof reason !== "string" || NATURAL_STOP.has(reason)) return undefined; + if (reason === "max_tokens") return "max_tokens"; + return reason; +} + +export function mapAcpUsage(raw: unknown): OcxUsage | undefined { + if (!raw || typeof raw !== "object") return undefined; + const u = raw as Record; + const input = typeof u.inputTokens === "number" ? u.inputTokens : 0; + const output = typeof u.outputTokens === "number" ? u.outputTokens : 0; + if (input === 0 && output === 0) return undefined; + const total = typeof u.totalTokens === "number" ? u.totalTokens : input + output; + return { inputTokens: input, outputTokens: output, ...(total > 0 ? { totalTokens: total } : {}) }; +} + +function chunkText(content: unknown): string { + if (typeof content === "string") return content; + if (content && typeof content === "object") { + const text = (content as { text?: unknown }).text; + if (typeof text === "string") return text; + } + return ""; +} + +/** + * Translate one session/update notification into adapter events. + * + * Tool lifecycle is explicit in ACP: `tool_call` opens one and + * `tool_call_update` with a terminal status closes it, so the caller does not + * have to infer boundaries from interleaving the way a delta-only wire forces. + */ +export function acpUpdateToEvents(update: Record): AdapterEvent[] { + const kind = update.sessionUpdate; + if (kind === "agent_message_chunk") { + const text = chunkText(update.content); + return text ? [{ type: "text_delta", text }] : []; + } + if (kind === "agent_thought_chunk") { + const text = chunkText(update.content); + return text ? [{ type: "thinking_delta", thinking: text }] : []; + } + // The CLI's own tool calls are NOT client tools. Devin executes them itself + // inside its session, so emitting tool_call_start here would either fail the + // turn — the Responses bridge rejects a tool Codex never declared — or ask + // Codex to run something the agent has already run. Vendor tools stay + // internal and Codex keeps ownership of mutation, which is the same rule the + // CodeBuddy and Qoder adapters follow. + // + // They are not dropped silently, though. A Devin tool operation that runs + // longer than the bridge's stall timeout would otherwise look like upstream + // silence and get the still-working turn aborted, so an internal update + // becomes a heartbeat: proof of life without a client-visible tool. + if (kind === "tool_call" || kind === "tool_call_update" || kind === "plan" || kind === "current_mode_update") { + return [{ type: "heartbeat" }]; + } + return []; +} +/** Ceiling on the flattened conversation handed to one ACP prompt. */ +export const MAX_ACP_PROMPT_CHARS = 200_000; + +/** Fence a block label so a message body cannot forge one. */ +function fence(label: string, body: string): string { + // A user or tool result that contains a line reading `[System]` would + // otherwise appear to open a system block in the flattened prompt. + return `[${label}]\n${body.replace(/^\[(System|User|Assistant|Tool)\]/gm, " $&")}`; +} diff --git a/src/adapters/devin-cli/adapter.ts b/src/adapters/devin-cli/adapter.ts new file mode 100644 index 0000000000..1304521648 --- /dev/null +++ b/src/adapters/devin-cli/adapter.ts @@ -0,0 +1,334 @@ +/** + * Devin CLI adapter: one ACP session per turn over stdio. + * + * This is the local half of Devin support. The cloud-direct `devin` adapter + * talks to Cognition's api-server; this one drives the installed `devin` CLI, + * which carries its own credentials from `devin auth login`, so the proxy never + * sees a token for this provider. + * + * runTurn-only, like the Cursor and cloud Devin adapters: a JSON-RPC handshake + * over a child process has no fetch-shaped request to hand to the generic wire + * path. + * + * The child is treated as untrusted and unprivileged. It gets a scoped + * environment rather than the proxy's, its permission requests are refused + * unless an operator opted in, and it is reaped rather than merely signalled, + * because a Devin grandchild that ignores SIGTERM would otherwise keep writing + * in the operator's tree after the turn returned. + */ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../../types"; +import type { IncomingMeta, ProviderAdapter } from "../base"; +import { baseScopedEnv } from "../coding-agent/turn"; +import { + ACP_INITIALIZE_ID, + ACP_SESSION_NEW_ID, + ACP_SESSION_PROMPT_ID, + MAX_ACP_LINE_BYTES, + MAX_ACP_TOTAL_BYTES, + acpUpdateToEvents, + buildAcpPrompt, + initializeFrame, + mapAcpStopReason, + mapAcpUsage, + permissionResponseFrame, + sessionNewFrame, + sessionPromptFrame, +} from "./acp"; +import { DEVIN_CLI_INSTALL_HINT, resolveDevinCliBinary } from "./binary"; + +/** A turn that has not produced a prompt reply by this point is abandoned. */ +const DEVIN_CLI_TURN_TIMEOUT_MS = 10 * 60 * 1000; +/** Grace between SIGTERM and SIGKILL when reaping the child. */ +const DEVIN_CLI_KILL_GRACE_MS = 2_000; +/** How long to wait for the child to actually exit before giving up on it. */ +const DEVIN_CLI_REAP_MS = 5_000; + +/** + * Identity URL for the provider. The CLI does the real transport over stdio; + * this is only what the configuration records as the destination, and it has to + * be an http(s) URL because provider config validation rejects other schemes. + */ +export const DEVIN_CLI_IDENTITY_URL = "https://cli.devin.ai"; + +/** + * Opt-in for letting the CLI act on the machine. + * + * Off by default: this provider runs an agent in the operator's own tree, and a + * proxy that auto-approves whatever a prompt asks for is a remote shell. + */ +const DEVIN_CLI_ALLOW_TOOLS_ENV = "OPENCODEX_DEVIN_CLI_ALLOW_TOOLS"; + +export type DevinCliSpawn = (binary: string, args: string[], options: { cwd: string; env: Record }) => ChildProcessWithoutNullStreams; + +export function devinCliToolsAllowed(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = env[DEVIN_CLI_ALLOW_TOOLS_ENV]?.trim().toLowerCase(); + return raw === "1" || raw === "true" || raw === "yes"; +} + +export function createDevinCliAdapter(provider: OcxProviderConfig, deps?: { spawn?: DevinCliSpawn }): ProviderAdapter { + const spawnChild: DevinCliSpawn = deps?.spawn + ?? ((binary, args, options) => spawn(binary, args, { + ...options, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + // Give the child its own process group on POSIX so the reap below can + // signal the whole tree. Devin spawns shells and tools of its own when + // the operator allows them, and signalling only the direct pid leaves + // those descendants writing in the operator's tree after the turn ended. + detached: process.platform !== "win32", + }) as ChildProcessWithoutNullStreams); + + return { + name: "devin-cli", + + buildRequest() { + // Placeholder: this adapter never travels the fetch path. The URL is the + // provider's identity, not a destination anything connects to. + return { url: provider.baseUrl || DEVIN_CLI_IDENTITY_URL, method: "POST", headers: {}, body: "" }; + }, + + async *parseStream(): AsyncGenerator { + yield { type: "error", message: "Devin CLI adapter uses runTurn; the fetch/parseStream path is disabled." }; + }, + + async runTurn(parsed: OcxParsedRequest, incoming: IncomingMeta, emit: (event: AdapterEvent) => void) { + if (incoming.abortSignal?.aborted) { + emit({ type: "error", message: "Devin CLI turn was aborted before start." }); + return; + } + const binary = resolveDevinCliBinary(); + if (!binary) { + emit({ type: "error", message: `Devin CLI not found. ${DEVIN_CLI_INSTALL_HINT}` }); + return; + } + + const modelId = parsed.modelId.includes("/") + ? parsed.modelId.slice(parsed.modelId.lastIndexOf("/") + 1) + : parsed.modelId; + const cwd = process.env.OPENCODEX_DEVIN_CLI_CWD?.trim() || process.cwd(); + const toolsAllowed = devinCliToolsAllowed(); + + await new Promise((resolve) => { + let child: ChildProcessWithoutNullStreams; + try { + child = spawnChild(binary, ["acp"], { + cwd, + env: { + // A scoped environment, not the proxy's. The child would + // otherwise inherit every credential this process holds. + ...baseScopedEnv(), + NO_COLOR: "1", + DEVIN_PERMISSION_MODE: toolsAllowed ? (process.env.DEVIN_PERMISSION_MODE ?? "bypass") : "ask", + }, + }); + } catch (error) { + emit({ type: "error", message: `Devin CLI failed to start (${binary}): ${(error as Error).message}. ${DEVIN_CLI_INSTALL_HINT}` }); + return resolve(); + } + + let settled = false; + let closed = false; + let sawProtocolFrame = false; + let sawPromptReply = false; + let buffer = ""; + let totalBytes = 0; + let usage: OcxUsage | undefined; + let stopReason: string | undefined; + let stderrTail = ""; + + const turnTimer = setTimeout( + () => finish(`Devin CLI turn exceeded ${DEVIN_CLI_TURN_TIMEOUT_MS}ms`), + DEVIN_CLI_TURN_TIMEOUT_MS, + ); + const onAbort = () => finish("Devin CLI turn was aborted."); + + /** + * Reap the child rather than just signalling it, then resolve. + * + * `child.killed` only records that a signal was sent. Resolving on that + * lets a grandchild keep running in the operator's tree after runTurn + * returned, which is why this waits for `close` and escalates. + */ + function reapAndResolve(): void { + if (closed || child.exitCode !== null || child.signalCode !== null) return resolve(); + let done = false; + const settle = () => { + if (done) return; + done = true; + clearTimeout(killTimer); + clearTimeout(reapTimer); + resolve(); + }; + child.once("close", settle); + signalTree("SIGTERM"); + const killTimer = setTimeout(() => signalTree("SIGKILL"), DEVIN_CLI_KILL_GRACE_MS); + const reapTimer = setTimeout(settle, DEVIN_CLI_REAP_MS); + } + + /** + * Signal the child's whole process group where the platform has one. + * Devin launches shells and tools of its own once the operator allows + * them, and those descendants do not receive a signal aimed at the + * direct pid. Falls back to the single process when the group send is + * unavailable or the group is already gone. + */ + function signalTree(signal: NodeJS.Signals): void { + const pid = child.pid; + if (pid !== undefined && process.platform !== "win32") { + try { + process.kill(-pid, signal); + return; + } catch { /* no group, or already reaped - fall through */ } + } + try { child.kill(signal); } catch { /* already gone */ } + } + + /** Terminate the turn exactly once, with an error when given a reason. */ + function finish(errorMessage?: string): void { + if (settled) return; + settled = true; + clearTimeout(turnTimer); + incoming.abortSignal?.removeEventListener("abort", onAbort); + child.stdout.destroy(); + if (errorMessage) emit({ type: "error", message: errorMessage, ...(usage ? { usage } : {}) }); + else emit({ type: "done", ...(usage ? { usage } : {}), ...(stopReason ? { stopReason } : {}) }); + reapAndResolve(); + } + + incoming.abortSignal?.addEventListener("abort", onAbort, { once: true }); + // The signal can fire between the pre-spawn check and this listener. + if (incoming.abortSignal?.aborted) return finish("Devin CLI turn was aborted."); + + const send = (frame: Record): void => { + if (!child.stdin.destroyed) child.stdin.write(`${JSON.stringify(frame)}\n`); + }; + // EPIPE after the child is killed is an ordinary race, not a crash. + child.stdin.on("error", () => {}); + + child.on("error", (err) => finish(`Devin CLI failed to start (${binary}): ${err.message}. ${DEVIN_CLI_INSTALL_HINT}`)); + + child.on("close", (code) => { + closed = true; + if (settled) return; + // Flush a final frame that arrived without a trailing newline before + // deciding the turn failed: the prompt reply carrying usage and the + // stop reason is often the last line written. + flush(buffer); + buffer = ""; + if (settled) return; + // A close without a prompt reply is a failure, not an empty success. + const detail = stderrTail.trim().slice(-400); + finish( + `Devin CLI exited (code ${code ?? "null"}) before answering the prompt` + + (detail ? `: ${detail}` : "."), + ); + }); + + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + // Bounded: diagnostics are for the error message, not a buffer to grow. + stderrTail = (stderrTail + chunk).slice(-4096); + }); + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + if (settled) return; + totalBytes += Buffer.byteLength(chunk, "utf8"); + if (totalBytes > MAX_ACP_TOTAL_BYTES) return finish("Devin CLI produced more output than one turn may consume."); + buffer += chunk; + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + flush(line); + if (settled) return; + } + if (Buffer.byteLength(buffer, "utf8") > MAX_ACP_LINE_BYTES) { + finish("Devin CLI emitted a single line larger than the frame cap."); + } + }); + + // The prompt reply carrying usage and the stop reason is often the last + // thing written, and it is not guaranteed to end with a newline. Flush + // the remainder when the stream ends rather than waiting for the child + // to exit and then calling a complete turn a failure. + child.stdout.on("end", () => { + const tail = buffer; + buffer = ""; + flush(tail); + }); + + function flush(raw: string): void { + const line = raw.trim(); + if (!line || settled) return; + let frame: Record; + try { + frame = JSON.parse(line) as Record; + } catch { + // The CLI prints a banner before the protocol starts, so plain text + // is expected up to the first valid frame. After that the stream is + // protocol, and a line that is shaped like a frame but does not + // parse is corruption: dropping it silently loses a session/update + // or lets the turn wait out the timeout for a reply that already + // arrived damaged. + if (sawProtocolFrame || line.startsWith("{")) { + finish("Devin CLI emitted a malformed ACP frame."); + } + return; + } + sawProtocolFrame = true; + handle(frame); + } + + /** JSON-RPC ids are allowed to come back as strings. */ + const idOf = (value: unknown): number | undefined => { + if (typeof value === "number") return value; + if (typeof value === "string" && /^\d+$/.test(value)) return Number(value); + return undefined; + }; + + function handle(frame: Record): void { + if (settled) return; + const id = idOf(frame.id); + const error = frame.error as { message?: string } | undefined; + + if (id === ACP_INITIALIZE_ID) { + if (error) return finish(`Devin CLI initialize failed: ${error.message ?? "unknown error"}`); + send(sessionNewFrame(cwd, modelId)); + return; + } + if (id === ACP_SESSION_NEW_ID) { + if (error) return finish(`Devin CLI session/new failed: ${error.message ?? "unknown error"}`); + const sessionId = (frame.result as { sessionId?: string } | undefined)?.sessionId; + if (!sessionId) return finish("Devin CLI session/new returned no sessionId."); + send(sessionPromptFrame(sessionId, buildAcpPrompt(parsed))); + return; + } + if (frame.method === "session/request_permission" && frame.id != null) { + const params = frame.params as { options?: Array<{ optionId?: string; name?: string; kind?: string }> } | undefined; + send(permissionResponseFrame(frame.id as number | string, params?.options, toolsAllowed)); + return; + } + if (frame.method === "session/update") { + const update = (frame.params as { update?: Record } | undefined)?.update; + if (!update) return; + for (const event of acpUpdateToEvents(update)) emit(event); + return; + } + if (id === ACP_SESSION_PROMPT_ID) { + if (error) return finish(`Devin CLI session/prompt failed: ${error.message ?? "unknown error"}`); + sawPromptReply = true; + const result = frame.result as { stopReason?: unknown; usage?: unknown } | undefined; + usage = mapAcpUsage(result?.usage) ?? usage; + stopReason = mapAcpStopReason(result?.stopReason); + finish(); + } + } + + void sawPromptReply; + send(initializeFrame(process.env.OPENCODEX_VERSION ?? "0.0.0")); + }); + }, + }; +} diff --git a/src/adapters/devin-cli/binary.ts b/src/adapters/devin-cli/binary.ts new file mode 100644 index 0000000000..7435e262f1 --- /dev/null +++ b/src/adapters/devin-cli/binary.ts @@ -0,0 +1,69 @@ +/** + * Locate the Devin CLI. + * + * The official installer (`curl -fsSL https://cli.devin.ai/install.sh | bash`) + * and the Homebrew cask both drop the binary in one of a small set of places. + * The environment override comes first so an operator can point at a specific + * build without touching PATH, and PATH is the last resort rather than the + * first so a shadowed name cannot silently win. + */ +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { homedir } from "node:os"; + +export const DEVIN_CLI_BIN_ENV = "OPENCODEX_DEVIN_CLI_BIN"; + +export const DEVIN_CLI_INSTALL_HINT = + "Install the Devin CLI with `curl -fsSL https://cli.devin.ai/install.sh | bash` or `brew install --cask devin-cli`, then run `devin auth login`."; + +let cached: string | undefined; + +/** Reset the discovery cache (tests, or an explicit re-check after an install). */ +export function clearDevinCliBinaryCache(): void { + cached = undefined; +} + +function candidatePaths(home: string): string[] { + return [ + join(home, "AppData", "Local", "Microsoft", "WinGet", "Links", "devin.exe"), + join(home, ".local", "share", "devin", "bin", "devin"), + join(home, ".devin", "bin", "devin"), + join(home, ".local", "bin", "devin"), + "/opt/homebrew/bin/devin", + "/usr/local/bin/devin", + "/usr/bin/devin", + ]; +} + +function fromPath(exists: (p: string) => boolean): string | undefined { + const pathVar = process.env.PATH ?? ""; + for (const dir of pathVar.split(delimiter)) { + if (!dir) continue; + for (const name of ["devin", "devin.exe"]) { + const full = join(dir, name); + if (exists(full)) return full; + } + } + return undefined; +} + +/** + * Resolve the executable, or undefined when it is not installed. + * + * `exists` and `home` are seams so the resolution order can be tested without + * depending on what happens to be installed on the machine running the tests. + */ +export function resolveDevinCliBinary(opts?: { exists?: (p: string) => boolean; home?: string; useCache?: boolean }): string | undefined { + const exists = opts?.exists ?? existsSync; + const useCache = opts?.useCache ?? opts === undefined; + if (useCache && cached) return cached; + const override = process.env[DEVIN_CLI_BIN_ENV]?.trim(); + if (override) { + if (useCache) cached = override; + return override; + } + const home = opts?.home ?? homedir(); + const found = candidatePaths(home).find((p) => exists(p)) ?? fromPath(exists); + if (found && useCache) cached = found; + return found; +} diff --git a/src/adapters/devin-cli/models.ts b/src/adapters/devin-cli/models.ts new file mode 100644 index 0000000000..ea614c5d66 --- /dev/null +++ b/src/adapters/devin-cli/models.ts @@ -0,0 +1,23 @@ +/** + * Models the Devin CLI accepts on `session/new`. + * + * The CLI picks its own default when no model is named, so this roster exists + * for the picker rather than as a gate. It is a static list on purpose: ACP has + * no discovery call, and the vendor roster moves faster than a pinned copy + * would, so an unknown id is passed through to the CLI to accept or refuse. + */ +export const DEVIN_CLI_DEFAULT_MODEL = "swe-2"; + +export const DEVIN_CLI_MODELS = [ + "swe-2", + "swe-2-high", + "claude-opus-5-medium", + "claude-fable-5-1-medium", + "claude-sonnet-5-medium", + "gpt-6-astra-medium", + "gpt-5-6-sol-medium", + "gemini-3-8-flash-medium", + "glm-5-3-high", + "glm-5-3-low", + "kimi-k3-high", +] as const; diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index d8edbead92..6ed9674c9f 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -6,6 +6,7 @@ import { createCodeBuddyAdapter } from "./codebuddy/adapter"; import { createQoderAdapter } from "./qoder/adapter"; import { createCommandCodeAdapter } from "./command-code"; import { createCursorAdapter } from "./cursor"; +import { createDevinCliAdapter } from "./devin-cli/adapter"; import { createGoogleAdapter } from "./google"; import { createKiroAdapter } from "./kiro"; import { createMimoFreeAdapter } from "./mimo-free"; @@ -30,7 +31,8 @@ export type AdapterWire = | "openai-responses" | "google" | "kiro" - | "cursor"; + | "cursor" + | "devin-cli"; export type AdapterMutationContract = | "codex-owned" @@ -112,6 +114,11 @@ export const ADAPTER_REGISTRY = { mutation: "codex-owned-with-gated-native-fallback", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createCursorAdapter(provider), }, + "devin-cli": { + wire: "devin-cli", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createDevinCliAdapter(provider), + }, "mimo-free": { contractParent: "openai-chat", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createMimoFreeAdapter(provider), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index fb9db46101..f72bb7650b 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,6 +1,7 @@ import type { CodexAccountMode, FastWire, OcxProviderConfig } from "../types"; import { fastWireDeclarationError } from "./fastwire"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; +import { DEVIN_CLI_DEFAULT_MODEL, DEVIN_CLI_MODELS } from "../adapters/devin-cli/models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "./antigravity-models"; import type { ProviderBaseUrlChoice } from "./base-url-choices"; import { @@ -1276,6 +1277,27 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // still advertises image for noVision members so Codex can attach (sidecar option B). noVisionModels: [...CURSOR_NO_VISION_MODELS], }, + { + // Drives the locally installed Devin CLI. The CLI owns its own credentials + // from `devin auth login`, so this provider takes no key and the proxy never + // holds one. Inference happens in the child process, which is why the + // destination is a stdio scheme rather than a URL. + id: "devin-cli", + label: "Devin CLI (local)", + adapter: "devin-cli", + // A canonical identity URL, not a transport. The CLI performs the real + // transport over stdio; this is the destination the config records, and it + // has to be an http(s) URL because providerBaseUrlConfigError rejects any + // other scheme — a `devin://` destination made the generated config + // unloadable. Same shape as the other CLI-backed providers. + baseUrl: "https://cli.devin.ai", + authKind: "local", + featured: false, + dashboardPreset: false, + note: "Drives the locally installed Devin CLI over the Agent Client Protocol (`devin acp`, newline-delimited JSON-RPC on stdio). Requires the CLI on PATH and a completed `devin auth login`; no API key is stored by opencodex. Set OPENCODEX_DEVIN_CLI_BIN to point at a specific build, and OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1 to let the CLI read and write files — the default is to refuse.", + models: [...DEVIN_CLI_MODELS], + defaultModel: DEVIN_CLI_DEFAULT_MODEL, + }, { id: "xai", label: "xAI Grok", diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index 2677864454..3ce81accbf 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -14,6 +14,7 @@ export function upstreamProtocolForAdapter(adapter: string): string { case "openai-chat": case "command-code": case "cursor": + case "devin-cli": case "azure": case "azure-openai": case "kiro": diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 864e14dc78..1ae7440a76 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -13,6 +13,11 @@ Some adapters share another adapter's routed-tool semantics while retaining inde - `azure` and `azure-openai` inherit the `openai-responses` contract. - `mimo-free` inherits the `openai-chat` contract. - `cursor` stays direct because its `runTurn` transport and gated native-file fallback are distinct. +- `devin-cli` stays direct for the same reason, one layer further out: it has no HTTP transport at + all. The turn runs as an Agent Client Protocol session against a local `devin acp` child process, + so `buildRequest` returns a placeholder and `parseStream` is disabled. Its registry `baseUrl` is a + canonical identity URL rather than a destination anything connects to, which is what keeps the + generated configuration loadable: `providerBaseUrlConfigError` accepts only `http(s)` schemes. The registry records those relationships with `contractParent`. A parent relationship does **not** mean the registry recursively constructs a parent adapter and injects it into the child. Azure and MiMo keep owning their existing internal composition. This avoids making production constructors depend on test/conformance needs and keeps this authority refactor behavior-neutral. diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index d7bac03afe..e7e8f60b4f 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -21,6 +21,7 @@ const EXPECTED_ADAPTER_NAMES = { azure: "azure-openai", "azure-openai": "azure-openai", cursor: "cursor", + "devin-cli": "devin-cli", "mimo-free": "mimo-free", qoder: "qoder", } as const; diff --git a/tests/adapters/adapter-tool-conformance.test.ts b/tests/adapters/adapter-tool-conformance.test.ts index 91fd5a399b..284add7992 100644 --- a/tests/adapters/adapter-tool-conformance.test.ts +++ b/tests/adapters/adapter-tool-conformance.test.ts @@ -420,10 +420,14 @@ describe("registry-derived routed tool conformance", () => { }); const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder"]); + // devin-cli drives a local CLI over ACP stdio: buildRequest returns a + // placeholder and tools never travel the wire path. + const RUN_TURN_ONLY_WIRES = new Set(["devin-cli"]); test("every registered adapter keeps the nested apply_patch helper in its final request", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const body = await outbound(adapterId, codeModeParsed(contract.wire)); const advertised = advertisedToolNames(contract.wire, body); @@ -438,6 +442,7 @@ describe("registry-derived routed tool conformance", () => { test("tool_choice none disables every registered adapter's callable tool surface", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const enabledBody = await outbound(adapterId, toolChoiceParsed(contract.wire)); expect(advertisedToolNames(contract.wire, enabledBody).length, `${adapterId}:enabled`).toBeGreaterThan(0); @@ -449,12 +454,13 @@ describe("registry-derived routed tool conformance", () => { test("every parsed streaming wire restores hostile freeform input exactly", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; if (!driver.streamingToolCall) { // OpenAI Responses is a normal passthrough here and only parses routed compaction; // Cursor's proprietary runTurn stream has focused parser coverage elsewhere. - expect(["openai-responses", "cursor"]).toContain(contract.wire); + expect(["openai-responses", "cursor", "devin-cli"]).toContain(contract.wire); continue; } expect(await restoredStreamInput(adapterId, contract.wire), adapterId).toBe(PATCH); @@ -464,8 +470,9 @@ describe("registry-derived routed tool conformance", () => { test("every buffered adapter preserves same-name tools from different namespaces", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); - if (contract.wire === "openai-responses" || contract.wire === "cursor") { + if (contract.wire === "openai-responses" || contract.wire === "cursor" || contract.wire === "devin-cli") { // Native Responses passthrough and Cursor's protobuf transport do not use the routed // adapter tool declaration surface exercised by this registry-wide check. continue; @@ -479,8 +486,9 @@ describe("registry-derived routed tool conformance", () => { test("every routed adapter fails closed for an ambiguous bare selector", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); - if (contract.wire === "openai-responses" || contract.wire === "cursor") continue; + if (contract.wire === "openai-responses" || contract.wire === "cursor" || contract.wire === "devin-cli") continue; const parsed = namespacedCollisionParsed(contract.wire); // parseRequest rejects this shape for real inbound traffic; keeping the policy mutation here // also proves each adapter remains fail-closed when a caller reaches it with a prebuilt AST. @@ -505,10 +513,11 @@ describe("registry-derived routed tool conformance", () => { test("every streaming adapter restores namespaced custom/function collisions distinctly", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; if (!driver.streamingToolCall || !driver.extractWireToolName) { - expect(["openai-responses", "cursor"]).toContain(contract.wire); + expect(["openai-responses", "cursor", "devin-cli"]).toContain(contract.wire); continue; } @@ -548,6 +557,7 @@ describe("registry-derived routed tool conformance", () => { test("every registered adapter replays the exact apply_patch input on continuation", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const body = await outbound(adapterId, continuationParsed(contract.wire)); expect(continuationInput(contract.wire, body), adapterId).toBe(PATCH); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 39bcb31f19..90b71a5483 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -454,6 +454,7 @@ "desktop-profile.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", + "devin-cli-adapter.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", diff --git a/tests/providers/devin-cli-adapter.test.ts b/tests/providers/devin-cli-adapter.test.ts new file mode 100644 index 0000000000..270832cfa1 --- /dev/null +++ b/tests/providers/devin-cli-adapter.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test } from "bun:test"; +import { + ACP_SESSION_NEW_ID, + acpUpdateToEvents, + buildAcpPrompt, + initializeFrame, + mapAcpStopReason, + mapAcpUsage, + permissionResponseFrame, + sessionNewFrame, + sessionPromptFrame, +} from "../../src/adapters/devin-cli/acp"; +import { DEVIN_CLI_BIN_ENV, resolveDevinCliBinary } from "../../src/adapters/devin-cli/binary"; +import { createDevinCliAdapter } from "../../src/adapters/devin-cli/adapter"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import type { AdapterEvent, OcxParsedRequest } from "../../src/types"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; + +describe("devin-cli registration", () => { + test("is a local provider that stores no credential", () => { + const entry = PROVIDER_REGISTRY.find((row) => row.id === "devin-cli"); + expect(entry?.adapter).toBe("devin-cli"); + // The installed CLI carries its own credentials from `devin auth login`, + // so the proxy must never ask for or hold a key for this provider. + expect(entry?.authKind).toBe("local"); + expect(entry?.dashboardPreset).toBe(false); + expect(createDevinCliAdapter({ adapter: "devin-cli", baseUrl: "https://cli.devin.ai" }).name).toBe("devin-cli"); + }); +}); + +describe("acp handshake frames", () => { + test("initialize declares protocol 1 and session/new carries cwd", () => { + expect(initializeFrame("1.2.3")).toMatchObject({ + jsonrpc: "2.0", + method: "initialize", + params: { protocolVersion: 1, clientInfo: { name: "opencodex", version: "1.2.3" } }, + }); + const withModel = sessionNewFrame("/repo", "swe-2") as { id: number; params: Record }; + expect(withModel.id).toBe(ACP_SESSION_NEW_ID); + expect(withModel.params).toEqual({ cwd: "/repo", mcpServers: [], model: "swe-2" }); + // No model named means the CLI picks its own default, so the key is absent + // rather than present and empty. + expect((sessionNewFrame("/repo") as { params: Record }).params).toEqual({ cwd: "/repo", mcpServers: [] }); + expect(sessionPromptFrame("s1", "hi")).toMatchObject({ + method: "session/prompt", + params: { sessionId: "s1", prompt: [{ type: "text", text: "hi" }] }, + }); + }); + + test("a permission request is refused unless the operator opted in", () => { + // This provider runs an agent in the operator's own tree. Auto-approving + // whatever a prompt asks for would make the proxy a remote shell. + const options = [ + { optionId: "no", name: "Reject", kind: "reject_once" }, + { optionId: "yes", name: "Approve", kind: "allow_once" }, + ]; + expect(permissionResponseFrame(9, options)).toMatchObject({ result: { outcome: { outcome: "cancelled" } } }); + const allowed = permissionResponseFrame(9, options, true) as { result: { outcome: { optionId: string } } }; + // Positional guessing would have taken the reject here. + expect(allowed.result.outcome.optionId).toBe("yes"); + expect( + (permissionResponseFrame(9, [{ optionId: "accept-all", name: "Accept" }], true) as { result: { outcome: { optionId: string } } }) + .result.outcome.optionId, + ).toBe("accept-all"); + // Nothing on offer says allow, so approving would mean selecting a + // rejection and calling it approval. + expect(permissionResponseFrame(9, [{ optionId: "no", kind: "reject_once" }], true)).toMatchObject({ + result: { outcome: { outcome: "cancelled" } }, + }); + expect(permissionResponseFrame(9, undefined, true)).toMatchObject({ result: { outcome: { outcome: "cancelled" } } }); + }); +}); + +describe("acp prompt projection", () => { + test("system, tool calls and tool results all survive the flattening", () => { + const parsed = { + modelId: "swe-2", + stream: true, + context: { + systemPrompt: ["be brief"], + messages: [ + { role: "user", content: "hi", timestamp: 1 }, + { + role: "assistant", + content: [ + { type: "text", text: "looking" }, + { type: "toolCall", id: "c1", name: "lookup", arguments: { q: "x" } }, + ], + timestamp: 2, + }, + { role: "toolResult", toolCallId: "c1", toolName: "lookup", content: "ok", isError: false, timestamp: 3 }, + ], + tools: [], + }, + options: {}, + } as unknown as OcxParsedRequest; + const prompt = buildAcpPrompt(parsed); + expect(prompt).toContain("[System]\nbe brief"); + expect(prompt).toContain("[User]\nhi"); + // ACP takes one string, so a dropped tool loop would lose the thread. + expect(prompt).toContain("[call lookup id=c1]"); + expect(prompt).toContain('{"q":"x"}'); + expect(prompt).toContain("[result id=c1]"); + expect(buildAcpPrompt({ ...parsed, context: { ...parsed.context, systemPrompt: [], messages: [] } })).toBe("(empty)"); + }); +}); + +describe("acp update mapping", () => { + test("message and thought chunks map to their own channels", () => { + expect(acpUpdateToEvents({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "a" } })).toEqual([ + { type: "text_delta", text: "a" }, + ]); + expect(acpUpdateToEvents({ sessionUpdate: "agent_thought_chunk", content: "why" })).toEqual([ + { type: "thinking_delta", thinking: "why" }, + ]); + expect(acpUpdateToEvents({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "" } })).toEqual([]); + }); + + test("the CLI's own tool calls become heartbeats, never Codex client tools", () => { + // Devin executes these itself inside its session. Emitting tool_call_start + // would either fail the turn, because the bridge rejects a tool Codex never + // declared, or ask Codex to run something the agent already ran. Dropping + // them outright is not right either: a long internal tool operation would + // read as upstream silence and get the working turn stall-aborted. + expect(acpUpdateToEvents({ sessionUpdate: "tool_call", toolCallId: "t1", title: "read", rawInput: { path: "a" } })).toEqual([ + { type: "heartbeat" }, + ]); + expect(acpUpdateToEvents({ sessionUpdate: "tool_call_update", toolCallId: "t1", status: "completed" })).toEqual([ + { type: "heartbeat" }, + ]); + expect(acpUpdateToEvents({ sessionUpdate: "plan", entries: [] })).toEqual([{ type: "heartbeat" }]); + expect(acpUpdateToEvents({ sessionUpdate: "something_new" })).toEqual([]); + }); + +}); + +describe("acp turn outcome", () => { + test("a natural end carries no stopReason", () => { + // The bridge reads any truthy stopReason as "this turn did not finish", so + // reporting end_turn would cost every clean turn its final_answer phase. + expect(mapAcpStopReason("end_turn")).toBeUndefined(); + expect(mapAcpStopReason(undefined)).toBeUndefined(); + expect(mapAcpStopReason("max_tokens")).toBe("max_tokens"); + expect(mapAcpStopReason("refusal")).toBe("refusal"); + }); + + test("usage is reported only when the agent actually counted something", () => { + expect(mapAcpUsage({ inputTokens: 10, outputTokens: 4 })).toEqual({ inputTokens: 10, outputTokens: 4, totalTokens: 14 }); + expect(mapAcpUsage({ inputTokens: 1, outputTokens: 2, totalTokens: 9 })).toEqual({ + inputTokens: 1, + outputTokens: 2, + totalTokens: 9, + }); + expect(mapAcpUsage({ inputTokens: 0, outputTokens: 0 })).toBeUndefined(); + expect(mapAcpUsage(undefined)).toBeUndefined(); + }); +}); + +describe("devin cli discovery", () => { + test("the environment override wins over every install path", () => { + const previous = process.env[DEVIN_CLI_BIN_ENV]; + process.env[DEVIN_CLI_BIN_ENV] = "/custom/devin"; + try { + expect(resolveDevinCliBinary({ exists: () => true, home: "/home/u", useCache: false })).toBe("/custom/devin"); + } finally { + if (previous === undefined) delete process.env[DEVIN_CLI_BIN_ENV]; + else process.env[DEVIN_CLI_BIN_ENV] = previous; + } + }); + + test("known install paths are preferred over a shadowed PATH entry, and absence is undefined", () => { + const previous = process.env[DEVIN_CLI_BIN_ENV]; + delete process.env[DEVIN_CLI_BIN_ENV]; + try { + const only = (p: string) => p === "/home/u/.local/bin/devin"; + expect(resolveDevinCliBinary({ exists: only, home: "/home/u", useCache: false })).toBe("/home/u/.local/bin/devin"); + expect(resolveDevinCliBinary({ exists: () => false, home: "/home/u", useCache: false })).toBeUndefined(); + } finally { + if (previous !== undefined) process.env[DEVIN_CLI_BIN_ENV] = previous; + } + }); +}); + +describe("devin-cli runTurn", () => { + // A fake ACP child: stdin collects the frames the adapter sends, stdout is a + // script the test pushes. This is the seam the coding-agent family uses, and + // without it the abort, crash and post-terminal paths cannot fail CI. + function fakeChild() { + const stdinWrites: string[] = []; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new EventEmitter() as unknown as ChildProcessWithoutNullStreams & { exitCode: number | null; signalCode: string | null; killed: boolean }; + Object.assign(child, { + stdout, + stderr, + stdin: Object.assign(new PassThrough(), { + write: (chunk: string) => { stdinWrites.push(String(chunk)); return true; }, + destroyed: false, + }), + exitCode: null, + signalCode: null, + killed: false, + // A real child emits close after being signalled; the adapter waits for + // that rather than trusting `killed`, so the fake has to as well. + kill: () => { + (child as { killed: boolean }).killed = true; + queueMicrotask(() => child.emit("close", null)); + return true; + }, + }); + return { child, stdout, stdinWrites }; + } + + const parsed = { + modelId: "swe-2", + stream: true, + context: { systemPrompt: [], messages: [{ role: "user", content: "hi", timestamp: 1 }], tools: [] }, + options: {}, + } as unknown as OcxParsedRequest; + + async function run(script: (stdout: PassThrough, child: EventEmitter) => void) { + const { child, stdout, stdinWrites } = fakeChild(); + const events: AdapterEvent[] = []; + process.env[DEVIN_CLI_BIN_ENV] = "/fake/devin"; + const adapter = createDevinCliAdapter({ adapter: "devin-cli", baseUrl: "https://cli.devin.ai" }, { + spawn: () => { queueMicrotask(() => script(stdout, child as unknown as EventEmitter)); return child; }, + }); + await adapter.runTurn!(parsed, {} as never, (e) => events.push(e)); + delete process.env[DEVIN_CLI_BIN_ENV]; + return { events, stdinWrites }; + } + + test("a complete handshake produces exactly one terminal event, carrying usage", async () => { + const { events, stdinWrites } = await run((stdout) => { + stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\n'); + queueMicrotask(() => { + stdout.write('{"jsonrpc":"2.0","id":2,"result":{"sessionId":"s1"}}\n'); + queueMicrotask(() => { + stdout.write('{"jsonrpc":"2.0","method":"session/update","params":{"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}}\n'); + // The prompt reply arrives WITHOUT a trailing newline, and more + // output follows it. Both used to break this adapter. + stdout.write('{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":3,"outputTokens":1}}}'); + stdout.end(); + }); + }); + }); + const terminals = events.filter((e) => e.type === "done" || e.type === "error"); + expect(terminals).toHaveLength(1); + expect(terminals[0]).toMatchObject({ type: "done", usage: { inputTokens: 3, outputTokens: 1, totalTokens: 4 } }); + // A natural end reports no stopReason. + expect((terminals[0] as { stopReason?: string }).stopReason).toBeUndefined(); + expect(events.filter((e) => e.type === "text_delta")).toEqual([{ type: "text_delta", text: "PONG" }]); + expect(stdinWrites.join("")).toContain('"method":"session/prompt"'); + }); + + test("a crash before the prompt reply is an error, not an empty success", async () => { + const { events } = await run((stdout, child) => { + stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\n'); + queueMicrotask(() => { + child.emit("close", 1); + }); + }); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("error"); + expect((events[0] as { message: string }).message).toMatch(/exited \(code 1\) before answering/); + }); + + test("a session/new failure reports the CLI's reason", async () => { + const { events } = await run((stdout) => { + stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\n'); + queueMicrotask(() => { + stdout.write('{"jsonrpc":"2.0","id":2,"error":{"message":"not authenticated"}}\n'); + }); + }); + expect(events).toHaveLength(1); + expect((events[0] as { message: string }).message).toMatch(/session\/new failed: not authenticated/); + }); + + test("a malformed frame after the protocol starts fails the turn instead of vanishing", async () => { + // A banner line before the first frame is expected noise. A broken frame + // afterwards is corruption: swallowing it loses output, or waits out the + // ten-minute timeout for a reply that already arrived damaged. + const { events } = await run((stdout) => { + stdout.write("Devin CLI v3000.10.21\n"); + stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\n'); + queueMicrotask(() => stdout.write('{"jsonrpc":"2.0","id":2,"result":{"sessionId"\n')); + }); + expect(events).toHaveLength(1); + expect((events[0] as { message: string }).message).toMatch(/malformed ACP frame/); + }); +}); From 8c1edce0e533bfc9932166e8ec96876563c0aab1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:23:38 +0900 Subject: [PATCH 079/231] feat(providers): rank API keys by quota headroom apiKeyPoolStrategy gains a quota value, matching what every other pool in this codebase already does. The selector is synchronous and on the first-attempt path, so it reads a new cache-only per-key quota accessor that never probes; an unavailable row counts as no evidence rather than as its stale last-good measurement. --- src/config.ts | 2 +- src/providers/key-failover.ts | 58 +++++++++++++++++++++++++++++ src/providers/quota-key-accounts.ts | 40 ++++++++++++++++++++ src/types/provider.ts | 2 +- 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3cdd699549..f1e8cfb6b5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -583,7 +583,7 @@ const providerConfigSchema = z.object({ // Validated rather than left to passthrough: an unrecognized strategy would otherwise // load silently and then be ignored at selection time, which reads as a broken feature // rather than a rejected setting. - apiKeyPoolStrategy: z.enum(["round-robin", "fill-first"]).optional(), + apiKeyPoolStrategy: z.enum(["round-robin", "fill-first", "quota"]).optional(), adapter: z.string().min(1), baseUrl: z.string().min(1), alias: z.string().optional(), diff --git a/src/providers/key-failover.ts b/src/providers/key-failover.ts index bce8f3368d..5427beb0b4 100644 --- a/src/providers/key-failover.ts +++ b/src/providers/key-failover.ts @@ -15,6 +15,10 @@ import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetry import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport"; import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; +// quota-key-accounts imports only node:crypto, the key store and the quota types -- NOT +// providers/quota.ts -- so the cached reader reaches the dispatch path without dragging the +// probe machinery onto it. +import { cachedApiKeyQuota } from "./quota-key-accounts"; // ---- cooldown state (in-memory, same as codex/routing.ts) ---- @@ -113,6 +117,56 @@ export function forgetApiKeyRotationCursor(providerName: string): void { keyRotationCursor.delete(providerName); } +/** The pool entry shape is inline on OcxProviderConfig; name it once rather than re-spelling it. */ +type ApiKeyPoolEntry = NonNullable[number]; + +/** + * Remaining headroom for one key, or null when nothing current measures it. + * + * Same definition as `headroomOf` on the OAuth side, so the two pools cannot disagree about + * what "more room" means. `creditsUsd` is deliberately excluded: it is a currency amount, not + * a percentage, and ranking one against the other produces an order that means nothing. + */ +function keyHeadroom(providerName: string, provider: OcxProviderConfig, entry: ApiKeyPoolEntry): number | null { + const quota = cachedApiKeyQuota(providerName, provider, entry.id, entry.key); + if (!quota) return null; + const percents = [ + quota.fiveHourPercent, + quota.weeklyPercent, + quota.monthlyPercent, + ...(quota.customWindows ?? []).map((window: { percent?: number }) => window.percent), + ].filter((value): value is number => typeof value === "number"); + if (percents.length === 0) return null; + return 100 - Math.max(...percents); +} + +/** + * Order eligible keys best-first, in the same three buckets `rankAccountsByHeadroom` uses: + * measured-with-headroom, then unmeasured, then measured-and-spent. Ties keep the roster order. + * + * An unmeasured key is NOT assumed spent, and not assumed fresh either -- it sits between the + * two, which is the only honest position for a key nothing has looked at. A provider that + * publishes no per-key differentiation (DeepSeek reports every key at the same percent) ties + * across the board and falls through to the roster order, which is exactly today's behaviour. + */ +function rankKeysByHeadroom( + providerName: string, + provider: OcxProviderConfig, + eligible: readonly ApiKeyPoolEntry[], +): ApiKeyPoolEntry[] { + return eligible + .map((entry, index) => { + const headroom = keyHeadroom(providerName, provider, entry); + const bucket = headroom === null ? 1 : headroom <= 0 ? 2 : 0; + return { entry, bucket, headroom: headroom ?? 0, index }; + }) + .sort((left, right) => (left.bucket - right.bucket) + || (right.headroom - left.headroom) + || (left.index - right.index)) + .map(row => row.entry); +} + + /** * Pick a better key BEFORE the first attempt when the committed one is already cooling. * @@ -154,6 +208,10 @@ export function selectProactiveApiKey( chosen = candidate; break; } + } else if (strategy === "quota") { + // else-if, deliberately. `fill-first` is not a named branch here -- it is the eligible[0] + // default above, so replacing that default would silently retarget it. + chosen = rankKeysByHeadroom(providerName, provider, eligible)[0] ?? chosen; } if (chosen.key === provider.apiKey) return null; diff --git a/src/providers/quota-key-accounts.ts b/src/providers/quota-key-accounts.ts index 7f406e0bed..55304b15e4 100644 --- a/src/providers/quota-key-accounts.ts +++ b/src/providers/quota-key-accounts.ts @@ -29,6 +29,46 @@ export function clearProviderApiKeyQuotaCache(): void { flights.clear(); } +/** + * Cached-only, synchronous per-key quota. Never probes, never awaits, never schedules a read. + * + * The selector that calls this sits on the first-attempt path, where a network read would be a + * worse defect than the one it is there to fix. A miss is simply "no evidence". + * + * An `unavailable` row is a miss too, and that is the whole point of the check. `readEntry` + * keeps a last-good quota attached for up to LAST_GOOD_MS after a probe starts failing, so + * returning `entry.quota` on any hit would rank on a number up to half an hour stale -- and + * rank it ABOVE a key with no row at all. Last-good is a display value, not a selection input. + */ +export function cachedApiKeyQuota( + name: string, + provider: OcxProviderConfig, + keyId: string, + key: string, +): ProviderQuota | null { + let resolved: string | undefined; + // resolveProviderApiKey swallows its own failures; the catch is belt-and-braces because this + // runs on the dispatch path and must not throw there under any future change. + try { resolved = resolveProviderApiKey(key)?.trim(); } catch { return null; } + if (!resolved) return null; + const entry = cache.get(identity(name, provider, keyId, resolved)); + if (!entry || entry.unavailable || !entry.quota) return null; + return entry.quota; +} + +/** Test seam: keyed on identity(), so it takes the raw key rather than an account id. */ +export function setCachedProviderApiKeyQuotaForTests( + name: string, + provider: OcxProviderConfig, + keyId: string, + key: string, + quota: ProviderQuota | null, +): void { + const resolved = resolveProviderApiKey(key)?.trim(); + if (!resolved) return; + remember(identity(name, provider, keyId, resolved), { ts: Date.now(), quota }); +} + /** Four workers per roster, not a process-wide network limit. */ export async function mapQuotaRoster(rows: readonly T[], read: (row: T) => Promise): Promise { const out = new Array(rows.length); diff --git a/src/types/provider.ts b/src/types/provider.ts index fc56c88e19..476cd120ff 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -396,7 +396,7 @@ export interface OcxProviderConfig { * Absent means today's behaviour: no pre-dispatch pick at all, only the reactive * 429/401 walk in `key-failover`. */ - apiKeyPoolStrategy?: "round-robin" | "fill-first"; + apiKeyPoolStrategy?: "round-robin" | "fill-first" | "quota"; /** Changes on manual selection (including re-selection) and committed automatic allocation. */ apiKeySelectionRevision?: string; /** Runtime only. Never expose in management responses or persist a routed provider. */ From 9fbff86c9cb62f329dbfa6d5ce49beb1667a66ec Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:25:01 +0900 Subject: [PATCH 080/231] test(providers): cover quota-ranked API key selection --- src/providers/quota-key-accounts.ts | 3 +- tests/adapters/key-failover.test.ts | 52 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/providers/quota-key-accounts.ts b/src/providers/quota-key-accounts.ts index 55304b15e4..f52cfa54e3 100644 --- a/src/providers/quota-key-accounts.ts +++ b/src/providers/quota-key-accounts.ts @@ -63,10 +63,11 @@ export function setCachedProviderApiKeyQuotaForTests( keyId: string, key: string, quota: ProviderQuota | null, + unavailable?: true, ): void { const resolved = resolveProviderApiKey(key)?.trim(); if (!resolved) return; - remember(identity(name, provider, keyId, resolved), { ts: Date.now(), quota }); + remember(identity(name, provider, keyId, resolved), { ts: Date.now(), quota, ...(unavailable ? { unavailable } : {}) }); } /** Four workers per roster, not a process-wide network limit. */ diff --git a/tests/adapters/key-failover.test.ts b/tests/adapters/key-failover.test.ts index 1bd89af487..8a0b290cd3 100644 --- a/tests/adapters/key-failover.test.ts +++ b/tests/adapters/key-failover.test.ts @@ -27,6 +27,7 @@ import { deriveXaiConvId } from "../../src/providers/xai-transport"; import { routeModel, routedProviderConfig } from "../../src/router"; import { setProviderKeychainEntryFactoryForTests } from "../../src/providers/key-store"; import { setActiveProviderApiKey } from "../../src/providers/api-keys"; +import { clearProviderApiKeyQuotaCache, setCachedProviderApiKeyQuotaForTests } from "../../src/providers/quota-key-accounts"; import { subscribeAccountSelections } from "../../src/lib/account-selection-events"; import { providerManagementConfigError, safeConfigDTO } from "../../src/server/auth-cors"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; @@ -517,5 +518,56 @@ describe("rotateKeyOn401", () => { forgetApiKeyRotationCursor("p"); expect(selectProactiveApiKey(config, "p", now)).toBeNull(); }); + + /** Quota rows live in a private cache keyed on the resolved secret; seed it through the seam. */ + function seedQuota(config: OcxConfig, keyId: string, key: string, percent: number | null, unavailable?: true) { + setCachedProviderApiKeyQuotaForTests( + "p", config.providers.p!, keyId, key, + percent === null ? null : { weeklyPercent: percent, updatedAt: Date.now() } as never, + unavailable, + ); + } + + function cooledFirstKey(strategy: "round-robin" | "quota") { + const config = makeConfig({ apiKey: "key-alpha-000111222333", apiKeyPool: pool3(), apiKeyPoolStrategy: strategy }); + forgetApiKeyRotationCursor("p"); + clearProviderApiKeyQuotaCache(); + rotateKeyOn429(config, "p", null, now); + setActiveProviderApiKey(config, "p", "k1"); + return config; + } + + test("quota picks the roomiest eligible key", () => { + const config = cooledFirstKey("quota"); + // beta is nearly spent, gamma is barely touched. Round-robin would have taken beta simply + // because it is next; ranking is the entire difference. + seedQuota(config, "k2", "key-beta-444555666777", 80); + seedQuota(config, "k3", "key-gamma-888999000111", 10); + expect(selectProactiveApiKey(config, "p", now)?.apiKey).toBe("key-gamma-888999000111"); + }); + + test("an unmeasured key outranks a measured-and-spent one", () => { + const config = cooledFirstKey("quota"); + // beta is provably at its limit; gamma has never been measured. Unmeasured is not assumed + // fresh, but it is not assumed spent either -- and "spent" is the one thing we know here. + seedQuota(config, "k2", "key-beta-444555666777", 100); + expect(selectProactiveApiKey(config, "p", now)?.apiKey).toBe("key-gamma-888999000111"); + }); + + test("a stale unavailable row is not evidence", () => { + const config = cooledFirstKey("quota"); + // beta carries a roomy last-good measurement attached to a FAILING probe, which the cache + // keeps for half an hour. Ranking on it would prefer a number nothing currently supports. + seedQuota(config, "k2", "key-beta-444555666777", 0, true); + seedQuota(config, "k3", "key-gamma-888999000111", 70); + expect(selectProactiveApiKey(config, "p", now)?.apiKey).toBe("key-gamma-888999000111"); + }); + + test("with nothing measured the first eligible key is taken", () => { + const config = cooledFirstKey("quota"); + // A provider with no per-key quota reader must land on exactly today's behaviour. + expect(selectProactiveApiKey(config, "p", now)?.apiKey).toBe("key-beta-444555666777"); + }); + }); }); From ba2164443534b48de33cc0900f8558a9db92d3db Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:26:09 +0900 Subject: [PATCH 081/231] docs: document apiKeyPoolStrategy, including the new quota value The field shipped in #4277 with no docs-site row at all. Shipping a third undocumented value is how the generic OAuth pool ended up inert and unexplained. --- docs-site/src/content/docs/reference/configuration/providers.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 39b5bc7dfa..d23ecbe88e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -153,6 +153,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `apiKey?` | `string` | API key, an `${ENV_VAR}` / `$ENV_VAR` reference, or a `keychain:` reference written by `ocx provider keychain store`. References resolve at request time. See [Storing keys in the OS keychain](#storing-keys-in-the-os-keychain). | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key header style. Defaults to native `x-api-key`; valid only for key-auth `anthropic` providers. | | `apiKeyPool?` | `ApiKeyPoolEntry[]` | Multi-key pool. `apiKey` mirrors the active entry; each item has `id`, `key`, optional `label`, and optional numeric `addedAt`. | +| `apiKeyPoolStrategy?` | `"round-robin" \| "fill-first" \| "quota"` | How a warm key is chosen **before** the first attempt when the committed key is already cooling. Omitted keeps rotation reactive-only: the pool moves after a 429 or 401 and not before. `round-robin` takes the next key in the pool, `fill-first` keeps the first eligible one, and `quota` prefers the key with the most remaining headroom, falling back to `fill-first` order for a provider whose per-key quota is unknown. A healthy committed key is never overridden, so a manual key selection stands. | | `defaultModel?` | `string` | Model used when this provider is selected without an explicit model. | | `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, a nonempty `models` list is followed by `retainModels`; an empty or omitted `models` list instead seeds `defaultModel` (if configured), then `retainModels`, removing duplicate ids in first-seen order. | | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | From 083d0449e8d1298ee312a582b9d94a103b64779a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:27:59 +0900 Subject: [PATCH 082/231] chore(skills): regenerate the management surface map --- skills/ocx/references/01_management_surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 7fcf629e50..c7ae57c14b 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -539,7 +539,7 @@ JSON mode: `envelope`. - A bare invocation reads and never writes. - The APPLIED value is echoed, not the requested one, so a server-side normalization stays visible. - Values are not re-validated in the CLI: the server owns the strategy names and the 1-100 sticky bound. -- `anthropic` owns the full pool contract. Other OAuth providers reach the same endpoint with a generic subset (enabled/strategy/autoSwitchThreshold) whose settings persist but do not yet steer selection; `sticky` and `quotaWindow` are refused for them. +- `anthropic` owns the full pool contract. Other OAuth providers reach the same endpoint with a generic subset (enabled/strategy/autoSwitchThreshold/sticky); those settings steer selection only while `pool.kernel` is on, which is what the `inert` field reports. `quotaWindow` is still refused for them. ### `ocx account sticky` From e5ff46cc2b5813d5fa5a3c0aeb28b85c167f3397 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:34:19 +0900 Subject: [PATCH 083/231] docs(devlog): plan the pool-settings contract consolidation --- .../050_phase5_surface_consolidation.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md index 0144d41f82..9d93d4164d 100644 --- a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -64,3 +64,69 @@ tests/server/account-pool-management-api.test.ts for the unified DTO and the deprecated alias; tests/cli/cli-account-pool-verbs.test.ts for CLI parity; a GUI test that the panel mounts for a generic OAuth provider. A gui-labelled PR needs a screenshot in its description per AGENTS.md. + +## wp5 plan — one pool-settings contract + +## What "three contracts" actually means + +Not three routes with one shape. Three shapes, three storage locations and three +re-implementations of the same validation. + +| Kind | Route | Storage | DTO fields | +|---|---|---|---| +| Codex | `PUT /api/codex-auth/auto-switch`, `PUT\|PATCH /api/codex-auth/pool-strategy` | `runtimeConfig.autoSwitchThreshold`, `.accountPoolStrategy`, `.accountPoolStickyLimit` | threshold; strategy + stickyLimit, split across two routes | +| Anthropic | `GET\|PUT\|PATCH /api/oauth/accounts/pool?provider=anthropic` | `config.anthropicAccountPool` | enabled, autoSwitchThreshold, strategy, stickyLimit, quotaWindow, `experimental: true` | +| generic | same route, other branch | `providers..oauthAccountFailover` | enabled, strategy, autoSwitchThreshold, stickyLimit, `inert` | + +Anchors: `src/codex/auth-api.ts`:2465 and :2478; `src/server/management/oauth-account-routes.ts`:354 +and :379; `src/oauth/pool-settings-capability.ts`:57. + +Three consequences, all observable today. The Codex kind is the only one that cannot be READ +through a pool route at all — the CLI reads `/api/codex-auth/active` instead +(`src/cli/account-extended.ts`:854-887 already documents the asymmetry as a table, which is the +tell). Every kind re-parses `strategy` and `stickyLimit` with its own copy of the same bounds. +And a field that exists for one kind is absent rather than declared-unsupported for the others, +so a dashboard cannot tell "this pool has no quotaWindow" from "this pool forgot to send it". + +## The unit + +**One DTO, one validator, one route. The three existing paths stay as aliases.** + +NEW `src/server/management/pool-settings-contract.ts` — a single `PoolSettingsDto` with every +field the union needs and an explicit `supported` set per kind, plus one validator that owns the +strategy names, the 1..100 sticky bound and the 0..100 threshold bound. The three kinds keep +their own STORAGE; only the shape and the validation are shared. + +NEW route `GET\|PUT /api/pool/settings?provider=` in +`src/server/management/oauth-account-routes.ts`, registered in `route-registry.ts`, serving all +three kinds through `poolSettingsCapability`. + +The three existing paths keep working, unchanged, delegating to the same module. This is +additive on purpose: the management API is a public contract with CLI and GUI clients, and a +breaking change is not what "consolidate" has to mean. The registry marks the old paths +superseded so the next reader knows which one is canonical. + +MODIFY `src/cli/account-extended.ts` — the transport table at :854-887 exists precisely because +the two contracts disagree. It collapses to one path, and the comment explaining the asymmetry +goes with it. + +## Out of scope, and why + +**The GUI half is its own work-phase (wp5b).** `gui/src/codex-auto-switch.ts` and +`gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx` are two separate pool +surfaces, and merging them is a visual change. This repository's `enforce-target` gate requires +a screenshot in the description of any PR whose title or description mentions `gui`, which means +building and running the dashboard to capture one. That is a real deliverable, not a formality, +and bolting it onto a server-side PR would either skip the evidence or stall the server work +behind it. + +## Acceptance + +- One module owns strategy/sticky/threshold validation; a bad value is rejected identically on + every kind, proven by a table-driven test across all three. +- `GET /api/pool/settings?provider=` answers for Codex, Anthropic and a generic provider, and + each response declares which fields that kind supports rather than omitting them. +- The three legacy paths return byte-identical bodies to today, proven by tests that predate this + change and must not be edited. +- Red control: each new shared-validator case must fail if the shared bound is loosened. + From 2f89dd2b776cb4e6969487fd7781f2451f175275 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:35:17 +0900 Subject: [PATCH 084/231] docs: close the omo unit and fix two leftovers it created A post-merge audit of dev found three documentation mirrors that the omo change missed. Two are its own debt and are fixed here: the integrations guide's loopback sentence did not name omo even though it ships loopback-only, and the header comment in export-command.ts still claimed eight clients and listed Gajae among the YAML dialects -- stale on both counts, since there were thirteen before omo. The third is left alone on purpose. The zh-TW management-api page lists the /api/client-config clients only as far as dsh, which already omitted zcode, prime, aside and raycast before omo existed; widening it quietly here would hide a pre-existing translation gap rather than fix it. It is recorded in the outcome note instead. 090_outcome.md closes the plan unit: what landed, the three claims that were checked rather than assumed, what four audit rounds changed, and the two mistakes worth keeping visible. --- .../050_wp6_gjc_rename.md | 18 +++++ .../090_outcome.md | 65 +++++++++++++++++++ .../src/content/docs/guides/integrations.md | 2 +- src/cli/export-command.ts | 6 +- 4 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/260912_omo_client_integration/090_outcome.md diff --git a/devlog/_plan/260912_omo_client_integration/050_wp6_gjc_rename.md b/devlog/_plan/260912_omo_client_integration/050_wp6_gjc_rename.md index 4ddceebc44..1b96990ade 100644 --- a/devlog/_plan/260912_omo_client_integration/050_wp6_gjc_rename.md +++ b/devlog/_plan/260912_omo_client_integration/050_wp6_gjc_rename.md @@ -47,3 +47,21 @@ catalogs, the CLI summary prose, and `docs-site`. A bare tree-wide search is the wrong check and would report itself failing forever, because the internal identifiers (`GajaeGeneratedConfig`, `gajaeConfigPath`, `buildGajaeClientConfig`, `OPENCODEX_GAJAE_API_KEY`) are exactly what this phase is not touching. + +## Verified after merge + +An independent audit read `origin/dev` and ran that check against what actually +landed: zero `Gajae Code` across the nine locale catalogs, `src/cli/registry.ts` +and `docs-site`. Every surviving `Gajae` is an identifier, the +`OPENCODEX_GAJAE_API_KEY` env var, an i18n key *name* whose value is now `gjc`, +an internal type, or a comment. The line this phase promised not to cross held: +the client id is still `gajae`, the config path is still `~/.gjc/agent/models.yml`, +the route is still `/api/client-integrations/gajae`, the download filename is +still `gajae-models.yaml`, and the tab hash is still `integrations/gajae`. So an +install that had already connected the client keeps resolving its stored enable +record, which is the whole reason the id stayed put. + +The audit did find one mention this plan had missed: the header comment in +`src/cli/export-command.ts` still said "Eight clients" and listed `Gajae` among +the YAML dialects. It was stale on both counts — there were thirteen clients +before omo — and it is corrected alongside the outcome note. diff --git a/devlog/_plan/260912_omo_client_integration/090_outcome.md b/devlog/_plan/260912_omo_client_integration/090_outcome.md new file mode 100644 index 0000000000..0afa6f993b --- /dev/null +++ b/devlog/_plan/260912_omo_client_integration/090_outcome.md @@ -0,0 +1,65 @@ +# Outcome + +Shipped as PR #4290, merged into `dev` on 2026-09-12 as `eb314c53a0` at head +`9689ee8ceb0d868faa0643b036f8e8d9be4bd03c`. CI on that exact head: 25 pass, 0 +fail, 2 conditional jobs skipped. Merged under the `MAINTAINERS.md` dev-only +maintainer-integration exception, with the decision and CI evidence recorded on +the PR. + +## What landed + +`omo` is the fourteenth export and file-integration client. It reuses the Pi +builder, opted into session affinity, resolves `~/.omo/agent/models.json` under +omo's own three-variable precedence, detects on the agent directory, and is +loopback-only by deferral. `Gajae Code` now reads `gjc` everywhere a user looks, +with the id, config path, API route and env var deliberately unchanged. + +## The evidence that mattered + +Three claims could not have been settled by inspection, and each was checked: + +1. **senpi accepts the Pi document.** The file a live Apply actually wrote + returned true from senpi's own compiled `validateModelsConfig`, while an + `audio` input modality and a keyed `models` object both returned false, so + the check could not be vacuous. +2. **The v4 false positive is rejected.** With `~/.omo` holding only + `binary-runtime` and no `agent/` — the exact state of the machine this was + built on — the row reads *Not installed* with Apply disabled, and creating + `~/.omo/agent` flips it to *Not applied*. +3. **The page renders.** Tab, row and mark captured from the built GUI; + `evidence/integrations-omo-tab.png`. + +## What the process caught + +Four independent audit rounds returned FAIL or NEAR-PASS and changed the work: + +- The backend/GUI split was abandoned after two rounds proved no ordering of + the halves leaves `tests/gui/integrations-invariants.test.ts` green. +- `omo` moved to the end of `EXPORT_CLIENTS` rather than beside `prime`, because + `EXPORT_CLIENT_IDS` is `Object.keys` order and three tests assert it exactly. +- `buildOmoContribution` gained the session-affinity flag, which `build` already + had; without it `ocx export` and an enabled integration would have written + different documents. +- The catalog-refresh decision was forced to confront four disagreeing fan-out + lists instead of the one the checklist named. + +Two mistakes are worth keeping visible. The first attempt at the rendered proof +ran `ocx start` with only `OPENCODEX_HOME` redirected, which is not isolation — +it rewrote the user's real Codex catalog and pointed `~/.grok/config.toml` at a +port that was about to die. Both were restored and the second attempt redirected +`HOME` and `CODEX_HOME` too. And `privacy:scan` passed locally while failing on +three CI jobs, because the file it objected to was still untracked when the +local scan ran. + +## Left open + +- `docs-site/.../zh-tw/reference/management-api.md` lists the + `GET /api/client-config` clients only as far as `dsh`. That list already + omitted `zcode`, `prime`, `aside` and `raycast` before omo existed, so it is a + pre-existing translation gap rather than this unit's debt; widening it quietly + here would hide it. +- `prime` is in none of the catalog-refresh fan-outs. That looks like an + oversight from when it landed, and is recorded in `002` so the next person + does not read it as a pattern to copy. +- No CI check compares the docs client tables against `EXPORT_CLIENT_IDS`, so + the docs rows stay guarded by review alone. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 16242c9f77..f5d6dfa6a4 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -212,7 +212,7 @@ typed values into quoted strings. This includes values inside arrays and inline tables. Quoted date strings remain supported; an unquoted date must be preserved by editing the configuration manually. -**Pi, Kimi Code, gjc, MiniMax Code, Prime Agent and the managed DSH integration only work against a loopback bind.** +**Pi, Kimi Code, gjc, MiniMax Code, Prime Agent, Aside, Raycast, omo and the managed DSH integration only work against a loopback bind.** The first four have no config field for the `x-opencodex-api-key` header a non-loopback bind requires. DSH has a generic headers map, but rc.6 does not document that dedicated admission header as a supported integration contract, so the managed writer fails closed instead of diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 739889a026..123068f943 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -1,8 +1,10 @@ /** * `ocx export --client ` — print a client config for the live proxy. * - * Eight clients, four formats: OpenCode and Pi are JSON; OMP, Hermes, Gajae and - * MiniMax Code are YAML; OpenClaw is JSON5; Kimi is TOML. + * Fourteen clients, five formats. The accepted list is `EXPORT_CLIENT_IDS`, not + * this comment: OpenCode, Pi, Prime, Aside, ZCode and omo are JSON; OMP, + * Hermes, gjc, DSH, MiniMax Code and Raycast are YAML; OpenClaw is JSON5; Kimi + * is TOML. * * Two consumers, one payload (devlog 260731_client_config_export/020): * From 5a8618081d9db6d20d4496e1da9bae7250cb1891 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:41:08 +0900 Subject: [PATCH 085/231] docs(devlog): fold the wp5 audit, which resized the unit --- .../050_phase5_surface_consolidation.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md index 9d93d4164d..2c2bd1b443 100644 --- a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -130,3 +130,66 @@ behind it. change and must not be edited. - Red control: each new shared-validator case must fail if the shared bound is loosened. +### wp5 plan audit — FAIL, folded + +**Blocker 1 — the compatibility guard this plan leans on does not exist.** "Byte-identical, +proven by tests that predate this change and must not be edited" is false. The Codex and +Anthropic assertions use `toMatchObject`, which passes when extra keys appear, and the Codex +`PUT /api/codex-auth/auto-switch` test checks only status 200, never the body +(`tests/server/account-pool-management-api.test.ts`:42, :187, :266; +`tests/codex-integration/codex-auth-api.test.ts`:3645). Only the generic GET uses a full +`toEqual` (:483). So the refactor would have been guarded by tests that cannot detect the +regression they were cited for. + +The unit therefore starts by WRITING that guard: exact-body assertions for all three legacy +responses, committed and green BEFORE any shared module exists. A characterization test written +after the change proves nothing about what the change did. + +**Blocker 2 — "delegating to the same module" skipped the adapter.** The three routes do not +merely differ in shape, they disagree on every axis: Codex auto-switch takes `{threshold}` and +answers `{ok:true}`; Codex pool-strategy takes `{strategy, stickyLimit}` and answers +`{ok, accountPoolStrategy, accountPoolStickyLimit}`; the OAuth route takes `{provider, ...}` +and answers with different key names again. A shared handler would 400 live CLI and GUI writes. + +What is actually shared is narrower and still worth it: the shared module owns VALUE validation — +the strategy names, the 1..100 sticky bound, the 0..100 threshold bound — while each route keeps +its own request parsing and response shaping as an explicit adapter. "One validator, three +adapters", not "one handler". + +**Major — a new management route is not a one-line registration.** It must appear in +`route-registry.ts` (`tests/server/management-route-registry.test.ts` compares source and +registry as exact pairs), AND in `src/cli/capabilities.ts` or one of the two exemption lists in +`tests/cli/cli-capabilities.test.ts`:174/:344, AND — if capabilities change — the generated +`skills/ocx/references/01_management_surface.md` must be regenerated, which is the gate that +went red on #4289 this session. Also `PATCH` exists on both legacy writes while the proposed +route was `GET|PUT` only. + +**Major — a fourth storage location the plan missed.** Top-level +`config.oauthAccountFailover.enabled` (`src/types/config.ts`:917) participates in generic +activation through `isProactivePreferenceEnabled`, but the generic DTO reads only +`providers..oauthAccountFailover`. So `enabled: null` currently means "nothing stored +here" while the effective answer may be `true` from the global. That is a reporting defect in +its own right and belongs in this unit, since honest per-kind field reporting is the point. + +**Major — more clients than the plan named:** `gui/src/account-pool-strategy.ts`, +`gui/src/components/.../CodexPoolStrategySetting.tsx` and `gui/src/hooks/useCodexAccountPool.ts` +join `codex-auto-switch.ts`, and `cmdAutoSwitch` sends `threshold` where the OAuth route expects +`autoSwitchThreshold`. + +**Recorded:** `docs-site/src/content/docs/reference/management-api.md`:332 already claims the +pool route 400s for non-Anthropic providers, which stopped being true when the generic contract +shipped. Stale before this unit; fixed by it. + +**Minors.** The anchor `pool-settings-capability.ts`:57 points at a comment; the kinds are +:23-28 and `inert` is :63. The kind table omits `provider`/`kind` from the DTO rows. Codex and +Anthropic already share `parseAccountPoolStrategy` from `pool-kernel.ts` while the generic kind +keeps a private copy — that duplication is the smallest true instance of the problem this unit +exists to fix, and is the natural first thing to collapse. + +### Status + +Planned and audited, NOT implemented. The audit turned a one-route consolidation into a +four-part unit: write the missing exact-body guard first, collapse the duplicate validators, +add the route with all four registrations, then fix the `enabled` reporting defect. That is a +larger cycle than it looked, and the sequencing above is the deliverable of this A phase. + From 4fd83774ff265019d5c2212403ae88770968a97e Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:42:12 +0900 Subject: [PATCH 086/231] docs(devlog): narrow the wp5 cycle to the guard and the validator collapse --- .../050_phase5_surface_consolidation.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md index 2c2bd1b443..305b9adc55 100644 --- a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -193,3 +193,29 @@ four-part unit: write the missing exact-body guard first, collapse the duplicate add the route with all four registrations, then fix the `enabled` reporting defect. That is a larger cycle than it looked, and the sequencing above is the deliverable of this A phase. +### wp5 cycle scope, after the audit resized it + +The audit turned one route change into four parts. This cycle takes the two that stand alone +and are verifiable on their own; the route and the reporting fix become wp5c, because adding a +management route touches four registration surfaces and is a different kind of risk from +deduplicating a validator. + +**In this cycle** + +1. Write the missing compatibility guard: exact-body assertions for all three legacy pool + responses, green BEFORE anything is shared. This is the test the plan wrongly assumed existed. +2. Collapse the duplicate validators onto one module. Codex and Anthropic already share + `parseAccountPoolStrategy` from `pool-kernel.ts`; the generic kind keeps a private copy in + `pool-settings-capability.ts`. That is the smallest true instance of the problem this phase + exists to fix, and closing it is what makes a bad value behave identically on every kind. + +**Deferred to wp5c** + +3. `GET|PUT|PATCH /api/pool/settings` with its four registrations. +4. The `enabled: null` reporting defect, where the generic DTO ignores the top-level + `oauthAccountFailover.enabled` that actually participates in activation. + +Splitting here is not scope avoidance: part 1 is the precondition for parts 3 and 4 being +checkable at all, and shipping it separately means the guard exists in `dev` before the risky +change is written rather than alongside it. + From 7a1e30ba1da507c0cf887016c2c320c2fe5940a3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:46:36 +0900 Subject: [PATCH 087/231] docs(devlog): fold the re-audit residuals and name the guard targets --- .../050_phase5_surface_consolidation.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md index 305b9adc55..93193f90e4 100644 --- a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -219,3 +219,26 @@ Splitting here is not scope avoidance: part 1 is the precondition for parts 3 an checkable at all, and shipping it separately means the guard exists in `dev` before the risky change is written rather than alongside it. +### Residuals from the re-audit, folded + +**The three guard targets, named exactly.** Not all four responses are unguarded. Codex +`GET /api/codex-auth/active` already pins its pool fields with a full `toEqual` +(`tests/codex-integration/codex-auth-api.test.ts`:1575). The live holes are precisely: +`PUT /api/codex-auth/auto-switch` (status-only, :3645), `PUT /api/codex-auth/pool-strategy` and +the Anthropic `PUT /api/oauth/accounts/pool` (both `toMatchObject`), and the Anthropic +`GET /api/oauth/accounts/pool` (`toMatchObject`). Those four assertions are the deliverable; +the Codex GET needs nothing. + +**The section above is superseded where it disagrees.** "## The unit" and its Acceptance list +still describe the pre-audit shape — one new route, the CLI transport collapse, and +"pre-existing tests must not be edited". The cycle scope below overrides all three: the route +and the CLI collapse move to wp5c, and writing the guard IS editing the test files, which is the +point rather than a violation. The original text stays as the record of what was planned before +the audit rather than being rewritten to look prescient. + +**Part 1 does not make part 4 checkable by itself.** The generic GET golden already pins +`enabled: null` (`tests/server/account-pool-management-api.test.ts`:483), so wp5c's reporting +fix has to change that assertion deliberately. The guard is an alias-safety net for the route +change in part 3 and only a tripwire for part 4 — it tells wp5c that it is changing a published +answer, which is exactly what a golden should do, but it does not prove the new answer correct. + From 3213e8b6fc3bd363e1e141fe85cd16f81723860d Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:47:40 +0900 Subject: [PATCH 088/231] test(server): pin the three legacy pool contracts exactly The compatibility net these contracts were assumed to have did not exist: the Codex and Anthropic assertions use toMatchObject, which passes when extra keys appear, and PUT /api/codex-auth/auto-switch checked only a status code. Committed before anything is shared between the three, so the guard predates the change it guards. --- .../account-pool-management-api.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index 502062c1a5..79ec01a933 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -519,3 +519,87 @@ describe("generic OAuth pool-settings contract (#695)", () => { } }); }); + +describe("legacy pool contract goldens (#wp5)", () => { + /** + * Exact-body pins for the three pool contracts, written BEFORE anything is shared between + * them. The existing coverage could not serve as the compatibility net it was assumed to be: + * the Codex and Anthropic assertions use toMatchObject, which passes when extra keys appear, + * and PUT /api/codex-auth/auto-switch checked only the status code. A refactor guarded by + * those would not have noticed the regression it was supposed to catch. + * + * GET /api/codex-auth/active is deliberately absent: it already carries a full toEqual in + * tests/codex-integration/codex-auth-api.test.ts. + */ + test("PUT /api/codex-auth/auto-switch answers exactly { ok: true }", async () => { + const req = new Request("http://localhost/api/codex-auth/auto-switch", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ threshold: 70 }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeCodexConfig()); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ ok: true }); + }); + + test("PUT /api/codex-auth/pool-strategy answers exactly its three keys", async () => { + const req = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ strategy: "round-robin", stickyLimit: 5 }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeCodexConfig()); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ + ok: true, + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 5, + }); + }); + + test("GET /api/oauth/accounts/pool answers exactly the anthropic shape", async () => { + const server = startServer(0); + try { + const res = await fetch(new URL("/api/oauth/accounts/pool?provider=anthropic", server.url)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + provider: "anthropic", + enabled: false, + autoSwitchThreshold: 80, + strategy: "quota", + stickyLimit: 1, + quotaWindow: "five-hour", + experimental: true, + }); + } finally { + await server.stop(true); + } + }); + + test("PUT /api/oauth/accounts/pool answers exactly the anthropic shape", async () => { + const server = startServer(0); + try { + const res = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", enabled: true, autoSwitchThreshold: 70, + strategy: "round-robin", stickyLimit: 4, + }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + ok: true, + provider: "anthropic", + enabled: true, + autoSwitchThreshold: 70, + strategy: "round-robin", + stickyLimit: 4, + quotaWindow: "five-hour", + experimental: true, + }); + } finally { + await server.stop(true); + } + }); +}); From ee595446316a81bdf8c6d02775f7ed49f3b99f57 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:48:45 +0900 Subject: [PATCH 089/231] refactor(oauth): one validator for pool strategy and sticky limit The generic kind carried a private copy of the strategy names and the 1..100 sticky bound while Codex and Anthropic already shared pool-kernel's. Three pools accepting the same three names from three implementations is how they drift apart; the parsers now delegate and a table-driven test proves a bad value is rejected identically on every kind. --- src/oauth/pool-settings-capability.ts | 11 +++-- .../account-pool-management-api.test.ts | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/oauth/pool-settings-capability.ts b/src/oauth/pool-settings-capability.ts index eb98a0d4dc..950a7abd97 100644 --- a/src/oauth/pool-settings-capability.ts +++ b/src/oauth/pool-settings-capability.ts @@ -1,4 +1,5 @@ import { isGenericFailoverProvider } from "./generic-account-failover"; +import { parseAccountPoolStickyLimit, parseAccountPoolStrategy } from "./pool-kernel"; import type { OcxProviderConfig } from "../types"; /** @@ -28,9 +29,11 @@ export function poolSettingsCapability(name: string, provider: OcxProviderConfig } export function parseGenericPoolStrategy(value: unknown): GenericPoolStrategy | null { - return typeof value === "string" && (GENERIC_POOL_STRATEGIES as readonly string[]).includes(value) - ? value as GenericPoolStrategy - : null; + // Delegated, not re-implemented. Three pools accepting the same three names from three + // private copies of the same check is how they drift apart: the Codex and Anthropic kinds + // already shared this parser while the generic kind carried its own. The names and the + // 1..100 bound live in pool-kernel.ts, once. + return parseAccountPoolStrategy(value) as GenericPoolStrategy | null; } export function parseGenericAutoSwitchThreshold(value: unknown): number | null { @@ -38,7 +41,7 @@ export function parseGenericAutoSwitchThreshold(value: unknown): number | null { } export function parseGenericStickyLimit(value: unknown): number | null { - return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100 ? value : null; + return parseAccountPoolStickyLimit(value); } export interface GenericPoolSettingsDto { diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index 79ec01a933..3f162d0321 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -602,4 +602,44 @@ describe("legacy pool contract goldens (#wp5)", () => { await server.stop(true); } }); + + test("a bad strategy and a bad stickyLimit are rejected identically on every kind", async () => { + // One validator, three adapters. The kinds keep their own request and response shapes -- + // that is what the goldens above pin -- but the VALUE rules are now a single implementation, + // so "quota, round-robin, fill-first" and the 1..100 sticky bound cannot drift apart per + // kind. Before this, the generic kind carried a private copy of both. + const codex = async (payload: Record) => { + const req = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeCodexConfig()); + return resp!.status; + }; + const server = startServer(0); + try { + const oauth = async (payload: Record) => { + const res = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), + }); + return res.status; + }; + for (const strategy of ["weighted", "", 3, null]) { + expect(await codex({ strategy })).toBe(400); + expect(await oauth({ provider: "anthropic", strategy })).toBe(400); + expect(await oauth({ provider: "google-antigravity", strategy })).toBe(400); + } + // 0 and 101 sit just outside the shared bound; 1 and 100 are the edges that must pass. + for (const stickyLimit of [0, 101, 1.5]) { + expect(await codex({ stickyLimit })).toBe(400); + expect(await oauth({ provider: "anthropic", stickyLimit })).toBe(400); + expect(await oauth({ provider: "google-antigravity", stickyLimit })).toBe(400); + } + for (const stickyLimit of [1, 100]) { + expect(await codex({ stickyLimit })).toBe(200); + } + } finally { + await server.stop(true); + } + }); + }); From 999c1ba4d5f5352d924f75826c4d67f513121508 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:52:45 +0900 Subject: [PATCH 090/231] docs(devlog): scope wp3 to the ordering criterion c-4 actually states --- .../030_phase3_cache_affinity.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md index 393c498934..9d7a1f0326 100644 --- a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md +++ b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md @@ -101,3 +101,66 @@ The Anthropic and generic halves are not frozen, so a narrower first slice exist unify the affinity key for those two kinds only, leaving the Codex thread-affinity map on its current key until the freeze lifts. That slice still needs assumption 1 answered, which is why this phase stays closed rather than being re-scoped now. + +## wp3 plan — what criterion c-4 actually requires + +This phase was recorded as blocked on three product decisions: the shared affinity key shape, +the shared-cohort `prompt_cache_key` fallback, and a minimum-token cache gate. Re-reading the +criterion against the code shows none of the three is on the path to it. + +> c-4: Account selection consults cache affinity before quota for subscription pools, proven by +> a test where the cache-affine account is chosen over a higher-headroom one. + +That is a statement about **ordering**, not about key shape. The phase title pairs ordering with +"a unified affinity key", but only the ordering half is an acceptance criterion, and the two are +separable: reordering uses each kind's EXISTING affinity binding and introduces no new key. +Assumption 1 gates the unified key, not this. Assumption 2 is a property of the Anthropic +session-key derivation, which the ordering change does not touch. Assumption 3 is explicitly +optional in the original text ("decide whether to implement") and is not required by c-4. + +So the unified key stays open and stays out of this cycle. The ordering ships now. + +## Only one kind actually breaks cache affinity + +Verified on the branch head rather than assumed: + +- **Anthropic already honours affinity unconditionally.** `src/oauth/anthropic-routing.ts`:604-610 + returns `{ reason: "affinity" }` whenever the affined account is present, not reauth-flagged, + not cooled and credential-usable. `autoSwitchThreshold` governs NEW-session picks + (`anthropicAutoSwitchThreshold`, :111) and never rebinds a live session. +- **Codex does not.** `reevaluateAffinityQuota` (`src/codex/routing.ts`:2031) rebinds a live + thread whenever the quota strategy is active and usage crosses `autoSwitchThreshold` (:2047), + which throws away a warm prompt cache on a hint rather than on evidence. +- The generic OAuth kind has no affinity at all, so it has nothing to reorder. + +That makes this a one-function change, and it makes the criterion's "pools" plural satisfiable: +after it, both subscription pools keep a bound conversation on its account until that account +genuinely cannot serve. + +## Change surface + +`src/codex/routing.ts`, `reevaluateAffinityQuota` only. Under `pool.kernel`, the rebind bar +stops being "crossed the threshold" and becomes the same **drained** test the pin-release path +already uses (`releaseDrainedCodexAccountPin`, :1866): + +``` +!isCodexAccountUsable(config, entry.accountId, selectionOptions) + || !hasCodexQuotaHeadroom(config, entry.accountId, selectionOptions, now) +``` + +Reusing that predicate rather than inventing a second notion of "spent" is deliberate: two +definitions of exhausted in one file is how they drift. The reeval-interval short circuit keeps +its current shape so a bound thread is still not re-scored more than once a minute. + +Flag off restores today's behaviour exactly, which is what makes shipping this without the three +open decisions safe rather than presumptuous. + +## Acceptance + +- A bound thread on an account at 90% usage with `autoSwitchThreshold: 80` and a sibling at 10% + KEEPS its account while the flag is on — the cache-affine account chosen over the + higher-headroom one, which is c-4 verbatim. +- The same fixture with the flag off still moves, so the old behaviour is provably intact. +- A bound thread whose account is genuinely drained still moves with the flag on, so the change + is a reordering and not a pin. +- Red control: with the flag branch removed, the first case must fail. From cd5d07057d6c4420c35fccf9bb42a75523bddc29 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 02:57:54 +0900 Subject: [PATCH 091/231] docs(devlog): fold the wp3 blockers, including a bar that was the threshold --- .../030_phase3_cache_affinity.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md index 9d7a1f0326..1b990cf509 100644 --- a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md +++ b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md @@ -164,3 +164,62 @@ open decisions safe rather than presumptuous. - A bound thread whose account is genuinely drained still moves with the flag on, so the change is a reordering and not a pin. - Red control: with the flag branch removed, the first case must fail. + +### wp3 plan audit — FAIL, folded + +**Blocker 1 — the "drained" bar I proposed IS the threshold.** `releaseDrainedCodexAccountPin` +reads `!isCodexAccountUsable || !hasCodexQuotaHeadroom`, and `hasCodexQuotaHeadroom` +(`src/codex/routing.ts`:1387-1395) is `usage < (autoSwitchThreshold ?? 80)`. Reusing it inside +`reevaluateAffinityQuota` would have preserved today's 80% rebind exactly, so the plan's own +acceptance case — a bound thread at 90% with threshold 80 KEEPING its account — could not have +passed. The argument for reuse ("don't invent a second notion of spent") was right in spirit and +wrong in fact: the pin path deliberately releases at the auto-switch crossing, which is a +different question from whether the account can still serve. + +The bar this phase needs is genuine exhaustion, and it is not expressible as the existing +predicate. Definition used instead, local to the reeval and stated once: + +``` +spent = !isCodexAccountUsable(config, id, selectionOptions) // reauth, excluded, cooled + || (!isUnknownUsage(usage) && usage >= 100) // allowance actually gone +``` + +Per minor 7 the usable half is already guaranteed by the caller, which requires +`isCodexAccountSelectable`, so in practice the test reduces to the usage half — kept explicit +anyway so the predicate reads correctly on its own. + +**Major 3 — `previewReusableAffinityAccount` duplicates the same threshold move.** +`src/codex/routing.ts`:1984 carries its own copy for the preview path. Changing only the +mutating site would make `previewCodexAccountForRequest` disagree with +`resolveCodexAccountForThreadDetailed` — and the suite already contains cases asserting those +two agree. Both move together. + +**Major 4 — the reeval interval must stop keying off the old bar.** The short circuit stamps +`lastReevalAt` only when `overThreshold`, so leaving it as-is while the rebind bar changes +re-scores a thread on every request through the whole 80-99% band. The short circuit follows the +new bar, keeping the once-a-minute ceiling intact. + +**Minor 6, taken — the flag is wrong.** `pool.kernel` is the generic-OAuth strategy-consume +flag introduced in wp2b; reusing it for a Codex affinity rule would overload one switch with two +unrelated meanings and make either one impossible to turn on alone. This uses its own +`pool.cacheAffinity`, defaulting off. + +**Minor 5 recorded.** `tests/codex-integration/codex-routing.test.ts` contains cases that require +the immediate over-threshold switch. They stay green because the flag defaults off, and that is +the check that proves flag-off is byte-identical rather than merely claimed. + +### Major 2 — rebutted, with its limit stated + +The audit is right that today's stickiness is keyed on thread and session identity rather than +on a cache key, and that a thread-keep test therefore proves "identity stickiness outranks +quota", not "a measured cache is consulted". That distinction is real and is exactly what the +deferred unified key would close. + +It does not block c-4. In this codebase the thread/session binding IS the mechanism by which a +warm prompt cache stays reachable: the cache lives on the account that served the conversation, +so keeping the conversation there is what preserves it. c-4 asks that the affine account win +over a higher-headroom one, and after this change it does. What remains open — and is recorded +as open rather than quietly satisfied — is making the binding explicitly cache-derived instead +of identity-derived. The criterion's plural "pools" is likewise honest only because Anthropic +already holds its live sessions; this change brings Codex to the behaviour Anthropic has, rather +than adding a second implementation. From 3f0e79decd8d26e94d62b0ac866a01e51866b862 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:02:20 +0900 Subject: [PATCH 092/231] docs(devlog): mark the pre-audit wp3 change surface superseded --- .../030_phase3_cache_affinity.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md index 1b990cf509..dcf7ac9924 100644 --- a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md +++ b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md @@ -223,3 +223,17 @@ as open rather than quietly satisfied — is making the binding explicitly cache of identity-derived. The criterion's plural "pools" is likewise honest only because Anthropic already holds its live sessions; this change brings Codex to the behaviour Anthropic has, rather than adding a second implementation. + +### The "## Change surface" block above is SUPERSEDED + +It still names `pool.kernel`, `hasCodexQuotaHeadroom` and `reevaluateAffinityQuota` alone. +Implementing it as written fails three of the folded findings and cannot pass the 90% keep case. +The fold is the spec. Concretely, the build is: + +- `src/types/config.ts` and `src/config.ts` — `pool.cacheAffinity?: boolean`, default off. +- `src/codex/routing.ts` `reevaluateAffinityQuota` AND `previewReusableAffinityAccount` — both + copies swap the rebind bar to `!isCodexAccountUsable || (!isUnknownUsage(usage) && usage >= 100)` + when the flag is on, and the `lastReevalAt` short circuit keys off that same bar. + +The pre-audit block stays as the record of what was planned before the audit rather than being +rewritten to look correct. From 270c175e98f73b3785cddba6aef752c905969efe Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:04:06 +0900 Subject: [PATCH 093/231] feat(codex): keep a bound thread on its account until that account is spent Moving a live conversation discards the prompt cache warmed on its account, so under pool.cacheAffinity a threshold crossing no longer justifies the move; the account has to be unable to serve. Both copies of the rule move together - the mutating reevaluateAffinityQuota and the preview one - and the re-score interval keys off the same bar so the 80-99% band is not re-scored every request. Deliberately not hasCodexQuotaHeadroom, which reads usage < autoSwitchThreshold and would have reproduced the old rule under a new name. --- src/codex/routing.ts | 39 +++++++++++++++++++++++++++++++++++---- src/config.ts | 5 ++++- src/types/config.ts | 15 ++++++++++++++- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 95c99e5d15..01db220236 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2007,7 +2007,9 @@ function previewReusableAffinityAccount( getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now, ); - if (!isUnknownUsage(usage) && usage >= threshold) { + // Preview must agree with resolve: this is the second copy of the same rule, and the + // suite asserts the two answer identically. + if (mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) { const best = pickLowerUsageAccount( config, entry.accountId, @@ -2024,6 +2026,32 @@ function previewReusableAffinityAccount( return entry.accountId; } +/** + * May a LIVE binding be moved for quota reasons? + * + * Default: yes once usage crosses `autoSwitchThreshold`, which is the historical rule. + * + * With `pool.cacheAffinity` on, the bar becomes genuine exhaustion. Moving a bound + * conversation discards the prompt cache warmed on its account, so a threshold crossing -- a + * hint that the account is getting busy -- does not justify paying that cost; the account has + * to be unable to serve. Deliberately NOT `hasCodexQuotaHeadroom`, which reads + * `usage < autoSwitchThreshold` and would reproduce the old rule under a new name. + */ +function mayRebindAffinityForQuota( + config: OcxConfig, + accountId: string, + usage: number, + threshold: number, + selectionOptions?: CodexAccountUsabilityOptions, +): boolean { + const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; + if (config.pool?.cacheAffinity !== true) return overThreshold; + // The usable half is already guaranteed by both callers, which gate on + // isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own. + return !isCodexAccountUsable(config, accountId, selectionOptions) + || (!isUnknownUsage(usage) && usage >= 100); +} + /** * Re-evaluate an affined account under the quota strategy. Returns a strictly * cooler replacement, or null when the current binding should remain. @@ -2044,15 +2072,18 @@ function reevaluateAffinityQuota( now, ) : 0; - const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold; + // One bar, used for BOTH the rebind decision and the re-score interval. Keying the short + // circuit off the old threshold while the rebind bar moved would re-score a bound thread on + // every request through the whole 80-99% band instead of once a minute. + const mayRebind = mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions); if ( - !overThreshold + !mayRebind && now - entry.lastReevalAt < CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS ) { return null; } entry.lastReevalAt = now; - if (!overThreshold) return null; + if (!mayRebind) return null; const best = pickLowerUsageAccount( config, entry.accountId, diff --git a/src/config.ts b/src/config.ts index f1e8cfb6b5..5c4f12269b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1307,7 +1307,10 @@ const configSchema = z.object({ }).optional().catch(undefined), // Same degrade-to-off rule as the flags above: a hand-edited typo in an opt-in pool // feature must never cost the operator their providers. - pool: z.object({ kernel: z.boolean().optional() }).optional().catch(undefined), + pool: z.object({ + kernel: z.boolean().optional(), + cacheAffinity: z.boolean().optional(), + }).optional().catch(undefined), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole diff --git a/src/types/config.ts b/src/types/config.ts index 979310f80c..8825059303 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -839,7 +839,20 @@ export interface OcxConfig { * Off restores the pre-kernel path exactly, which is why the DTO keeps reporting * `inert: true` until this is on. A malformed value reads as off. */ - pool?: { kernel?: boolean }; + pool?: { + kernel?: boolean; + /** + * Opt-in cache-affinity ordering, off by default. + * + * With it on, a bound Codex thread keeps its account until that account genuinely cannot + * serve, instead of moving the moment usage crosses `autoSwitchThreshold`. Moving a live + * conversation throws away the prompt cache warmed on that account, and a threshold + * crossing is a hint rather than evidence the account is spent. Separate from `kernel` + * on purpose: that one governs the generic OAuth strategy consumer, and one switch + * carrying two unrelated meanings cannot be turned on alone. + */ + cacheAffinity?: boolean; + }; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ From 68e7074f93432c26c99897cf15b41554f32bc0c2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:05:20 +0900 Subject: [PATCH 094/231] test(codex): prove cache affinity outranks quota but not exhaustion --- .../codex-pool-rotation.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index f5a5c38c49..1f7905a16f 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -20,6 +20,7 @@ import { clearCodexUpstreamHealthForAccount, clearThreadAccountMap, CODEX_TRANSIENT_SOFT_AVOID_MS, + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS, previewCodexAccountForRequest, getEffectiveActiveCodexAccountId, isCodexAccountInCooldown, @@ -1043,6 +1044,84 @@ describe("selection order across rotation strategies", () => { }); describe("an operator selection outranks the pool cursor", () => { + + test.each([true, false])( + "cache affinity outranks quota when the flag is %s", + (cacheAffinity) => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + ...(cacheAffinity ? { pool: { cacheAffinity: true } } : {}), + } as Partial); + const threadId = "cache-affine-thread"; + // Bind the thread while "a" is the natural quota pick, which is how a real conversation + // acquires its affinity in the first place. + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + // Now "a" is past the threshold but NOT spent, and the siblings have far more room. + updateAccountQuota("a", 90); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const later = Date.now() + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + const served = resolveCodexAccountForThread(threadId, config, later); + if (cacheAffinity) { + // c-4: the cache-affine account is chosen over the higher-headroom one. The prompt + // cache lives on "a"; crossing a threshold is a hint, not evidence "a" cannot serve. + expect(served).toBe("a"); + } else { + // Flag off is byte-identical to today: the thread moves at the threshold. + expect(served).not.toBe("a"); + } + }, + ); + + test("a bound thread still leaves an account that is genuinely spent", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + pool: { cacheAffinity: true }, + } as Partial); + updateAccountQuota("a", 10); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + + const threadId = "spent-account-thread"; + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + + // Fully spent, not merely busy. This is the half that keeps the change a REORDERING rather + // than a pin: affinity outranks quota, it does not outrank exhaustion. + updateAccountQuota("a", 100); + const later = Date.now() + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + expect(resolveCodexAccountForThread(threadId, config, later)).not.toBe("a"); + }); + + test("preview and resolve agree under cache affinity", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + pool: { cacheAffinity: true }, + } as Partial); + const threadId = "preview-agrees-thread"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 50); + updateAccountQuota("c", 50); + expect(resolveCodexAccountForThread(threadId, config)).toBe("a"); + updateAccountQuota("a", 90); + updateAccountQuota("b", 10); + updateAccountQuota("c", 10); + const later = Date.now() + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1; + // Two copies of the same rule live in this file; a preview that disagreed with the final + // answer would hand subagent fallback a different account than the request actually uses. + expect(previewCodexAccountForRequest(threadId, config, later)).toBe("a"); + expect(resolveCodexAccountForThread(threadId, config, later)).toBe("a"); + }); + test("the pool moves, then a manual pick wins the next unbound dispatch", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin", From 035bd5469b395f5cd206798a188dcf875d806ca1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:08:35 +0900 Subject: [PATCH 095/231] docs(devlog): plan the compact and images key-pick seams --- .../040_phase4_key_pool_strategy.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md index 668a9a72e1..b90a99e9f1 100644 --- a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md +++ b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md @@ -300,3 +300,57 @@ required — kept, and labelled as such. **Deliberate:** a `quota` pick still records `keyRotationCursor`. The cursor is where the pool last was, not a round-robin private; leaving it accurate means switching an operator to `round-robin` later resumes from the key actually in use instead of the start of the ring. + +## wp4c plan — the two first-send paths that never enter core.ts + +wp4b wired `selectProactiveApiKey` into the Responses core and native chat. The audit that +produced it named two dispatch paths those two call sites do not cover, and they became this +unit rather than riding along untested. + +| Seam | File | Line | Shape | +|---|---|---|---| +| native compact | `src/server/responses/compact.ts` | 745-746 | `compactProvider` object; key applied as a header | +| keyed images | `src/server/images.ts` | 701-703 | `candidates.keyed` destructured to `{ provider, apiKey, providerName }` | + +Both are genuinely independent: native compact runs only when +`supportsNativeResponsesCompactEndpoint` accepts the destination and never reaches +`handleResponses`, and the keyed image path builds its own URL and Authorization header +without a route object at all. + +### One seam per file, and only first sends + +`compact.ts`:745 is the native-compact branch: + +``` +if (compactProvider.authMode !== "forward" && compactProvider.apiKey) { + headers.set("authorization", `Bearer ${resolveProviderApiKey(compactProvider.apiKey)}`); +``` + +The pick goes immediately above it, reassigning `compactProvider` from the returned clone — +the same assign-then-use shape wp4b established, and for the same reason: the picker returns a +clone and never mutates its argument. + +`images.ts`:701 destructures `{ provider, apiKey, providerName }`. The pick runs before the +destructure so the header below is built from the chosen key. + +**Explicitly NOT a seam:** `compact.ts`:446 sits inside `resolveAlternateCompactContext`, which +runs after a failure. It is the compact analogue of the 429 rotation loops and must stay +reactive; putting a proactive pick there would move a retry off the account the retry exists to +replace. + +### What stays out + +No change to `selectProactiveApiKey`, to the reactive rotation, or to the strategies. The picker +already returns null unless a strategy is configured AND the committed key is cooling, so an +install that never set `apiKeyPoolStrategy` evaluates one predicate on each of these paths and +stops — including the persisted-write path, which is never reached. + +### Acceptance + +- A cooled committed key with a configured strategy is replaced on the FIRST native-compact send + and on the FIRST keyed image send, proven end to end rather than by unit-calling the picker. +- Without a configured strategy both paths still use the committed key, so rotation stays + reactive-only for an install that never asked otherwise. +- Red control: with each call site removed, its case must fail with the cooled key on the wire. +- The Lab boundary suite runs, because `compact.ts` imports from the same module family the core + path does. From 0349ee5202fc2c761c37806ce16dee3534cd0d78 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:08:11 +0900 Subject: [PATCH 096/231] docs(devlog): plan ZCode Responses attachment, GLM-5.3 modality fix, and Z.AI Responses default Issues: #4295, #4296, #4297 --- .../000_plan.md | 45 +++++ .../001_research_zai_model_facts.md | 61 ++++++ .../002_research_zcode_bundle_schema.md | 87 +++++++++ .../003_research_ocx_internals.md | 75 ++++++++ .../010_wp2_zcode_export_responses.md | 79 ++++++++ .../020_wp3_glm53_flash_modalities.md | 70 +++++++ .../030_wp4_zai_responses_default.md | 179 ++++++++++++++++++ .../evidence/zai-responses-models.json | 115 +++++++++++ 8 files changed, 711 insertions(+) create mode 100644 devlog/_plan/260912_zcode_protocol_and_catalog/000_plan.md create mode 100644 devlog/_plan/260912_zcode_protocol_and_catalog/001_research_zai_model_facts.md create mode 100644 devlog/_plan/260912_zcode_protocol_and_catalog/002_research_zcode_bundle_schema.md create mode 100644 devlog/_plan/260912_zcode_protocol_and_catalog/003_research_ocx_internals.md create mode 100644 devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md create mode 100644 devlog/_plan/260912_zcode_protocol_and_catalog/020_wp3_glm53_flash_modalities.md create mode 100644 devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md create mode 100644 devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/000_plan.md b/devlog/_plan/260912_zcode_protocol_and_catalog/000_plan.md new file mode 100644 index 0000000000..18f2236231 --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/000_plan.md @@ -0,0 +1,45 @@ +# 000 — ZCode 프로토콜 정렬 + GLM-5.3 모달리티 정정 + +## 목표 + +ZCode 연동에서 확인된 세 가지 결함을 1차 근거와 함께 고친다. + +1. ocx가 ZCode에 내보내는 프로바이더 블록이 `kind: "openai-compatible"`(Chat Completions)로 붙는다. + ocx는 Responses-우선 프록시라 Chat 인바운드는 Responses로 번역된 뒤 다시 Chat으로 역번역된다. + ZCode는 `kind: "openai"`로 `{baseURL}/responses`를 직접 호출할 수 있으므로 번역을 0회로 줄인다. +2. `zai`와 `zhipu-bigmodel-coding` 행이 `glm-5.3-flash`의 입력 모달리티를 음수 선언 + (`noVisionModels` 제외)으로만 다뤄서, 클라이언트 export 피커에 네이티브 VLM이 text-only로 나간다. +3. `zai` 행이 Chat Completions 한 갈래에 고정되어 있다. Z.AI 는 같은 키로 Responses 도 서빙하고 + (`https://api.z.ai/api/v1`) 실사용에서 Chat 경로가 불안정하다. Responses 를 기본으로 돌리고 + Chat 은 opt-in 으로 남긴다. + +## 제약 + +- 로컬 상태(`~/.opencodex`, `~/.zcode`, `zcode-ocx-sidecar`)는 건드리지 않는다. 사용자 지시. +- `zai` 행은 제자리에서 Responses 로 전환한다. 별도 행을 추가하지 않는다 — 사용자 결정 + ("다 통합하고 chat optin 으로, 기존 사용자도 response 전환"). 초안에 있던 로스터 손실 우려는 + 실측으로 반증됐다: glm-5.2 / glm-5.1 / glm-5 / glm-4.6 / glm-5-turbo 전부 Responses 에서 200 이다 + (030 라이브 표). 030 이 wp4 의 SSOT 다. +- 프로토콜 전환은 조용히 일어나므로 릴리스 노트에 적는다. Chat 전용 키를 가진 사용자는 + `modelAdapters` 로 모델마다 `openai-chat` 을 지정해야 한다. 마이그레이션 코드는 넣지 않는다. +- wp2 와 wp3 는 서로 독립이다. wp4 는 wp3 가 넣는 `ZAI_GLM_5X_INPUT_MODALITIES` 상수에 의존하므로 + wp3 가 `dev` 에 들어간 뒤에 올린다. 각 수정은 자기 이슈를 닫는 PR 로 가고 베이스는 `dev` 다. + +## 작업 단계 + +| work-phase | 내용 | 이슈 | 문서 | +|---|---|---|---| +| wp1 | 조사 + 로드맵 + 이슈 3건 등록 (docs-only) | — | 000-003 | +| wp2 | ZCode export를 `kind: "openai"`(Responses)로 | [#4295](https://github.com/lidge-jun/opencodex/issues/4295) | 010 | +| wp3 | `glm-5.3-flash` 양수 모달리티 선언 | [#4296](https://github.com/lidge-jun/opencodex/issues/4296) | 020 | +| wp4 | `zai` 를 Responses 기본으로 전환 + `chatCompletionsPath` 로 Chat opt-in | [#4297](https://github.com/lidge-jun/opencodex/issues/4297) | 030 | + +wp2 는 독립이다. wp4 는 wp3 뒤에 온다(위 제약). + +## 검증 + +- `bun run typecheck` +- `bun test tests/providers/zcode-client.test.ts tests/config/client-config-export.test.ts` +- `bun test tests/providers/provider-registry-parity.test.ts` +- `bun run structure:check` (structure/clients/integrations.md 소유 영역 변경 시) +- PR-ready 게이트로 `bun run test` diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/001_research_zai_model_facts.md b/devlog/_plan/260912_zcode_protocol_and_catalog/001_research_zai_model_facts.md new file mode 100644 index 0000000000..359a102743 --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/001_research_zai_model_facts.md @@ -0,0 +1,61 @@ +# 001 — Z.AI GLM-5.3 계열 1차 근거 (Aside 세션 조사) + +수집 경로: `aside exec --permission full-access` (CLI 1.26.902, 세션 `cye9q0tV093bZeFJ`), 2026-09-12. +아래 인용은 Aside 에이전트가 실제로 연 공식 문서 페이지에서 그대로 가져온 문장이다. + +## 입력 모달리티 + +| 모델 | 입력 모달리티 | 컨텍스트 | 최대 출력 | reasoning 사다리 | 출처 | +|---|---|---|---|---|---| +| GLM-5.3 | text only | 1M | 128K | low / high / max (비활성화 불가) | https://docs.z.ai/guides/llm/glm-5.3 | +| GLM-5.3-Flash | video / image / text / file | 1M | 128K | low / high / max (비활성화 불가) | https://docs.z.ai/guides/vlm/glm-5.3-flash | + +verbatim: + +> "GLM-5.3 currently supports text-only inputs, with a 1M-token context window and a maximum output length of 128K tokens." +> — https://docs.z.ai/guides/llm/glm-5.3 + +> "GLM-5.3 目前仅支持处理文本模态信息,支持 1M 上下文窗口,最大输出 Tokens 为 128K。" +> — https://docs.bigmodel.cn/cn/guide/models/text/glm-5.3 + +> "GLM-5.3-Flash is the first native multimodal model in the GLM-5 series" +> "Input Modality: Video / Image / Text / File" +> — https://docs.z.ai/guides/vlm/glm-5.3-flash + +> "GLM-5.3 is a text-only model, so uncheck Support Images; GLM-5.3-FLASH is a multimodal model, so Support Images can be checked" +> — https://docs.z.ai/devpack/latest-model + +이미지 입력 전달 방식: + +> "Image Parameters: Add a content block with type: image_url to messages[].content[], and pass the image URL (recommended) or a Base64 Data URL through image_url.url." +> — https://docs.z.ai/guides/vlm/glm-5.3-flash + +## reasoning effort + +> "reasoning_effort: Controls the degree of reasoning within the thought chain... Available values: max (default and recommended, deep inference), high (enhanced inference), low (mild inference, only supported by GLM-5.3 and GLM-5.3-FLASH)" +> "For GLM-5.3 and GLM-5.3-FLASH, only max, high and low are supported. Any other input will result in an error." +> — https://docs.z.ai/guides/capabilities/thinking + +리포지토리의 `ZAI_GLM_53_REASONING_EFFORTS = ["low", "high", "max"]` 와 일치한다. + +## 프로토콜 엔드포인트 (세 갈래) + +> "| Protocol | Base URL | +> | OpenAI Chat Completion Protocol | https://api.z.ai/api/coding/paas/v4 | +> | OpenAI Response Protocol | https://api.z.ai/api/v1 | +> | Anthropic Message Protocol | https://api.z.ai/api/anthropic |" +> — https://docs.z.ai/guides/llm/glm-5.3 + +> "Claude Code / Goose (Anthropic-compatible): https://api.z.ai/api/anthropic +> Codex: https://api.z.ai/api/v1 +> Other OpenAI-compatible tools: https://api.z.ai/api/coding/paas/v4" +> — https://docs.z.ai/devpack/latest-model + +상충하는 단서 하나 (해결 필요): + +> "If you have previously subscribed to a GLM Coding Plan, including an expired subscription, you can currently access the model API only through the OpenAI Chat Completion-compatible protocol." +> — https://docs.z.ai/guides/llm/glm-5.3 + +즉 Coding Plan 구독 이력이 있는 키는 Responses 엔드포인트에서 거절될 수 있다. Responses 전환을 +제안하기 전에 실제 키로 확인이 필요하다. + diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/002_research_zcode_bundle_schema.md b/devlog/_plan/260912_zcode_protocol_and_catalog/002_research_zcode_bundle_schema.md new file mode 100644 index 0000000000..94aa236ca4 --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/002_research_zcode_bundle_schema.md @@ -0,0 +1,87 @@ +# 002 — ZCode 3.11.2 번들 스키마 (app.asar 추출) + +조사 대상: `/Applications/ZCode.app/Contents/Resources/app.asar` (307MB, `rg -a`로 추출). +버전 확인: 실행 로그 `[arms] electron initialized env=prod version=3.11.2`. +리포지토리 주석은 3.7.7 / 3.8.1 기준이라 3.11.2 재확인이 필요했다. + +## kind enum — 정확히 3값 + +```js +bt=t.enum(["anthropic","openai","openai-compatible"]) +mh=t.enum(["anthropic-messages","openai-chat-completions","openai-responses"]) +``` + +`mh`는 내부 `apiFormat` 표현이고 `bt`가 사용자 config의 `kind`다. + +## kind → 요청 경로 + +```js +function fL(e){switch(e){case"anthropic":return"/v1/messages";case"openai":return"/responses";case"openai-compatible":return"/chat/completions"}} +s(fL,"getDefaultModelProviderEndpointPathForKind") + +function IHe(e,t){let n=t.replace(/\/+$/,"");switch(e){case"anthropic":return`${n}/v1/messages`;case"openai_chat":return`${n}/chat/completions`;case"openai_responses":return`${n}/responses`;case"gemini":return n}} +s(IHe,"buildConnectivityRequestUrl") +``` + +baseURL 정규화는 kind별 접미사를 자동으로 떼어낸다: + +```js +function lp(e,o){let r={anthropic:["/v1/messages","/messages"],openai:["/responses"],"openai-compatible":["/chat/completions"]},...} +s(lp,"normalizeModelProviderBaseUrlForKind") +``` + +따라서 `kind:"openai"` + `baseURL:"http://127.0.0.1:10100/v1"` → `POST http://127.0.0.1:10100/v1/responses`. +ocx는 그 경로를 실제로 서빙한다(`src/server/index.ts:1994`). + +## reasoning 필드 + +사용자 config 모델 엔트리는 `variants`/`defaultVariant` 형태다: + +```js +XWe=Q.object({enabled:Q.boolean().optional(),variants:Q.array(Q.string().min(1)).optional(),defaultVariant:Q.string().min(1).optional(),aliases:Q.record(Q.string(),Q.string()).optional()}).passthrough() +``` + +내부 카탈로그는 `levels`/`defaultLevel`이고 양방향 변환기(`openCodeReasoningToModelReasoning`)가 있다. +즉 현재 export가 쓰는 `variants`/`defaultVariant`는 kind를 바꿔도 그대로 유효하다. + +wire 변환은 kind마다 다르다: + +| kind | 요청 필드 | +|---|---| +| openai-compatible | `reasoning_effort` | +| openai | `reasoning: { effort }` | +| anthropic | `output_config: { effort }` (+ 선택 `thinking`) | + +`kind:"openai"`가 보내는 `reasoning.effort`는 ocx `/v1/responses`가 네이티브로 읽는 필드다. + +## 모달리티 + +```js +Zse=Q.enum(["text","image","video","audio","pdf"]) +modalities:Q.object({input:Q.array(Zse).optional(),output:Q.array(Zse).optional()}).optional() +``` + +kind별 제한이 없고, `image`가 있으면 `supportsImages` 케파빌리티로 투영된다: + +```js +w.supportsImages=y.modalities.input.includes("image") +``` + +## options / apiKeyRequired + +`options`는 `Q.record(Q.string(),Q.unknown())` 자유형이고, `apiKeyRequired:false`면 크리덴셜 요구를 +건너뛴다: + +```js +function yA(e){if(e.apiKeyRequired===!1)return!0;...}s(yA,"hasRuntimeCredential") +``` + +## anthropic kind의 추가 요구사항 — 없음 + +```js +function rje(e){return e.kind?e.kind:...}s(rje,"resolveOpenCodeProviderDefaultKind") +``` + +명시된 `kind`가 최우선이고 `defaultKind`/`apiFormat`/`providerMappings`는 전부 optional 폴백이다. +세 kind 어느 쪽으로 내보내도 추가 필드는 필요 없다. + diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/003_research_ocx_internals.md b/devlog/_plan/260912_zcode_protocol_and_catalog/003_research_ocx_internals.md new file mode 100644 index 0000000000..0307a1ea56 --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/003_research_ocx_internals.md @@ -0,0 +1,75 @@ +# 003 — ocx 내부 경로 (서브에이전트 3레인 조사) + +## 인바운드 라우트 + +| 경로 | 등록 | +|---|---| +| `POST /v1/responses` | `src/server/index.ts:1994` | +| `POST /v1/messages` | `src/server/index.ts:2065` | +| `POST /v1/messages/count_tokens` | `src/server/index.ts:2047` | +| `POST /v1/chat/completions` | `src/server/index.ts:2097` | + +Anthropic과 Chat 인바운드는 둘 다 본문을 Responses 형태로 번역해 내부적으로 `handleResponses`로 +리플레이한다(`src/server/claude-messages.ts:2-7`, `src/server/chat-completions.ts:2-4`, +`claude-messages.ts:900`의 `new Request("http://localhost/v1/responses", ...)`). +Responses 인바운드만 번역이 0회다. + +ocx 자신도 ZCode를 Responses 클라이언트로 이미 인지하고 있다: + +> "Generic Responses-API clients (AI-SDK apps such as ZCode) omit `store`" +> — `tests/responses/responses-inbound-store-default.test.ts:2` + +## 업스트림 와이어 결정 + +인바운드가 아니라 라우트된 프로바이더의 `adapter`가 결정한다 +(`src/server/adapter-resolve.ts:13-15`, 하드핀 → 모델별 오버라이드 → 레지스트리 기본 → `provider.adapter`). +`zai`는 `adapter: "openai-chat"`이므로 어떤 인바운드로 들어와도 업스트림은 Chat Completions다. + +## 클라이언트 export 프로토콜 지형 + +| 클라이언트 | 프로토콜 | baseURL 규칙 | +|---|---|---| +| zcode | `kind:"openai-compatible"` → chat | base + `/v1` | +| mcode | `api:"anthropic-messages"` | base에서 `/v1` 제거 | +| dsh | `api:"openai-responses"` | base 그대로(`/v1` 포함) | +| omp / raycast | chat completions | base 그대로 | + +즉 Responses로 붙는 클라이언트(dsh)와 Anthropic으로 붙는 클라이언트(mcode) 선례가 둘 다 있다. + +## ZCode 소유권 정책 + +`src/integrations/ownership-policy.ts:66-84`가 refreshable로 인정하는 경로는 +`models..reasoning`, `models..limit.output`, (권위 컨텍스트 부재 시) `models..limit.context` 뿐이다. +`kind`는 보호 필드다: + +> "Provider identity and connection fields (`name`, `kind`, `enabled`, `source`, and every `options` member), model membership, model names, modalities, and authoritative context limits remain protected. Changing any of them stays `conflict / foreign-edit`." +> — `structure/clients/integrations.md:112-114` + +이건 사용자 편집에 대한 규칙이다. ocx가 생성 계약 자체를 바꾸면 desired contribution이 달라지므로 +기존 기록과 대조해 refresh 경로를 타야 한다. wp2에서 마이그레이션 동작을 반드시 확인한다. + +## 모달리티 전파 경로 + +`registry.ts` → `configuredInputModalities`(`src/codex/catalog/provider-fetch.ts:674-677`) +→ vision sidecar 보정(`provider-fetch.ts:787-798`) → 카탈로그 `input_modalities` +→ `inputModalitiesForClient`(`src/clients/config-export/model-metadata.ts:61-72`) → 각 클라이언트 export. + +`zai` / `zhipu-bigmodel-coding` 행은 `modelInputModalities`를 아예 선언하지 않고 +`noVisionModels`(음수 선언)만 쓴다. 그래서 `glm-5.3-flash`는 sidecar 우회는 면하지만 +양수 선언이 없어 export 피커에서 `["text"]` 플로어로 떨어진다. + +`zhipu-bigmodel-responses` 행은 반대로 양수 선언을 갖는다 +(`modelInputModalities: { "glm-5.3": ["text"], "glm-5.3-flash": ["text", "image"], "glm-5-turbo": ["text"] }`). + +## 상류 권위 카탈로그 (라이브 확인, 2026-09-12) + +`GET https://api.z.ai/api/v1/models` → 200, Codex 형식 카탈로그: + +```json +{"slug": "glm-5.3", "input_modalities": ["text"], "context_window": 1048576, "default_reasoning_level": "max"} +{"slug": "glm-5.3-flash", "input_modalities": ["text", "image"], "context_window": 1048576, "default_reasoning_level": "max"} +``` + +전체 응답은 `evidence/zai-responses-models.json`. `POST https://api.z.ai/api/v1/responses`도 200을 반환했으므로 +"Coding Plan 구독 이력 키는 Chat만 가능"이라는 문서 문장은 이 키에 적용되지 않는다. + diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md b/devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md new file mode 100644 index 0000000000..2ebc0211cc --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md @@ -0,0 +1,79 @@ +# 010 — wp2 / ISSUE-1: ZCode export를 Responses kind로 + +## 결함 + +src/clients/config-export/zcode.ts 가 kind "openai-compatible" 을 내보낸다. ZCode 는 그 kind 에서 +{baseURL}/chat/completions 를 호출하고, ocx 의 Chat 인바운드는 본문을 Responses 로 번역해 +handleResponses 로 리플레이한 뒤 응답을 다시 Chat SSE 로 역번역한다. 왕복 2회 번역이고 그 과정에서 +tool-call delta 와 reasoning 블록이 형태를 바꾼다. + +ZCode 는 kind "openai" 로 {baseURL}/responses 를 직접 호출한다(002 문서의 fL / IHe 인용). +ocx 는 POST /v1/responses 를 네이티브로 서빙한다(src/server/index.ts:1994). 번역 0회. + +## 변경 + +### MODIFY src/clients/config-export/zcode.ts + +다섯 지점이다. 타입 리터럴(33행), 빌더 값(94행), 그리고 세 주석 블록(10-11행, 49-54행, 76-78행). + + - kind: "openai-compatible"; + + kind: "openai"; + + - kind: "openai-compatible", + + kind: "openai", + +주석은 3.11.2 번들에서 재추출한 사실로 갱신한다: getDefaultModelProviderEndpointPathForKind 가 +anthropic 을 /v1/messages, openai 를 /responses, openai-compatible 을 /chat/completions 로 보낸다는 +것과, Responses 가 프록시의 네이티브 인바운드라 이전 배선이 턴마다 번역 두 번을 냈다는 것. + +baseURL 은 그대로 ctx.baseUrl 에서 /v1 을 떼고 다시 "/v1" 을 붙인 값이다. ZCode 의 +normalizeModelProviderBaseUrlForKind 는 openai kind 에서 /responses 접미사만 떼므로 /v1 은 보존되고 +최종 URL 은 http://127.0.0.1:/v1/responses 가 된다. + +reasoning 블록(enabled / variants / defaultVariant)은 kind 와 무관하게 같은 스키마다. openai kind 에서는 +선택된 variant 가 reasoning.effort 로 나가고, 그건 ocx /v1/responses 가 네이티브로 읽는 필드다. + +76-78행 주석이 "ZCode forwards the selected variant as reasoning_effort" 라고 말하는데 그건 +openai-compatible kind 의 wire 필드다. openai kind 는 reasoning.effort 로 보낸다(002 문서의 +withOpenAiResponsesThoughtLevel 인용). 동작은 스키마가 같아 그대로지만 주석은 틀리므로 함께 고친다. + +### MODIFY tests/providers/zcode-client.test.ts + + - expect(provider.kind).toBe("openai-compatible"); + + expect(provider.kind).toBe("openai"); + +options 기대값(baseURL http://127.0.0.1:10100/v1)은 바뀌지 않는다. 같은 describe 에 회귀 테스트를 +하나 추가해, ZCode 가 openai kind 에서 조립하는 최종 URL 이 프록시가 실제로 서빙하는 경로와 +일치한다는 것을 고정한다. + + test("the exported kind resolves to the proxy's native Responses route", () => { + const document = buildClientConfig("zcode", context()) as ZcodeGeneratedConfig; + const provider = document.provider[OPENCODE_PROVIDER_ID]!; + // ZCode 3.11.2 getDefaultModelProviderEndpointPathForKind: openai -> "/responses". + expect(provider.kind).toBe("openai"); + expect(provider.options.baseURL + "/responses").toBe("http://127.0.0.1:10100/v1/responses"); + }); + +### MODIFY tests/config/client-config-export.test.ts + +123행 직렬화 바이트 고정값에서 "kind":"openai-compatible" 을 "kind":"openai" 로 바꾼다. +나머지 필드 순서와 값은 동일하다. + +## 마이그레이션 — 기대 결과는 stale -> rewrite + +감사에서 확정됐다. kind 는 refreshable 경로가 아니지만(ownership-policy.ts:66-84), 사용자가 파일을 +손대지 않았다면 recordedBlockIsOwned 가 기존 지문으로 true 를 돌려주고(integrations/state.ts:213) +desired 지문만 달라져 상태가 stale 이 된다(state.ts:411). JSON 클라이언트인 zcode 는 stale refresh 에서 +프래그먼트를 다시 쓴다. 즉 미수정 설치는 자동으로 따라온다. + +--overwrite-conflict 는 사용자가 kind 나 options 를 직접 고쳐 이미 foreign-edit 인 경우에만 필요하다. +tests/clients/integrations-writer.test.ts:534 의 conflict 케이스는 사용자가 baseURL 을 편집한 상황이지 +ocx 가 kind 를 바꾸는 상황이 아니다. + +회귀 테스트를 추가한다: 생성 계약의 kind 가 바뀌었을 때 미수정 기록이 conflict 가 아니라 stale 로 +분류되고 refresh 가 프래그먼트를 다시 쓴다는 것을 tests/clients/integrations-writer.test.ts 에 고정한다. + +## 검증 + + bun test tests/providers/zcode-client.test.ts tests/config/client-config-export.test.ts tests/clients/integrations-writer.test.ts + bun run typecheck diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/020_wp3_glm53_flash_modalities.md b/devlog/_plan/260912_zcode_protocol_and_catalog/020_wp3_glm53_flash_modalities.md new file mode 100644 index 0000000000..619742aeaa --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/020_wp3_glm53_flash_modalities.md @@ -0,0 +1,70 @@ +# 020 — wp3 / ISSUE-2: glm-5.3-flash 입력 모달리티 양수 선언 + +## 결함 + +glm-5.3 은 text-only 이고 glm-5.3-flash 는 네이티브 VLM 이다(001 문서, 그리고 상류 +GET https://api.z.ai/api/v1/models 의 input_modalities). + +zai 와 zhipu-bigmodel-coding 행은 이 사실을 noVisionModels 음수 선언으로만 표현한다. +ZAI_GLM_5X_SIDECAR_VISION_MODELS 가 flash 를 제외하므로 vision sidecar 우회는 막히지만, +modelInputModalities 가 없어 configuredInputModalities 가 undefined 를 돌려주고 카탈로그가 +["text"] 플로어로 떨어진다. 결과적으로 클라이언트 export(ZCode / Pi / OMP)의 모델 피커에 +네이티브 VLM 이 text-only 로 실리고 이미지 첨부가 막힌다. + +zhipu-bigmodel-responses 행은 이미 양수로 선언한다. 같은 모델인데 행마다 다르게 표현된 상태다. + +## 변경 + +### MODIFY src/providers/registry.ts + +ZAI_GLM_5X_SIDECAR_VISION_MODELS 정의 바로 아래에 공유 상수를 추가한다. + + const ZAI_GLM_5X_INPUT_MODALITIES: Record = { + ...Object.fromEntries(ZAI_GLM_5X_SIDECAR_VISION_MODELS.map(id => [id, ["text"]])), + "glm-5.3-flash": ["text", "image"], + }; + +주석으로 남길 근거: noVisionModels 는 음수 진술이라 sidecar 우회만 막고 카탈로그에 모델이 무엇을 +읽을 수 있는지 말해주지 않는다는 것, 그리고 권위 출처가 GET https://api.z.ai/api/v1/models 의 +input_modalities(["text"] vs ["text","image"], evidence/zai-responses-models.json 에 캡처)와 +docs.z.ai/devpack/latest-model 의 산문이라는 것. + +zai 행과 zhipu-bigmodel-coding 행 각각에 한 줄씩 추가한다. + + noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, + + modelInputModalities: ZAI_GLM_5X_INPUT_MODALITIES, + modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS, + +glm-4.6 은 두 행의 models 에 있지만 5.x 가족이 아니라 이 맵에 없다. 선언이 없으면 기존 폴백 동작이 +유지되므로 의도적으로 건드리지 않는다. + +### MODIFY tests/providers/provider-registry-parity.test.ts + +기존 전역 assertion 은 "flash 선언이 있으면 image 를 포함해야 한다"는 조건부다. 이제 Chat 행에서도 +선언이 존재해야 하므로 고정 기대값을 추가한다. + + test("the Chat-path Z.AI rows declare glm-5.3-flash as multimodal, not just out of the sidecar list", () => { + for (const id of ["zai", "zhipu-bigmodel-coding"] as const) { + const row = PROVIDER_REGISTRY.find(entry => entry.id === id); + expect(row?.modelInputModalities?.["glm-5.3-flash"]).toEqual(["text", "image"]); + expect(row?.modelInputModalities?.["glm-5.3"]).toEqual(["text"]); + expect(row?.noVisionModels ?? []).not.toContain("glm-5.3-flash"); + } + }); + +## 범위 밖 + +src/generated/model-metadata.ts 와 scripts/model-metadata.source.json 의 zai 번들에는 glm-5.3-flash +행 자체가 없다. 그 파일은 vendored 스냅샷 + 생성물이고 tests/codex-integration/model-metadata-sync.test.ts +가 바이트 동기화를 강제한다. 스냅샷 갱신은 별도의 의도적 커밋이므로 이 PR 에 섞지 않는다. +레지스트리 선언이 폴백보다 우선하므로 이 결함은 레지스트리 한 곳에서 닫힌다. + +상류가 말하는 Flash 의 입력은 Video / Image / Text / File 이지만 이 변경은 image 까지만 선언한다. +ocx 의 내부 모달리티 어휘는 text / image / audio 이고 ZCode·Pi export 어휘는 text / image 뿐이라 +(src/clients/config-export/model-metadata.ts:56-58) video 와 file 은 표현할 자리가 없다. +피커 결함은 image 선언만으로 닫힌다. video / file 은 명시적으로 범위 밖이다. + +## 검증 + + bun test tests/providers/provider-registry-parity.test.ts tests/codex-integration/catalog-vision-sidecar-modalities.test.ts + bun run typecheck diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md b/devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md new file mode 100644 index 0000000000..817993dc98 --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md @@ -0,0 +1,179 @@ +# 030 — wp4 / ISSUE-3: Z.AI 를 Responses 기본으로 통합하고 Chat 을 opt-in 으로 + +## 결함 + +Z.AI 는 같은 키로 세 프로토콜을 서빙한다(001 문서 인용). + + OpenAI Chat Completion https://api.z.ai/api/coding/paas/v4 + OpenAI Response https://api.z.ai/api/v1 + Anthropic Message https://api.z.ai/api/anthropic + +ocx 의 zai 행은 Chat 한 갈래에 고정되어 있다(adapter openai-chat). Chat 경로는 실사용에서 불안정하고, +Z.AI 자신의 devpack 안내도 Codex 계열 클라이언트에 Responses 엔드포인트를 지정한다("Codex: +https://api.z.ai/api/v1"). 국내판 Responses 행(zhipu-bigmodel-responses)만 있고 국제판이 없다. + +## 라이브 확인 (2026-09-12, 사용자 키) + + GET https://api.z.ai/api/v1/models -> 200 (Codex 형식, slug/input_modalities) + POST https://api.z.ai/api/v1/responses glm-5.3 -> 200 + POST https://api.z.ai/api/v1/responses glm-5.3-flash -> 200 + POST https://api.z.ai/api/v1/responses glm-5.2 -> 200 + POST https://api.z.ai/api/v1/responses glm-5.1 -> 200 + POST https://api.z.ai/api/v1/responses glm-5 -> 200 + POST https://api.z.ai/api/v1/responses glm-4.6 -> 200 + POST https://api.z.ai/api/v1/responses glm-5-turbo -> 200 + POST https://api.z.ai/api/v1/chat/completions glm-5.3 -> 403 model_access_denied + POST https://api.z.ai/api/coding/paas/v4/chat/completions glm-5.3 -> 200 + +두 가지가 확정된다. Responses 엔드포인트가 로스터 전체를 받으므로 전환은 모델 손실이 없다. +그리고 두 와이어는 서로 다른 경로 접두를 쓰므로 한 baseUrl 로는 둘 다 맞출 수 없다. + +문서의 "Coding Plan 구독 이력 키는 Chat 으로만 접근 가능"이라는 문장은 이 키에 해당하지 않는다. + +## 설계 + +한 행으로 통합한다. Responses 가 기본이고 Chat 은 opt-in 이며, Chat 이 받지 않는 모델은 +레지스트리가 Responses 로 고정한다. xAI 행과 방향이 같지는 않다 — 거기는 provider-wide Chat 에 +일부 모델만 modelWireDefaults 로 Responses 를 씌운다. 여기서는 반대로 provider-wide Responses 에 +Chat 을 opt-in 으로 둔다. 빌려오는 것은 modelWireDefaults 로 특정 모델의 와이어를 못박는 부분뿐이다. + +막히는 지점은 하나다. resolveWireProtocolOverride 는 adapter 만 바꾸고 baseUrl 은 그대로 둔다 +(src/server/adapter-resolve.ts:26-47). openai-chat 어댑터는 openaiChatCompletionsUrl(provider.baseUrl) +로 URL 을 만들고(src/adapters/openai-chat.ts:99), openai-responses 어댑터만 provider.responsesPath +라는 상대 경로 오버라이드를 갖는다(src/adapters/openai-responses.ts:2357-2362). +즉 Responses 쪽에는 이미 경로 오버라이드가 있고 Chat 쪽에만 없다. + +그래서 responsesPath 의 대칭짝을 만든다. + +### NEW FIELD chatCompletionsPath + +MODIFY src/types/provider.ts — responsesPath 선언 바로 아래. + + /** + * Relative send path for the openai-chat wire, mirroring responsesPath. + * Absent keeps openaiChatCompletionsUrl(baseUrl). Needed when one upstream serves + * Chat Completions and Responses under different path prefixes, so a per-model wire + * override cannot reach the right endpoint by swapping the adapter alone. + */ + chatCompletionsPath?: string; + +MODIFY src/config.ts — responsesPath 검증과 같은 규칙을 재사용한다: 스킴 없는 상대 경로, "/" 로 시작, +쿼리/프래그먼트 금지. providerResponsesPathConfigError 를 경로 이름만 받는 공용 함수로 일반화하고 +두 필드에 각각 적용한다. + +MODIFY src/adapters/openai-chat.ts — URL 조립을 responses 쪽과 같은 모양으로 바꾼다. + + - return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential }; + + const url = provider.chatCompletionsPath === undefined + + ? openaiChatCompletionsUrl(provider.baseUrl) + + : provider.baseUrl.replace(/\/$/, "") + provider.chatCompletionsPath; + + return { url, headers, hasCredential }; + +responsesPath 가 실제로 흐르는 경로 전체를 대칭으로 따라가야 한다. 감사에서 확인된 지점이다. +빠뜨리면 typecheck 가 즉시 깨지거나(auth-cors 의 satisfies Record) +런타임에 필드가 사라져 Chat opt-in 이 https://api.z.ai/chat/completions 로 나간다. + + src/providers/registry.ts:231 ProviderRegistryEntry 에 필드 선언 + src/providers/registry.ts:363 ProviderConfigSeed Pick 목록 + src/providers/derive.ts:18, :73 DerivedKeyLoginProvider / DerivedProviderPreset + src/providers/derive.ts:224, :259 providerConfigSeed (복사가 두 군데다) + src/providers/derive.ts:296 deriveKeyLoginMap + src/providers/derive.ts:480, :523 enrichProviderFromRegistry + src/providers/derive.ts:604 entryToPreset + src/router.ts:377-378 fill-if-absent 시딩 + src/config.ts zod 스키마 + 경로 검증(responsesPath 규칙 재사용) + src/server/auth-cors.ts:801 필드 권한 맵에 "editor" + gui/src/provider-payload.ts:5, :74, :89-90 + gui/src/components/provider-catalog/provider-presets.ts:18 + gui/src/components/AddProviderModal.tsx:151 + tests/server/config.test.ts:1476 허용/거절 검증 3건의 대칭 + +openai-chat 쪽 URL 조립은 openAIChatTransport 한 곳(src/adapters/openai-chat.ts:99)이면 된다. +116행과 1460행은 그 함수를 탄다. + +### NEW modelSuffixBracketStrip 을 Responses 어댑터에도 적용 + +감사와 사전 조사가 일치한다. 이 플래그는 openai-chat.ts:119, :743, :1466 과 ollama-native.ts:214 에만 +있고 openai-responses.ts 에는 매치가 0건이다. zai 로스터는 glm-5.3[1m] 과 glm-5.2[1m] 를 포함하고, +상류 실측에서 괄호 id 는 400 model_not_found 였다. 지금 상태로 Responses 를 기본으로 돌리면 +두 별칭이 기본 경로에서 죽는다. + +해결책은 둘이다. Responses buildRequest 의 wire model 에 스트립을 넣거나, 로스터에서 별칭을 뺀다. +후자는 zai/glm-5.3[1m] 을 고른 기존 사용자 선택을 깨고 parity 테스트가 고정한 별칭 메타데이터 +(provider-registry-parity.test.ts:462, :490-504)까지 무너뜨린다. 결함 크기에 비해 파괴가 크다. +전자를 택한다: provider.modelSuffixBracketStrip 이 true 일 때만 wire model 을 정규화하고 +카탈로그 slug 는 그대로 둔다. openai-chat 이 이미 하는 것과 같은 동작이다. +tests/adapters/openai/openai-chat-model-suffix.test.ts 의 Responses 대칭 테스트를 추가한다. + +### MODIFY zai 행 + + id: "zai", label: "Z.AI — GLM Coding Plan", + baseUrl: "https://api.z.ai", + adapter: "openai-responses", + responsesPath: "/api/v1/responses", + chatCompletionsPath: "/api/coding/paas/v4/chat/completions", + +models 로스터는 유지한다. modelContextWindows 의 5.3 가족은 상류 카탈로그가 말하는 1_048_576 으로 +맞춘다(현재 1_000_000, 국내 Responses 행은 이미 1_048_576). modelInputModalities 는 020 에서 넣은 +ZAI_GLM_5X_INPUT_MODALITIES 를 그대로 쓴다. preserveResponsesReasoningContent 를 켜고, +Chat 전용이던 preserveReasoningContentModels 는 유지한다(opt-in 한 사용자가 여전히 Chat 을 탄다). + +### Chat opt-in 과 Responses 고정 + +opt-in 은 기존 수단을 그대로 쓴다: 사용자가 modelAdapters 에 "openai-chat" 을 적으면 +resolveWireProtocolOverride 가 어댑터를 바꾸고, 새 chatCompletionsPath 가 올바른 경로로 보낸다. + +Chat 이 받지 않는 모델은 레지스트리가 Responses 로 고정한다. B 단계에서 coding/paas/v4 chat 경로에 +로스터 전체를 실제로 던져 어떤 모델이 400/403 을 내는지 확인하고, 해당 모델만 modelWireDefaults 에 +wire "openai-responses" 와 inbound ["responses", "chat", "anthropic"] 로 선언한다. +grok-4.20-multi-agent 행의 주석이 같은 상황을 같은 방식으로 처리한 선례다. + +### 감사에서 정리된 사항 + +- routedProviderConfig 는 매 요청 저장 설정을 레지스트리 값으로 덮는다(src/router.ts:375). 호스트가 + api.z.ai 로 같으므로 키가 다른 호스트로 가지 않는다. quota 매핑도 https://api.z.ai 와 /api/v1 을 + 이미 허용한다(src/providers/quota.ts:348-356). +- liveModels 는 켜지 않는다. 상류가 Codex 형식(models[] + slug)을 돌려주는데 ocx 라이브 발견은 + OpenAI /models(data[] + id) 계약을 기대한다. 확인되지 않은 라이브 주장은 빈 피커를 만든다. +- free-directory 의 glm id 는 별개다(src/providers/free-directory.ts:112). 계속 + https://api.z.ai/api/coding/paas/v4 + openai-chat 에 남고 zai 전환을 따라가지 않는다. +- structure 문서 의무: src/adapters/ 와 src/config.ts 와 src/providers/ 가 소유 문서를 갖는다. + structure/transports/responses.md:253 의 responsesPath 서술에 chatCompletionsPath 대칭 문장을 넣고 + bun run structure:check 를 wp4 검증에 포함한다. +- 이 변경은 020 이 넣는 ZAI_GLM_5X_INPUT_MODALITIES 상수를 쓴다. wp3 가 dev 에 들어간 뒤 올린다. + +## 테스트 + +갱신이 필요한 기존 고정 테스트. 감사가 열거한 목록이다. + + tests/providers/provider-registry-parity.test.ts:281, :313, :462, :512 + (:462 는 modelContextWindows 를 1_000_000 으로 고정한다 -> 1_048_576) + tests/providers/zhipu-bigmodel-provider.test.ts:86 + (glm free-directory 는 coding/paas/v4 에 남는다. zai 와 섞지 말 것) + tests/adapters/openai/openai-chat-model-suffix.test.ts:31 + quota / catalog / reasoning 테스트 중 zai Chat URL 을 fixture 로 쓰는 것들 + +MODIFY tests/providers/provider-registry-parity.test.ts + + test("the Z.AI row defaults to Responses and keeps Chat reachable as an opt-in", () => { + const row = PROVIDER_REGISTRY.find(entry => entry.id === "zai"); + expect(row?.adapter).toBe("openai-responses"); + expect(row?.baseUrl).toBe("https://api.z.ai"); + expect(row?.responsesPath).toBe("/api/v1/responses"); + expect(row?.chatCompletionsPath).toBe("/api/coding/paas/v4/chat/completions"); + }); + +NEW tests/adapters/openai/openai-chat-path-override.test.ts — chatCompletionsPath 가 있을 때와 +없을 때의 최종 URL 을 고정하고, modelAdapters 로 openai-chat 을 opt-in 한 zai 라우트가 +coding/paas/v4 경로로 나가는지 end-to-end 로 확인한다. + +경로가 tests/adapters 루트가 아니라 tests/adapters/openai 인 이유는 layout 규칙이다: +scripts/test-layout/layout.json 의 자식 규칙이 ^(?:openai)- 를 adapters/openai 로 보낸다. +AGENTS.md 대로 새 파일은 layout.json 의 explicit 과 tests/fixtures/test-layout-expected.json 양쪽에 +"openai-chat-path-override.test.ts": "adapters/openai" 를 등록해야 한다. + +## 검증 + + bun test tests/providers/provider-registry-parity.test.ts tests/adapters + bun run typecheck + bun run test diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json b/devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json new file mode 100644 index 0000000000..9f5f462daf --- /dev/null +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json @@ -0,0 +1,115 @@ +{ + "models": [ + { + "apply_patch_tool_type": "freeform", + "base_instructions": "", + "context_window": 1048576, + "default_reasoning_level": "max", + "default_reasoning_summary": "none", + "description": "Z.ai's latest flagship model", + "display_name": "glm-5.3", + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": [ + "text" + ], + "max_context_window": 1048576, + "priority": 0, + "shell_type": "shell_command", + "slug": "glm-5.3", + "support_verbosity": false, + "supported_in_api": true, + "supported_reasoning_levels": [ + { + "description": "Light reasoning", + "effort": "low" + }, + { + "description": "Enhanced reasoning", + "effort": "high" + }, + { + "description": "Deep reasoning", + "effort": "max" + } + ], + "supports_parallel_tool_calls": true, + "supports_reasoning_summaries": true, + "truncation_policy": { + "limit": 10000, + "mode": "bytes" + }, + "visibility": "list" + }, + { + "apply_patch_tool_type": "freeform", + "base_instructions": "", + "context_window": 1048576, + "default_reasoning_level": "max", + "default_reasoning_summary": "none", + "description": "Fast multimodal coding model", + "display_name": "glm-5.3-flash", + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": [ + "text", + "image" + ], + "max_context_window": 1048576, + "priority": 1, + "shell_type": "shell_command", + "slug": "glm-5.3-flash", + "support_verbosity": false, + "supported_in_api": true, + "supported_reasoning_levels": [ + { + "description": "Light reasoning", + "effort": "low" + }, + { + "description": "Enhanced reasoning", + "effort": "high" + }, + { + "description": "Deep reasoning", + "effort": "max" + } + ], + "supports_parallel_tool_calls": true, + "supports_reasoning_summaries": true, + "truncation_policy": { + "limit": 10000, + "mode": "bytes" + }, + "visibility": "list" + }, + { + "apply_patch_tool_type": "freeform", + "base_instructions": "", + "context_window": 204800, + "default_reasoning_level": "max", + "default_reasoning_summary": "none", + "description": "Agent-optimized model", + "display_name": "glm-5-turbo", + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": [ + "text" + ], + "max_context_window": 204800, + "priority": 2, + "shell_type": "shell_command", + "slug": "glm-5-turbo", + "support_verbosity": false, + "supported_in_api": true, + "supported_reasoning_levels": [], + "supports_parallel_tool_calls": true, + "supports_reasoning_summaries": true, + "truncation_policy": { + "limit": 10000, + "mode": "bytes" + }, + "visibility": "list" + } + ] +} \ No newline at end of file From 26282138e295a01c035e210b00d43f502875270f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:14:00 +0900 Subject: [PATCH 097/231] docs(devlog): fold the wp4c audit, including the images snapshot trap --- .../040_phase4_key_pool_strategy.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md index b90a99e9f1..810e5256db 100644 --- a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md +++ b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md @@ -354,3 +354,42 @@ stops — including the persisted-write path, which is never reached. - Red control: with each call site removed, its case must fail with the cooled key on the wire. - The Lab boundary suite runs, because `compact.ts` imports from the same module family the core path does. + +### wp4c plan audit — PASS-WITH-FINDINGS, folded + +**Major 1 — the images seam carries a resolved SNAPSHOT, not a live field.** +`candidates.keyed.apiKey` is built once by `selectImagesProvider` (`src/server/openai-sidecar.ts`:237-238, +:282), so the literal "pick, then destructure" would set the Authorization header from the OLD +key while the picker had already persisted the new one — a request on a cooled key plus a config +write, which is strictly worse than doing nothing. The header is rebuilt from the returned clone +through `resolveProviderApiKey` instead. + +This is the same class of mistake wp4b's blocker caught: the picker returns a clone and mutates +nothing, so every seam has to be asked "what does the send actually read?" rather than "did I +call it". + +The call also stays INSIDE the `candidates.keyed` branch rather than moving up next to +`selectImagesProvider`. Higher up it would run — and write config — even on requests that +ChatGPT forward goes on to serve, spending a rotation on a path that never used the key. + +**Minor 2 — gate the compact reassignment.** `compactProvider` starts as `route.provider` and is +overlaid only for `codexAccountMode` or custom reserve-forward. The picker returns null for +forward providers, so an ungated assign would be harmless today, but it stays inside the +existing `authMode !== "forward" && apiKey` branch so a future overlay cannot be clobbered by +accident. The provider name to pass is `route.providerName`. + +**Minor 3 — my lease concern was overstated, corrected.** Key-auth native compact does not hold +host-circuit admission at all: `preAuthUpstreamHostCircuitKey` requires +`codexAccountMode === "pool"` with `authMode === "forward"`. Turn admission is a counter and the +config write is SQLite, so there is no shared mutex to deadlock on and the lease stays valid — +the same situation wp4b already ships at the core seam. The plan's caution was unfounded and is +struck rather than left standing as a vague worry. + +**Minor 4 — confirmed there are no other first-send key applications in either file.** +`compact.ts`:289 and :380 are 401 refresh paths, and :446 is the 429/402 pool alternate. + +**Both paths are e2e-testable**, which is what lets the acceptance claim an end-to-end proof +rather than a unit call: native compact through the openai-apikey harness in +`tests/adapters/openai/openai-api-virtual-models.test.ts`, and the keyed image path through +`tests/server/server-images.test.ts`, whose keyed fallback already asserts a specific Bearer. +The cooled-committed-key setup is the one wp4b built in `server-key-failover-e2e.test.ts`. From 2ad973eec95c6bb66087ab16dcf9b55a65943432 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:15:52 +0900 Subject: [PATCH 098/231] feat(server): pick a warm API key on the compact and images paths Native compact and the keyed /v1/images relay each build their own request and never enter handleResponses, so the two call sites wp4b added did not reach them. The images header is rebuilt from the picker's clone rather than from candidates.keyed.apiKey, which is a snapshot resolved earlier - reusing it would have sent the old key while the picker had already persisted the new one. --- src/server/images.ts | 17 ++++++++++++++++- src/server/responses/compact.ts | 7 ++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/server/images.ts b/src/server/images.ts index 5642038474..0378aba753 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -28,6 +28,8 @@ import { readBoundedResponseBytes, type BoundedBytesResult } from "../lib/bounde import { sidecarEnter } from "../lib/sidecar-tracker"; import type { OcxConfig } from "../types"; import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar"; +import { selectProactiveApiKey } from "../providers/key-failover"; +import { resolveProviderApiKey } from "../providers/key-store"; import { getProviderRegistryEntry } from "../providers/registry"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors"; @@ -698,7 +700,20 @@ export async function handleImages( // Do not hide a broken/expired pool behind separately billed API-key image generation. return forwardAuthError; } else if (candidates.keyed) { - const { provider, apiKey, providerName } = candidates.keyed; + const { providerName } = candidates.keyed; + // The keyed image path builds its own URL and Authorization header and never enters + // handleResponses, so the pre-dispatch key pick happens here. + // + // Two things about the placement. It stays INSIDE this branch because higher up it would + // also run for requests ChatGPT forward goes on to serve, spending a rotation on a path + // that never used the key. And the header is rebuilt from the returned clone rather than + // from candidates.keyed.apiKey, which is a snapshot resolved earlier: reusing it would + // send the OLD key while the picker had already persisted the new one. + const warmKeyProvider = selectProactiveApiKey(config, providerName); + const provider = warmKeyProvider ?? candidates.keyed.provider; + const apiKey = warmKeyProvider?.apiKey + ? (resolveProviderApiKey(warmKeyProvider.apiKey) ?? candidates.keyed.apiKey) + : candidates.keyed.apiKey; if (provider.headers) Object.assign(headers, provider.headers); headers["authorization"] = `Bearer ${apiKey}`; logCtx.provider = providerName; diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 45bdb3fa3d..6a0e39b0ce 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -111,7 +111,7 @@ import { UnsupportedContentEncodingError, } from "../request-decompress"; import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve"; -import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../../providers/key-failover"; +import { hasKeyPoolFailover, rotateProviderTransportOn429, selectProactiveApiKey } from "../../providers/key-failover"; import { shouldAttemptImageTierRetry } from "../image-retry"; import { resolveProviderTransport } from "../../providers/xai-transport"; import type { WsData } from "../ws-bridge"; @@ -743,6 +743,11 @@ export async function handleResponsesCompact( ? CODEX_FORWARD_BASE_URL : (compactProvider.baseUrl ?? "").replace(/\/+$/, ""); if (compactProvider.authMode !== "forward" && compactProvider.apiKey) { + // Native compact never enters handleResponses, so it needs its own pre-dispatch key + // pick. Kept inside this branch on purpose: the overlay above owns the forward and + // codexAccountMode cases, and the picker returns null for them anyway. + const warmKeyProvider = selectProactiveApiKey(config, route.providerName); + if (warmKeyProvider?.apiKey) compactProvider = warmKeyProvider; headers.set("authorization", `Bearer ${resolveProviderApiKey(compactProvider.apiKey)}`); } const { reasoning: _reasoning, ...compactBodyRaw } = raw as typeof raw & { reasoning?: unknown }; From 400b1112367c59a55502fb7dce285a6a9891b952 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:17:58 +0900 Subject: [PATCH 099/231] test(server): cover the keyed image first-send key pick --- tests/server/server-images.test.ts | 91 +++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/tests/server/server-images.test.ts b/tests/server/server-images.test.ts index 371aa264c0..205f1b6b76 100644 --- a/tests/server/server-images.test.ts +++ b/tests/server/server-images.test.ts @@ -9,7 +9,8 @@ import { join } from "node:path"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { clearAccountNeedsReauth, clearAccountQuota } from "../../src/codex/auth-api"; import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth } from "../../src/codex/routing"; -import { saveConfig } from "../../src/config"; +import { loadConfig, saveConfig } from "../../src/config"; +import { clearKeyCooldowns, rotateKeyOn429 } from "../../src/providers/key-failover"; import { selectImagesProvider } from "../../src/providers/openai-sidecar"; import { startServer } from "../../src/server"; import { handleImages, IMAGES_RESPONSE_MAX_BYTES, readImageResponseBytes, setXaiResultPinnedDownloadForTests } from "../../src/server/images"; @@ -685,6 +686,94 @@ test("zstd-compressed request bodies are decoded before the relay", async () => } }); + +test("a cooled committed key is replaced before the first keyed image send", async () => { + const captured: CapturedRequest[] = []; + const upstream = fakeImagesUpstream(captured); + clearKeyCooldowns(); + const pooled = { + ...keyedProvider(upstream.url.toString().replace(/\/$/, "")), + apiKeyPoolStrategy: "round-robin", + apiKeyPool: [ + { id: "first", key: "sk-platform-key" }, + { id: "second", key: "sk-warm-key" }, + ], + }; + saveConfig({ + port: 0, + defaultProvider: "openai-apikey", + openaiProviderTierVersion: 2, + providers: { openai: disabledOpenAiProvider, "openai-apikey": pooled }, + } as unknown as OcxConfig); + + // Cool the committed key the way a real 429 does, then point the stored selection back at it. + // This is the state an operator lands in after a rotation plus a restart or a config reload. + const live = loadConfig(); + rotateKeyOn429(live, "openai-apikey", null, Date.now(), "sk-platform-key"); + const restored = loadConfig(); + restored.providers["openai-apikey"]!.apiKey = "sk-platform-key"; + saveConfig(restored); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}` }, + body: JSON.stringify({ prompt: "a cat", model: "gpt-image-2" }), + }); + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + // The warm key, on the FIRST send. This path builds its own Authorization header from a + // snapshot resolved before the pick, so a naive wiring would have sent sk-platform-key here + // while the picker had already committed sk-warm-key to config. + expect(captured[0].headers.get("authorization")).toBe("Bearer sk-warm-key"); + } finally { + await server.stop(true); + await upstream.stop(true); + clearKeyCooldowns(); + } +}); + +test("without a configured strategy the keyed image send keeps the cooled key", async () => { + const captured: CapturedRequest[] = []; + const upstream = fakeImagesUpstream(captured); + clearKeyCooldowns(); + const pooled = { + ...keyedProvider(upstream.url.toString().replace(/\/$/, "")), + apiKeyPool: [ + { id: "first", key: "sk-platform-key" }, + { id: "second", key: "sk-warm-key" }, + ], + }; + saveConfig({ + port: 0, + defaultProvider: "openai-apikey", + openaiProviderTierVersion: 2, + providers: { openai: disabledOpenAiProvider, "openai-apikey": pooled }, + } as unknown as OcxConfig); + const live = loadConfig(); + rotateKeyOn429(live, "openai-apikey", null, Date.now(), "sk-platform-key"); + const restored = loadConfig(); + restored.providers["openai-apikey"]!.apiKey = "sk-platform-key"; + saveConfig(restored); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}` }, + body: JSON.stringify({ prompt: "a cat", model: "gpt-image-2" }), + }); + expect(response.status).toBe(200); + // Rotation stays reactive-only for an install that never asked for a strategy. + expect(captured[0].headers.get("authorization")).toBe("Bearer sk-platform-key"); + } finally { + await server.stop(true); + await upstream.stop(true); + clearKeyCooldowns(); + } +}); + test("falls back to a keyed openai-responses provider when no forward provider exists", async () => { const captured: CapturedRequest[] = []; const upstream = fakeImagesUpstream(captured); From a37e794bc09f91801c1b897dfb11bf03afe50be9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:19:17 +0900 Subject: [PATCH 100/231] test(adapters): cover the native compact first-send key pick --- .../openai/openai-api-virtual-models.test.ts | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/adapters/openai/openai-api-virtual-models.test.ts b/tests/adapters/openai/openai-api-virtual-models.test.ts index dc72ea8313..9a30f3b718 100644 --- a/tests/adapters/openai/openai-api-virtual-models.test.ts +++ b/tests/adapters/openai/openai-api-virtual-models.test.ts @@ -13,7 +13,8 @@ import { } from "../../../src/providers/openai-virtual-models"; import { PROVIDER_REGISTRY } from "../../../src/providers/registry"; import { resolveWireProtocolOverride } from "../../../src/server/adapter-resolve"; -import { saveConfig } from "../../../src/config"; +import { loadConfig, saveConfig } from "../../../src/config"; +import { clearKeyCooldowns, rotateKeyOn429 } from "../../../src/providers/key-failover"; import { startServer } from "../../../src/server"; import { usageLogPath } from "../../../src/usage/log"; @@ -176,6 +177,64 @@ describe("validateOpenAiVirtualModelDefinition", () => { }); describe("OpenAI API compact transport", () => { + + test("a cooled committed key is replaced before the first native compact send", async () => { + const originalFetch = globalThis.fetch; + const home = mkdtempSync(join(tmpdir(), "ocx-openai-api-compact-pool-")); + process.env.OPENCODEX_HOME = home; + clearKeyCooldowns(); + saveConfig({ + port: 0, + defaultProvider: "openai-apikey", + openaiProviderTierVersion: 2, + providers: { + "openai-apikey": { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + apiKey: "sk-platform", + apiKeyPoolStrategy: "round-robin", + apiKeyPool: [ + { id: "first", key: "sk-platform" }, + { id: "second", key: "sk-warm" }, + ], + }, + }, + } as never); + + // Cool the committed key the way a real 429 does, then point the stored selection back at + // it. Native compact never enters handleResponses, so nothing else would move it. + const live = loadConfig(); + rotateKeyOn429(live, "openai-apikey", null, Date.now(), "sk-platform"); + const restored = loadConfig(); + restored.providers["openai-apikey"]!.apiKey = "sk-platform"; + saveConfig(restored); + + const seen: Array = []; + globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url !== "https://api.openai.com/v1/responses/compact") throw new Error(`unexpected upstream URL: ${url}`); + seen.push(new Headers(init?.headers).get("authorization")); + return new Response(JSON.stringify({ output: [] }), { headers: { "content-type": "application/json" } }); + }; + + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses/compact", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "openai-apikey/gpt-5.6-sol", input: [] }), + }); + expect(response.status).toBe(200); + expect(seen).toEqual(["Bearer sk-warm"]); + } finally { + globalThis.fetch = originalFetch; + await server.stop(true); + clearKeyCooldowns(); + removeTreeWithRetry(home); + } + }); + test("maps every Pro id to base, strips reasoning, buffers failures, caps bodies, and logs exactly once", async () => { const originalFetch = globalThis.fetch; const home = mkdtempSync(join(tmpdir(), "ocx-openai-api-compact-")); From f2034368dbb1480d6a6cd39498478d7b23ffd723 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:19:26 +0900 Subject: [PATCH 101/231] fix(zcode): attach over the proxy Responses route instead of Chat Completions ZCode dispatches kind "openai" to {baseURL}/responses, which is the only surface the proxy speaks natively; the Chat inbound translated each turn into a Responses body and back. Closes #4295. --- .../010_wp2_zcode_export_responses.md | 9 +++-- src/clients/config-export/zcode.ts | 35 ++++++++++++------- tests/clients/integrations-writer.test.ts | 23 ++++++++++++ tests/config/client-config-export.test.ts | 2 +- tests/providers/zcode-client.test.ts | 13 ++++++- 5 files changed, 65 insertions(+), 17 deletions(-) diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md b/devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md index 2ebc0211cc..ba57892c69 100644 --- a/devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md +++ b/devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md @@ -67,11 +67,14 @@ desired 지문만 달라져 상태가 stale 이 된다(state.ts:411). JSON 클 프래그먼트를 다시 쓴다. 즉 미수정 설치는 자동으로 따라온다. --overwrite-conflict 는 사용자가 kind 나 options 를 직접 고쳐 이미 foreign-edit 인 경우에만 필요하다. -tests/clients/integrations-writer.test.ts:534 의 conflict 케이스는 사용자가 baseURL 을 편집한 상황이지 +tests/clients/integrations-writer.test.ts:525 의 conflict 케이스는 사용자가 baseURL 을 편집한 상황이지 ocx 가 kind 를 바꾸는 상황이 아니다. -회귀 테스트를 추가한다: 생성 계약의 kind 가 바뀌었을 때 미수정 기록이 conflict 가 아니라 stale 로 -분류되고 refresh 가 프래그먼트를 다시 쓴다는 것을 tests/clients/integrations-writer.test.ts 에 고정한다. +회귀 테스트는 구성 가능한 쪽으로 넣는다. 이전 빌드가 쓴 기록(옛 지문)을 이 하네스에서 만들 수 없어 +"옛 기록 + 새 계약 -> stale" 은 직접 재현할 수 없다. 그 경로는 코드로만 확인된다 +(state.ts:213 recordedBlockIsOwned, state.ts:405-411 stale 분류, writer.ts:390-391 재작성). +대신 보완 관계인 보호 쪽을 고정한다: 사용자가 kind 를 손으로 되돌리면 여전히 conflict / foreign-edit 이고 +apply 가 거부된다. baseURL 편집에만 있던 보호를 kind 에도 명시적으로 건다. ## 검증 diff --git a/src/clients/config-export/zcode.ts b/src/clients/config-export/zcode.ts index d8ec37ff1a..edd200beba 100644 --- a/src/clients/config-export/zcode.ts +++ b/src/clients/config-export/zcode.ts @@ -6,9 +6,18 @@ import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants" /** - * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated - * live against ZCode 3.7.7 / 3.8.1). `kind: "openai-compatible"` selects the - * OpenAI Chat Completions protocol, which the proxy serves at `/v1/chat/completions`. + * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated live + * against ZCode 3.7.7 / 3.8.1 and re-extracted from 3.11.2's bundle). + * `kind: "openai"` selects the OpenAI Responses protocol, which the proxy serves at + * `/v1/responses`. ZCode's own dispatch is the authority: its + * `getDefaultModelProviderEndpointPathForKind` maps `anthropic` to `/v1/messages`, + * `openai` to `/responses`, and `openai-compatible` to `/chat/completions`. + * + * Responses is the only surface the proxy speaks natively. The Chat and Anthropic + * inbounds translate their body into a Responses shape and replay it through + * `handleResponses`, then translate the stream back, so the previous + * `openai-compatible` wiring paid two translations per turn and reshaped tool-call + * deltas and reasoning blocks on the way through. * `apiKeyRequired` keeps ZCode's UI from prompting for a key it does not need on * loopback; the serialized key is always the non-secret loopback placeholder. */ @@ -30,7 +39,7 @@ export interface ZcodeModelEntry { export interface ZcodeProviderBlock { name: "OpenCodex"; - kind: "openai-compatible"; + kind: "openai"; enabled: true; source: "custom"; options: { @@ -46,11 +55,12 @@ export interface ZcodeGeneratedConfig { } /** - * ZCode dials the OpenAI Chat Completions surface (`openai-compatible`), which - * appends `/chat/completions` to `baseURL`. We supply `baseURL` with the `/v1` - * suffix so requests land on `/v1/chat/completions`. Model ids are the proxy's canonical - * `provider/id` selectors, which `/v1/chat/completions` resolves directly. Context - * limits follow the authoritative-window rule: a model without one ships + * ZCode dials the OpenAI Responses surface (`openai`), which appends `/responses` to + * `baseURL`. We supply `baseURL` with the `/v1` suffix so requests land on + * `/v1/responses`; ZCode's `normalizeModelProviderBaseUrlForKind` strips only the + * `/responses` suffix for this kind, so the `/v1` root survives. Model ids are the + * proxy's canonical `provider/id` selectors, which `/v1/responses` resolves directly. + * Context limits follow the authoritative-window rule: a model without one ships * without `limit` rather than guessing. Modalities are ZCode's observed * `text`-floor vocabulary; image-capable rows advertise image input. */ @@ -73,8 +83,9 @@ export function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig entry.limit = { context }; } // `none` is a Codex omit-sentinel, not a ZCode picker option. Keep catalog - // `ultra` when present: ZCode forwards the selected variant as - // `reasoning_effort`. Set `defaultVariant` only when it survives that filter. + // `ultra` when present: ZCode forwards the selected variant to the wire field its + // kind uses — `reasoning.effort` on `openai`, which is what `/v1/responses` reads + // natively. Set `defaultVariant` only when it survives that filter. const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts) ?.filter(effort => effort !== "none"); if (efforts && efforts.length > 0) { @@ -91,7 +102,7 @@ export function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig provider: { [OPENCODE_PROVIDER_ID]: { name: "OpenCodex", - kind: "openai-compatible", + kind: "openai", enabled: true, source: "custom", options: { diff --git a/tests/clients/integrations-writer.test.ts b/tests/clients/integrations-writer.test.ts index c80c28481d..38c2498c32 100644 --- a/tests/clients/integrations-writer.test.ts +++ b/tests/clients/integrations-writer.test.ts @@ -549,6 +549,29 @@ describe("apply", () => { if (!result.ok) expect(result.reason).toBe("conflict"); }); + test("a hand-edited ZCode provider kind stays a hard conflict (#4295)", () => { + // The export moved from `openai-compatible` to `openai` so ZCode dials the proxy's + // native Responses route. `kind` is not a refreshable path, so a user who sets it + // back by hand must keep owning that decision instead of having it silently + // rewritten — the same protection `options` already has above. + const configPath = installZcode(); + const request = input({ clientId: "zcode" }); + expect(applyIntegration(request).ok).toBe(true); + + const document = JSON.parse(readFileSync(configPath, "utf8")) as { + provider: Record; + }; + expect(document.provider.opencodex!.kind).toBe("openai"); + document.provider.opencodex!.kind = "openai-compatible"; + writeFileSync(configPath, `${JSON.stringify(document, null, 2)}\n`); + + const status = readIntegrationState(request); + expect(status).toMatchObject({ state: "conflict", reason: "foreign-edit" }); + const result = applyIntegration(request); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("conflict"); + }); + test("a malformed recorded ZCode policy cannot widen refreshable drift (#2389)", () => { const configPath = installZcode(); const request = input({ clientId: "zcode" }); diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 5524b1c338..0dc53c31cd 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -120,7 +120,7 @@ describe("split config-export public facade", () => { ["omp", ["providers", "opencodex"], '{"providers":{"opencodex":{"baseUrl":"http://127.0.0.1:10100/v1","api":"openai-completions","apiKey":"opencodex-loopback","models":[{"id":"test/known","name":"known (test)","input":["text","image"],"contextWindow":8192,"maxTokens":8192,"reasoning":true,"thinking":{"mode":"effort","efforts":["high"]}}]}}}'], ["dsh", ["llm-pi-ai", "providers", "opencodex"], '{"llm-pi-ai":{"providers":{"opencodex":{"displayName":"OpenCodex","api":"openai-responses","baseURL":"http://127.0.0.1:10100/v1","headers":{"Authorization":"Bearer ocx_data_dsh"},"models":[{"id":"test/known","name":"known (test)","input":["text","image"],"contextWindow":8192,"reasoningEfforts":{"high":"high"}}]}}}}'], ["mcode", ["custom_provider", "opencodex"], '{"custom_provider":{"opencodex":{"name":"OpenCodex","kind":"custom","enabled":true,"api":"anthropic-messages","options":{"apiKey":"opencodex-loopback","baseURL":"http://127.0.0.1:10100","authMode":"api-key"},"models":{"test/known":{"limit":{"context":8192},"thinking":{"effortOptions":["high"]}}}}}}'], - ["zcode", ["provider", "opencodex"], '{"provider":{"opencodex":{"name":"OpenCodex","kind":"openai-compatible","enabled":true,"source":"custom","options":{"apiKey":"opencodex-loopback","baseURL":"http://127.0.0.1:10100/v1","apiKeyRequired":true},"models":{"test/known":{"name":"known (test)","modalities":{"input":["text","image"],"output":["text"]},"limit":{"context":8192},"reasoning":{"enabled":true,"variants":["high"]}}}}}}'], + ["zcode", ["provider", "opencodex"], '{"provider":{"opencodex":{"name":"OpenCodex","kind":"openai","enabled":true,"source":"custom","options":{"apiKey":"opencodex-loopback","baseURL":"http://127.0.0.1:10100/v1","apiKeyRequired":true},"models":{"test/known":{"name":"known (test)","modalities":{"input":["text","image"],"output":["text"]},"limit":{"context":8192},"reasoning":{"enabled":true,"variants":["high"]}}}}}}'], ] as const; for (const [id, path, expectedBytes] of cases) { const built = buildClientConfigText(id, context); diff --git a/tests/providers/zcode-client.test.ts b/tests/providers/zcode-client.test.ts index 92ff210627..3d4047f791 100644 --- a/tests/providers/zcode-client.test.ts +++ b/tests/providers/zcode-client.test.ts @@ -43,7 +43,7 @@ describe("ZCode client config", () => { expect(Object.keys(document)).toEqual(["provider"]); const provider = document.provider[OPENCODE_PROVIDER_ID]!; expect(provider.name).toBe("OpenCodex"); - expect(provider.kind).toBe("openai-compatible"); + expect(provider.kind).toBe("openai"); expect(provider.enabled).toBe(true); expect(provider.source).toBe("custom"); expect(provider.options).toEqual({ @@ -53,6 +53,17 @@ describe("ZCode client config", () => { }); }); + test("the exported kind resolves to the proxy's native Responses route", () => { + const document = buildClientConfig("zcode", context()) as ZcodeGeneratedConfig; + const provider = document.provider[OPENCODE_PROVIDER_ID]!; + // ZCode 3.11.2 getDefaultModelProviderEndpointPathForKind sends `openai` to + // `/responses` and normalizeModelProviderBaseUrlForKind strips only that suffix, + // so the `/v1` root we serialize survives and the turn lands on the route + // src/server/index.ts registers as POST /v1/responses. + expect(provider.kind).toBe("openai"); + expect(`${provider.options.baseURL}/responses`).toBe("http://127.0.0.1:10100/v1/responses"); + }); + test("models carry authoritative limits, text-floor modalities, and drop audio-only rows", () => { const document = buildClientConfig("zcode", context()) as ZcodeGeneratedConfig; const models = document.provider[OPENCODE_PROVIDER_ID]!.models; From ce98d2ba763d6e5ce4e7cbf9d1805683f9d86bc5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:24:34 +0900 Subject: [PATCH 102/231] docs(devlog): plan the unified pool route and the enabled defect --- .../050_phase5_surface_consolidation.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md index 93193f90e4..2f5cf59e65 100644 --- a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -242,3 +242,61 @@ fix has to change that assertion deliberately. The guard is an alias-safety net change in part 3 and only a tripwire for part 4 — it tells wp5c that it is changing a published answer, which is exactly what a golden should do, but it does not prove the new answer correct. +## wp5c plan — the unified route and the enabled reporting defect + +Part 3 and part 4 of the unit the wp5 audit resized. Parts 1 and 2 shipped: the exact-body +goldens for the three legacy responses, and one validator for strategy and sticky. + +### The route + +NEW `GET | PUT | PATCH /api/pool/settings?provider=` in +`src/server/management/oauth-account-routes.ts`, serving all three kinds through +`poolSettingsCapability`. The three legacy paths keep working unchanged — the goldens from +part 1 are what proves that, and they were written before any of this precisely so they could. + +**Four registration surfaces, each of which fails CI on its own.** This is the part that went +red on #4289 and is worth stating as a list rather than a sentence: + +1. `src/server/management/route-registry.ts` — `tests/server/management-route-registry.test.ts` + compares source and registry as exact pairs. +2. `src/cli/capabilities.ts` — `tests/cli/cli-capabilities.test.ts` fails on any registry route + that is neither declared, `exempt`, nor in the dated ratchet. The ratchet is NOT an option: + a sibling test asserts it only ever shrinks. +3. `skills/ocx/references/01_management_surface.md` — generated; `bun run skill:surface` must + run and the result must be committed, or `tests/ci-workflows/skill-ocx.test.ts` fails. +4. `docs-site` — `reference/management-api.md`:332 still claims the pool route 400s for + non-Anthropic providers, which stopped being true when the generic contract shipped. Stale + before this unit and fixed by it. + +Declaring the route in `capabilities.ts` rather than exempting it is the honest option only if +the CLI actually uses it, so `src/cli/account-extended.ts` switches its transport table to the +single path. That table exists today only because the two contracts disagreed. + +`PATCH` is included because both legacy writes accept it; a unified route that dropped it would +be a narrower contract wearing a wider name. + +### The enabled reporting defect + +`isProactivePreferenceEnabled` reads the per-provider `enabled` when it is a boolean and falls +back to the global `config.oauthAccountFailover.enabled`. The generic DTO reports only the +per-provider value, so `enabled: null` means "nothing stored here" while the effective answer +may be `true` from the global — a dashboard cannot tell a disabled pool from an inherited one. + +The fix ADDS `enabledEffective: boolean` rather than changing `enabled`. `enabled` is published +as "the stored provider override, `null` means unspecified, not inherited effective state" in +`docs-site/reference/cli/providers-accounts.md` and the CLI surfaces it as `poolEnabled`; +redefining it would break a documented field to fix a missing one. The generic GET golden at +`tests/server/account-pool-management-api.test.ts`:483 pins `enabled: null` and must be +extended deliberately — that is the tripwire firing exactly as intended, not a test to silence. + +### Acceptance + +- `GET /api/pool/settings?provider=` answers for Codex, Anthropic and a generic provider, each + declaring which fields its kind supports. +- The three legacy paths still return byte-identical bodies, proven by the part-1 goldens, which + are not edited. +- `enabledEffective` is true for a provider with no stored override under a global `true`, and + false under a global `false` or absence. +- Registry, capabilities, regenerated surface map and docs all move in the same commit. +- Red control: each new assertion must fail with its production branch removed. + From ad09340d79bae6db0a949f9a382c7574ecfc32e2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:25:52 +0900 Subject: [PATCH 103/231] fix(opencode): advertise per-model image capabilities in the exported config (#4300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenCode export emitted only `name` and `limit` per model, so opencode computed `capabilities.attachment` and `capabilities.input.image` as false for every opencodex model: the provider is absent from models.dev, and opencode's loader falls back to a hardcoded false for an entry that says nothing. Attachments were then refused client-side in the TUI before any request reached the proxy — including native OpenAI slugs that /api/models reports as ["text","image"], and text-only models the vision sidecar covers. Carry the catalog row's `inputModalities` through `OpencodeCatalogModel` and `opencodeCatalogFromProxyRows`, then serialize them as opencode's own per-model fields (`attachment`, `modalities`) as declared by opencode's published model schema. Both provider generations get them, so the two spellings of one model list cannot disagree; the V2 model schema expresses capabilities as `capabilities.{tools,input,output}` (which opencode fills by migrating this same `modalities` field) and its loader decodes with `onExcessProperty: "ignore"`. A row that declares nothing keeps the previous entry shape, which opencode already treats as text-only. Values outside opencode's enum (text|audio|image|video|pdf) are dropped rather than written through, the way `audio` had to be for Pi and Gajae; a row left with nothing acceptable keeps its entry without capability keys instead of being retyped as text. `exportModelsFromProxyRows` no longer re-joins modalities by `namespaced` — the catalog entry carries them now, from the same visibility-filtered row as the model itself, so a disabled duplicate cannot donate them. Closes #4286 Co-authored-by: Valerio Coltre --- docs-site/src/content/docs/guides/opencode.md | 28 ++++++ src/cli/export-command.ts | 30 ++---- src/cli/opencode.ts | 5 + src/clients/config-export.ts | 31 +++++- src/clients/config-export/contracts.ts | 7 ++ src/clients/config-export/model-metadata.ts | 33 +++++++ .../client-export-modality-enum.test.ts | 97 +++++++++++++++++++ tests/config/client-config-export.test.ts | 29 ++++++ tests/providers/opencode-cli.test.ts | 32 ++++++ .../management-client-config-route.test.ts | 10 +- 10 files changed, 275 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md index 52c95445bf..2c6d7c7008 100644 --- a/docs-site/src/content/docs/guides/opencode.md +++ b/docs-site/src/content/docs/guides/opencode.md @@ -51,6 +51,34 @@ No model-level default effort is written. The proxy keeps applying its own confi default whenever a request carries no effort, so a default you change in opencodex stays in force instead of being frozen into the config. +## Images and attachments + +opencode decides whether a model takes an image from the model entry itself, and it cannot ask +models.dev about `opencodex` — this provider is not there. The generated blocks therefore +carry opencode's own per-model capability fields, `attachment` and `modalities`, taken from +the metadata the proxy reports at `GET /api/models`: + +```json +"gpt-5.6-luna": { + "name": "gpt-5.6-luna (native)", + "limit": { "context": 272000, "output": 32000 }, + "attachment": true, + "modalities": { "input": ["text", "image"], "output": ["text"] } +} +``` + +Without those fields opencode assumes the model is text-only and refuses the paste on the +client side, so the image never reaches the proxy. That applies to text-only models too: when +the catalog reports image input for a model the vision sidecar covers, opencode lets the +attachment through so the sidecar can describe it before the upstream call. + +What is written comes from the row's declared input modalities in `GET /api/models`. For a +discovered model the catalog adds `image` itself for the sidecar case; a custom row is written +from the modalities stored on it, so a custom entry that declares text only stays text-only +even when the sidecar would cover it. A row that declares none — an undeclared custom model, +for example — keeps the plain entry (`name`, plus `limit` when its context window is known), +and opencode treats it as text-only. + ## Your own config is never modified The launcher does not copy or rewrite `~/.config/opencode/opencode.json`, diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 123068f943..21d62f59c7 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -60,16 +60,6 @@ export interface ExportCommandDeps extends RuntimeApiDeps { configImpl?: () => OcxConfig; } -/** - * `/api/models` row plus the modality list Pi consumes. The launcher's row type predates - * the Pi exporter and stops at the fields OpenCode needs. - */ -type ExportProxyModelRow = OpencodeProxyModelRow & { - inputModalities?: string[]; - reasoningEfforts?: string[]; - defaultReasoningEffort?: string; -}; - /** * Export rows from proxy `/api/models` rows. * @@ -80,20 +70,13 @@ type ExportProxyModelRow = OpencodeProxyModelRow & { * row as the model itself: a second lookup over the raw rows would let a hidden or disabled * duplicate donate its ladder to the visible entry. * - * Only modalities are re-joined by `namespaced`, because the catalog type does not carry them. + * Modalities need no such lookup: `opencodeCatalogFromProxyRows` carries them on the catalog + * entry, so the clients that filter them are handed the same filtered, deduped row. */ export function exportModelsFromProxyRows( - rows: readonly ExportProxyModelRow[], + rows: readonly OpencodeProxyModelRow[], config: OcxConfig, ): ExportModel[] { - const modalities = new Map(); - for (const row of rows) { - const namespaced = row.namespaced?.trim(); - if (!namespaced || modalities.has(namespaced)) continue; - if (Array.isArray(row.inputModalities) && row.inputModalities.length > 0) { - modalities.set(namespaced, [...row.inputModalities]); - } - } return opencodeCatalogFromProxyRows(rows, config).map(entry => { const model: ExportModel = { namespaced: entry.namespaced, @@ -108,8 +91,9 @@ export function exportModelsFromProxyRows( model.reasoningEfforts = [...entry.reasoningEfforts]; } if (entry.defaultReasoningEffort) model.defaultReasoningEffort = entry.defaultReasoningEffort; - const input = modalities.get(entry.namespaced); - if (input) model.inputModalities = [...input]; + if (entry.inputModalities && entry.inputModalities.length > 0) { + model.inputModalities = [...entry.inputModalities]; + } return model; }); } @@ -188,7 +172,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep } built = { document: exported.config, text: exported.text }; } else { - const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); + const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); if (!Array.isArray(rows)) { throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows); } diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index adcdea1095..09ea024746 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -98,6 +98,8 @@ export interface OpencodeProxyModelRow { displayName?: string; displayNameSource?: "operator" | "provider" | "fallback"; contextWindow?: number; + /** Declared input modalities from `/api/models`; carried into opencode model capabilities. */ + inputModalities?: string[]; /** Declared effort ladder from `/api/models`; carried into opencode model variants. */ reasoningEfforts?: string[]; /** Declared default effort from `/api/models`. */ @@ -396,6 +398,9 @@ export function opencodeCatalogFromProxyRows( id: row.id, contextWindow: row.contextWindow, displayName: row.displayNameSource === "fallback" ? undefined : row.displayName, + ...(Array.isArray(row.inputModalities) && row.inputModalities.length > 0 + ? { inputModalities: [...row.inputModalities] } + : {}), ...(typeof row.fastRowAvailable === "boolean" ? { fastRowAvailable: row.fastRowAvailable } : {}), ...(Array.isArray(row.reasoningEfforts) && row.reasoningEfforts.length > 0 ? { reasoningEfforts: [...row.reasoningEfforts] } diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 8551f190a9..13d8f952e5 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -42,7 +42,7 @@ export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts"; import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants"; -import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata"; +import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, opencodeModelCapabilities, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata"; import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp"; import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh"; import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode"; @@ -54,6 +54,14 @@ import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } export interface OpencodeModelEntry { name: string; limit?: { context: number; output: number }; + /** + * opencode's own capability fields, derived from the catalog row's declared input + * modalities. Written only when the row declares at least one — an entry without them is + * what opencode already treats as text-only, and omitting them keeps an undeclared model + * byte-identical to what shipped before. + */ + attachment?: boolean; + modalities?: { input: string[]; output: string[] }; } /** @@ -659,13 +667,30 @@ export function opencodeProviderBlocks( if (context !== undefined) { entry.limit = { context, output: outputBudgetFor(context) }; } + // `attachment` / `modalities` are fields of opencode's V1 model schema — the shape its + // published config.json defines and the one its loader reads (verified against opencode + // 1.18.30, src/provider/provider.ts: `model.attachment ?? …` / `model.modalities?.input`). + // They ride on both generations anyway: the two blocks are two spellings of one model list, + // and the V2 model schema (capabilities.{tools,input,output}, which opencode fills by + // migrating this same `modalities` field) ignores keys it does not define — its loader + // decodes with `onExcessProperty: "ignore"`. Same values on both, so a merge cannot make + // the two entries disagree. + const capabilities = opencodeModelCapabilities(model.inputModalities); + if (capabilities) { + entry.attachment = capabilities.attachment; + entry.modalities = capabilities.modalities; + } v1Models[key] = entry; const variants = opencodeEffortVariants(model); - // Own `limit` object, not a shared reference: the two blocks are serialized and reasoned - // about separately, and an in-place edit of one must never move the other. + // Own `limit` and `modalities` objects, not shared references: the two blocks are + // serialized and reasoned about separately, and an in-place edit of one must never move + // the other. v2Models[key] = { ...entry, ...(entry.limit ? { limit: { ...entry.limit } } : {}), + ...(entry.modalities + ? { modalities: { input: [...entry.modalities.input], output: [...entry.modalities.output] } } + : {}), ...(variants ? { variants } : {}), }; } diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index dc33732fe2..aa3eab4320 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -38,6 +38,13 @@ export interface OpencodeCatalogModel { id?: string; contextWindow?: number; displayName?: string; + /** + * Declared input modalities, carried verbatim from `/api/models`. Serialized as opencode's + * per-model `attachment` + `modalities`, because opencode gates attachments CLIENT-side: + * without them every `opencodex` model is text-only in its picker and an image never + * reaches the proxy or the vision sidecar (#4286). + */ + inputModalities?: readonly string[]; /** Declared effort ladder. Exported as opencode model variants where the client reads them. */ reasoningEfforts?: readonly string[]; /** diff --git a/src/clients/config-export/model-metadata.ts b/src/clients/config-export/model-metadata.ts index 7b24390341..5c69af92ca 100644 --- a/src/clients/config-export/model-metadata.ts +++ b/src/clients/config-export/model-metadata.ts @@ -72,6 +72,39 @@ export function inputModalitiesForClient( return kept.length > 0 ? kept : null; } +/** + * Input modalities opencode's model schema accepts (opencode.ai/config.json, both + * `modalities.input` and `modalities.output`). Wider than our internal `text | image | audio` + * vocabulary, so unlike Pi and Gajae this filter can only drop a value no current ingress + * produces: `/api/custom-models`, `ocx models add` and the catalog writer all normalize to + * the internal three. It exists so a future ingress cannot do to opencode what `audio` did + * to Gajae, whose loader rejected the whole config file over one out-of-enum value. + */ +const OPENCODE_INPUT_MODALITIES: ReadonlySet = new Set(["text", "audio", "image", "video", "pdf"]); + +/** + * opencode's per-model capability fields for one catalog row, or `undefined` when the row + * declares nothing. + * + * `undefined` rather than `{ input: ["text"] }`: opencode already computes an entry without + * capabilities as text-only, and leaving the keys out keeps every model that declares + * nothing byte-identical to what shipped before. A declared list is carried across as-is, so + * an audio-only row keeps `attachment: true` instead of being rewritten to text it cannot + * read — the same call Pi's exporter makes, in the opposite direction. + */ +export function opencodeModelCapabilities( + modalities: readonly string[] | undefined, +): { attachment: boolean; modalities: { input: string[]; output: string[] } } | undefined { + const input: string[] = []; + for (const value of modalities ?? []) { + if (OPENCODE_INPUT_MODALITIES.has(value) && !input.includes(value)) input.push(value); + } + if (input.length === 0) return undefined; + // `attachment` is what opencode's client gates pasting on; `modalities` refines it into + // which kinds. Output is always text — nothing in the catalog declares otherwise. + return { attachment: input.some(value => value !== "text"), modalities: { input, output: ["text"] } }; +} + /** * Label shared by every client: `" ()"`. The * provider suffix is what makes two same-named models from different upstreams diff --git a/tests/clients/client-export-modality-enum.test.ts b/tests/clients/client-export-modality-enum.test.ts index 36473391c6..849b1e6518 100644 --- a/tests/clients/client-export-modality-enum.test.ts +++ b/tests/clients/client-export-modality-enum.test.ts @@ -6,6 +6,7 @@ import { type ExportModel, type GajaeGeneratedConfig, type HermesGeneratedConfig, + type OpencodeGeneratedConfig, type PiGeneratedConfig, } from "../../src/clients/config-export"; import type { OcxConfig } from "../../src/types"; @@ -52,6 +53,11 @@ function hermesModels(models: ExportModel[]) { .providers[OPENCODE_PROVIDER_ID].models; } +function opencodeModels(models: ExportModel[]) { + return (buildClientConfig("opencode", ctx(models)) as OpencodeGeneratedConfig) + .providers[OPENCODE_PROVIDER_ID].models; +} + /** The live failure, by its real id and real modality list. */ const MIXED: ExportModel = { namespaced: "zenmux/meta-muse-spark-1.1", @@ -147,3 +153,94 @@ describe("exported modalities stay inside the enum each client accepts", () => { } }); }); + +/** + * opencode is the third shape of this problem, and the only one where the fix is a + * capability field rather than a filter. + * + * Its model schema accepts a WIDER enum than our internal vocabulary + * (`text | audio | image | video | pdf`, opencode.ai/config.json), and its client gates + * pasting on `attachment` / `modalities.input` INSTEAD of rejecting the file we hand it. So + * an out-of-enum value is dropped, but a row left with nothing acceptable keeps its entry + * and carries no capability keys — never a fabricated `text`, which would advertise input + * the model cannot read. + */ +describe("opencode receives the capability fields its client gates attachments on", () => { + test("a declared model advertises attachment plus every modality opencode accepts", () => { + // The live catalog shape: meta-muse-spark-1.1 declares text|image|audio, and audio is + // INSIDE opencode's enum, so unlike Pi and Gajae nothing is dropped here. + expect(opencodeModels([MIXED])["zenmux/meta-muse-spark-1.1"]).toEqual({ + name: "meta-muse-spark-1.1 (zenmux)", + limit: { context: 1_048_576, output: 32_000 }, + attachment: true, + modalities: { input: ["text", "image", "audio"], output: ["text"] }, + }); + }); + + test("an audio-only row stays audio-only instead of being retyped as text", () => { + // opencode accepts audio, so the Pi/Gajae answer — omit the row — would lose a model for + // no reason. Faithfulness costs nothing here. + expect(opencodeModels([AUDIO_ONLY])["p/audio-only"]).toEqual({ + name: "audio-only (p)", + attachment: true, + modalities: { input: ["audio"], output: ["text"] }, + }); + }); + + test("a text-only declaration is advertised as text-only rather than omitted", () => { + const textOnly: ExportModel = { namespaced: "p/text", provider: "p", id: "text", inputModalities: ["text"] }; + expect(opencodeModels([textOnly])["p/text"]).toEqual({ + name: "text (p)", + attachment: false, + modalities: { input: ["text"], output: ["text"] }, + }); + }); + + test("a row that declares nothing carries no capability keys at all", () => { + // Not the same as `{ input: ["text"] }`: opencode already falls back to text-only for an + // entry without capabilities, and the omission keeps the pre-#4286 bytes for every model + // whose row says nothing. + const bare: ExportModel = { namespaced: "p/bare", provider: "p", id: "bare" }; + const empty: ExportModel = { ...bare, namespaced: "p/empty", id: "empty", inputModalities: [] }; + const models = opencodeModels([bare, empty]); + expect(models["p/bare"]).toEqual({ name: "bare (p)" }); + expect(models["p/empty"]).toEqual({ name: "empty (p)" }); + }); + + test("an out-of-enum value is dropped and duplicates collapse", () => { + const odd: ExportModel = { + namespaced: "p/odd", provider: "p", id: "odd", inputModalities: ["file", "image", "image"], + }; + expect(opencodeModels([odd])["p/odd"]).toEqual({ + name: "odd (p)", + attachment: true, + modalities: { input: ["image"], output: ["text"] }, + }); + }); + + test("a model whose only declaration is out of enum keeps its entry, without capabilities", () => { + const foreign: ExportModel = { namespaced: "p/foreign", provider: "p", id: "foreign", inputModalities: ["file"] }; + expect(opencodeModels([foreign])["p/foreign"]).toEqual({ name: "foreign (p)" }); + }); + + test("no emitted entry in a whole catalog carries a value opencode rejects", () => { + const catalog: ExportModel[] = [ + MIXED, + AUDIO_ONLY, + { namespaced: "p/bare", provider: "p", id: "bare" }, + { namespaced: "p/foreign", provider: "p", id: "foreign", inputModalities: ["file"] }, + { namespaced: "p/vision", provider: "p", id: "vision", inputModalities: ["text", "image"] }, + ]; + const models = opencodeModels(catalog); + // The entry survives where Pi and Gajae would have dropped it; only its bad value goes. + expect(Object.keys(models)).toContain("p/foreign"); + for (const entry of Object.values(models)) { + for (const value of entry.modalities?.input ?? []) { + expect(["text", "audio", "image", "video", "pdf"]).toContain(value); + } + for (const value of entry.modalities?.output ?? []) { + expect(["text", "audio", "image", "video", "pdf"]).toContain(value); + } + } + }); +}); diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 5524b1c338..644fb39f6e 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -752,11 +752,16 @@ describe("hub-resolved Fast exports", () => { expect(block.models["z/sparse--fast"]).toEqual({ name: "z/sparse Fast (routed)" }); } const expanded = opencodeProviderBlocks(BASE_URL, [eligible], cfg({ fastRows: false })); + // A Fast row is a second selector for the same model, so it inherits the capabilities the + // base row declared. Without them opencode would gate images on exactly the row a user who + // turned Fast on selects (#4286). expect(expanded.v1.models["remote/model--fast"]).toEqual({ name: "Remote Model Fast (remote)", limit: { context: 8192, output: 8192 }, + attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, }); expect(expanded.v2.models["remote/model--fast"]).toEqual({ name: "Remote Model Fast (remote)", limit: { context: 8192, output: 8192 }, + attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, variants: [ { id: "high", settings: { reasoningEffort: "high" } }, { id: "ultra", settings: { reasoningEffort: "ultra" } }, @@ -769,6 +774,12 @@ describe("hub-resolved Fast exports", () => { expect(remote.v2.settings).toEqual(remote.v1.options); }); + test("each generation owns its modalities map, so an edit to one cannot move the other", () => { + const blocks = opencodeProviderBlocks(BASE_URL, [eligible], cfg({ fastRows: false })); + blocks.v1.models["remote/model"]!.modalities!.input.push("audio"); + expect(blocks.v2.models["remote/model"]!.modalities!.input).toEqual(["text", "image"]); + }); + test("both CLI projections retain hub true/false/absence despite conflicting local settings", () => { for (const localFast of [false, true]) { for (const hubFast of [undefined, false, true]) { @@ -805,6 +816,24 @@ describe("hub-resolved Fast exports", () => { expect(Object.keys(blocks.v1.models)).toEqual(["remote/model"]); expect(Object.keys(blocks.v2.models)).toEqual(["remote/model"]); }); + + test("a disabled duplicate cannot donate its modalities to the visible row", () => { + // `exportModelsFromProxyRows` used to re-join modalities from the RAW `/api/models` rows, + // keyed by `namespaced` with the first row winning — so a hidden or disabled duplicate + // could hand its modality list to the visible entry, the same donation the availability and + // ladder rules already refuse. The catalog entry carries them now, so the row that is + // exported is the row that declares. + const shadowed = { ...eligible, fastRowAvailable: false, inputModalities: ["text"] }; + const rows = [ + { ...eligible, fastRowAvailable: false, disabled: true, inputModalities: ["text", "image", "audio"] }, + shadowed, + ]; + const config = cfg({ fastRows: false }); + expect(exportModelsFromProxyRows(rows, config)).toEqual([shadowed]); + const blocks = opencodeProviderBlocks(BASE_URL, opencodeCatalogFromProxyRows(rows, config), config); + expect(blocks.v1.models["remote/model"]!.modalities).toEqual({ input: ["text"], output: ["text"] }); + expect(blocks.v2.models["remote/model"]!.modalities).toEqual({ input: ["text"], output: ["text"] }); + }); }); describe("EXPORT_CLIENTS registry", () => { diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index 4483e5ea99..1edfe9284f 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -421,6 +421,38 @@ describe("ocx opencode proxy model catalog", () => { expect(Object.keys(blocks.v1.models)).not.toContain("opencode-go/hidden"); }); + test("carries /api/models modalities into the blocks the launcher injects", () => { + // Same failure mode as the ladder above, one field over: the management API reports + // image input for these rows and opencode gates attachments client-side, so dropping the + // field here leaves the image blocked before any request reaches the proxy (#4286). + const rows = [ + { namespaced: "gpt-5.6-luna", native: true, provider: "openai", id: "gpt-5.6-luna", inputModalities: ["text", "image"] }, + { namespaced: "opencode-go/glm-5.3", provider: "opencode-go", id: "glm-5.3", inputModalities: ["text", "image"] }, + { namespaced: "opencode-go/text-only", provider: "opencode-go", id: "text-only", inputModalities: ["text"] }, + { namespaced: "opencode-go/undeclared", provider: "opencode-go", id: "undeclared" }, + { namespaced: "opencode-go/hidden", provider: "opencode-go", id: "hidden", disabled: true, inputModalities: ["text", "image"] }, + ]; + const catalog = opencodeCatalogFromProxyRows(rows, cfg()); + const blocks = buildOpencodeProviderBlocksFromCatalog(10100, catalog, undefined, cfg()); + + for (const block of [blocks.v1, blocks.v2]) { + expect(block.models["gpt-5.6-luna"]).toMatchObject({ + attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, + }); + expect(block.models["opencode-go/glm-5.3"]).toMatchObject({ + attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, + }); + expect(block.models["opencode-go/text-only"]).toMatchObject({ + attachment: false, modalities: { input: ["text"], output: ["text"] }, + }); + // A row that declares nothing keeps the exact entry shape opencode already reads as + // text-only — the pre-#4286 bytes, not a synthesized capability list. + expect(block.models["opencode-go/undeclared"]).not.toHaveProperty("attachment"); + expect(block.models["opencode-go/undeclared"]).not.toHaveProperty("modalities"); + expect(Object.keys(block.models)).not.toContain("opencode-go/hidden"); + } + }); + test("the launcher's V1 and V2 blocks share one connection", () => { const blocks = buildOpencodeProviderBlocksFromCatalog( 10100, diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index 9fcb92e81d..45514c9754 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -286,7 +286,15 @@ describe("GET /api/client-config", () => { const document = body.config as OpencodeGeneratedConfig; expect(document.$schema).toBe(OPENCODE_CONFIG_SCHEMA); const models = document.provider[OPENCODE_PROVIDER_ID].models; - expect(models["a/m1"]).toEqual({ name: "m1 (a)", limit: { context: 128_000, output: 32_000 } }); + // The row's declared modalities now reach opencode's own capability fields; without them + // opencode gates attachments client-side and the image never leaves the TUI (#4286). + expect(models["a/m1"]).toEqual({ + name: "m1 (a)", limit: { context: 128_000, output: 32_000 }, + attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, + }); + // m2 declares text-only in `modelInputModalities`, which is exactly what routes it through + // the vision sidecar: the catalog advertises image so the attachment can reach the proxy. + expect(models["a/m2"]!.modalities).toEqual({ input: ["text", "image"], output: ["text"] }); expect(models["b/no-context"]).toEqual({ name: "no-context (b)" }); }, 15_000); From 2c9e545270fdd8e38d64ff43032fe10672f86776 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:30:27 +0900 Subject: [PATCH 104/231] docs(devlog): fold the wp5c audit; the new field stays off the legacy DTO --- .../050_phase5_surface_consolidation.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md index 2f5cf59e65..2067e70a53 100644 --- a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -300,3 +300,42 @@ extended deliberately — that is the tripwire firing exactly as intended, not a - Registry, capabilities, regenerated surface map and docs all move in the same commit. - Red control: each new assertion must fail with its production branch removed. +### wp5c plan audit — PASS-WITH-FINDINGS, folded + +**Major 1 — the acceptance contradicted itself, and the resolution is the safer one.** +Adding `enabledEffective` to `genericPoolSettingsDto` would change the LEGACY +`GET /api/oauth/accounts/pool` too, so the part-1 golden at :483 would have to move — while the +same section promised the goldens stay unedited. Resolution: the new field appears ONLY on +`/api/pool/settings`. The legacy DTO is not touched, every part-1 golden stays byte-identical +and unedited, and the reporting defect is fixed on the surface that is meant to be canonical. +Choosing the other branch would have spent the tripwire on the first cycle that met it. + +**Major 2 — the CLI switch orphans a route's coverage.** Once `account strategy` and +`account sticky` stop driving `PUT /api/codex-auth/pool-strategy`, that route has no capability +declaring it and cannot enter the ratchet, which only shrinks. It gets a registry +`exempt: { reason: "compatibility-alias" }` naming the unified route as its replacement — an +honest description of what it becomes, rather than a capability entry claiming a CLI path that +no longer exists. `GET`/`PUT /api/oauth/accounts/pool` keep their declarations because +`cmdAutoSwitch` still uses them; the transport table this cycle collapses is strategy and sticky +only. + +**Major 3 — `PATCH /api/pool/settings` needs its own answer.** The CLI only PUTs, so the PATCH +verb is declared through the same capability entry as the PUT rather than left to a ratchet that +cannot take it. + +**Major 4 — do not reuse `isProactivePreferenceEnabled` for `enabledEffective`.** It is +unexported, and it additionally requires `hasFailoverAccountQuorum` — two or more eligible +accounts. Folding a roster condition into a settings field would make the DTO answer a different +question than the one it asks: the defect is stored-versus-global CONFIG, so the field resolves +exactly that and nothing else. Confirmed by the audit that no GUI or CLI consumer already +derives effective enablement: the CLI's `poolEnabled` is stored-only and the Anthropic GUI reads +`enabled === true`. + +**Minor 6 — two more locales.** `ko` and `ru` carry the same stale "400 for non-Anthropic" pool +row as the English `reference/management-api.md`. They move with it. + +**Confirmed by the audit, no action:** `poolSettingsCapability("openai") === "codex"` is the +right discriminator; the unified GET must NOT copy the mixed pin+failover+pool DTO that +`GET /api/codex-auth/active` returns; and CORS, the Vite `/api` proxy, OpenAPI and the +management-auth enumeration are not gates for a new path. + From f306e4f476471df4dd1880638da3f33fddbbfeaa Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:36:45 +0900 Subject: [PATCH 105/231] feat(management): one pool-settings contract for all three kinds GET|PUT|PATCH /api/pool/settings answers with the same keys for codex, anthropic and generic, and declares in supported which of them each kind honours, so an unsupported field is a stated null rather than an absence a caller has to guess about. It consolidates the CONTRACT, not the persistence: each kind still reads and writes its own storage. The CLI transport table collapses with it. That table existed only because the two contracts disagreed - different paths, different response keys, and a provider field mandatory on one body and forbidden on the other. All four registration surfaces move together: registry, capabilities, the regenerated surface map, and the dated ratchet, which shrinks by one now that account auto-switch is declared for the first time. --- .../ocx/references/01_management_surface.md | 39 +++++-- src/cli/account-extended.ts | 37 ++----- src/cli/capabilities.ts | 37 +++++-- src/oauth/pool-settings-capability.ts | 103 +++++++++++++++++- src/server/management/oauth-account-routes.ts | 85 +++++++++++++++ src/server/management/route-registry.ts | 5 +- tests/cli/cli-account-pool-verbs.test.ts | 26 +++-- tests/cli/cli-capabilities.test.ts | 1 - 8 files changed, 274 insertions(+), 59 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index c7ae57c14b..880988adc1 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -525,10 +525,9 @@ Show or set how an account pool picks the next account. | Method | Route | |---|---| -| GET | `/api/codex-auth/active` | -| PUT | `/api/codex-auth/pool-strategy` | -| GET | `/api/oauth/accounts/pool` | -| PUT | `/api/oauth/accounts/pool` | +| GET | `/api/pool/settings` | +| PUT | `/api/pool/settings` | +| PATCH | `/api/pool/settings` | | Flag | Value | Meaning | |---|---|---| @@ -539,26 +538,46 @@ JSON mode: `envelope`. - A bare invocation reads and never writes. - The APPLIED value is echoed, not the requested one, so a server-side normalization stays visible. - Values are not re-validated in the CLI: the server owns the strategy names and the 1-100 sticky bound. -- `anthropic` owns the full pool contract. Other OAuth providers reach the same endpoint with a generic subset (enabled/strategy/autoSwitchThreshold/sticky); those settings steer selection only while `pool.kernel` is on, which is what the `inert` field reports. `quotaWindow` is still refused for them. +- One route answers for every pool kind and declares which fields that kind honours in `supported`, so an unsupported field is a stated null rather than an absence. `anthropic` alone carries `quotaWindow`. Generic-provider settings steer selection only while `pool.kernel` is on. The legacy per-pool paths still work and are unchanged. ### `ocx account sticky` Show or set how many consecutive requests stay on one account. +| Method | Route | +|---|---| +| GET | `/api/pool/settings` | +| PUT | `/api/pool/settings` | +| PATCH | `/api/pool/settings` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the applied strategy and sticky limit as JSON. | + +JSON mode: `envelope`. + +- Only meaningful under the sticky-capable strategies; the pool strategy is the other half of this setting. + +### `ocx account auto-switch` + +Show or set the usage percentage at which a pool moves to another account. + | Method | Route | |---|---| | GET | `/api/codex-auth/active` | -| PUT | `/api/codex-auth/pool-strategy` | +| PUT | `/api/codex-auth/auto-switch` | | GET | `/api/oauth/accounts/pool` | | PUT | `/api/oauth/accounts/pool` | | Flag | Value | Meaning | |---|---|---| -| `--json` | boolean | Emit the applied strategy and sticky limit as JSON. | +| `--json` | boolean | Emit the stored threshold and whether it is applied. | JSON mode: `envelope`. -- Only meaningful under the sticky-capable strategies; the pool strategy is the other half of this setting. +- A bare invocation reads and never writes. +- `on` stores 80%, `off` stores 0%, and `threshold ` accepts 0-100. +- For a generic OAuth pool, `inert: true` means the threshold is stored but not applied, `inert: false` means the pool is applying it, and an absent `inert` is an unknown capability. ### `ocx storage cleanup` @@ -728,6 +747,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 39 -- of those, state-changing: 18 +- declared capabilities: 40 +- of those, state-changing: 19 - head-resolved invocations: 2 diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index a8484f8b73..18fca00fb7 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -847,21 +847,15 @@ export async function cmdPauseExhausted(args: string[], deps: AccountDeps): Prom } /** - * Two pools expose strategy and sticky, and they are NOT reached the same way: + * One transport, because there is now one contract. * - * | | Codex pool | Anthropic pool | - * |---|---|---| - * | read | `GET /api/codex-auth/active` | `GET /api/oauth/accounts/pool?provider=` | - * | write | `PUT /api/codex-auth/pool-strategy` | `PUT /api/oauth/accounts/pool` | - * | keys | `accountPoolStrategy`/`accountPoolStickyLimit` | `strategy`/`stickyLimit` | - * | body | bare field | field **plus** a mandatory `provider` | + * This used to be a table of the differences between the Codex and Anthropic pools -- different + * read path, different write path, different response keys, and a `provider` field mandatory on + * one body and forbidden on the other. That table existed only because the two contracts + * disagreed; `/api/pool/settings` answers with the same keys for every kind, so the table + * collapses to a single shape and the asymmetry it encoded is gone rather than relocated. * - * Omitting `provider` from the Anthropic write body earns a 400 - * (`oauth-account-routes.ts:344`), so the asymmetry has to be encoded somewhere. Encoding it - * here keeps ONE verb pair working on both pools. The alternative the plan left open -- a second - * `provider-strategy`/`provider-sticky` pair -- would double the surface an operator must learn - * to express one idea, and a CLI that can steer one pool and not the other is exactly the trap - * this unit exists to remove. + * The legacy paths still work and still have their own goldens. Nothing here reads them. */ interface PoolTransport { readPath: string; @@ -873,18 +867,10 @@ interface PoolTransport { writeBody: (field: "strategy" | "stickyLimit", value: unknown) => Record; } -const CODEX_POOL_TRANSPORT: PoolTransport = { - readPath: "/api/codex-auth/active", - writePath: "/api/codex-auth/pool-strategy", - strategyKey: "accountPoolStrategy", - stickyKey: "accountPoolStickyLimit", - writeBody: (field, value) => ({ [field]: value }), -}; - -function anthropicPoolTransport(provider: string): PoolTransport { +function unifiedPoolTransport(provider: string): PoolTransport { return { - readPath: `/api/oauth/accounts/pool?provider=${encodeURIComponent(provider)}`, - writePath: "/api/oauth/accounts/pool", + readPath: `/api/pool/settings?provider=${encodeURIComponent(provider)}`, + writePath: "/api/pool/settings", strategyKey: "strategy", stickyKey: "stickyLimit", writeBody: (field, value) => ({ provider, [field]: value }), @@ -900,8 +886,7 @@ function poolTransportFor( classified: { type: "codex" | "oauth" | "api-key" }, name: string, ): PoolTransport | string { - if (classified.type === "codex") return CODEX_POOL_TRANSPORT; - if (classified.type === "oauth") return anthropicPoolTransport(name); + if (classified.type === "codex" || classified.type === "oauth") return unifiedPoolTransport(name); return `pool settings apply to OAuth account pools, not the API-key provider "${name}"`; } diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 60dfc67380..82a12ae693 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -316,10 +316,9 @@ export const CAPABILITIES: readonly Capability[] = [ // Both pools, because both have the setting. The Codex pool reads its applied values // from the active payload; the Anthropic pool has its own GET. routes: [ - { method: "GET", path: "/api/codex-auth/active" }, - { method: "PUT", path: "/api/codex-auth/pool-strategy" }, - { method: "GET", path: "/api/oauth/accounts/pool" }, - { method: "PUT", path: "/api/oauth/accounts/pool" }, + { method: "GET", path: "/api/pool/settings" }, + { method: "PUT", path: "/api/pool/settings" }, + { method: "PATCH", path: "/api/pool/settings" }, ], flags: [{ name: "--json", value: "boolean", summary: "Emit the applied strategy and sticky limit as JSON." }], mutates: true, @@ -328,23 +327,45 @@ export const CAPABILITIES: readonly Capability[] = [ "A bare invocation reads and never writes.", "The APPLIED value is echoed, not the requested one, so a server-side normalization stays visible.", "Values are not re-validated in the CLI: the server owns the strategy names and the 1-100 sticky bound.", - "`anthropic` owns the full pool contract. Other OAuth providers reach the same endpoint with a generic subset (enabled/strategy/autoSwitchThreshold/sticky); those settings steer selection only while `pool.kernel` is on, which is what the `inert` field reports. `quotaWindow` is still refused for them.", + "One route answers for every pool kind and declares which fields that kind honours in `supported`, so an unsupported field is a stated null rather than an absence. `anthropic` alone carries `quotaWindow`. Generic-provider settings steer selection only while `pool.kernel` is on. The legacy per-pool paths still work and are unchanged.", ], }, { command: ["account", "sticky"], summary: "Show or set how many consecutive requests stay on one account.", + routes: [ + { method: "GET", path: "/api/pool/settings" }, + { method: "PUT", path: "/api/pool/settings" }, + { method: "PATCH", path: "/api/pool/settings" }, + ], + flags: [{ name: "--json", value: "boolean", summary: "Emit the applied strategy and sticky limit as JSON." }], + mutates: true, + json: "envelope", + details: ["Only meaningful under the sticky-capable strategies; the pool strategy is the other half of this setting."], + }, + { + command: ["account", "auto-switch"], + summary: "Show or set the usage percentage at which a pool moves to another account.", + // Declared here rather than riding on `account strategy`, which is what it did before the + // unified route existed. `auto-switch` genuinely drives these three: the Codex pool reads + // its applied threshold from the active payload and writes through its own route, and a + // generic OAuth pool reads and writes the per-provider pool settings. routes: [ { method: "GET", path: "/api/codex-auth/active" }, - { method: "PUT", path: "/api/codex-auth/pool-strategy" }, + { method: "PUT", path: "/api/codex-auth/auto-switch" }, { method: "GET", path: "/api/oauth/accounts/pool" }, { method: "PUT", path: "/api/oauth/accounts/pool" }, ], - flags: [{ name: "--json", value: "boolean", summary: "Emit the applied strategy and sticky limit as JSON." }], + flags: [{ name: "--json", value: "boolean", summary: "Emit the stored threshold and whether it is applied." }], mutates: true, json: "envelope", - details: ["Only meaningful under the sticky-capable strategies; the pool strategy is the other half of this setting."], + details: [ + "A bare invocation reads and never writes.", + "`on` stores 80%, `off` stores 0%, and `threshold ` accepts 0-100.", + "For a generic OAuth pool, `inert: true` means the threshold is stored but not applied, `inert: false` means the pool is applying it, and an absent `inert` is an unknown capability.", + ], }, + { command: ["logs"], summary: "Recent request log rows, filterable by provider, model, conversation, account, and status.", diff --git a/src/oauth/pool-settings-capability.ts b/src/oauth/pool-settings-capability.ts index 950a7abd97..946a92d357 100644 --- a/src/oauth/pool-settings-capability.ts +++ b/src/oauth/pool-settings-capability.ts @@ -1,6 +1,6 @@ import { isGenericFailoverProvider } from "./generic-account-failover"; import { parseAccountPoolStickyLimit, parseAccountPoolStrategy } from "./pool-kernel"; -import type { OcxProviderConfig } from "../types"; +import type { OcxConfig, OcxProviderConfig } from "../types"; /** * Which pool-settings contract a provider speaks (#695, slice 1). @@ -44,6 +44,43 @@ export function parseGenericStickyLimit(value: unknown): number | null { return parseAccountPoolStickyLimit(value); } +/** Fields the unified pool-settings contract can carry, per kind. */ +export const POOL_SETTINGS_FIELDS = [ + "enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow", +] as const; +export type PoolSettingsField = typeof POOL_SETTINGS_FIELDS[number]; + +/** + * One shape for all three pool kinds. + * + * `supported` is the reason this is a consolidation rather than a fourth contract: a field a + * kind does not honour is DECLARED unsupported instead of being omitted, so a dashboard can + * tell "this pool has no quotaWindow" from "this response forgot to send one". Every kind + * answers with the same keys. + */ +export interface PoolSettingsDto { + provider: string; + kind: PoolSettingsKind; + supported: PoolSettingsField[]; + /** The STORED override. null means nothing is stored here, not "off". */ + enabled: boolean | null; + /** + * What the runtime actually resolves for `enabled`, after the global default. + * + * The generic kind inherits `config.oauthAccountFailover.enabled` when it stores no override + * of its own, so `enabled: null` alone cannot distinguish a disabled pool from an inherited + * one. This resolves exactly that config question and nothing else -- deliberately NOT the + * roster quorum the dispatch predicate also applies, because a settings field that folded in + * "how many accounts are logged in" would be answering a different question than it asks. + */ + enabledEffective: boolean; + strategy: string | null; + stickyLimit: number | null; + autoSwitchThreshold: number | null; + quotaWindow: string | null; +} + + export interface GenericPoolSettingsDto { provider: string; kind: "generic"; @@ -82,3 +119,67 @@ export function genericPoolSettingsDto( inert: kernelEnabled !== true, }; } + +/** Which fields each kind actually honours. Declared, never silently omitted. */ +const SUPPORTED_BY_KIND: Record = { + codex: ["strategy", "stickyLimit", "autoSwitchThreshold"], + anthropic: ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow"], + generic: ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold"], +}; + +/** + * The one projection behind `/api/pool/settings`. + * + * Reads each kind's own storage -- this consolidates the CONTRACT, not the persistence -- and + * answers with identical keys plus a `supported` list, so an unsupported field is a declared + * `null` rather than an absence a caller has to guess about. + */ +export function unifiedPoolSettingsDto( + config: OcxConfig, + provider: string, + kind: PoolSettingsKind, +): PoolSettingsDto { + const base = { provider, kind, supported: SUPPORTED_BY_KIND[kind] }; + if (kind === "codex") { + return { + ...base, + // The Codex pool has no enablement switch: it is on whenever accounts exist, so the + // honest answer is "not a field here" rather than a fabricated true. + enabled: null, + enabledEffective: true, + strategy: parseGenericPoolStrategy(config.accountPoolStrategy) ?? "quota", + stickyLimit: parseGenericStickyLimit(config.accountPoolStickyLimit) ?? 1, + autoSwitchThreshold: parseGenericAutoSwitchThreshold(config.autoSwitchThreshold) ?? 80, + quotaWindow: null, + }; + } + if (kind === "anthropic") { + const pool = config.anthropicAccountPool ?? {}; + const enabled = typeof pool.enabled === "boolean" ? pool.enabled : null; + return { + ...base, + enabled, + enabledEffective: enabled === true, + strategy: parseGenericPoolStrategy(pool.strategy) ?? "quota", + stickyLimit: parseGenericStickyLimit(pool.stickyLimit) ?? 1, + autoSwitchThreshold: parseGenericAutoSwitchThreshold(pool.autoSwitchThreshold) ?? 80, + quotaWindow: typeof pool.quotaWindow === "string" ? pool.quotaWindow : "five-hour", + }; + } + const failover = config.providers?.[provider]?.oauthAccountFailover ?? {}; + const stored = typeof failover.enabled === "boolean" ? failover.enabled : null; + return { + ...base, + enabled: stored, + // The defect this field exists to close: a generic provider with no stored override + // inherits the global, so `enabled: null` alone cannot tell a disabled pool from an + // inherited one. Config only -- the roster quorum the dispatch predicate also applies is a + // different question and stays out of a settings field. + enabledEffective: stored ?? (config.oauthAccountFailover?.enabled === true), + strategy: parseGenericPoolStrategy(failover.strategy), + stickyLimit: parseGenericStickyLimit(failover.stickyLimit), + autoSwitchThreshold: parseGenericAutoSwitchThreshold(failover.autoSwitchThreshold), + quotaWindow: null, + }; +} + diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 4e9f0ffef4..2a15c3b855 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -350,6 +350,91 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ ok: true, provider, activeAccountId: body.accountId }); } + // The unified pool-settings contract (#695 wp5c). The three legacy paths keep working and + // keep their own shapes -- goldens pin them -- but this is the one an operator or a dashboard + // should read, because it answers with the same keys for every kind and DECLARES which of + // them that kind honours. + if (url.pathname === "/api/pool/settings" && (req.method === "GET" || req.method === "PUT" || req.method === "PATCH")) { + const { + poolSettingsCapability, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold, parseGenericStickyLimit, + unifiedPoolSettingsDto, + } = await import("../../oauth/pool-settings-capability"); + const rawBody = req.method === "GET" ? {} : await readManagementJsonBodyOr(req, {}); + if (req.method !== "GET" && !isPlainRecord(rawBody)) { + return jsonResponse({ error: "body must be an object" }, 400); + } + const fields = rawBody as { provider?: unknown; enabled?: unknown; strategy?: unknown; stickyLimit?: unknown; autoSwitchThreshold?: unknown; quotaWindow?: unknown }; + const provider = req.method === "GET" + ? (url.searchParams.get("provider") ?? "").trim().toLowerCase() + : (typeof fields.provider === "string" ? fields.provider.trim().toLowerCase() : ""); + const kind = provider ? poolSettingsCapability(provider, config.providers?.[provider]) : null; + if (!provider || !kind) { + return jsonResponse({ error: "pool settings are only available for the codex, anthropic and generic OAuth pools" }, 400); + } + // Validated by the SHARED parsers before any kind-specific write, so a bad strategy or + // sticky limit is refused identically whichever pool is addressed. + let strategy: string | undefined; + if (fields.strategy !== undefined) { + const parsed = parseGenericPoolStrategy(fields.strategy); + if (parsed === null) return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400); + strategy = parsed; + } + let stickyLimit: number | undefined; + if (fields.stickyLimit !== undefined) { + const parsed = parseGenericStickyLimit(fields.stickyLimit); + if (parsed === null) return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400); + stickyLimit = parsed; + } + let autoSwitchThreshold: number | undefined; + if (fields.autoSwitchThreshold !== undefined) { + const parsed = parseGenericAutoSwitchThreshold(fields.autoSwitchThreshold); + if (parsed === null) return jsonResponse({ error: "autoSwitchThreshold must be an integer 0-100" }, 400); + autoSwitchThreshold = parsed; + } + if (fields.quotaWindow !== undefined && kind !== "anthropic") { + return jsonResponse({ error: "quotaWindow is only part of the anthropic pool contract" }, 400); + } + let quotaWindow: string | undefined; + if (fields.quotaWindow !== undefined) { + const parsed = parseAccountPoolQuotaWindow(fields.quotaWindow); + if (parsed === null) return jsonResponse({ error: "quotaWindow must be one of: five-hour, weekly, max-utilization" }, 400); + quotaWindow = parsed; + } + if (fields.enabled !== undefined) { + if (kind === "codex") return jsonResponse({ error: "enabled is not part of the codex pool contract" }, 400); + if (typeof fields.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400); + } + + if (req.method !== "GET") { + if (kind === "codex") { + if (strategy !== undefined) config.accountPoolStrategy = strategy as never; + if (stickyLimit !== undefined) config.accountPoolStickyLimit = stickyLimit; + if (autoSwitchThreshold !== undefined) config.autoSwitchThreshold = autoSwitchThreshold; + } else if (kind === "anthropic") { + const pool = { ...(config.anthropicAccountPool ?? {}) }; + if (fields.enabled !== undefined) pool.enabled = fields.enabled as boolean; + if (strategy !== undefined) pool.strategy = strategy as never; + if (stickyLimit !== undefined) pool.stickyLimit = stickyLimit; + if (autoSwitchThreshold !== undefined) pool.autoSwitchThreshold = autoSwitchThreshold; + if (quotaWindow !== undefined) pool.quotaWindow = quotaWindow as never; + config.anthropicAccountPool = pool; + } else { + const prov = config.providers[provider]!; + const next = { ...(prov.oauthAccountFailover ?? {}) }; + if (fields.enabled !== undefined) next.enabled = fields.enabled as boolean; + if (strategy !== undefined) next.strategy = strategy as never; + if (stickyLimit !== undefined) next.stickyLimit = stickyLimit; + if (autoSwitchThreshold !== undefined) next.autoSwitchThreshold = autoSwitchThreshold; + if (Object.keys(next).length > 0) prov.oauthAccountFailover = next; + else delete prov.oauthAccountFailover; + } + saveConfigPreservingClaudeCode(config); + reconcileLiveStateStores(); + } + return jsonResponse(unifiedPoolSettingsDto(config, provider, kind)); + } + + // Opt-in Anthropic OAuth account pool (#294): enable/threshold/strategy + clear cooldown. if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") { const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index d8f81de5ec..391db84c88 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -107,7 +107,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "PUT", path: "/api/codex-auth/active", module: "codex/auth-api", mutates: true }, { method: "PUT", path: "/api/codex-auth/auto-switch", module: "codex/auth-api", mutates: true }, { method: "PUT", path: "/api/codex-auth/failover", module: "codex/auth-api", mutates: true }, - { method: "PUT", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true }, + { method: "PUT", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true, exempt: { reason: "compatibility-alias", why: "Superseded by PUT /api/pool/settings, which the CLI now drives. Kept working for existing clients and pinned by exact-body goldens in tests/server/account-pool-management-api.test.ts; no CLI verb targets it any more." } }, // codex/native-profile-api { method: "GET", path: "/api/native-main-profiles", module: "codex/native-profile-api", mutates: false }, { method: "GET", path: "/api/native-main-profiles/doctor", module: "codex/native-profile-api", mutates: false }, @@ -261,6 +261,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/oauth/accounts", module: "server/management/oauth-account-routes", mutates: false }, { method: "GET", path: "/api/accounts/events", module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "gui-invalidation", why: "Dashboard selection invalidation stream; CLI account commands read the authoritative account/key resources directly rather than subscribing to browser refresh notifications." } }, { method: "GET", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: false }, + { method: "GET", path: "/api/pool/settings", module: "server/management/oauth-account-routes", mutates: false }, + { method: "PUT", path: "/api/pool/settings", module: "server/management/oauth-account-routes", mutates: true }, + { method: "PATCH", path: "/api/pool/settings", module: "server/management/oauth-account-routes", mutates: true }, { method: "GET", path: "/api/oauth/providers", module: "server/management/oauth-account-routes", mutates: false }, { method: "GET", path: "/api/oauth/status", module: "server/management/oauth-account-routes", mutates: false }, { method: "GET", path: "/api/providers/keys", module: "server/management/oauth-account-routes", mutates: false }, diff --git a/tests/cli/cli-account-pool-verbs.test.ts b/tests/cli/cli-account-pool-verbs.test.ts index 0eacd8e1bf..afab5fea2f 100644 --- a/tests/cli/cli-account-pool-verbs.test.ts +++ b/tests/cli/cli-account-pool-verbs.test.ts @@ -168,7 +168,7 @@ describe("ocx account strategy / sticky", () => { const out = capture(); try { await cmdStrategy(["openai"], deps(() => ({ - json: { accountPoolStrategy: "round-robin", accountPoolStickyLimit: 4 }, + json: { strategy: "round-robin", stickyLimit: 4 }, }), calls)); } finally { out.restore(); } expect(calls.every(call => call.method === "GET")).toBe(true); @@ -180,14 +180,16 @@ describe("ocx account strategy / sticky", () => { const stickyCalls: Captured[] = []; const out = capture(); try { - await cmdStrategy(["openai", "fill-first"], deps(() => ({ json: { accountPoolStrategy: "fill-first", accountPoolStickyLimit: 1 } }), strategyCalls)); - await cmdSticky(["openai", "7"], deps(() => ({ json: { accountPoolStrategy: "fill-first", accountPoolStickyLimit: 7 } }), stickyCalls)); + await cmdStrategy(["openai", "fill-first"], deps(() => ({ json: { strategy: "fill-first", stickyLimit: 1 } }), strategyCalls)); + await cmdSticky(["openai", "7"], deps(() => ({ json: { strategy: "fill-first", stickyLimit: 7 } }), stickyCalls)); } finally { out.restore(); } - expect(strategyCalls[0]?.path).toBe("/api/codex-auth/pool-strategy"); - expect(stickyCalls[0]?.path).toBe("/api/codex-auth/pool-strategy"); - expect(strategyCalls[0]?.body).toEqual({ strategy: "fill-first" }); + expect(strategyCalls[0]?.path).toBe("/api/pool/settings"); + expect(stickyCalls[0]?.path).toBe("/api/pool/settings"); + // Every kind now carries `provider`, including Codex. The bare-field body was the other + // half of the asymmetry the unified route removes. + expect(strategyCalls[0]?.body).toEqual({ provider: "openai", strategy: "fill-first" }); // Sent as a number so the server sees the type it validates. - expect(stickyCalls[0]?.body).toEqual({ stickyLimit: 7 }); + expect(stickyCalls[0]?.body).toEqual({ provider: "openai", stickyLimit: 7 }); }); test("the APPLIED value is echoed, not the requested one", async () => { @@ -195,7 +197,7 @@ describe("ocx account strategy / sticky", () => { // should see. const out = capture(); try { - await cmdSticky(["openai", "9"], deps(() => ({ json: { accountPoolStrategy: "quota", accountPoolStickyLimit: 3 } }), [])); + await cmdSticky(["openai", "9"], deps(() => ({ json: { strategy: "quota", stickyLimit: 3 } }), [])); } finally { out.restore(); } expect(out.lines.join("\n")).toContain("3"); expect(out.lines.join("\n")).not.toContain("9"); @@ -257,7 +259,7 @@ describe("ocx account strategy / sticky on the anthropic pool", () => { await cmdStrategy(["anthropic"], anthropicDeps(() => ({ json: { strategy: "round-robin", stickyLimit: 5 } }), calls)); } finally { out.restore(); } expect(calls[0]?.method).toBe("GET"); - expect(calls[0]?.path).toBe("/api/oauth/accounts/pool?provider=anthropic"); + expect(calls[0]?.path).toBe("/api/pool/settings?provider=anthropic"); // Unprefixed keys: this route spells the same settings without `accountPool`. expect(out.lines.join("\n")).toContain("round-robin"); }); @@ -269,7 +271,7 @@ describe("ocx account strategy / sticky on the anthropic pool", () => { await cmdSticky(["anthropic", "6"], anthropicDeps(() => ({ json: { ok: true, strategy: "quota", stickyLimit: 6 } }), calls)); } finally { out.restore(); } expect(calls[0]?.method).toBe("PUT"); - expect(calls[0]?.path).toBe("/api/oauth/accounts/pool"); + expect(calls[0]?.path).toBe("/api/pool/settings"); // Omitting `provider` here earns a 400 from the real route, so it is asserted exactly. expect(calls[0]?.body).toEqual({ provider: "anthropic", stickyLimit: 6 }); expect(out.lines.join("\n")).toContain("6"); @@ -286,7 +288,7 @@ describe("ocx account strategy / sticky on the anthropic pool", () => { test("the codex pool keeps its own prefixed keys mapped onto the same neutral output", async () => { const out = capture(); try { - await cmdStrategy(["openai", "--json"], deps(() => ({ json: { accountPoolStrategy: "quota", accountPoolStickyLimit: 1 } }), [])); + await cmdStrategy(["openai", "--json"], deps(() => ({ json: { strategy: "quota", stickyLimit: 1 } }), [])); } finally { out.restore(); } expect(JSON.parse(out.lines.join("\n"))).toMatchObject({ provider: "openai", strategy: "quota", stickyLimit: 1 }); }); @@ -342,7 +344,7 @@ describe("generic OAuth pool-settings contract (#695)", () => { try { await cmdStrategy(["google-antigravity", "round-robin"], genericDeps(() => ({ json: { ok: true, strategy: "round-robin", stickyLimit: null } }), calls)); } finally { out.restore(); } - expect(calls[0]).toMatchObject({ method: "PUT", path: "/api/oauth/accounts/pool", body: { provider: "google-antigravity", strategy: "round-robin" } }); + expect(calls[0]).toMatchObject({ method: "PUT", path: "/api/pool/settings", body: { provider: "google-antigravity", strategy: "round-robin" } }); }); test("auto-switch on a generic provider writes autoSwitchThreshold through the pool route", async () => { diff --git a/tests/cli/cli-capabilities.test.ts b/tests/cli/cli-capabilities.test.ts index 9a3191804a..4210a0d595 100644 --- a/tests/cli/cli-capabilities.test.ts +++ b/tests/cli/cli-capabilities.test.ts @@ -310,7 +310,6 @@ const UNDECLARED_ROUTES_2026_08_28: readonly string[] = [ "PUT /api/codex-auth/accounts/alias", "PUT /api/codex-auth/accounts/priority", "PUT /api/codex-auth/active", - "PUT /api/codex-auth/auto-switch", "PUT /api/codex-auth/failover", "PUT /api/combos", "PUT /api/custom-models/{id}", From 5a0a8f9703b920b865c6a9133907984bc8abab6d Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:38:43 +0900 Subject: [PATCH 106/231] test(server): cover the unified pool contract and the inherited enabled --- .../account-pool-management-api.test.ts | 133 +++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index 3f162d0321..feec9a8151 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleCodexAuthAPI } from "../../src/codex/auth-api"; -import { saveConfig } from "../../src/config"; +import { loadConfig, saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; @@ -643,3 +643,134 @@ describe("legacy pool contract goldens (#wp5)", () => { }); }); + +describe("unified pool-settings contract (#695 wp5c)", () => { + let previousHome2: string | undefined; + let dir = ""; + beforeEach(() => { + previousHome2 = process.env.OPENCODEX_HOME; + dir = mkdtempSync(join(tmpdir(), "ocx-pool-unified-")); + process.env.OPENCODEX_HOME = dir; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }, + deepseek: { adapter: "openai-chat", baseUrl: "https://api.deepseek.com/v1", apiKey: "deepseek-key-fixture" }, + }, + } as OcxConfig); + }); + afterEach(() => { + if (previousHome2 === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome2; + if (dir) removeTreeWithRetry(dir); + }); + + test("every kind answers with the same keys and declares what it supports", async () => { + const server = startServer(0); + try { + for (const [provider, kind, supported] of [ + ["openai", "codex", ["strategy", "stickyLimit", "autoSwitchThreshold"]], + ["anthropic", "anthropic", ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow"]], + ["google-antigravity", "generic", ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold"]], + ] as const) { + const res = await fetch(new URL(`/api/pool/settings?provider=${provider}`, server.url)); + expect(res.status).toBe(200); + const dto = await res.json() as Record; + // Same key set for every kind. An unsupported field is a declared null, not an absence, + // which is the whole difference between a consolidation and a fourth contract. + expect(Object.keys(dto).sort()).toEqual([ + "autoSwitchThreshold", "enabled", "enabledEffective", "kind", "provider", + "quotaWindow", "stickyLimit", "strategy", "supported", + ]); + expect(dto.kind).toBe(kind); + expect(dto.supported).toEqual([...supported]); + // quotaWindow belongs to anthropic alone; the others state null rather than omitting it. + if (kind !== "anthropic") expect(dto.quotaWindow).toBeNull(); + } + // An API-key provider has no pool at all and is refused rather than answered with nulls. + expect((await fetch(new URL("/api/pool/settings?provider=deepseek", server.url))).status).toBe(400); + } finally { + await server.stop(true); + } + }); + + test("a generic pool with no stored override reports the inherited global", async () => { + const config = loadConfig(); + config.oauthAccountFailover = { enabled: true }; + saveConfig(config); + const server = startServer(0); + try { + const dto = await (await fetch(new URL("/api/pool/settings?provider=google-antigravity", server.url))).json() as Record; + // The defect this field closes: `enabled: null` means "nothing stored here", which alone + // cannot distinguish a disabled pool from one inheriting a global true. + expect(dto.enabled).toBeNull(); + expect(dto.enabledEffective).toBe(true); + } finally { + await server.stop(true); + } + }); + + test("a global false leaves an unset generic pool effectively off", async () => { + const config = loadConfig(); + config.oauthAccountFailover = { enabled: false }; + saveConfig(config); + const server = startServer(0); + try { + const dto = await (await fetch(new URL("/api/pool/settings?provider=google-antigravity", server.url))).json() as Record; + expect(dto.enabled).toBeNull(); + expect(dto.enabledEffective).toBe(false); + } finally { + await server.stop(true); + } + }); + + test("a stored provider override beats the global in both directions", async () => { + const config = loadConfig(); + config.oauthAccountFailover = { enabled: true }; + config.providers["google-antigravity"]!.oauthAccountFailover = { enabled: false }; + saveConfig(config); + const server = startServer(0); + try { + const dto = await (await fetch(new URL("/api/pool/settings?provider=google-antigravity", server.url))).json() as Record; + expect(dto.enabled).toBe(false); + expect(dto.enabledEffective).toBe(false); + } finally { + await server.stop(true); + } + }); + + test("a write reaches each kind's own storage and is refused identically on bad values", async () => { + const server = startServer(0); + try { + const put = async (payload: Record) => { + const res = await fetch(new URL("/api/pool/settings", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), + }); + return { status: res.status, body: await res.json() as Record }; + }; + // Consolidating the contract does not consolidate the persistence: each kind still lands + // in its own place, which is what keeps the legacy paths answering byte-identically. + expect((await put({ provider: "openai", strategy: "round-robin", stickyLimit: 5 })).body).toMatchObject({ strategy: "round-robin", stickyLimit: 5 }); + expect((await put({ provider: "anthropic", strategy: "fill-first", quotaWindow: "weekly" })).body).toMatchObject({ strategy: "fill-first", quotaWindow: "weekly" }); + expect((await put({ provider: "google-antigravity", strategy: "round-robin", enabled: true })).body).toMatchObject({ strategy: "round-robin", enabled: true, enabledEffective: true }); + const saved = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")); + expect(saved.accountPoolStrategy).toBe("round-robin"); + expect(saved.anthropicAccountPool.strategy).toBe("fill-first"); + expect(saved.providers["google-antigravity"].oauthAccountFailover.strategy).toBe("round-robin"); + + for (const provider of ["openai", "anthropic", "google-antigravity"]) { + expect((await put({ provider, strategy: "weighted" })).status).toBe(400); + expect((await put({ provider, stickyLimit: 0 })).status).toBe(400); + } + // quotaWindow and enabled are declared unsupported for the kinds that lack them, and the + // route says so instead of silently dropping the field. + expect((await put({ provider: "openai", quotaWindow: "weekly" })).status).toBe(400); + expect((await put({ provider: "openai", enabled: true })).status).toBe(400); + expect((await put({ provider: "google-antigravity", quotaWindow: "weekly" })).status).toBe(400); + } finally { + await server.stop(true); + } + }); +}); From 105313877f720fc7a4f737bdcc457bfbe70b8194 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:39:38 +0900 Subject: [PATCH 107/231] docs: describe the unified pool route and correct a stale claim The management-api table said the pool route 400s for non-Anthropic providers, which stopped being true when the generic contract shipped. English plus the ko and ru locales that copied it. --- docs-site/src/content/docs/ko/reference/management-api.md | 3 ++- docs-site/src/content/docs/reference/management-api.md | 3 ++- docs-site/src/content/docs/ru/reference/management-api.md | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index 086dc5aa49..86af1ff7b5 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -171,7 +171,8 @@ Authorization: Bearer | `POST /api/oauth/logout` | 선택된 provider 자격 증명을 제거합니다 | 400 알 수 없는 provider; `oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | 마스킹된 계정을 나열하거나 계정 하나를 제거합니다 | 400 잘못된 provider/id; 404 계정 없음; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | 활성 OAuth 계정을 선택합니다 | 400 잘못된 provider/account; `oauth_mutation_busy` | -| `GET, PUT, PATCH /api/oauth/accounts/pool` | Anthropic OAuth pool policy를 읽거나 업데이트합니다 | 400 Anthropic이 아닌 provider 또는 잘못된 policy | +| `GET, PUT, PATCH /api/pool/settings` | 모든 pool 종류(codex, anthropic, generic)의 policy를 읽거나 업데이트합니다. 세 종류 모두 같은 키로 응답하고, 해당 종류가 실제로 적용하는 필드는 `supported`에 나옵니다 | 400 알 수 없는 provider, 해당 종류가 지원하지 않는 필드, 잘못된 값 | +| `GET, PUT, PATCH /api/oauth/accounts/pool` | Anthropic과 일반 OAuth provider의 기존 pool policy입니다. `/api/pool/settings`로 대체되었고 기존 클라이언트를 위해 유지합니다 | 400 codex 또는 API 키 provider, 잘못된 policy | | `POST /api/oauth/accounts/clear-cooldown` | OAuth 계정 하나의 런타임 cooldown을 지웁니다 | 400 잘못된 provider/account | | `PUT /api/oauth/accounts/alias` | OAuth 계정 alias를 설정하거나 지웁니다 | 400 잘못된 provider/account/alias | | `GET, POST, DELETE /api/providers/keys` | 마스킹된 provider key를 나열, 추가/활성화, 또는 제거합니다 | 400 잘못된 입력; 404 provider/key 없음 | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index b7fdbf072f..961108451e 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -329,7 +329,8 @@ outcome fields from an older server do not establish successful recovery. | `POST /api/oauth/logout` | Remove the selected provider credential | 400 unknown provider; `oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | List masked accounts or remove one account | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | Select the active OAuth account | 400 invalid provider/account; `oauth_mutation_busy` | -| `GET, PUT, PATCH /api/oauth/accounts/pool` | Read or update Anthropic OAuth pool policy | 400 non-Anthropic provider or invalid policy | +| `GET, PUT, PATCH /api/pool/settings` | Read or update pool policy for any kind (codex, anthropic, generic); answers with the same keys for all three and declares in `supported` which the kind honours | 400 unknown provider, a field the kind does not support, or an invalid value | +| `GET, PUT, PATCH /api/oauth/accounts/pool` | Legacy per-pool policy for Anthropic and generic OAuth providers; superseded by `/api/pool/settings` and kept for existing clients | 400 codex or api-key provider, or invalid policy | | `POST /api/oauth/accounts/clear-cooldown` | Clear one OAuth account's runtime cooldown | 400 invalid provider/account | | `PUT /api/oauth/accounts/alias` | Set or clear an OAuth account alias | 400 invalid provider/account/alias | | `GET, POST, DELETE /api/providers/keys` | List masked provider keys, add/activate one, or remove one | 400 invalid input; 404 provider/key missing | diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index aefc4e09cf..96249228d2 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -190,7 +190,8 @@ Endpoint'ы storage cleanup могут перемещать или навсег | `POST /api/oauth/logout` | Удалить сохранённый credential выбранного провайдера | 400 unknown provider; `oauth_mutation_busy` | | `GET, DELETE /api/oauth/accounts` | Показать список masked-аккаунтов или удалить один аккаунт | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | Выбрать активный OAuth-аккаунт | 400 invalid provider/account; `oauth_mutation_busy` | -| `GET, PUT, PATCH /api/oauth/accounts/pool` | Прочитать или обновить policy Anthropic OAuth pool | 400 non-Anthropic provider or invalid policy | +| `GET, PUT, PATCH /api/pool/settings` | Прочитать или обновить policy пула любого вида (codex, anthropic, generic); все три отвечают одинаковыми ключами, а поля, которые вид действительно применяет, перечислены в `supported` | 400 неизвестный provider, поле, которое вид не поддерживает, или недопустимое значение | +| `GET, PUT, PATCH /api/oauth/accounts/pool` | Прежняя policy пула для Anthropic и обычных OAuth-провайдеров; заменена на `/api/pool/settings` и сохранена для существующих клиентов | 400 codex или api-key provider, либо недопустимая policy | | `POST /api/oauth/accounts/clear-cooldown` | Очистить runtime cooldown одного OAuth-аккаунта | 400 invalid provider/account | | `PUT /api/oauth/accounts/alias` | Задать или очистить alias OAuth-аккаунта | 400 invalid provider/account/alias | | `GET, POST, DELETE /api/providers/keys` | Показать список masked provider-key'ов, добавить/активировать один или удалить один | 400 invalid input; 404 provider/key missing | From 1394b3408a82341cce8c536f52c35a96b5ffac0e Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:40:53 +0900 Subject: [PATCH 108/231] feat(devin): Cognition cloud provider, carried from #4078 and hardened (#4285) * feat(devin): add experimental Cognition (Devin/Windsurf) adapter Carries the adapter from #4078 onto current dev and places the test in its layout domain (tests/providers/devin-adapter.test.ts) with the layout map and membership fixture updated. Co-authored-by: Sayo * fix(devin): harden the cloud-direct adapter before it ships Four independent reviews of the carried #4078 adapter found one credential-leak blocker, one abort blocker, and a set of routing and lifecycle defects. Credentials: RegisterUser and GetUserJwt copied raw upstream bodies into Error.message, which reaches CLI output, the adapter error event, and /api/logs. A Connect error can quote the request, and the request holds the sign-in token or the api_key; redactSecretString does not match a bare JWT. Every auth and chat error now reports status, an allowlisted Connect code, and a trace id only. The four credential-bearing POSTs stop following redirects, and the api-server host is checked against a Cognition allowlist before it reaches a URL - including on the way into auth.json, so an EU or FedStart tenant host survives a reload instead of being dropped by the Copilot-only validator. Routing: the adapter posted to the static registry baseUrl, so an EU or FedStart account signed in and then sent every RPC to a server it is not provisioned on. The signed-in account's tenant now decides the host. Cancellation: after headers arrived nothing observed the caller's signal, so a client cancel drained until the idle timer fired and then surfaced as truncated_stream, while the adapter emitted neither done nor error and left the bridge to synthesize adapter_eof. The body is cancelled on abort and the turn reports the cancellation. Also: a natural completion no longer reports stopReason "stop", which was costing every clean turn its final_answer phase; sampling options reach the cloud instead of its 128k/0.7 defaults; thinking stays out of replayed assistant content; usage survives an error; gzip frames are bounded on output as well as input; dotted model ids normalize to the catalog spelling; the session cache is bounded; and logout clears the cached user_jwt whose payload carries the api_key. Co-authored-by: Sayo * test(devin): assemble the userinfo URL so the privacy scanner does not read it as an email * feat(devin): pin the client version to the shipped release and accept the real token shape Evidence from a live free-tier account and the shipped Devin Desktop 3.9.19 bundle. Details in devlog/_plan/260911_devin_two_providers/003_live_evidence.md. The sign-in token is not a JWT. A real sign-in returns a 47-character ott$ one-time value and RegisterUser accepts it, so the JWT-shape gate would have rejected every real login. The paste parser now recognises one opaque credential-shaped word rather than a token format. RegisterUser returned api_server_url https://server.self-serve.windsurf.com for an ordinary free account, which is what the tenant-routing fix in the previous commit exists for: the hardcoded server.codeium.com was wrong for this account before anyone reached an enterprise tenant. The api-server allowlist gains the staging and beta hosts the shipped bundle names, and the client version default moves from 2.0.0 - which predates the Devin rebrand - to the 3.9.19 the desktop client reports, overridable through OPENCODEX_DEVIN_CLIENT_VERSION. Co-authored-by: Sayo * docs(devin): carry the upstream MIT notice on the derived cloud-direct files A similarity check against rsvedant/opencode-windsurf-auth puts wire.ts at 1.000, index.ts at 0.988, chat.ts at 0.912, metadata.ts at 0.863, auth.ts at 0.835 and catalog.ts at 0.753 - same module split, same comments, same field layout. These files are a derivative of that repository's src/cloud-direct/, which is MIT licensed, Copyright (c) 2026 Vedant, and the carry arrived with no notice at all. The full permission notice sits in the module entry point and the other five files carry a short attribution header pointing at it, which is what MIT asks for in a distributed source tree. Co-authored-by: Sayo * docs(devin): record that the cloud chat path is unverified on a free account * fix(devin): explain the opaque cloud denial instead of guessing at it * docs(devin): restore the cloud provider rows and adapter sections on the new dev devin-cli landed on dev as #4288, so this branch now carries only the cloud provider. The provider rows, the login line, and the adapters reference sections for devin are re-added on top of the current docs, and every locale still leads with the measured result that its chat path is unverified. * fix(devin): restore the invalid_argument matcher the rebuild dropped The rebuild re-applied the last commit's documentation by hand and its chat.ts hunk went with it, so the runtime explanation was back to keying only on permission_denied while the measured free account returns invalid_argument - the one trailer it needed to fire for. Also from the rebuild audit: the conformance test no longer names devin-cli in guards the RUN_TURN_ONLY_WIRES set already skips, the two layout maps list the devin test files alphabetically, and structure/adapters/registry.md records why the cloud devin wire is a direct registry entry alongside devin-cli. * feat(devin): fix the request encoding that made every chat turn fail The cloud provider could not complete a single turn on any account. A paid account settled what it was not: all 229 catalogue models came back enabled and GetChatMessage failed exactly as it had on the free tier, so entitlement was never the cause. Importing the working reference's zero-dependency builder and sending its request through our own transport returned HTTP 200 and a real stream, which put the fault in our encoder rather than the wire. Diffing the two encoded messages field by field left one difference: in CompletionConfiguration, #2 is the output cap and #3 is the context window, and we had them swapped. A caller asking for 32 output tokens wrote 32 into the context-window field, and Cognition answered with an opaque invalid_argument. Fields #6 and #11 are not part of the message at all. A temperature of exactly 0 is refused with that same opaque error. Deterministic output is the ordinary case for a coding client, so it is clamped to the smallest accepted value rather than silently replaced with the service default. Three transport facts had to hold together, which is why testing them one at a time looked fruitless: the credential is the session token doubled and dash-joined in an Authorization: Basic header while the body keeps one copy, the request envelope is uncompressed, and Metadata #31 carries a 732-character fingerprint whose length the service checks. The metadata identity is its own seven-field shape rather than the desktop client's telemetry set, the request carries the verified tag set, and the short-lived user_jwt is now opt-in because the chat path does not need it. Verified live on a paid account: six combinations, two hosts by three models, all returning PONG with a finish reason and usage. A regression test pins the tag map so the swap cannot return silently. Co-authored-by: Sayo * fix(devin): stop the opaque-denial message asserting a retracted explanation The error string still told the user entitlement was proved and that the request fields had been ruled out. That was the hypothesis this work retracted: the same sentence came back for every turn until the CompletionConfiguration tag map was corrected, and a temperature of exactly 0 still produces it. It now points at the request first and names the test that pins the accepted field layout, and only then at the account's model access. Also from the pre-merge review: the comments claiming the hosted chat path needs the user_jwt, the stale 128k output-default comment, the promptId that is now optional because #22 is omitted on a first turn, and an English docs line that claimed tool calls were verified when the live evidence is chat and usage across three models. --------- Co-authored-by: Sayo --- .../260911_devin_two_providers/001_plan.md | 76 + .../260911_devin_two_providers/002_audit.md | 69 + .../003_live_evidence.md | 170 +++ .../004_devin_cli_split.md | 33 + .../src/content/docs/fr/guides/providers.md | 2 + .../src/content/docs/fr/reference/adapters.md | 10 + .../src/content/docs/guides/providers.md | 2 + .../src/content/docs/ja/guides/providers.md | 2 + .../src/content/docs/ja/reference/adapters.md | 10 + .../src/content/docs/ko/guides/providers.md | 2 + .../src/content/docs/ko/reference/adapters.md | 10 + .../src/content/docs/reference/adapters.md | 29 + .../src/content/docs/ru/guides/providers.md | 2 + .../src/content/docs/ru/reference/adapters.md | 10 + .../src/content/docs/tr/guides/providers.md | 2 + .../src/content/docs/tr/reference/adapters.md | 10 + .../content/docs/zh-cn/guides/providers.md | 2 + .../content/docs/zh-cn/reference/adapters.md | 10 + .../content/docs/zh-tw/guides/providers.md | 2 + .../content/docs/zh-tw/reference/adapters.md | 10 + scripts/test-layout/layout.json | 2 + src/adapters/devin.ts | 319 +++++ src/adapters/devin/cloud-direct/auth.ts | 264 ++++ src/adapters/devin/cloud-direct/catalog.ts | 279 ++++ src/adapters/devin/cloud-direct/chat.ts | 1244 +++++++++++++++++ src/adapters/devin/cloud-direct/index.ts | 65 + src/adapters/devin/cloud-direct/metadata.ts | 134 ++ src/adapters/devin/cloud-direct/wire.ts | 206 +++ src/adapters/devin/live-models.ts | 97 ++ src/adapters/registry.ts | 9 +- src/codex/catalog/provider-fetch.ts | 45 + src/lib/abort.ts | 36 + src/oauth/devin.ts | 166 +++ src/oauth/devin/api-base.ts | 63 + src/oauth/devin/login.ts | 1 + src/oauth/devin/register-user.ts | 186 +++ src/oauth/devin/types.ts | 71 + src/oauth/index.ts | 8 + src/oauth/store.ts | 10 +- src/providers/registry.ts | 15 + src/routing/compatibility/behavior.ts | 1 + src/server/management/oauth-account-routes.ts | 8 + src/server/request-log.ts | 4 + structure/adapters/registry.md | 5 + .../adapter-registry-authority.test.ts | 1 + .../adapters/adapter-tool-conformance.test.ts | 20 +- tests/fixtures/test-layout-expected.json | 2 + tests/providers/devin-adapter.test.ts | 91 ++ tests/providers/devin-hardening.test.ts | 249 ++++ 49 files changed, 4051 insertions(+), 13 deletions(-) create mode 100644 devlog/_plan/260911_devin_two_providers/001_plan.md create mode 100644 devlog/_plan/260911_devin_two_providers/002_audit.md create mode 100644 devlog/_plan/260911_devin_two_providers/003_live_evidence.md create mode 100644 devlog/_plan/260911_devin_two_providers/004_devin_cli_split.md create mode 100644 src/adapters/devin.ts create mode 100644 src/adapters/devin/cloud-direct/auth.ts create mode 100644 src/adapters/devin/cloud-direct/catalog.ts create mode 100644 src/adapters/devin/cloud-direct/chat.ts create mode 100644 src/adapters/devin/cloud-direct/index.ts create mode 100644 src/adapters/devin/cloud-direct/metadata.ts create mode 100644 src/adapters/devin/cloud-direct/wire.ts create mode 100644 src/adapters/devin/live-models.ts create mode 100644 src/oauth/devin.ts create mode 100644 src/oauth/devin/api-base.ts create mode 100644 src/oauth/devin/login.ts create mode 100644 src/oauth/devin/register-user.ts create mode 100644 src/oauth/devin/types.ts create mode 100644 tests/providers/devin-adapter.test.ts create mode 100644 tests/providers/devin-hardening.test.ts diff --git a/devlog/_plan/260911_devin_two_providers/001_plan.md b/devlog/_plan/260911_devin_two_providers/001_plan.md new file mode 100644 index 0000000000..16a72b7d57 --- /dev/null +++ b/devlog/_plan/260911_devin_two_providers/001_plan.md @@ -0,0 +1,76 @@ +# 001 — Devin/Cognition as two providers + +Objective: opencodex gains two Devin-family providers. + +- `devin` — cloud-direct. Connect-RPC to Cognition's `exa.api_server_pb.ApiServerService`, + carried from PR #4078 (author @wtfsayo) onto current `dev` and hardened. +- `devin-cli` — local. Spawns the Devin CLI and speaks Agent Client Protocol + (newline-delimited JSON-RPC on stdio), modeled on the user-supplied working + `server.mjs` proxy and the reference executor in `.tmp/openproxy-ref`. + +`.tmp/openproxy-ref` (quangdang46/openproxy) is read-only reference. No code or +license-bearing text from it enters this repository. + +## Work phases + +| id | outcome | +|---|---| +| wp1 | Carry + harden the cloud-direct `devin` adapter on current `dev` | +| wp2 | Live Cognition evidence (free signup + client download via aside), fold verified constants in | +| wp3 | Second provider `devin-cli` over ACP stdio | +| wp4 | Docs/locale parity, full gates, PR, merge into `dev` | + +## wp1 — what changes and why + +The carry itself is done: `git merge --squash pr4078` applied cleanly onto +`9ea5759226`, the root-level test moved to its layout domain +(`tests/providers/devin-adapter.test.ts`) with `scripts/test-layout/layout.json` +and `tests/fixtures/test-layout-expected.json` updated, and the focused suites pass +(36/36). Four independent reviewers audited the result. Their findings define wp1's +diff: + +### 1. Tenant api-server routing (major, real runtime failure) + +`src/oauth/devin.ts` stores RegisterUser's `api_server_url` on the credential, but +`src/adapters/devin.ts` always posts GetUserJwt / GetCascadeModelConfigs / +GetChatMessage to `provider.baseUrl`, which `src/providers/registry.ts` hardcodes to +`https://server.codeium.com`. EU and FedStart tenants return a different host +(`eu.windsurf.com/_route/api_server`, `windsurf.fedstart.com/_route/api_server`), so +those accounts log in and then send every call to the wrong server. GitHub Copilot +already threads `credential.apiBaseUrl` through; Devin must do the same, falling back +to the default host only when RegisterUser returned nothing. + +### 2. Portal/register override (major, real runtime failure) + +Login always signs in against `DEFAULT_REGION`. `src/oauth/devin/types.ts` documents +a `--portal-url` override that nothing wires, so a non-US tenant never reaches its +matching RegisterUser host. Honor the override and persist it next to the api-server +URL on the credential. + +### 3. Model-id normalization (minor, degraded path) + +`src/adapters/devin.ts` has no dotted-to-hyphen map. With the live catalog missing we +append `-medium` to the raw id, turning `swe-1.6` into `swe-1.6-medium`, which +Cognition answers with an opaque `permission_denied`. Normalize `.` to `-` before +lookup and suffix only ids that actually carry an effort segment. + +### 4. Docs/locale parity (major, deferred to wp4) + +English `providers.md` and `reference/adapters.md` gained `devin`; the seven locales +(`ko ja zh-cn zh-tw fr ru tr`) still jump from `cursor` to `github-copilot` and from +`cursor` to `azure-openai`. No test compares them, but AGENTS.md forbids a locale +contradicting the English source. Both providers land in every locale in wp4, once +the final surface is known. + +### 5. Auth and streaming findings + +Two reviewers (credential handling; streaming terminal/abort semantics) are still +running. Their blockers and majors fold into this same wp1 diff before A closes. + +## Boundaries + +- No change to `src/router.ts`, `src/server/lifecycle.ts`, or + `src/server/responses/core.ts` reaching `src/lab/`. +- No new CLI command, so `skills/ocx/` and `src/cli/capabilities.ts` stay as they are. +- `devin` keeps `dashboardPreset: false` and stays out of the featured lists. +- Security notes stay in `.tmp/`, never in `devlog/`. diff --git a/devlog/_plan/260911_devin_two_providers/002_audit.md b/devlog/_plan/260911_devin_two_providers/002_audit.md new file mode 100644 index 0000000000..257db1d2e1 --- /dev/null +++ b/devlog/_plan/260911_devin_two_providers/002_audit.md @@ -0,0 +1,69 @@ +# 002 — wp1 audit: folded reviewer findings + +Four independent reviewers (xai/grok-4.6, high effort) audited the carried commit +`142c095673`. Three returned; the streaming reviewer is still running and its +findings fold into this same cycle if they arrive before C. Verdicts below are mine +after reading the cited code. + +## Accepted — blocker + +**Raw upstream bodies in auth error messages.** `register-user.ts:96,113` and +`cloud-direct/auth.ts:99,126` copy the response body into `Error.message`. That +message reaches CLI output, the adapter's `emit({ type: "error" })`, and +`/api/logs`. A Connect error that echoes `firebase_id_token`, or a 200 whose +`user_jwt` fails the shape regex, publishes a live credential; `redactSecretString` +does not match a bare `eyJ…` JWT. Confirmed by reading both files. Fix: status plus +allowlisted Connect code plus trace id, never the body. + +## Accepted — major + +1. **Tenant api-server routing.** `credential.apiBaseUrl` is written at login but + no call site reads it, and `store.ts:461` only persists Copilot origins, so an + EU/FedStart host is dropped on the next load anyway. Thread it through + `mintUserJwt`, the catalog fetch, and `streamChatEvents`, and teach the store to + persist a validated Devin origin. +2. **Redirect following on credential POSTs.** Both credential POSTs use the default + `redirect: "follow"`, so a 307/308 forwards the Firebase token or the protobuf + `api_key` to an attacker-chosen `Location`. Set `redirect: "error"` and validate + the host the same way `validateCopilotApiBaseUrl` does. +3. **Credential shape.** `refresh: ""` makes `detectOAuthWarning` report + `stale_credentials` for every Devin account from the moment of login, and + `refreshDevinToken` extends the expiry without contacting Cognition, so a revoked + key keeps looking valid. Use the durable-key house pattern: `refresh` carries the + key, expiry is effectively unbounded, and refresh throws so a 401 marks + `needsReauth`. +4. **Paste parsing.** `loginDevin` posts the entire pasted string as + `firebase_id_token`. The on-screen value is a token, but a user who pastes the + callback URL instead sends a URL. Parse a fragment/query token out of a URL paste + and reject a paste that contains no token. +5. **`clearCachedUserJwt` is never called.** The cached `user_jwt` (its payload + contains `api_key`) survives logout in process memory. Wire it into the Devin + logout path. + +## Accepted — minor + +6. `result.name` overwrites the JWT `email` with a display name, so reauth identity + comparison collides. Keep the email; the name is not an identity. +7. `registerUser` does not receive `ctrl.signal`, so cancelling login does not abort + the exchange. +8. No dotted-to-hyphen model-id map, so a degraded-path `swe-1.6` becomes + `swe-1.6-medium` and Cognition answers `permission_denied`. + +## Rejected / deferred + +- **Copying the reference's gRPC-web framing.** `.tmp/openproxy-ref` talks to + `LanguageServerService` over gRPC-web with a Bearer header; we talk to + `ApiServerService` over Connect-RPC with the key inside `Metadata`. They are two + different products. Adopting the reference's headers or field numbers would break + auth and proto decode. Reference value is the CLI/ACP executor, which is wp3. +- **`defaultRefreshPolicy: "disabled"`.** Correct for a durable key; keep it. +- **Docs/locale parity.** Real and required, but the final surface is not known until + `devin-cli` lands, so it is wp4. +- **Dead plugin types** (`PersistedCredentials`, `syncedViaOpencodeAuth`). Removed + where they are genuinely unreferenced; not a leak either way. + +## Verification for this cycle + +`bun x tsc --noEmit`, the focused Devin/adapter/layout suites, `bun run privacy:scan`, +plus new regression tests for: error messages that must not contain a token, redirect +refusal, host allowlist rejection, tenant host threading, and the dotted model id. diff --git a/devlog/_plan/260911_devin_two_providers/003_live_evidence.md b/devlog/_plan/260911_devin_two_providers/003_live_evidence.md new file mode 100644 index 0000000000..19901478e1 --- /dev/null +++ b/devlog/_plan/260911_devin_two_providers/003_live_evidence.md @@ -0,0 +1,170 @@ +# 003 — wp2: live Cognition evidence + +A free Cognition account was created through the browser on 2026-09-12 and the +shipped desktop client was downloaded. Everything below is measured, not inferred. + +## What the account looks like + +Devin Desktop 3.9.19 (`Devin-darwin-arm64-3.9.19.dmg`, 337 MB). Windsurf has been +rebranded: `windsurf.com` now redirects to `devin.ai/desktop`, and the bundled +extension still identifies itself as `publisher: codeium`, `name: windsurf`, +`displayName: Devin`. `product.json` reports `windsurfVersion: 3.9.19` and +`codeiumVersion: 1.48.2`. + +## Constants confirmed against the shipped client + +Read from `Devin.app/Contents/Resources/app/extensions/windsurf/dist/extension.js`: + +- Auth0 client id `3GUryQ7ldAeKEuD2obYnppsnmj58eP5u` — present verbatim. The + carried adapter's value is correct. +- Hosts: `server.codeium.com`, `server-staging.codeium.com`, + `server-beta.codeium.com`, `register.windsurf.com`, `eu.windsurf.com/_route/api_server`, + `windsurf.fedstart.com/_route/api_server`, and the tenant template + `your-company.windsurf.com`. The allowlist in `src/oauth/devin/api-base.ts` was + widened to the two staging/beta hosts on this evidence. +- Method names `RegisterUser`, `GetChatMessage` and `GetCascadeModelConfigs` all + appear as string literals. + +## What the live calls proved + +1. **The sign-in token is not a JWT.** A real sign-in returned a 47-character + `ott$` one-time token, and RegisterUser exchanged it successfully. + The JWT-shape gate added during wp1 would have rejected every real login, so + `parseDevinAuthPaste` now checks for one opaque credential-shaped word instead + of a token format. The token is single-use: the second exchange of the same + value fails, which is why the probe needed a fresh sign-in. + +2. **The tenant-routing fix is load-bearing, not theoretical.** RegisterUser + returned `api_server_url: https://server.self-serve.windsurf.com` for an + ordinary free account — not `server.codeium.com`, which the registry hardcodes + and the carried adapter always used. Without wp1's change every free-tier + account would have sent its RPCs to a host it is not provisioned on. + +3. **The api_key and the catalog work.** `GetCascadeModelConfigs` against that + host returned 227 model uids. Exactly one is enabled on the free tier: + `swe-1-6-slow`. The site advertises "unlimited SWE-2"; the API does not agree, + which is worth knowing before anyone documents a model list. + +4. **`GetChatMessage` fails with `invalid_argument`.** Message is the opaque + "an internal error occurred (trace ID: …)". Client version strings `3.9.19`, + `2.0.0` and `1.48.2` in Metadata fields 2 and 7 all fail identically, so the + version pin is not the cause — the comment in `metadata.ts` claiming a version + mismatch produces exactly this error is no longer a sufficient explanation. + The version default was still moved to the shipped `3.9.19` with an + `OPENCODEX_DEVIN_CLIENT_VERSION` override, because `2.0.0` predates the rebrand + and nothing argues for keeping it. + + This is the open item. The request encoding is being compared field by field + against the shipped bundle and against the two actively maintained references. + +## Ecosystem survey + +Twelve independent Windsurf/Cognition proxies were catalogued. The two that +matter here: + +- `dwgx/WindsurfAPI` (~2975 stars, updated this week) uses the same + `server.codeium.com` `GetChatMessage` Connect-RPC path we do. +- `rsvedant/opencode-windsurf-auth` (~70 stars) is a direct-cloud Connect-RPC + streaming client for an opencode plugin. Our carried files reference + `opencode auth login`, `syncedViaOpencodeAuth` and an + `opencode-windsurf-auth` CLI in `src/oauth/devin/types.ts`, so #4078 very + likely derives from it. Its license and the derivation are being checked; if + it is derived, attribution is required before this merges. + +`quangdang46/openproxy` talks to a different product (gRPC-web +`LanguageServerService`), so it is a secondary reference only. + +## wp2 outcome: the cloud chat path stays unverified + +Every request-shape hypothesis was tried against the live account and none of +them changed the trailer. In probe order: client version `3.9.19`, `2.0.0`, +`1.48.2`; the Connect request frame sent uncompressed with +`Connect-Content-Encoding` dropped; `Metadata` #31 filled with 732 hex +characters; `GetChatMessageRequest` #2, #15 and #20 added and #22 dropped on the +first turn; `ChatMessagePrompt` #1 `message_id` added; `Authorization: Basic` +in both base64 and raw doubled-key forms; and both hosts. Same +`invalid_argument: an internal error occurred` every time, with a fresh trace id. + +The model gate is provably fine. `swe-2-high` and `claude-sonnet-5-medium` are +refused locally as disabled, and a bogus uid is refused as unlisted, so the +failure is specific to `swe-1-6-slow` — the one model a free account has, and a +"slow" lane at that. + +**Entitlement now outranks request shape as the explanation.** The site +advertises "Slow Devin Cloud access with limited quotas" for free accounts, and a +slow lane plausibly is not served by this RPC at all. #4078's author reported a +live PONG on 2026-09-09 with the *original* field set, which is the deciding +fact: shipping unverified wire changes would risk regressing an account that +works today in exchange for no measured gain here. The whole experimental delta +was reverted; only the wp1 hardening and the MIT notice remain. + +Confirming this needs a paid account or a captured working request. Neither is +available in this session, so the cloud provider is not merge-ready and the +adapter's own model gate is what stops a user hitting this blindly. + +## The chat path works. What was actually wrong. + +A paid account was obtained on 2026-09-12 and the entitlement hypothesis died +immediately: all 229 catalogue models came back enabled, and `GetChatMessage` +failed exactly as it had on the free account. The failure was never about the +plan. + +Isolating it took one decisive move. The most actively maintained reference +(`dwgx/WindsurfAPI`) is zero-dependency ESM, so its request builder can simply be +imported. Building a turn with the reference builder and sending it through our +own transport returned **HTTP 200** and a real Connect stream — which proved the +transport, the headers and the credential were all fine, and put the fault in our +request encoder. Diffing the two encoded messages field by field left exactly one +difference: `CompletionConfiguration` (#8). + + reference #1=1 #2=8192 #3=128000 #5=double #7=40 #8=double + ours #1=1 #2=64000 #3=32 #5=double #6=double #7=50 #8=double #11=double + +**#2 is the output cap and #3 is the context window; we had them swapped.** A +caller asking for 32 output tokens wrote 32 into the context-window field, and +Cognition answered with an opaque `invalid_argument: an internal error occurred`. +That is why every account failed identically and why no amount of probing the +transport helped. The reference's own comments record the same mis-tagging and +the same re-calibration. + +A second, independent trap sat behind it: **a temperature of exactly 0 is +refused** with the same opaque error. Deterministic output is the common case for +coding clients, so it is clamped to the smallest accepted value rather than +silently replaced with the service default. + +Three transport facts also had to be right together, and testing them one at a +time is why they looked useless earlier: + +- the credential is the session token doubled and dash-joined in + `Authorization: Basic`, while the protobuf body keeps a single copy; +- the request envelope is uncompressed; +- `Metadata` #31 carries 732 hex characters, whose length the service checks and + whose value it does not. + +The metadata identity is also its own shape — seven fields, the optional +`user_jwt`, and the fingerprint — not the desktop client's fuller telemetry set. + +### Verified + +Six combinations, two hosts by three models, all returning `PONG` with a finish +reason and usage: + +| host | model | result | +|---|---|---| +| `server.codeium.com` | `swe-2-high` | PONG, stop, 476/36 | +| `server.codeium.com` | `claude-sonnet-5-medium` | PONG, stop, 576/5 | +| `server.codeium.com` | `gpt-5-6-sol-medium` | PONG, 394/6 | +| `server.self-serve.windsurf.com` | `swe-2-high` | PONG, stop, 1/36 | +| `server.self-serve.windsurf.com` | `claude-sonnet-5-medium` | PONG, stop, 576/5 | +| `server.self-serve.windsurf.com` | `gpt-5-6-sol-medium` | PONG, 394/6 | + +The tag map is now pinned by a regression test that builds a request and asserts +the field layout, so the swap cannot come back silently. + +### What this retracts + +The earlier conclusion in this document — that entitlement was the leading +explanation and that the request shape had been ruled out — was wrong. The +request shape was the whole problem; the probing that "ruled it out" changed one +variable at a time against a broken `CompletionConfiguration` that no single +variable could rescue. diff --git a/devlog/_plan/260911_devin_two_providers/004_devin_cli_split.md b/devlog/_plan/260911_devin_two_providers/004_devin_cli_split.md new file mode 100644 index 0000000000..f6a41bd7d0 --- /dev/null +++ b/devlog/_plan/260911_devin_two_providers/004_devin_cli_split.md @@ -0,0 +1,33 @@ +# 004 — wp5: splitting devin-cli out + +The wp4 audit recommended splitting, citing MAINTAINERS.md: a new canonical +registry destination is a maintained promise, and when the evidence is incomplete +the repository wants an inert directory row rather than a registry entry. The +cloud `devin` provider cannot complete a turn on the account we can measure. +`devin-cli` does not share that RPC. + +## What moved + +Branch `codex/260912-devin-cli-provider` from a freshly fetched `origin/dev` +(`29d632ff25`). It carries `src/adapters/devin-cli/` and +`tests/providers/devin-cli-adapter.test.ts` byte-identical, plus only the +`devin-cli` hunks of the adapter registry, the provider registry, the routing +behaviour table, the layout map and the membership fixture. Docs get the English +provider row and adapters section and the provider row in all seven locales. + +The tool-conformance skip lists needed care: on the other branch they name both +wires, and here only `devin-cli` exists, so naming a wire that is absent would +have been a silent no-op rather than a skip. + +## What stayed + +Everything cloud-direct: `src/adapters/devin/`, `src/oauth/devin*`, the `devin` +registry entry and its documentation, the MIT notice for the derived files, and +this plan unit. PR #4285 keeps them. + +## Verification + +`bun x tsc --noEmit` clean; 76 focused tests pass; `privacy:scan` green. An +independent audit of the split diff (21 files, +925/-5) found no cloud-provider +leakage, agreeing registries, resolving imports, and a PR description that +matches the code. Remote CI on the exact head is the suite gate. diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index eb4f4a0718..8f35e68241 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -111,6 +111,7 @@ ocx login kiro # import kiro-cli credentials (or token fallback) ocx login google-antigravity ocx login cursor # standalone Cursor PKCE login ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json) +ocx login devin # Connexion navigateur Auth0 Cognition/Devin ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business) ocx login codex # pool de comptes Codex (alias : chatgpt, openai ; nécessite un proxy en cours d'exécution) ocx logout @@ -125,6 +126,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | La connexion initiale importe la session de l'installation locale de `kiro-cli`, déjà authentifiée (sous Unix, installez avec `curl -fsSL https://cli.kiro.dev/install` | `bash`; sous Windows PowerShell, utilisez `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; puis exécutez `kiro-cli login`). **Ajouter un compte** déconnecte `kiro-cli`, lance une nouvelle connexion dans le navigateur qui change le compte utilisé par `kiro-cli`, puis enregistre les métadonnées propres au profil. Les comptes OpenCodex existants sont préservés ; une annulation ou un échec restaure la session `kiro-cli` précédente. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Connexion PKCE expérimentale, transport HTTP/2 en direct et découverte de modèles filtrés par compte. | +| `devin` | `devin` | `https://server.codeium.com` | Passerelle Cognition/Devin non officielle et expérimentale. La connexion ouvre l'authentification Auth0 dans le navigateur, puis échange le jeton via `RegisterUser` contre une clé d'API durable. Les modèles sont découverts par compte avec `GetCascadeModelConfigs` ; le streaming passe uniquement par `runTurn` sur Connect-RPC. Absente du préréglage du tableau de bord par défaut. | | `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Pilote la CLI Devin installée localement via l'Agent Client Protocol (`devin acp`, JSON-RPC sur stdio). La CLI détient ses propres identifiants issus de `devin auth login`, donc opencodex ne stocke aucune clé. `OPENCODEX_DEVIN_CLI_BIN` désigne l'exécutable ; pour autoriser la CLI à lire et écrire des fichiers, il faut définir explicitement `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`, le refus étant la valeur par défaut. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Expérimental. Flux d'appareil GitHub et échange `copilot_internal` (client OAuth de VS Code). Nécessite un abonnement Copilot actif ; il ne s'agit pas d'une API tierce officielle. | diff --git a/docs-site/src/content/docs/fr/reference/adapters.md b/docs-site/src/content/docs/fr/reference/adapters.md index 2b33a26e2e..9adb60f68b 100644 --- a/docs-site/src/content/docs/fr/reference/adapters.md +++ b/docs-site/src/content/docs/fr/reference/adapters.md @@ -144,6 +144,16 @@ Si Kiro s’arrête sans appeler l’outil d’achèvement, l’adaptateur effec - Envoie les niveaux ordinaires de `cursor/grok-4.5` avec les identifiants de protocole exacts issus de la découverte en direct de Cursor (`cursor-grok-4.5-low`, `-medium` ou `-high`). `cursor/grok-4.5-fast` reste sélectionnable, mais le modèle canonique `grok-4.5` est envoyé avec des paramètres distincts `effort` et `fast=true`. - L’exécution locale native de commandes sur le système de fichiers, le shell ou le réseau par Cursor est refusée par défaut. Les intégrations explicites `mcpServers` et `desktopExecutor` disposent d’activations distinctes ; `nativeLocalExec: "on"` active l’exécuteur intégré plus large et contourne la sémantique d’approbation et de bac à sable de Codex. L’ancien réglage `unsafeAllowNativeLocalExec: true` reste équivalent uniquement lorsque `nativeLocalExec` n’est pas défini. +## `devin` + +**Cible :** `exa.api_server_pb.ApiServerService/GetChatMessage` de Cognition, en streaming Connect sur `server.codeium.com`. +**Authentification :** clé d'API Devin/Cognition issue de `provider.apiKey` ou de l'en-tête authorization transmis. La connexion ouvre l'authentification Auth0 dans le navigateur, puis échange le jeton via `SeatManagementService.RegisterUser` contre une clé durable. + +- Utilise `runTurn` plutôt que le chemin fetch/parse ordinaire. Les requêtes et les événements serveur passent par le cadrage protobuf manuel de `devin/cloud-direct/wire.ts`. +- Les modèles sont découverts par compte avec `GetCascadeModelConfigs` ; ceux qui ne figurent pas dans l'offre disparaissent de la liste au lieu d'échouer au moment de la requête. +- Cognition impose une limite de longueur sur les descriptions d'outils et une liste de phrases interdites. L'adaptateur réécrit les formulations connues et tronque les descriptions trop longues. +- Les clés ne se renouvellent pas. Relancez `ocx login devin` lorsqu'une clé expire ou est révoquée. + ## `azure-openai` (alias : `azure`) **Cibles :** **Azure OpenAI**. Encapsule `openai-responses` (et utilise donc également `passthrough: true`). diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 2196f1288e..ec169b2505 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -176,6 +176,7 @@ ocx login google-antigravity ocx login cursor # standalone Cursor PKCE login ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json) ocx login orcarouter-oauth # OrcaRouter browser consent + PKCE +ocx login devin # Cognition/Devin Auth0 browser sign-in ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business) ocx login codex # Codex account pool (aliases: chatgpt, openai; needs a running proxy) ocx logout @@ -191,6 +192,7 @@ ocx logout | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | Browser consent and key exchange use `https://www.orcarouter.ai` with S256 PKCE. The returned user-owned `sk-orca-…` API key is stored in the existing credential store and reused until revoked. | +| `devin` | `devin` | `https://server.codeium.com` | Experimental unofficial Cognition/Devin bridge. Login opens Auth0 browser sign-in, then exchanges the token via Cognition's `RegisterUser` for a long-lived API key; models are discovered per account with `GetCascadeModelConfigs`. Not shown in the dashboard preset by default. Chat and usage reporting are verified against a live account across three models. | | `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Drives the locally installed Devin CLI over the Agent Client Protocol (`devin acp`, newline-delimited JSON-RPC on stdio). The CLI holds its own credentials from `devin auth login`, so opencodex stores no key for it. Point `OPENCODEX_DEVIN_CLI_BIN` at a specific build; letting the CLI read and write files requires setting `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` explicitly, because the default is to refuse. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 6b5bd881e6..0ebfeae96c 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -100,6 +100,7 @@ ocx login kiro # kiro-cli 認証情報の取り込み(トークンフォ ocx login google-antigravity ocx login cursor # Cursor 専用 PKCE ログイン ocx login command-code # Command Code のブラウザ OAuth (または ~/.commandcode/auth.json を取り込み) +ocx login devin # Cognition/Devin の Auth0 ブラウザサインイン ocx login github-copilot # GitHub デバイスフロー → Copilot トークン (Copilot Pro/Business) ocx login codex # Codex アカウントプール (別名: chatgpt, openai / プロキシの起動が必要) ocx logout @@ -114,6 +115,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは、インストール済みでサインインした `kiro-cli` セッションを取り込みます(Unix では `curl -fsSL https://cli.kiro.dev/install` | `bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1'` | `iex` でインストールしてから `kiro-cli login` を実行)。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | +| `devin` | `devin` | `https://server.codeium.com` | 実験的な非公式 Cognition/Devin ブリッジ。ログインは Auth0 のブラウザサインインを開き、取得したトークンを `RegisterUser` で長期 API キーに交換します。モデル一覧は `GetCascadeModelConfigs` でアカウントごとに取得し、ストリーミングは Connect-RPC 上の `runTurn` 経路のみを使います。ダッシュボードのプリセットには既定で含まれません。 | | `devin-cli` | `devin-cli` | `https://cli.devin.ai` | ローカルにインストールされた Devin CLI を Agent Client Protocol(`devin acp`、stdio 上の JSON-RPC)で駆動します。CLI が `devin auth login` の資格情報を保持するため、opencodex 側はキーを保存しません。実行ファイルは `OPENCODEX_DEVIN_CLI_BIN` で指定でき、CLI にファイル操作を許可するには `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` の明示が必要です(既定は拒否)。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index 0fea01bc2b..50c206d541 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -175,6 +175,16 @@ model discovery の両方に適用されます。 モデルを送信し、個別の `effort` および `fast=true` 値は `requested_model.parameters` に格納します。 - Cursor ネイティブのローカルファイルシステム/shell/network 実行はデフォルトで拒否します。明示的な `mcpServers` と `desktopExecutor` 統合はそれぞれ別の opt-in です。`nativeLocalExec: "on"` はより広い組み込み executor を有効にし、Codex の承認/サンドボックスルールを迂回します。従来の `unsafeAllowNativeLocalExec: true` は、`nativeLocalExec` が設定されていない場合にのみ同等です。 +## `devin` + +**対象:** Cognition の `exa.api_server_pb.ApiServerService/GetChatMessage`(`server.codeium.com`、Connect ストリーミング)。 +**認証:** `provider.apiKey` または転送された authorization ヘッダーの Devin/Cognition API キー。ログインは Auth0 のブラウザサインインを開き、`SeatManagementService.RegisterUser` で長期キーに交換します。 + +- 通常の fetch/parse ではなく `runTurn` を使います。リクエストとサーバーイベントは `devin/cloud-direct/wire.ts` の手動 protobuf フレーミングで扱います。 +- `GetCascadeModelConfigs` でアカウントごとにモデルを取得し、プランに含まれないモデルはリクエスト時ではなく一覧の段階で外れます。 +- Cognition はツール説明の長さ制限と完全一致のブロックリストを課します。アダプターが既知の語句を書き換え、長すぎる説明を切り詰めます。 +- キーは更新されません。失効したら `ocx login devin` をやり直してください。 + ## `azure-openai`(別名: `azure`) **対象:** **Azure OpenAI**。`openai-responses` を包むため、同じく `passthrough: true` です。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 6a470c2d89..ad734bb676 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -98,6 +98,7 @@ ocx login kiro # kiro-cli 자격 증명 가져오기(토큰 폴백 지 ocx login google-antigravity ocx login cursor # Cursor 전용 PKCE 로그인 ocx login command-code # Command Code 브라우저 OAuth (또는 ~/.commandcode/auth.json 가져오기) +ocx login devin # Cognition/Devin Auth0 브라우저 로그인 ocx login github-copilot # GitHub 디바이스 플로우 → Copilot 토큰 (Copilot Pro/Business) ocx login codex # Codex 계정 풀 (별칭: chatgpt, openai / 프록시가 실행 중이어야 함) ocx logout @@ -112,6 +113,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 설치하고 로그인한 `kiro-cli` 세션을 가져옵니다(Unix에서는 `curl -fsSL https://cli.kiro.dev/install` | `bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`로 설치한 뒤 `kiro-cli login` 실행). **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | +| `devin` | `devin` | `https://server.codeium.com` | 실험적인 비공식 Cognition/Devin 브리지. 로그인은 Auth0 브라우저 사인인을 열고, 받은 토큰을 `RegisterUser`로 교환해 장기 API 키를 얻습니다. 모델 목록은 `GetCascadeModelConfigs`로 계정마다 조회하며, 스트리밍은 Connect-RPC 위에서 `runTurn` 경로만 씁니다. 대시보드 프리셋에는 기본으로 없으니 직접 추가하세요. | | `devin-cli` | `devin-cli` | `https://cli.devin.ai` | 로컬에 설치된 Devin CLI를 Agent Client Protocol(`devin acp`, stdio 위 JSON-RPC)로 구동합니다. CLI가 `devin auth login` 자격증명을 직접 들고 있어 opencodex는 키를 저장하지 않습니다. 실행 파일은 `OPENCODEX_DEVIN_CLI_BIN`으로 지정할 수 있고, CLI가 파일을 읽고 쓰도록 허용하려면 `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`을 명시해야 합니다. 기본값은 거부입니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index c086eb4c11..ceadeb5cc4 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -210,6 +210,16 @@ discovery에 모두 적용됩니다. 더 넓은 내장 executor를 켜며 Codex 승인/샌드박스 규칙을 우회합니다. 예전 설정인 `unsafeAllowNativeLocalExec: true`는 `nativeLocalExec`을 지정하지 않았을 때만 같은 뜻입니다. +## `devin` + +**대상:** Cognition의 `exa.api_server_pb.ApiServerService/GetChatMessage`(`server.codeium.com`, Connect 스트리밍). +**인증:** `provider.apiKey` 또는 전달된 authorization 헤더의 Devin/Cognition API 키. 로그인은 Auth0 브라우저 사인인을 연 뒤 `SeatManagementService.RegisterUser`로 장기 키를 받습니다. + +- 일반 fetch/parse 대신 `runTurn`을 씁니다. 요청과 서버 이벤트는 `devin/cloud-direct/wire.ts`의 수동 protobuf 프레이밍으로 다룹니다. +- `GetCascadeModelConfigs`로 계정별 모델을 조회하고, 플랜에 없는 모델은 요청 시점이 아니라 목록에서 걸러집니다. +- Cognition은 도구 설명 길이 제한과 정확 문구 차단 목록을 적용합니다. 어댑터가 알려진 문구를 바꾸고 긴 설명을 잘라냅니다. +- 키는 갱신되지 않습니다. 만료되거나 폐기되면 `ocx login devin`을 다시 실행하세요. + ## `azure-openai` (별칭: `azure`) **대상:** **Azure OpenAI**. `openai-responses`를 감싸므로 마찬가지로 `passthrough: true`입니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 3afecaa230..d2661a80ea 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -422,6 +422,35 @@ bare `exec_command` and `shell_command` names are reserved for non-freeform shel bridges. Namespace a custom freeform tool that uses either name. These schema declarations do not grant approval or change execution policy. +## `devin` + +**Targets:** Cognition's `exa.api_server_pb.ApiServerService/GetChatMessage` over HTTPS Connect +streaming at `server.codeium.com`. +**Auth:** Devin/Cognition API key from `provider.apiKey` or the forwarded authorization header. +Login opens Auth0 browser sign-in, then exchanges the Firebase ID token via +`SeatManagementService.RegisterUser` for a long-lived API key. + +- Uses `runTurn` rather than the ordinary fetch/parse path. Requests and server events are encoded + with manual protobuf framing in `devin/cloud-direct/wire.ts`; the ordinary `buildRequest` / + `parseStream` path is disabled. +- Live model discovery via `GetCascadeModelConfigs`; the static seed is filtered against the + account's live roster so models not on the plan drop out instead of failing at request time. +- Tool definitions are encoded in the request and tool-call events are decoded from the response + stream. Cognition enforces a per-tool-description length limit (6,998 chars) and an exact-phrase + blocklist; the adapter sanitizes known triggers and truncates over-long descriptions before + encoding. +- Devin/Cognition API keys do not refresh. Run `ocx login devin` again when the key expires or is + revoked. +- The chat request is calibrated, not guessed. Three things gate it together: the credential is the + session token doubled and dash-joined in an `Authorization: Basic` header while the protobuf body + keeps one copy, the request envelope goes up uncompressed, and `Metadata` #31 carries a + 732-character device fingerprint whose length — not value — the service checks. Inside + `CompletionConfiguration`, #2 is the output cap and #3 is the context window; swapping those two + makes every turn fail with an opaque `invalid_argument`. A temperature of exactly 0 is refused, so + it is clamped to the smallest accepted value. +- Experimental unofficial bridge; not shown in the dashboard preset by default. See the + [provider guide](/guides/providers/) for login instructions. + ## `devin-cli` **Targets:** the locally installed Devin CLI, over the Agent Client Protocol — `devin acp` speaking diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 337dbfe8e4..266e333824 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -109,6 +109,7 @@ ocx login kiro # импорт учётных данных kiro-cli (с ocx login google-antigravity ocx login cursor # отдельный PKCE-вход Cursor ocx login command-code # браузерный OAuth Command Code (или импорт ~/.commandcode/auth.json) +ocx login devin # Вход в Cognition/Devin через браузер (Auth0) ocx login github-copilot # device flow GitHub → токен Copilot (Copilot Pro/Business) ocx login codex # пул аккаунтов Codex (псевдонимы: chatgpt, openai; нужен запущенный прокси) ocx logout @@ -123,6 +124,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (в Unix: `curl -fsSL https://cli.kiro.dev/install` | `bash`; в Windows PowerShell: `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; затем выполните `kiro-cli login`). **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | +| `devin` | `devin` | `https://server.codeium.com` | Экспериментальный неофициальный мост к Cognition/Devin. Вход открывает страницу Auth0 в браузере, затем токен обменивается через `RegisterUser` на долгоживущий API-ключ. Список моделей запрашивается для каждой учётной записи через `GetCascadeModelConfigs`; потоковая передача идёт только по пути `runTurn` поверх Connect-RPC. В пресете панели по умолчанию отсутствует. | | `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Управляет локально установленным Devin CLI по Agent Client Protocol (`devin acp`, JSON-RPC поверх stdio). Учётные данные хранит сам CLI после `devin auth login`, поэтому opencodex не сохраняет ключ. Путь к исполняемому файлу задаётся через `OPENCODEX_DEVIN_CLI_BIN`; чтобы разрешить CLI читать и писать файлы, нужно явно выставить `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` — по умолчанию запрос отклоняется. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index 0ec59367b7..f93dcc0b83 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -235,6 +235,16 @@ authorization. одобрений/песочницы Codex; устаревший `unsafeAllowNativeLocalExec: true` эквивалентен только если `nativeLocalExec` не задан. +## `devin` + +**Назначение:** `exa.api_server_pb.ApiServerService/GetChatMessage` в Cognition, потоковая передача Connect на `server.codeium.com`. +**Аутентификация:** ключ API Devin/Cognition из `provider.apiKey` или переданного заголовка authorization. Вход открывает страницу Auth0 в браузере, после чего токен обменивается через `SeatManagementService.RegisterUser` на долгоживущий ключ. + +- Используется `runTurn`, а не обычный путь fetch/parse. Запросы и серверные события кодируются вручную в `devin/cloud-direct/wire.ts`. +- Модели запрашиваются для каждой учётной записи через `GetCascadeModelConfigs`; отсутствующие в тарифе отсеиваются в списке, а не падают в момент запроса. +- Cognition ограничивает длину описаний инструментов и блокирует точные фразы. Адаптер переписывает известные формулировки и обрезает слишком длинные описания. +- Ключи не обновляются. После истечения или отзыва выполните `ocx login devin` заново. + ## `azure-openai` (алиас: `azure`) **Назначение:** **Azure OpenAI**. Обёртка над `openai-responses` (поэтому тоже diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 29ecc83a95..9d5a2655f2 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -124,6 +124,7 @@ ocx login kiro # kiro-cli kimlik bilgilerini içe aktarın (veya belirte ocx login google-antigravity ocx login cursor # bağımsız Cursor PKCE girişi ocx login command-code # Command Code tarayıcı OAuth (veya ~/.commandcode/auth.json içe aktarma) +ocx login devin # Cognition/Devin için Auth0 tarayıcı girişi ocx login github-copilot # GitHub cihaz akışı → Copilot belirteci (Copilot Pro/Business) ocx login codex # Codex hesap havuzu (takma adlar: chatgpt, openai; çalışan bir proxy gerekir) ocx logout @@ -138,6 +139,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | İlk oturum açma, kurulu ve oturum açılmış `kiro-cli` oturumunu içe aktarır (Unix'te `curl -fsSL https://cli.kiro.dev/install` | `bash` ile kurun; Windows PowerShell'de `irm 'https://cli.kiro.dev/install.ps1'` | `iex` kullanın; ardından `kiro-cli login` çalıştırın). **Hesap ekle**, `kiro-cli` oturumunu kapatır, `kiro-cli` tarafından kullanılan hesabı değiştiren yeni bir tarayıcı girişi başlatır ve hesap kapsamlı profil meta verilerini saklar. Mevcut OpenCodex hesapları korunur ve iptal veya başarısızlık önceki `kiro-cli` oturumunu geri yükler. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Deneysel PKCE girişi, canlı HTTP/2 aktarımı ve hesap filtreli model keşfi. | +| `devin` | `devin` | `https://server.codeium.com` | Deneysel, resmi olmayan Cognition/Devin köprüsü. Giriş tarayıcıda Auth0 oturumunu açar, ardından belirteci `RegisterUser` ile uzun ömürlü bir API anahtarına dönüştürür. Modeller hesaba göre `GetCascadeModelConfigs` ile keşfedilir; akış yalnızca Connect-RPC üzerindeki `runTurn` yolunu kullanır. Panel ön ayarında varsayılan olarak yer almaz. | | `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Yerelde kurulu Devin CLI'yi Agent Client Protocol ile (`devin acp`, stdio üzerinde JSON-RPC) çalıştırır. Kimlik bilgilerini `devin auth login` sonrası CLI'nin kendisi taşır, bu yüzden opencodex hiçbir anahtar saklamaz. Çalıştırılabilir dosya `OPENCODEX_DEVIN_CLI_BIN` ile belirtilir; CLI'nin dosya okuyup yazmasına izin vermek için `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` açıkça ayarlanmalıdır, varsayılan reddetmektir. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Deneysel. GitHub cihaz akışı + `copilot_internal` değişimi (VS Code OAuth istemcisi). Aktif bir Copilot aboneliği gerektirir; resmi bir üçüncü taraf API değildir. | diff --git a/docs-site/src/content/docs/tr/reference/adapters.md b/docs-site/src/content/docs/tr/reference/adapters.md index 8d4e65e696..4040ce4157 100644 --- a/docs-site/src/content/docs/tr/reference/adapters.md +++ b/docs-site/src/content/docs/tr/reference/adapters.md @@ -308,6 +308,16 @@ başlığından Cursor OAuth/erişim belirteci. `unsafeAllowNativeLocalExec: true` yalnızca `nativeLocalExec` ayarlanmadığında eşdeğer kalır. +## `devin` + +**Hedef:** Cognition'ın `exa.api_server_pb.ApiServerService/GetChatMessage` uç noktası; `server.codeium.com` üzerinde Connect akışı. +**Kimlik doğrulama:** `provider.apiKey` veya iletilen authorization başlığındaki Devin/Cognition API anahtarı. Giriş tarayıcıda Auth0 oturumunu açar, ardından belirteci `SeatManagementService.RegisterUser` ile uzun ömürlü bir anahtara dönüştürür. + +- Olağan fetch/parse yolu yerine `runTurn` kullanır. İstekler ve sunucu olayları `devin/cloud-direct/wire.ts` içindeki elle yazılmış protobuf çerçevelemesiyle işlenir. +- Modeller hesaba göre `GetCascadeModelConfigs` ile keşfedilir; pakette olmayanlar istek anında hata vermek yerine listeden düşer. +- Cognition araç açıklamaları için uzunluk sınırı ve birebir ifade engeli uygular. Bağdaştırıcı bilinen ifadeleri yeniden yazar, uzun açıklamaları kırpar. +- Anahtarlar yenilenmez. Süresi dolduğunda veya iptal edildiğinde `ocx login devin` komutunu yeniden çalıştırın. + ## `azure-openai` (takma ad: `azure`) **Hedefler:** **Azure OpenAI**. `openai-responses`'ı sarar (bu nedenle diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index afdc1738a6..643ee7a351 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -91,6 +91,7 @@ ocx login google-antigravity ocx login cursor # 独立的 Cursor PKCE 登录 ocx login command-code # Command Code 浏览器 OAuth(或导入 ~/.commandcode/auth.json) ocx login orcarouter-oauth # OrcaRouter 浏览器授权 + PKCE +ocx login devin # Cognition/Devin 的 Auth0 浏览器登录 ocx login github-copilot # GitHub 设备流 → Copilot 令牌(Copilot Pro/Business) ocx login codex # Codex 账号池(别名:chatgpt、openai;需要代理正在运行) ocx logout @@ -106,6 +107,7 @@ ocx logout | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | 浏览器授权与密钥交换走 `https://www.orcarouter.ai` + S256 PKCE。交换结果是用户自己的普通 `sk-orca-…` API key,保存在现有凭据库中并持续复用,直到被撤销。 | +| `devin` | `devin` | `https://server.codeium.com` | 实验性的非官方 Cognition/Devin 桥接。登录会打开 Auth0 浏览器页面,再用 `RegisterUser` 把令牌换成长期 API 密钥。模型列表按账号通过 `GetCascadeModelConfigs` 实时获取,流式仅走 Connect-RPC 上的 `runTurn` 路径。默认不在仪表盘预设中,需要手动启用。 | | `devin-cli` | `devin-cli` | `https://cli.devin.ai` | 通过 Agent Client Protocol(`devin acp`,stdio 上的 JSON-RPC)驱动本地安装的 Devin CLI。凭据由 CLI 自己通过 `devin auth login` 持有,opencodex 不保存密钥。可用 `OPENCODEX_DEVIN_CLI_BIN` 指定可执行文件;要允许 CLI 读写文件,必须显式设置 `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`,默认拒绝。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 61fab20c1f..730bf04aa6 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -194,6 +194,16 @@ Cursor 的 HTTP/1.1 兼容传输:通过 `agent.v1.AgentService/RunSSE` 接收 executor,并绕过 Codex 审批和 sandbox 语义;旧的 `unsafeAllowNativeLocalExec: true` 仅在 `nativeLocalExec` 未设置时等同。 +## `devin` + +**目标:** Cognition 的 `exa.api_server_pb.ApiServerService/GetChatMessage`(`server.codeium.com`,Connect 流式)。 +**认证:** 来自 `provider.apiKey` 或转发的 authorization 头的 Devin/Cognition API 密钥。登录会打开 Auth0 浏览器页面,再通过 `SeatManagementService.RegisterUser` 换取长期密钥。 + +- 使用 `runTurn` 而非常规的 fetch/parse 路径。请求与服务端事件由 `devin/cloud-direct/wire.ts` 手写的 protobuf 分帧处理。 +- 通过 `GetCascadeModelConfigs` 按账号获取模型;不在套餐内的模型在列表阶段就被过滤,而不是到请求时才失败。 +- Cognition 对工具说明有长度上限和精确短语黑名单。适配器会改写已知短语并截断过长的说明。 +- 密钥不会刷新。失效后请重新执行 `ocx login devin`。 + ## `azure-openai`(别名:`azure`) **目标:** **Azure OpenAI**。封装 `openai-responses`,因此同样是 `passthrough: true`。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 6a02d43546..7487dfac3a 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -97,6 +97,7 @@ ocx login kiro # 匯入 kiro-cli credential(或 token fallback) ocx login google-antigravity ocx login cursor # 獨立 Cursor PKCE 登入 ocx login command-code # Command Code browser OAuth(或匯入 ~/.commandcode/auth.json) +ocx login devin # Cognition/Devin 的 Auth0 瀏覽器登入 ocx login github-copilot # GitHub device flow → Copilot token(Copilot Pro/Business) ocx login codex # Codex 帳號池(別名:chatgpt、openai;需要 proxy 正在執行) ocx logout @@ -111,6 +112,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初次登入會匯入已安裝且已登入的 `kiro-cli` session。Unix 可用 `curl -fsSL https://cli.kiro.dev/install` | `bash` 安裝;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`,再執行 `kiro-cli login`。**Add account** 會先登出 `kiro-cli`、啟動新的 browser login,切換 `kiro-cli` 所使用的帳號並保存 account-scoped profile metadata。既有 OpenCodex 帳號會保留;取消或失敗時會恢復先前的 `kiro-cli` session。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 實驗性 PKCE 登入、即時 HTTP/2 transport 與按帳號篩選的模型探索。 | +| `devin` | `devin` | `https://server.codeium.com` | 實驗性的非官方 Cognition/Devin 橋接。登入會開啟 Auth0 瀏覽器頁面,再以 `RegisterUser` 將權杖換成長期 API 金鑰。模型清單依帳號透過 `GetCascadeModelConfigs` 即時取得,串流僅走 Connect-RPC 上的 `runTurn` 路徑。預設不在儀表板預設集內,需手動啟用。 | | `devin-cli` | `devin-cli` | `https://cli.devin.ai` | 透過 Agent Client Protocol(`devin acp`,stdio 上的 JSON-RPC)驅動本機安裝的 Devin CLI。憑證由 CLI 以 `devin auth login` 自行保管,opencodex 不會儲存金鑰。可用 `OPENCODEX_DEVIN_CLI_BIN` 指定執行檔;要允許 CLI 讀寫檔案,必須明確設定 `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`,預設為拒絕。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 實驗性。GitHub device flow + `copilot_internal` exchange(VS Code OAuth client)。需要有效 Copilot 訂閱;不是官方第三方 API。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/adapters.md b/docs-site/src/content/docs/zh-tw/reference/adapters.md index c74a22304a..6fe65246b0 100644 --- a/docs-site/src/content/docs/zh-tw/reference/adapters.md +++ b/docs-site/src/content/docs/zh-tw/reference/adapters.md @@ -166,6 +166,16 @@ Kiro 的 assistant 文字本身沒有可靠的回合結束標記,但終止的 executor,並繞過 Codex 審批和 sandbox 語義;舊的 `unsafeAllowNativeLocalExec: true` 僅在 `nativeLocalExec` 未設定時等效。 +## `devin` + +**目標:** Cognition 的 `exa.api_server_pb.ApiServerService/GetChatMessage`(`server.codeium.com`,Connect 串流)。 +**認證:** 來自 `provider.apiKey` 或轉送 authorization 標頭的 Devin/Cognition API 金鑰。登入會開啟 Auth0 瀏覽器頁面,再透過 `SeatManagementService.RegisterUser` 換取長期金鑰。 + +- 使用 `runTurn` 而非一般的 fetch/parse 路徑。請求與伺服器事件由 `devin/cloud-direct/wire.ts` 手寫的 protobuf 分幀處理。 +- 以 `GetCascadeModelConfigs` 依帳號取得模型;方案未涵蓋的模型在清單階段就被濾除。 +- Cognition 對工具說明設有長度上限與完全比對的封鎖清單。轉接器會改寫已知語句並截斷過長說明。 +- 金鑰不會更新。失效後請重新執行 `ocx login devin`。 + ## `azure-openai`(別名:`azure`) **目標:** **Azure OpenAI**。封裝 `openai-responses`,因此同樣是 `passthrough: true`。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9b79f96b6a..23ed0c910c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -619,7 +619,9 @@ "desktop-profile.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", + "devin-adapter.test.ts": "providers", "devin-cli-adapter.test.ts": "providers", + "devin-hardening.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts new file mode 100644 index 0000000000..6ca903c29d --- /dev/null +++ b/src/adapters/devin.ts @@ -0,0 +1,319 @@ +/** + * Devin / Cognition / Windsurf adapter. + * + * Uses the unofficial cloud-direct Connect-RPC client (GetChatMessage). + * OpenCodex injects the OAuth API key onto provider.apiKey + * before runTurn. This adapter maps OcxContext <-> ChatHistoryItem and + * streams CloudChatEvent into AdapterEvent. + */ +import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxToolCall, OcxToolResultMessage, OcxUsage } from "../types"; +import type { IncomingMeta, ProviderAdapter } from "./base"; +import { streamChatEvents, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct"; +import { getCachedCatalog } from "./devin/cloud-direct/catalog"; +import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiServer } from "../oauth/devin"; + +export const DEVIN_API_SERVER = DEVIN_DEFAULT_API_SERVER; + +const EFFORT_SUFFIXES = new Set(["low", "medium", "high", "xhigh", "max", "none", "1m", "max-1m", "none-1m", "fast"]); + +/** + * Cognition's catalog spells model ids with hyphens (`swe-1-7`), but the same + * models appear elsewhere - other proxies, hand-written config - with the dotted + * version number (`swe-1.7`). Left alone, a dotted id misses every catalog + * lookup and then gets an effort suffix appended to a name the server does not + * know, which Cognition answers with an opaque permission_denied. + */ +export function normalizeDevinModelId(modelId: string): string { + return modelId.replace(/\./g, "-"); +} + +function hasEffortSuffix(modelId: string): boolean { + const parts = modelId.split("-"); + return parts.length > 1 && EFFORT_SUFFIXES.has(parts[parts.length - 1]!); +} + +/** + * Resolve the wire model UID using the live catalog as the source of truth. + * Cognition's catalog lists most models with an effort suffix + * (e.g. `gpt-5-6-sol-high`); the base id alone is not accepted for those. + * + * If the catalog is available: use the exact UID when it exists, otherwise + * append the reasoning effort (or `medium` default) and pick a variant the + * account actually has. + * + * If the catalog is unavailable (degraded mode): append the effort suffix + * for any base id that doesn't already carry one, mirroring the catalog shape. + */ +async function resolveWireModelUid( + rawModelId: string, + apiKey: string, + host: string, + reasoningEffort?: string, +): Promise { + const modelId = normalizeDevinModelId(rawModelId); + if (hasEffortSuffix(modelId)) return modelId; + const catalog = await getCachedCatalog(apiKey, host); + if (catalog) { + if (catalog.byUid.has(modelId)) return modelId; + const effort = reasoningEffort && EFFORT_SUFFIXES.has(reasoningEffort) ? reasoningEffort : "medium"; + const suffixed = `${modelId}-${effort}`; + if (catalog.byUid.has(suffixed)) return suffixed; + // Fall back to any enabled variant of this base model. + for (const uid of catalog.byUid.keys()) { + if (uid.startsWith(modelId + "-") && !catalog.byUid.get(uid)?.disabled) return uid; + } + } + // Degraded mode: append the default effort suffix. + const effort = reasoningEffort && EFFORT_SUFFIXES.has(reasoningEffort) ? reasoningEffort : "medium"; + return `${modelId}-${effort}`; +} + +export class DevinMissingCredentialError extends Error { + constructor() { + super("Devin live transport requires a Devin API key. Run ocx login devin to sign in with your Cognition/Devin account."); + this.name = "DevinMissingCredentialError"; + } +} + +export function resolveDevinToken(provider: OcxProviderConfig, headers?: Headers): string { + const providerKey = provider.apiKey?.trim(); + if (providerKey) return providerKey; + const forwarded = headers?.get("authorization") ?? headers?.get("Authorization"); + if (forwarded?.toLowerCase().startsWith("bearer ")) return forwarded.slice("bearer ".length).trim(); + const envToken = process.env.OPENCODEX_DEVIN_TEST_TOKEN?.trim(); + if (envToken) return envToken; + throw new DevinMissingCredentialError(); +} + +function textFromParts(content: string | OcxContentPart[] | undefined): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.map((part) => (part.type === "text" ? part.text : "")).filter(Boolean).join("\n"); +} + +function toolResultText(message: OcxToolResultMessage): string { + const body = textFromParts(message.content); + return message.isError ? ("ERROR: " + body) : body; +} + +function assistantToolCalls(message: OcxAssistantMessage): Array<{ id: string; name: string; arguments: string }> { + return message.content + .filter((part): part is OcxToolCall => part.type === "toolCall") + .map((part) => ({ + id: part.id, + name: part.name, + arguments: JSON.stringify(part.arguments ?? {}), + })); +} + +function assistantText(message: OcxAssistantMessage): string { + return message.content + // Thinking stays out of the replayed content. Cognition has no reasoning + // replay field, and folding chain-of-thought into assistant text sends it + // back as visible prior output - which the model then treats as something + // it said to the user. + .map((part) => (part.type === "text" ? part.text : "")) + .filter(Boolean) + .join("\n"); +} + +export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem[] { + const items: ChatHistoryItem[] = []; + const system = parsed.context.systemPrompt?.filter((line) => line.trim().length > 0).join("\n"); + if (system) items.push({ role: "system", content: system }); + + for (const message of parsed.context.messages) { + const mapped = mapOneMessage(message); + if (mapped) items.push(mapped); + } + return items; +} + +function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined { + if (message.role === "user" || message.role === "developer") { + const text = textFromParts(message.content).trim(); + if (!text) return undefined; + return { role: message.role === "developer" ? "system" : "user", content: text }; + } + if (message.role === "assistant") { + const toolCalls = assistantToolCalls(message); + const text = assistantText(message); + if (!text && toolCalls.length === 0) return undefined; + return { + role: "assistant", + content: text || "", + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), + }; + } + if (message.role === "toolResult") { + return { + role: "tool", + content: toolResultText(message), + tool_call_id: message.toolCallId, + }; + } + return undefined; +} + +export function mapOcxToolsToDevin(tools: OcxTool[] | undefined): ToolDef[] | undefined { + if (!tools || tools.length === 0) return undefined; + return tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? "", + parameters: tool.parameters ?? { type: "object", properties: {} }, + })); +} + +export function createDevinAdapter(provider: OcxProviderConfig): ProviderAdapter { + const cascadeIds = new Map(); + const CASCADE_ID_MAX = 256; + + return { + name: "devin", + + buildRequest() { + return { + url: provider.baseUrl || DEVIN_API_SERVER, + method: "POST", + headers: {}, + body: "", + }; + }, + + async *parseStream(): AsyncGenerator { + yield { + type: "error", + message: "Devin adapter uses runTurn; the fetch/parseStream path is disabled.", + }; + }, + + async runTurn(parsed: OcxParsedRequest, incoming: IncomingMeta, emit: (event: AdapterEvent) => void) { + if (incoming.abortSignal?.aborted) { + emit({ type: "error", message: "Devin turn was aborted before start." }); + return; + } + let apiKey: string; + try { + apiKey = resolveDevinToken(provider, incoming.headers); + } catch (error) { + emit({ type: "error", message: error instanceof Error ? error.message : String(error) }); + return; + } + + const threadKey = parsed._clientThreadId || parsed.previousResponseId || "default"; + let cascadeId = cascadeIds.get(threadKey); + if (!cascadeId) { + // Evict oldest entries to bound memory in long-running proxy processes. + if (cascadeIds.size >= CASCADE_ID_MAX) { + const firstKey = cascadeIds.keys().next().value; + if (firstKey) cascadeIds.delete(firstKey); + } + cascadeId = allocateCascadeId(); + cascadeIds.set(threadKey, cascadeId); + } + + const rawModelId = parsed.modelId.includes("/") ? parsed.modelId.slice(parsed.modelId.lastIndexOf("/") + 1) : parsed.modelId; + // The signed-in account's tenant decides the host, not the static registry + // entry: an EU or FedStart account that used provider.baseUrl would send + // every RPC to the US server it is not provisioned on. + const host = resolveDevinApiServer(provider.baseUrl); + const modelUid = await resolveWireModelUid(rawModelId, apiKey, host, parsed.options.reasoning); + let openToolId: string | undefined; + let usage: OcxUsage | undefined; + let stopReason: string | undefined; + + const closeOpenTool = () => { + if (!openToolId) return; + emit({ type: "tool_call_end" }); + openToolId = undefined; + }; + + try { + for await (const event of streamChatEvents({ + apiKey, + apiServerUrl: host, + modelUid, + messages: mapOcxMessagesToDevin(parsed), + tools: mapOcxToolsToDevin(parsed.context.tools), + cascadeId, + // Without these the request falls back to the encoder's defaults + // (8192 output, a 128k context window, temperature 0.7), so a client + // that asked for a 4k cap never got one. + completionOpts: { + ...(typeof parsed.options.maxOutputTokens === "number" ? { maxOutputTokens: parsed.options.maxOutputTokens } : {}), + ...(typeof parsed.options.temperature === "number" ? { temperature: parsed.options.temperature } : {}), + ...(typeof parsed.options.topP === "number" ? { topP: parsed.options.topP } : {}), + }, + signal: incoming.abortSignal, + })) { + if (incoming.abortSignal?.aborted) { + // Emitting nothing here left the bridge to synthesize adapter_eof. + // Say what happened instead, the way the other runTurn-only adapter + // does, and carry any usage already seen. + closeOpenTool(); + emit({ type: "error", message: "Devin turn was aborted.", ...(usage ? { usage } : {}) }); + return; + } + if (event.kind === "text") { + closeOpenTool(); + if (event.text) emit({ type: "text_delta", text: event.text }); + continue; + } + if (event.kind === "reasoning") { + if (event.text) emit({ type: "thinking_delta", thinking: event.text }); + continue; + } + if (event.kind === "tool_call_start") { + closeOpenTool(); + openToolId = event.id; + emit({ type: "tool_call_start", id: event.id, name: event.name }); + continue; + } + if (event.kind === "tool_call_args") { + if (event.argsDelta) emit({ type: "tool_call_delta", arguments: event.argsDelta }); + continue; + } + if (event.kind === "finish") { + closeOpenTool(); + // A natural completion carries no stopReason: the bridge reads any + // truthy value as "this turn did not reach a final answer", so + // reporting "stop" costs every clean Devin turn its final_answer + // phase. + stopReason = event.reason === "length" ? "max_tokens" : event.reason === "stop" ? undefined : event.reason; + continue; + } + if (event.kind === "usage") { + const total = event.totalTokens ?? ((event.promptTokens ?? 0) + (event.completionTokens ?? 0)); + usage = { + inputTokens: event.promptTokens ?? 0, + outputTokens: event.completionTokens ?? 0, + ...(total > 0 ? { totalTokens: total } : {}), + ...(event.cachedInputTokens !== undefined ? { cachedInputTokens: event.cachedInputTokens } : {}), + ...(event.cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens: event.cacheCreationInputTokens } : {}), + ...(event.reasoningTokens !== undefined ? { reasoningOutputTokens: event.reasoningTokens } : {}), + }; + continue; + } + } + closeOpenTool(); + if (incoming.abortSignal?.aborted) { + emit({ type: "error", message: "Devin turn was aborted.", ...(usage ? { usage } : {}) }); + } else { + emit({ type: "done", ...(usage ? { usage } : {}), ...(stopReason ? { stopReason } : {}) }); + } + } catch (error) { + closeOpenTool(); + if (incoming.abortSignal?.aborted) { + emit({ type: "error", message: "Devin turn was aborted.", ...(usage ? { usage } : {}) }); + return; + } + const message = error instanceof CloudChatError + ? ("Devin cloud error" + (error.code ? " " + error.code : "") + ": " + error.message) + : error instanceof Error ? error.message : String(error); + // Usage that already arrived is still real; dropping it loses the + // accounting for a turn that did most of its work before failing. + emit({ type: "error", message, ...(usage ? { usage } : {}) }); + } + }, + }; +} diff --git a/src/adapters/devin/cloud-direct/auth.ts b/src/adapters/devin/cloud-direct/auth.ts new file mode 100644 index 0000000000..4aa4e02a57 --- /dev/null +++ b/src/adapters/devin/cloud-direct/auth.ts @@ -0,0 +1,264 @@ +/* + * Derived from rsvedant/opencode-windsurf-auth (src/cloud-direct/), MIT licensed, + * Copyright (c) 2026 Vedant. The full notice is in ./index.ts. + */ +/** + * Mint the short-lived `user_jwt` that accompanies the persistent OAuth-issued + * `api_key`. The catalog RPC uses it. The hosted chat path does not need it and + * only sends it when an operator opts in, so a mint failure here cannot take + * down a turn. + * + * POST https://server.codeium.com/exa.auth_pb.AuthService/GetUserJwt + * Content-Type: application/proto ← unary, NOT streaming + * Body: GetUserJwtRequest { metadata: Metadata } + * Response: GetUserJwtResponse { user_jwt: string } (field 1) + * + * The returned JWT has a payload like: + * { + * "api_key": "devin-synthetic-apikey$account-…$user-…", + * "auth_uid": "devin-auth-uid$…", + * "email": "user@example.com", + * "exp": , ← ~24 minute TTL + * "pro": true, + * "teams_tier": "TEAMS_TIER_DEVIN_PRO", + * ... + * } + * + * The JWT is signed HS256 by the server — can't be forged client-side. We + * cache it and refresh shortly before `exp`. + */ + +import * as crypto from 'crypto'; +import { encodeMessage, iterFields } from './wire.js'; +import { buildMetadata } from './metadata.js'; +import { anySignal } from '../../../lib/abort.js'; +import { validateDevinApiBaseUrl } from '../../../oauth/devin/api-base.js'; + +const DEFAULT_HOST = 'https://server.codeium.com'; + +export interface MintedUserJwt { + jwt: string; + /** Unix epoch seconds when the JWT expires. */ + expiresAt: number; +} + +export class CloudAuthError extends Error { + constructor(message: string, public readonly status?: number) { + super(message); + this.name = 'CloudAuthError'; + } +} + +/** + * Default mint timeout — 30s is generous (the endpoint responds in ~200ms + * in steady state) but enough headroom for slow networks. Callers can pass + * a tighter `signal` to override. + */ +const MINT_TIMEOUT_MS = 30_000; + +/** + * Mint a fresh user_jwt by calling exa.auth_pb.AuthService/GetUserJwt. + * `host` defaults to https://server.codeium.com — pass your tenant URL if your + * RegisterUser response gave a different host. + * + * Always applies an internal 30s timeout so a network stall here can't + * deadlock every concurrent chat request. If the caller passes a `signal`, + * we honor whichever fires first via AbortSignal.any. + */ +export async function mintUserJwt( + apiKey: string, + host: string = DEFAULT_HOST, + signal?: AbortSignal, +): Promise { + const metadata = buildMetadata({ + apiKey, + sessionId: crypto.randomUUID(), + requestId: BigInt(Date.now()), + triggerId: crypto.randomUUID(), + }); + // GetUserJwtRequest { metadata: Metadata } — Metadata is field 1 + const req = encodeMessage(1, metadata); + + // Compose caller signal with our internal timeout via `anySignal` — a + // small polyfill of `AbortSignal.any` for runtimes (Node 18 / older + // Bun) that lack the built-in. The previous fallback silently dropped + // the CALLER's signal on those runtimes, so a chat-cancel during a + // GetUserJwt mint would keep the network request alive for up to the + // full 30s timeout. + const timeoutSignal = AbortSignal.timeout(MINT_TIMEOUT_MS); + const composed = signal ? anySignal([signal, timeoutSignal]) : undefined; + const combinedSignal: AbortSignal = composed?.signal ?? timeoutSignal; + + // The host arrives from RegisterUser via the credential store. It is checked + // again here because this request carries the long-lived api_key inside the + // protobuf body, and a host that slipped past persistence would exfiltrate it. + const base = validateDevinApiBaseUrl(host); + if (!base) { + throw new CloudAuthError(`Refusing to mint a user_jwt against a non-Cognition host.`); + } + let resp: Response; + try { + resp = await fetch(`${base}/exa.auth_pb.AuthService/GetUserJwt`, { + method: 'POST', + headers: { + 'Content-Type': 'application/proto', + 'Connect-Protocol-Version': '1', + }, + body: new Uint8Array(req), + // A redirect would replay this POST - whose body holds the api_key - at + // whatever host Location names. + redirect: 'error', + signal: combinedSignal, + }); + } finally { + // The caller's signal belongs to a whole turn; do not keep a listener on it. + composed?.cleanup(); + } + const buf = Buffer.from(await resp.arrayBuffer()); + + if (!resp.ok) { + // The body is not echoed. A Connect error here can quote the request, and + // the request contains the api_key; this message reaches CLI output, the + // adapter's error event, and /api/logs. + throw new CloudAuthError(`GetUserJwt failed (HTTP ${resp.status})`, resp.status); + } + + // Response is GetUserJwtResponse { user_jwt: string } where user_jwt is + // field 1, length-delimited. Decode the field properly instead of + // regex-scanning the whole buffer — the previous regex would pick up + // any JWT-shaped substring in the response (trace IDs, signature + // headers, any cached token inadvertently logged) and could even land + // on a non-user_jwt if Cognition ever embeds another JWT in a sibling + // field. + let jwt: string | null = null; + for (const f of iterFields(buf)) { + if (f.num === 1 && f.wire === 2 && Buffer.isBuffer(f.value)) { + const s = (f.value as Buffer).toString('utf8'); + // Sanity-check the shape — defensive: if the cloud ever moves user_jwt + // out from field 1 we want a clean error, not silently wrong creds. + // base64url with OPTIONAL `=` padding on each segment. Most modern + // JWTs omit the `=`, but the spec allows it and a future server-side + // change could re-introduce it; either way it's still a valid token. + if (/^eyJ[A-Za-z0-9_-]{10,}={0,2}\.[A-Za-z0-9_-]+={0,2}\.[A-Za-z0-9_-]+={0,2}$/.test(s)) { + jwt = s; + break; + } + } + } + if (!jwt) { + throw new CloudAuthError( + // Same reason: a 200 whose field-1 value failed the shape check may still + // be a live token, so only the size is reported. + `GetUserJwt returned 200 without a usable field-1 JWT (${buf.length} bytes)`, + ); + } + + // Decode the payload to get the expiry. + let expiresAt = Math.floor(Date.now() / 1000) + 600; // fallback: 10 min + try { + const parts = jwt.split('.'); + const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4); + const payload = JSON.parse( + Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'), + ); + if (typeof payload.exp === 'number') expiresAt = payload.exp; + } catch { /* fall back to default */ } + + return { jwt, expiresAt }; +} + +// ---------------------------------------------------------------------------- +// In-memory cache — refresh ~60s before expiry +// ---------------------------------------------------------------------------- + +interface CacheEntry { + jwt: string; + expiresAt: number; + apiKey: string; + host: string; +} + +/** + * Cache is keyed by (apiKey, host). A single shared `cache` slot only holds + * the MOST RECENTLY USED entry — common case is one account at a time, so + * a single slot is enough. inFlight is a per-key map so a JWT mint for + * account A doesn't get returned to a concurrent request for account B. + * + * Previously `inFlight` was a singleton — if account A's mint was in flight + * and a request for account B arrived, B got A's JWT. That's the M1 + * "concurrent requests after account switch get wrong JWT" bug. + */ +let cache: CacheEntry | null = null; +const inFlight = new Map>(); +/** + * Monotonic epoch counter. Incremented on every `clearCachedUserJwt()` + * call so an in-flight mint that started BEFORE the clear can't + * repopulate the cache after-the-fact. Without this, a logout that + * happened concurrently with a mint would silently get its just- + * invalidated JWT cached and served for the next ~24 minutes. + */ +let cacheEpoch = 0; + +function flightKey(apiKey: string, host: string): string { + return `${host}\x1f${apiKey}`; +} + +/** + * Get a cached user_jwt or mint a new one. Refreshes when the cached JWT is + * within 60s of expiry. Multiple concurrent callers for the SAME (apiKey, host) + * share the same in-flight mint; concurrent callers for DIFFERENT keys each + * get their own mint. + */ +export async function getCachedUserJwt(apiKey: string, host: string = DEFAULT_HOST, signal?: AbortSignal): Promise { + const now = Math.floor(Date.now() / 1000); + if (cache && cache.apiKey === apiKey && cache.host === host && cache.expiresAt > now + 60) { + return cache.jwt; + } + // Race the caller's signal against the shared promise so one caller's + // cancellation doesn't propagate to unrelated callers sharing the mint. + // mintUserJwt has its own MINT_TIMEOUT_MS guard for the shared lifetime. + const raceSignal = (p: Promise): Promise => + signal + ? Promise.race([ + p, + new Promise((_, reject) => { + if (signal.aborted) reject(signal.reason); + else signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + ]) + : p; + const key = flightKey(apiKey, host); + const existing = inFlight.get(key); + if (existing) { + const minted = await raceSignal(existing); + return minted.jwt; + } + const promise = mintUserJwt(apiKey, host); + inFlight.set(key, promise); + // Snapshot the epoch BEFORE awaiting the mint. If clearCachedUserJwt() + // fires while we're awaiting (logout-during-mint), the epoch changes + // and we won't repopulate the cache with the just-invalidated JWT. + const epochAtStart = cacheEpoch; + try { + const minted = await raceSignal(promise); + if (cacheEpoch === epochAtStart) { + cache = { jwt: minted.jwt, expiresAt: minted.expiresAt, apiKey, host }; + } + return minted.jwt; + } finally { + inFlight.delete(key); + } +} + +/** + * Drop the in-memory JWT cache. Call after credential changes (logout, + * account switch) so long-running opencode processes don't keep using a + * JWT minted from a now-invalid api_key. Also bumps the cache epoch so + * any in-flight mint racing with this clear can't repopulate cache + * with the stale JWT after-the-fact. + */ +export function clearCachedUserJwt(): void { + cache = null; + inFlight.clear(); + cacheEpoch++; +} diff --git a/src/adapters/devin/cloud-direct/catalog.ts b/src/adapters/devin/cloud-direct/catalog.ts new file mode 100644 index 0000000000..665b896780 --- /dev/null +++ b/src/adapters/devin/cloud-direct/catalog.ts @@ -0,0 +1,279 @@ +/* + * Derived from rsvedant/opencode-windsurf-auth (src/cloud-direct/), MIT licensed, + * Copyright (c) 2026 Vedant. The full notice is in ./index.ts. + */ +/** + * Per-account model catalog from Cognition's `GetCascadeModelConfigs`. + * + * Why this exists — issue #14: + * The cloud's `GetChatMessage` returns a single Connect-streaming EOS frame + * containing `{"error":{"code":"permission_denied","message":"an internal + * error occurred (trace ID: )"}}` whenever the caller's account tier + * does not include the requested `model_uid`. Reproduced byte-identical on a + * `TEAMS_TIER_DEVIN_FREE` account for every Anthropic/Gemini/Premium UID + * (only `swe-1-6-slow` streamed a real reply). The user-facing message is + * indistinguishable from a transient server fault — issue #14's reporter + * spent multiple sessions guessing. + * + * The pre-flight here checks the per-account catalog (`disabled` flag on + * `ClientModelConfig` field #4) BEFORE we spend a roundtrip on a request + * the cloud will refuse. When the lookup fails (network, auth, schema + * drift) we silently fall back to the chat path so a transient catalog + * outage can't take chat down with it. + * + * Schema (verified against the bundled `extension.js`, + * `exa.codeium_common_pb.ClientModelConfig`): + * + * GetCascadeModelConfigsResponse { + * #1 client_model_configs: repeated ClientModelConfig + * } + * ClientModelConfig { + * #1 label string + * #4 disabled bool ← the gate this module reads + * #22 model_uid string ← what `GetChatMessage` accepts + * } + * + * Disabled semantics: TRUE means "this UID exists in the catalog but the + * caller's account/tier cannot run inference against it." BYOK models + * surface as `disabled: false` so users with their own provider keys still + * pass through — the only way they fail at chat time is a missing key, + * which surfaces with a different message. + * + * Cache: per (apiServerUrl, apiKey) for {@link CATALOG_TTL_MS}. Cognition + * doesn't bump catalog entries mid-session in normal operation, so a 10-min + * TTL trades one extra roundtrip per ~10 min for clear errors on every chat. + */ + +import * as crypto from 'crypto'; +import { buildMetadata } from './metadata.js'; +import { getCachedUserJwt } from './auth.js'; +import { encodeMessage, iterFields } from './wire.js'; +import { resolveDevinApiBaseUrl } from '../../../oauth/devin/api-base.js'; + +/** 10 minutes — see header. */ +const CATALOG_TTL_MS = 10 * 60 * 1000; + +/** Catalog endpoint inactivity timeout. Cognition responds in <500ms steady-state. */ +const CATALOG_FETCH_TIMEOUT_MS = 10_000; + +export interface ModelCatalogEntry { + /** Cloud-side `model_uid` (e.g. `claude-opus-4-7-medium`). */ + modelUid: string; + /** Human label (e.g. `Claude Opus 4.7 Medium`) — used in error messages. */ + label: string; + /** True when the caller's account tier cannot use this UID for chat. */ + disabled: boolean; +} + +export interface CacheEntry { + /** Lookup keyed by `model_uid`. */ + byUid: Map; + fetchedAt: number; + /** Cache key components, captured for invalidation/log purposes. */ + apiKey: string; + host: string; +} + +let cached: CacheEntry | null = null; +let inFlight: Promise | null = null; +let inFlightKey: string | null = null; +// Bumped on clearCachedCatalog so an in-flight fetch racing with a clear +// can't repopulate the cache with a just-invalidated catalog. +let cacheEpoch = 0; + +function flightKey(apiKey: string, host: string): string { + return `${host}\x1f${apiKey}`; +} + +/** + * Parse a GetCascadeModelConfigsResponse buffer into a UID-keyed map. + * A malformed catalog returns an empty map. + */ +function parseCatalogBuffer(buf: Buffer, apiKey: string, host: string): CacheEntry { + // GetCascadeModelConfigsResponse #1 (repeated ClientModelConfig) + const byUid = new Map(); + for (const f of iterFields(buf)) { + if (f.num !== 1 || f.wire !== 2 || !Buffer.isBuffer(f.value)) continue; + let label = ''; + let modelUid = ''; + let disabled = false; + for (const sf of iterFields(f.value as Buffer)) { + if (sf.num === 1 && sf.wire === 2 && Buffer.isBuffer(sf.value)) { + label = (sf.value as Buffer).toString('utf8'); + } else if (sf.num === 4 && sf.wire === 0) { + // #4 = disabled (bool, varint 0/1) + disabled = sf.value === 1n; + } else if (sf.num === 22 && sf.wire === 2 && Buffer.isBuffer(sf.value)) { + modelUid = (sf.value as Buffer).toString('utf8'); + } + } + if (modelUid.length > 0) { + byUid.set(modelUid, { modelUid, label: label || modelUid, disabled }); + } + } + return { byUid, fetchedAt: Date.now(), apiKey, host }; +} + +/** + * Fetch the cascade model catalog for `(apiKey, host)` and parse the + * subset of `ClientModelConfig` we care about into a UID-keyed map. + * + * Throws on transport/auth failure so the caller can decide whether to fall + * back to "skip pre-flight". Does NOT throw on an unexpected response body — + * a malformed catalog returns an empty map, treated the same as "model not + * listed" by the chat pre-flight. + * + * Uses only an internal timeout — caller cancellation is handled by + * getCachedCatalog racing each caller's signal against the shared promise. + */ +async function fetchCatalog(apiKey: string, host: string): Promise { + const userJwt = await getCachedUserJwt(apiKey, host); + + const metadata = buildMetadata({ + apiKey, + userJwt, + sessionId: crypto.randomUUID(), + requestId: BigInt(Date.now()), + triggerId: crypto.randomUUID(), + }); + // GetCascadeModelConfigsRequest { metadata: Metadata } — Metadata is #1. + const reqBody = encodeMessage(1, metadata); + + // Internal 10s timeout so a stalled catalog endpoint can't deadlock chat. + // The shared fetch uses only this internal timeout — caller cancellation is + // handled by racing each caller's signal against the shared promise in + // getCachedCatalog, so one caller's abort never propagates to unrelated + // callers sharing the same in-flight fetch. + const ac = new AbortController(); + const timer = setTimeout( + () => ac.abort(new Error(`catalog: fetch timeout (${CATALOG_FETCH_TIMEOUT_MS}ms)`)), + CATALOG_FETCH_TIMEOUT_MS, + ); + + let resp: Response; + try { + resp = await fetch(`${resolveDevinApiBaseUrl(host)}/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs`, { + method: 'POST', + headers: { 'Content-Type': 'application/proto', 'Connect-Protocol-Version': '1' }, + body: new Uint8Array(reqBody), + // This body carries the api_key; a redirect would replay it elsewhere. + redirect: 'error', + signal: ac.signal, + }); + if (!resp.ok) { + // Status only: the error body can quote the api_key-bearing request. + throw new Error(`GetCascadeModelConfigs failed (HTTP ${resp.status})`); + } + // Read the body BEFORE clearing the timeout — fetch resolves on headers, + // not body completion. A stalled body would otherwise block indefinitely. + const buf = Buffer.from(await resp.arrayBuffer()); + return parseCatalogBuffer(buf, apiKey, host); + } finally { + clearTimeout(timer); + } +} + +/** + * Get the cached catalog for `(apiKey, host)`, fetching when missing or stale. + * + * Concurrent callers for the SAME (apiKey, host) share one in-flight fetch + * (no thundering herd on startup). Concurrent callers for DIFFERENT keys + * serialise the in-flight slot but only one of them holds it at a time — + * good enough for opencode's single-account-at-a-time usage pattern. + * + * Returns `null` on fetch failure (network, transient 5xx, auth issue). The + * caller treats `null` as "skip pre-flight and let the chat path surface the + * server-side error itself." + */ +export async function getCachedCatalog( + apiKey: string, + host: string, + signal?: AbortSignal, +): Promise { + if (cached && cached.apiKey === apiKey && cached.host === host) { + if (Date.now() - cached.fetchedAt < CATALOG_TTL_MS) { + return cached; + } + } + + const key = flightKey(apiKey, host); + // Race the caller's signal against the shared promise so one caller's + // cancellation doesn't propagate to unrelated callers sharing the fetch. + const raceSignal = (p: Promise): Promise => + signal + ? Promise.race([ + p, + new Promise((_, reject) => { + if (signal.aborted) reject(signal.reason); + else signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + ]) + : p; + + if (inFlight && inFlightKey === key) { + try { + return await raceSignal(inFlight); + } catch { + return null; + } + } + + const promise = fetchCatalog(apiKey, host); + inFlight = promise; + inFlightKey = key; + const epochAtStart = cacheEpoch; + try { + const result = await raceSignal(promise); + if (cacheEpoch === epochAtStart) { + cached = result; + } + return result; + } catch { + return null; + } finally { + if (inFlight === promise) { + inFlight = null; + inFlightKey = null; + } + } +} + +/** + * Drop the cached catalog. Call after logout/account switch so a fresh + * sign-in doesn't see a previous account's allow-list. Bumps the cache + * epoch so an in-flight fetch racing with this clear can't repopulate + * the cache with the just-invalidated catalog. + */ +export function clearCachedCatalog(): void { + cached = null; + inFlight = null; + inFlightKey = null; + cacheEpoch++; +} + +/** + * Tier-disabled error — thrown by the chat pre-flight when the catalog lists + * a model as `disabled: true` for this account. The message names the model + * and points at the plan page, replacing Cognition's opaque + * "an internal error occurred" trailer. + */ +export class ModelNotAvailableError extends Error { + constructor( + public readonly modelUid: string, + public readonly label: string, + public readonly reason: 'disabled' | 'not_listed', + ) { + super( + reason === 'disabled' + ? `Model "${label}" (uid=${modelUid}) is not enabled for your Cognition account. ` + + `The Cognition catalog returned it with disabled=true — meaning your current plan/tier ` + + `does not include this model. ` + + `Check the model picker on https://codeium.com/account, or pick a different model. ` + + `(This message replaces Cognition's "an internal error occurred" — same root cause.)` + : `Model uid "${modelUid}" is not listed in the Cognition catalog for your account. ` + + `Either the UID has been retired upstream or your account/region doesn't serve it. ` + + `Run \`curl http://127.0.0.1:42100/v1/models\` to see the canonical names your plan accepts.`, + ); + this.name = 'ModelNotAvailableError'; + } +} diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts new file mode 100644 index 0000000000..a42fd95646 --- /dev/null +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -0,0 +1,1244 @@ +/* + * Derived from rsvedant/opencode-windsurf-auth (src/cloud-direct/), MIT licensed, + * Copyright (c) 2026 Vedant. The full notice is in ./index.ts. + */ +/** + * Cloud-direct streaming chat. Talks to + * `server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage` + * with no local language_server in the path. Returns an async iterable of + * CloudChatEvent deltas (text, reasoning, tool calls, usage, finish) so the + * caller can stream straight into opencodex's internal AdapterEvent model. + * + * What this supports: + * - Single- or multi-turn chat using the prompt-and-history pattern the LS + * uses (flatten history into one ChatMessagePrompt list) + * - All free Windsurf/Cognition models (swe-1-7, swe-1-7-lightning, etc.) + * and any model the user's api_key is entitled to + * - Streaming (uses Connect-streaming envelope, emits deltas as they arrive) + * - Tool definitions (encoded via `encodeToolDef`) and tool-call events + * (tool_call_start, tool_call_args) decoded from the response stream + * - Usage and finish-reason events for terminal completion + * + * Wire-protocol: Connect-RPC streaming over HTTPS with manual protobuf + * encoding (see `wire.ts`). + */ + +import * as crypto from 'crypto'; +import * as zlib from 'zlib'; +import { + encodeMessage, + encodeString, + encodeVarintField, + frameConnectStream, + iterFields, + parseConnectFrames, +} from './wire.js'; +import { buildMetadata } from './metadata.js'; +import { getCachedUserJwt } from './auth.js'; +import { getCachedCatalog, ModelNotAvailableError } from './catalog.js'; +import { anySignal, cancelBodyOnAbort } from '../../../lib/abort.js'; +import { resolveDevinApiBaseUrl } from '../../../oauth/devin/api-base.js'; + +/** + * Connect-RPC streaming inactivity timeout. If the cloud sends zero bytes + * for this long after the last chunk, we abort the fetch. The cloud's own + * idle limit is around 90s on most models; we set ours a little above so + * we only trigger when the server has genuinely stopped responding. + */ +const CLOUD_STREAM_IDLE_MS = 120_000; +/** Time-to-first-byte timeout. */ +const CLOUD_STREAM_TTFB_MS = 60_000; +/** Maximum acceptable Connect-RPC frame length (16 MB). */ +const MAX_FRAME_LEN = 16 * 1024 * 1024; + +/** + * Per-(apiKey, host) session/cascade ID cache. Cloud uses these for + * server-side context caching across turns of the same conversation; if we + * mint a fresh sessionId on every call (which we used to), every turn looks + * like a brand-new session and the prompt-cache hit ratio is zero. + * Single-process scope is enough: opencode lives in one runtime for a TUI + * session, and CLI one-shots don't benefit from caching anyway. + */ +interface SessionIds { + sessionId: string; + cascadeId: string; +} +/** + * Bounded the same way the adapter bounds its cascade-id map: a long-running + * proxy sees one entry per (host, api_key) pair, and nothing ever evicted them. + */ +const SESSION_CACHE_MAX = 256; +const sessionCache = new Map(); +function getOrAllocateSessionIds(apiKey: string, host: string, cascadeIdOverride?: string): SessionIds { + const key = `${host}\x1f${apiKey}`; + let ids = sessionCache.get(key); + if (!ids) { + ids = { + sessionId: crypto.randomUUID(), + cascadeId: cascadeIdOverride ?? allocateCascadeId(), + }; + if (sessionCache.size >= SESSION_CACHE_MAX) { + const oldest = sessionCache.keys().next().value; + if (oldest !== undefined) sessionCache.delete(oldest); + } + sessionCache.set(key, ids); + } else if (cascadeIdOverride && ids.cascadeId !== cascadeIdOverride) { + // Caller explicitly requested a different cascadeId — honor it. + ids = { sessionId: ids.sessionId, cascadeId: cascadeIdOverride }; + sessionCache.set(key, ids); + } + return ids; +} + +/** Drop the cached session IDs — call after logout so a new sign-in starts fresh. */ +export function clearSessionIds(): void { + sessionCache.clear(); +} + +// ---------------------------------------------------------------------------- +// Per-conversation cascade state — generated client-side; cloud lazy-registers +// ---------------------------------------------------------------------------- + +/** + * Allocate a fresh cascade UUID. The cloud lazy-registers cascade_id on first + * use — confirmed empirically (random UUID accepted, model responded). One + * cascade_id per opencode-CLI conversation is fine; reuse across turns to + * preserve server-side context. + */ +export function allocateCascadeId(): string { + return crypto.randomUUID(); +} + +// ---------------------------------------------------------------------------- +// Request encoders +// ---------------------------------------------------------------------------- + +/** + * ChatMessagePrompt { + * #2 source: enum CHAT_MESSAGE_SOURCE_USER=1 / ASSISTANT=2 / SYSTEM=3 / TOOL=4 + * #3 prompt: string (text content) + * #4 num_tokens: int (rough estimate) + * #5 safe_for_code_telemetry: bool (1 = ok to log) + * #10 images: repeated ImageData (multimodal) + * } + * + * ImageData (exa.codeium_common_pb.ImageData) { + * #1 base64_data: string + * #2 mime_type: string + * #3 caption: string (optional) + * } + */ +function encodeImageData(img: { mimeType: string; base64Data: string; caption?: string }): Buffer { + const parts: Buffer[] = [ + encodeString(1, img.base64Data), + encodeString(2, img.mimeType), + ]; + if (img.caption) parts.push(encodeString(3, img.caption)); + return Buffer.concat(parts); +} + +/** + * Encode one ChatToolCall sub-message: + * {#1 id, #2 name, #3 arguments_json} + * Verified against `exa.codeium_common_pb.ChatToolCall` from extension.js. + */ +function encodeChatToolCall(tc: { id: string; name: string; arguments: string }): Buffer { + return Buffer.concat([ + encodeString(1, tc.id), + encodeString(2, tc.name), + encodeString(3, tc.arguments), + ]); +} + +function encodeChatMessagePrompt( + content: ContentPart[], + source: number, + opts?: { toolCallId?: string; toolCalls?: Array<{ id: string; name: string; arguments: string }> }, +): Buffer { + const textParts = content.filter((p): p is { type: 'text'; text: string } => p.type === 'text'); + const imageParts = content.filter((p): p is { type: 'image'; mimeType: string; base64Data: string; caption?: string } => p.type === 'image'); + const joined = textParts.map((p) => p.text).join('\n'); + const parts: Buffer[] = [ + // #1 message_id. The verified turn-1 capture stamps one on every prompt. + encodeString(1, crypto.randomUUID()), + encodeVarintField(2, source), + encodeString(3, joined), + ]; + // Tool-result message: attach the id of the call this result answers. + // Without it, the model can't pair multi-tool conversations. + if (opts?.toolCallId) { + parts.push(encodeString(7, opts.toolCallId)); + } + // Assistant message with tool_calls: encode each as a ChatToolCall. + if (opts?.toolCalls && opts.toolCalls.length > 0) { + for (const tc of opts.toolCalls) { + parts.push(encodeMessage(6, encodeChatToolCall(tc))); + } + } + for (const img of imageParts) { + parts.push(encodeMessage(10, encodeImageData(img))); + } + return Buffer.concat(parts); +} + +const SOURCE_BY_ROLE: Record = { + user: 1, + assistant: 2, + // NOTE: do not send source=3 (SYSTEM) directly — the Codeium chat backend + // returns "third-party model provider is experiencing issues" when any + // ChatMessagePrompt has source=SYSTEM. The captured LS upstream traffic + // shows the IDE inlines system context into the *user* prompt (source=1) + // wrapped in .... We collapse + // role:'system' messages into the next user turn before building the + // proto — see `collapseSystemIntoUser` below. + system: 1, + tool: 4, +}; + +/** + * Collapse OpenAI-style messages so all `role:'system'` entries are inlined + * into the immediately-following user message, matching the wire format the + * IDE uses. Cognition's chat backend rejects raw role=system entries. + * + * [{system: "S1"}, {system: "S2"}, {user: "U1"}, {assistant: "A1"}, {user: "U2"}] + * + * becomes + * + * [{user: "\nS1\nS2\n\nU1"}, {assistant: "A1"}, {user: "U2"}] + * + * If there's no following user message, the trailing system messages get + * appended as a synthesized user turn. + */ +function collapseSystemIntoUser(messages: ChatHistoryItem[]): ChatHistoryItem[] { + const out: ChatHistoryItem[] = []; + let pendingSystem: string[] = []; + + const flushTextOf = (content: ContentPart[]): string => + content.filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map((p) => p.text).join('\n'); + + for (const m of messages) { + if (m.role === 'system') { + const parts = normalizeContent(m.content); + const text = flushTextOf(parts); + if (text) pendingSystem.push(text); + } else if (m.role === 'user' && pendingSystem.length > 0) { + const userParts = normalizeContent(m.content); + const userText = flushTextOf(userParts); + const userImages = userParts.filter((p) => p.type === 'image'); + const wrapped = `\n${pendingSystem.join('\n\n')}\n\n${userText}`; + const newContent: ContentPart[] = [{ type: 'text', text: wrapped }, ...userImages]; + out.push({ role: 'user', content: newContent }); + pendingSystem = []; + } else { + // Flush accumulated system text before any non-system, non-user turn + // (assistant / tool) so system instructions keep their leading position + // instead of being deferred to a trailing synthesized user message. + if (pendingSystem.length > 0) { + out.push({ + role: 'user', + content: [{ type: 'text', text: `\n${pendingSystem.join('\n\n')}\n` }], + }); + pendingSystem = []; + } + out.push(m); + } + } + if (pendingSystem.length > 0) { + // Trailing system messages with no following user turn — convert to a + // standalone user message so they still reach the model. + out.push({ + role: 'user', + content: [{ type: 'text', text: `\n${pendingSystem.join('\n\n')}\n` }], + }); + } + return out; +} + +/** + * CompletionConfiguration — mirrors the LS-shipped defaults, lets the caller + * override the obvious knobs. + */ +/** Output cap when the caller named none. */ +const DEFAULT_MAX_OUTPUT_TOKENS = 8192; +/** Context window when the caller named none. */ +const DEFAULT_CONTEXT_WINDOW = 128_000; + +/** + * Cognition rejects a temperature of exactly 0 with the same opaque internal + * error it uses for a malformed request, so a client asking for deterministic + * output would fail every turn. Clamp to the smallest value the wire accepts + * rather than silently substituting the service default, which would be a + * different answer than the caller asked for. + */ +const MIN_TEMPERATURE = 0.0001; + +function safeTemperature(value: number | undefined): number { + if (value === undefined) return 0.7; + return value <= 0 ? MIN_TEMPERATURE : value; +} + +function encodeCompletionConfiguration(opts: { + maxOutputTokens?: number; + maxInputTokens?: number; + temperature?: number; + topK?: number; + topP?: number; +}): Buffer { + const enc64 = (fieldNum: number, n: number): Buffer => { + const b = Buffer.alloc(8); + b.writeDoubleLE(n, 0); + return Buffer.concat([Buffer.from([(fieldNum << 3) | 1]), b]); + }; + // Tag map, verified by building the same turn with a working client and + // diffing the encoded messages field by field: #2 is the OUTPUT cap and #3 is + // the context window. This layout had those two swapped, so a caller asking + // for 32 output tokens put 32 into the context-window field and the request + // came back as an opaque "an internal error occurred" — for every turn, on + // every account, which is why free and paid failed identically. #6 and #11 + // are not part of the message the service accepts. + return Buffer.concat([ + encodeVarintField(1, 1), + encodeVarintField(2, opts.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS), + encodeVarintField(3, opts.maxInputTokens ?? DEFAULT_CONTEXT_WINDOW), + enc64(5, safeTemperature(opts.temperature)), + encodeVarintField(7, opts.topK ?? 40), + enc64(8, opts.topP ?? 1.0), + ]); +} + +/** + * Multimodal content part — text or image. + * + * Text: `{ type: 'text', text: '...' }` + * Image: `{ type: 'image', mimeType: 'image/png', base64Data: '...' [, caption: '...'] }` + * + * Matches the OpenAI/@ai-sdk multimodal message shape — we accept their + * `image_url: { url: 'data:image/png;base64,...' }` form via {@link parseContent}. + */ +export type ContentPart = + | { type: 'text'; text: string } + | { type: 'image'; mimeType: string; base64Data: string; caption?: string }; + +export interface ChatHistoryItem { + role: 'user' | 'assistant' | 'system' | 'tool'; + /** + * Either a plain string or an array of {@link ContentPart}. Plain strings are + * shorthand for `[{ type: 'text', text: '...' }]`. + */ + content: string | ContentPart[]; + /** + * For `role: 'tool'` only — the id of the assistant's preceding tool_call + * this message answers. Required by the cloud's chat backend to pair + * tool results with calls; without it, multi-tool conversations can't + * tell the model which call produced which result. Encoded as + * ChatMessagePrompt field #7 (verified against the Windsurf bundled + * extension.js proto schema `exa.chat_pb.ChatMessagePrompt`). + */ + tool_call_id?: string; + /** + * For `role: 'assistant'` only — the tool calls the assistant emitted. + * Encoded as ChatMessagePrompt field #6 (repeated ChatToolCall, where + * each ChatToolCall has #1 id, #2 name, #3 arguments_json). + */ + tool_calls?: Array<{ id: string; name: string; arguments: string }>; +} + +/** + * Normalize ChatHistoryItem content into structured parts. Accepts strings, + * OpenAI multimodal `[{type:'text',text}, {type:'image_url',image_url}]`, and + * our own `[{type:'image', mimeType, base64Data}]`. + */ +function normalizeContent(content: string | ContentPart[] | unknown): ContentPart[] { + if (typeof content === 'string') return [{ type: 'text', text: content }]; + if (!Array.isArray(content)) return []; + const out: ContentPart[] = []; + // Each element may follow our own ContentPart shape, the OpenAI multimodal + // `image_url` shape, or be malformed — narrow defensively per branch. + const parts = content as Array>; + for (const p of parts) { + if (!p || typeof p !== 'object') continue; + if (p.type === 'text' && typeof p.text === 'string') { + out.push({ type: 'text', text: p.text }); + } else if (p.type === 'image' && typeof p.base64Data === 'string') { + const mimeType = typeof p.mimeType === 'string' ? p.mimeType : 'image/png'; + const caption = typeof p.caption === 'string' ? p.caption : undefined; + out.push({ type: 'image', mimeType, base64Data: p.base64Data, caption }); + } else if (p.type === 'image_url' && p.image_url) { + // OpenAI/@ai-sdk shape — parse data: URL into base64 + mime. + const imgRef = p.image_url as string | { url?: string }; + const url: string = typeof imgRef === 'string' ? imgRef : (imgRef.url ?? ''); + const m = url.match(/^data:([^;]+);base64,(.+)$/); + if (m) out.push({ type: 'image', mimeType: m[1], base64Data: m[2] }); + else if (url) out.push({ type: 'text', text: `[image url: ${url}]` }); + } + } + return out; +} + +export interface ToolDef { + /** Function name. */ + name: string; + /** Plain-English description. */ + description: string; + /** JSON Schema for the function's arguments. */ + parameters: unknown; +} + +/** + * Streaming event emitted by the cloud-direct chat loop. + * + * - `text` : incremental visible content from the assistant + * - `reasoning` : incremental internal thinking (Anthropic-style, kept + * separate from visible content; @ai-sdk consumers can + * render in a collapsed/grey region) + * - `tool_call_*` : function-calling deltas (id+name once, args streamed) + * - `finish` : stream terminated cleanly with a reason + * - `usage` : final token-accounting block (input/output/total counts) + */ +export type CloudChatEvent = + | { kind: 'text'; text: string } + | { kind: 'reasoning'; text: string } + | { kind: 'tool_call_start'; id: string; name: string } + | { + kind: 'tool_call_args'; + argsDelta: string; + /** + * Tool-call id this delta belongs to, when the cloud surfaced one in + * this frame. Cognition's wire format only carries id on the START + * frame today, so most argsDelta events arrive without one — callers + * route those to the most-recent-start by convention. If Cognition + * ever interleaves args across calls, the consumer should prefer + * `id` over the rolling lastToolCallId. + */ + id?: string; + } + // Note: there is no `tool_call_end` event. Cognition's wire format + // signals the end of a tool call implicitly — args just stop arriving + // for the current id and either a new `tool_call_start` fires or the + // stream finishes. Consumers should treat each `tool_call_start` as + // ending the previous call. + | { kind: 'finish'; reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' } + | { + kind: 'usage'; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + /** + * Tokens served from the cache. Surfaced separately so callers tracking + * cost can distinguish them from fresh input tokens (Anthropic / OpenAI + * both bill cache reads cheaper than fresh prompts). + */ + cachedInputTokens?: number; + /** Tokens written to the cache on this request (Anthropic-style). */ + cacheCreationInputTokens?: number; + /** Reasoning tokens (gpt-5-x reasoning models, Claude thinking variants). */ + reasoningTokens?: number; + }; + +interface BuildArgs { + apiKey: string; + userJwt?: string; + modelUid: string; + messages: ChatHistoryItem[]; + cascadeId: string; + /** + * GetChatMessageRequest #22. Optional because it is omitted on a first turn; + * the working client only reuses one across a later tool loop. + */ + promptId?: string; + sessionId: string; + requestId: bigint; + triggerId: string; + tools?: ToolDef[]; + /** Default 5 = CHAT_MESSAGE_REQUEST_TYPE_CASCADE (matches captured LS body). */ + requestType?: number; + completionOpts?: { + maxOutputTokens?: number; + maxInputTokens?: number; + temperature?: number; + topK?: number; + topP?: number; + }; +} + +/** + * ChatToolDefinition proto, observed in the LS upstream traffic: + * { #1 name (string), #2 description (string), #3 parameters_schema (JSON string) } + * + * Truncation note: Codeium's tool validator rejects very long descriptions + * with a generic `failed_precondition: "Unable to process request due to an + * MCP configuration issue."` error. opencode ships some tools (notably `bash`) + * with ~9.6 KB descriptions packed with examples and rules. We truncate to a + * conservative `MAX_DESC_LEN` and append an ellipsis so the cloud accepts + * them. The model still gets the first chunk of the description (where the + * essential signature lives); detailed examples are sacrificed for + * compatibility. + */ +/** + * The Codeium tool validator rejects any tool whose description hits exactly + * 7,000 chars (or more) with a misleading `failed_precondition: "Unable to + * process request due to an MCP configuration issue."` error. Binary-search + * verified to char-precision: + * - 6,999 chars → server accepts + * - 7,000 chars → server returns MCP error + * + * The limit is per-description, content-sensitive (plain `a`-repeats up to + * 20K work fine; the bash description's exact byte at position 6999 trips + * it). We truncate to the maximum-1 (6,998) for a one-char safety margin. + * + * We do NOT need to aggregate-cap — 200K total tool descriptions across 200 + * tools was confirmed to pass server-side. Only per-string length is gated. + */ +const MAX_TOOL_DESC_LEN = 6998; + +/** + * Cognition's cloud enforces a case-sensitive, whitespace-exact exact-phrase + * blocklist on tool descriptions. Binary-search isolated the trigger to the + * 7-word phrase "Takes a task_id parameter identifying the task" — verbatim, + * capital T, single spaces — which causes a `permission_denied` trailer error + * regardless of model or account tier. Any deviation (lowercase, reword, + * reorder, extra whitespace) passes. The phrase appears verbatim in Claude + * Code's built-in TaskOutput tool description. + * + * Rewrite known triggers to meaning-preserving forms. This is a + * Cognition-specific constraint alongside the length limit above; if + * Cognition adds more blocklisted phrases, extend this table and add a + * regression test in tests/devin-adapter.test.ts. + */ +const COGNITION_BLOCKLIST_REWRITES: ReadonlyArray<[RegExp, string]> = [ + [/\bTakes a task_id parameter identifying the task\b/g, "Accepts a task_id parameter identifying the task"], +]; + +function sanitizeToolDescriptionForCognition(description: string): string { + let out = description; + for (const [pattern, replacement] of COGNITION_BLOCKLIST_REWRITES) { + out = out.replace(pattern, replacement); + } + return out; +} + +/** Test-only: exercise the Cognition blocklist rewrite directly. */ +export function sanitizeToolDescriptionForCognitionForTests(description: string): string { + return sanitizeToolDescriptionForCognition(description); +} + +function encodeToolDef(tool: ToolDef): Buffer { + const rawDesc = sanitizeToolDescriptionForCognition(tool.description ?? ''); + const desc = + rawDesc.length > MAX_TOOL_DESC_LEN + ? rawDesc.slice(0, MAX_TOOL_DESC_LEN - 24) + '\n…(truncated for cloud)' + : rawDesc; + return Buffer.concat([ + encodeString(1, tool.name), + encodeString(2, desc), + encodeString(3, JSON.stringify(tool.parameters ?? {})), + ]); +} + +export function buildGetChatMessageRequestForTests(args: BuildArgs): Buffer { + return buildGetChatMessageRequest(args); +} + +function buildGetChatMessageRequest(args: BuildArgs): Buffer { + const metadata = buildMetadata({ + apiKey: args.apiKey, + userJwt: args.userJwt, + sessionId: args.sessionId, + requestId: args.requestId, + triggerId: args.triggerId, + // GetChatMessage accepts only the calibrated identity shape. + cloudChatShape: true, + }); + + // System messages must be inlined into the user turn (Cognition cloud + // rejects source=3). See `collapseSystemIntoUser` for the format. + const collapsed = collapseSystemIntoUser(args.messages); + const promptParts = collapsed.map((m) => + encodeMessage( + 3, + encodeChatMessagePrompt( + normalizeContent(m.content), + SOURCE_BY_ROLE[m.role] ?? 1, + // Thread tool_call_id (for tool results) + tool_calls (for assistant + // turns that fired tools) into the proto. Cloud rejects multi-tool + // conversations otherwise — it can't pair a tool result with the + // assistant call that produced it. + { + toolCallId: m.role === 'tool' ? m.tool_call_id : undefined, + toolCalls: m.role === 'assistant' ? m.tool_calls : undefined, + }, + ), + ), + ); + + const completion = encodeCompletionConfiguration(args.completionOpts ?? {}); + + const toolParts: Buffer[] = (args.tools ?? []).map((t) => + encodeMessage(10, encodeToolDef(t)), + ); + + // Field layout from mitm capture of the LS: + // #1 metadata + // #3 chat_message_prompts (repeated — one element per history turn) + // #7 request_type (varint enum) + // #8 completion_configuration + // #10 tools (repeated ChatToolDefinition) + // #16 cascade_id (string) + // #21 chat_model_uid (string) + // #22 prompt_id (string) + return Buffer.concat([ + encodeMessage(1, metadata), + // #2 system_prompt is always written, empty when the caller had none. The + // system turn is separately collapsed into the first user message because + // source=SYSTEM is refused; this field is the one the wire expects here. + encodeString(2, ''), + ...promptParts, + encodeVarintField(7, args.requestType ?? 5), + encodeMessage(8, completion), + ...toolParts, + // #15 session model config: { id, turn, 4 }. Present on every verified + // request. + encodeMessage(15, Buffer.concat([ + encodeString(1, crypto.randomUUID()), + encodeVarintField(2, 1), + encodeVarintField(3, 4), + ])), + encodeString(16, args.cascadeId), + encodeVarintField(20, 1), + encodeString(21, args.modelUid), + // #22 is deliberately omitted. It is a user-exchange id that only appears + // from the second turn onward and is reused across that turn's tool loop; a + // fresh per-request uuid matches neither shape. + ]); +} + +// ---------------------------------------------------------------------------- +// Response parsing — pull `delta_text` (top-level field #9) out of each frame +// ---------------------------------------------------------------------------- + +/** + * Decode a single streaming ChatMessage proto frame into one or more + * CloudChatEvents. Captured shape (from a tool-using swe-1.6 chat): + * + * ChatMessage { + * #1 bot_id (string) + * #2 timestamp { seconds, nanos } + * #5 finish_reason (varint — 10 = "tool_calls" observed, others unknown) + * #6 ToolCallDelta { + * #1 id (string, only on first tool-call frame) + * #2 name (string, only on first tool-call frame) + * #3 arguments_delta (string, JSON fragment, streamed) + * } + * #7 ChatStatus { #6 status_code, #9 model_name } + * #9 delta_text (string) + * #12 (fixed64) some_hash + * #17 (string) message_uuid + * #28 UsageStats { #1 label, ... } + * } + * + * #9 appears both at top-level (text delta) AND inside #7 (model_name). + * iterFields walks top-level only, so we don't confuse the two. + * + * #5 is the finish_reason. Observed value `10` = tool_calls finish. We map + * any non-zero to 'tool_calls' for now (and let the caller fall back to + * 'stop' if no tool_call deltas were emitted). + */ +function* decodeChatFrame(proto: Buffer): Generator { + for (const f of iterFields(proto)) { + if (f.num === 3 && f.wire === 2 && Buffer.isBuffer(f.value)) { + // Visible delta_text — what the user should SEE in the chat. + // + // We previously had this mapping inverted (#3 = thinking, #9 = visible), + // which produced two compounding bugs in the TUI: + // 1. The model's CoT was rendered as plain content, so the user saw + // "The user wants me to X..." instead of the answer. + // 2. The actual answer (which lives in #3) was silently dropped — so + // the assistant turn appeared to end after the CoT with nothing + // after, matching the "model wrote reasoning then went silent" + // symptom the user reported. + // Verified live: prompted swe-1.6 with "explain then answer 2+2"; #3 + // streamed "2+2=4 because... 4" while #9 streamed the meta-narration + // "The user wants me to perform a reasoning task...". + const s = (f.value as Buffer).toString('utf8'); + if (s) yield { kind: 'text', text: s }; + } else if (f.num === 9 && f.wire === 2 && Buffer.isBuffer(f.value)) { + // Internal thinking / chain-of-thought. Surface as `reasoning` so + // @ai-sdk consumers (opencode TUI) render it in a collapsed grey + // block instead of inline with the answer. + const s = (f.value as Buffer).toString('utf8'); + if (s) yield { kind: 'reasoning', text: s }; + } else if (f.num === 6 && f.wire === 2 && Buffer.isBuffer(f.value)) { + let id: string | undefined; + let name: string | undefined; + let argsDelta: string | undefined; + for (const sf of iterFields(f.value as Buffer)) { + if (sf.wire === 2 && Buffer.isBuffer(sf.value)) { + const s = (sf.value as Buffer).toString('utf8'); + if (sf.num === 1) id = s; + else if (sf.num === 2) name = s; + else if (sf.num === 3) argsDelta = s; + } + } + if (id !== undefined && name !== undefined) { + yield { kind: 'tool_call_start', id, name }; + } + if (argsDelta !== undefined) { + // Pass through `id` when this frame carries one (Cognition only + // sets it on the start frame today, but defending against future + // interleaving). Callers should prefer `id` over their rolling + // lastToolCallId when both are available. + yield { kind: 'tool_call_args', argsDelta, ...(id !== undefined ? { id } : {}) }; + } + } else if (f.num === 5 && f.wire === 0) { + const v = Number(f.value); + // exa.codeium_common_pb.StopReason → OpenAI finish_reason. + // Source of truth: Windsurf extension.js sets `setEnumType("StopReason", [...])` + // 0 UNSPECIFIED → "stop" (no signal — treat as natural end) + // 1 INCOMPLETE → "length" (request cut short, model wanted more) + // 2 STOP_PATTERN → "stop" (model emitted its stop sequence — NORMAL) + // 3 MAX_TOKENS → "length" + // 4-9 internal → "stop" + // 10 FUNCTION_CALL → "tool_calls" + // 11 CONTENT_FILTER → "content_filter" + // 12 NON_INSERTION → "stop" + // 13 ERROR → "stop" (errors come as Connect trailer, not via this) + // + // We had 2 and 3 swapped previously, which made the model's normal + // STOP_PATTERN look like "length" → @ai-sdk treated complete responses + // as truncated. That was the "model wrote reasoning then went silent" + // symptom the user kept hitting. + let reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' = 'stop'; + if (v === 10) reason = 'tool_calls'; + else if (v === 11) reason = 'content_filter'; + else if (v === 1 || v === 3) reason = 'length'; + // else stays 'stop' for 0/2/4-9/12/13 + yield { kind: 'finish', reason }; + } else if (f.num === 28 && f.wire === 2 && Buffer.isBuffer(f.value)) { + const usage = decodeUsageBlock(f.value as Buffer); + if (usage) yield usage; + } + } +} + +/** + * UsageStats block at proto field #28. Captured shape (mitm of a real call): + * + * UsageStats { + * #1 label = "Token Usage" + * #2 entries [ + * UsageEntry { + * #1 label = "Input tokens" / "Output tokens" / "Cached tokens" / ... + * #2 value (fixed32 — IEEE 754 float, OpenAI-style count cast) + * #3 unit = " tokens" + * #5 metric_id = "input_tokens" / "output_tokens" / ... + * }, + * ... + * ] + * } + * + * We extract the standard input/output counts and synthesize a `total`. + * Anything else (cached, reasoning_tokens, …) is dropped for v1. + */ +function decodeUsageBlock(buf: Buffer): CloudChatEvent | null { + let promptTokens: number | undefined; + let completionTokens: number | undefined; + let cachedInputTokens: number | undefined; + let cacheCreationInputTokens: number | undefined; + let reasoningTokens: number | undefined; + + for (const f of iterFields(buf)) { + // Each UsageEntry lives at field 2 (repeated). Field 1 is the block label + // ("Token Usage"); skip. + if (f.num !== 2 || f.wire !== 2 || !Buffer.isBuffer(f.value)) continue; + + // Observed entry shape: + // UsageEntry { + // #4 (sub-message) { + // #1 label = "Input tokens" / "Output tokens" + // #2 (fixed32) value (IEEE 754 LE float — count as float) + // #3 unit = " token" + // #4 unit_plural = " tokens" + // } + // #5 metric_id = "input_tokens" / "output_tokens" / "cached_input_tokens" / ... + // } + let entryMetric: string | undefined; + let entryValue: number | undefined; + for (const sf of iterFields(f.value as Buffer)) { + if (sf.num === 5 && sf.wire === 2 && Buffer.isBuffer(sf.value)) { + entryMetric = (sf.value as Buffer).toString('utf8'); + } else if (sf.num === 4 && sf.wire === 2 && Buffer.isBuffer(sf.value)) { + // Recurse into the displayed-dimension submessage to pull the fixed32 + // value at its field 2. + for (const ssf of iterFields(sf.value as Buffer)) { + if (ssf.num === 2 && ssf.wire === 5 && Buffer.isBuffer(ssf.value)) { + entryValue = (ssf.value as Buffer).readFloatLE(0); + break; + } + } + } + } + if (entryMetric && entryValue !== undefined && Number.isFinite(entryValue)) { + const n = Math.round(entryValue); + if (entryMetric === 'input_tokens') promptTokens = n; + else if (entryMetric === 'output_tokens') completionTokens = n; + else if (entryMetric === 'cached_input_tokens' || entryMetric === 'cache_read_input_tokens') { + cachedInputTokens = (cachedInputTokens ?? 0) + n; + } else if (entryMetric === 'cache_creation_input_tokens') { + cacheCreationInputTokens = (cacheCreationInputTokens ?? 0) + n; + } else if (entryMetric === 'reasoning_tokens' || entryMetric === 'output_reasoning_tokens') { + reasoningTokens = (reasoningTokens ?? 0) + n; + } + } + } + if (promptTokens === undefined && completionTokens === undefined) return null; + // totalTokens reflects what OpenAI's API counts as billable: input + + // output. Cached / cache-creation / reasoning subtotals are surfaced as + // additional fields so callers that want a fuller picture (e.g. cost + // breakdown for reasoning models) can read them, but they're NOT + // double-counted into total. + const total = (promptTokens ?? 0) + (completionTokens ?? 0); + return { + kind: 'usage', + promptTokens, + completionTokens, + totalTokens: total > 0 ? total : undefined, + cachedInputTokens, + cacheCreationInputTokens, + reasoningTokens, + }; +} + +// ---------------------------------------------------------------------------- +// Public API: streamChat +// ---------------------------------------------------------------------------- + +export interface CloudChatRequest { + /** Persistent OAuth-issued api_key (`devin-session-token$`). */ + apiKey: string; + /** Pre-resolved API server URL from RegisterUser (falls back to default). */ + apiServerUrl?: string; + /** Model UID — e.g. `swe-1-6`, `kimi-k2-6`, `claude-opus-4-7-medium`. */ + modelUid: string; + /** Chat history. */ + messages: ChatHistoryItem[]; + /** + * Tool definitions available to the model. Cloud encodes these in the + * GetChatMessage request's `tools` field (proto #10). When set, the model + * may emit `tool_call_start`/`_args`/`_end` events instead of plain text. + */ + tools?: ToolDef[]; + /** Cascade ID — reuse across turns of the same conversation. */ + cascadeId?: string; + /** Optional sampling overrides. */ + completionOpts?: BuildArgs['completionOpts']; + /** Override request_type (default = 5, CASCADE). */ + requestType?: number; + /** Abort signal — closes the fetch stream. */ + signal?: AbortSignal; +} + +export class CloudChatError extends Error { + constructor(message: string, public readonly code?: string, public readonly traceId?: string) { + super(message); + this.name = 'CloudChatError'; + } +} + +const TRACE_ID_RE = /\(trace ID: ([0-9a-f]+)\)/i; + +/** + * Stream chat events from the cloud. Yields CloudChatEvent (text deltas, tool + * call deltas, finish reason). Use `streamChatText` for legacy text-only iteration. + * + * On error (auth fail, quota exhausted, malformed request) throws a + * CloudChatError with the cloud's `code` + `traceId` for diagnostics. + */ +export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator { + // The api-server host comes from RegisterUser through the credential store. + // Validate it here too: this request body carries the api_key, so an + // unallowlisted host is credential exfiltration rather than a wrong endpoint. + const host = resolveDevinApiBaseUrl(req.apiServerUrl); + // The hosted chat path does not require the short-lived user_jwt; the working + // reference omits it by default. Minting it is opt-in so a mint failure or a + // JWT the chat service does not accept cannot break every turn. + const userJwt = process.env.OPENCODEX_DEVIN_SEND_USER_JWT === "1" + ? await getCachedUserJwt(req.apiKey, host, req.signal) + : undefined; + + // Pre-flight: consult the per-account model catalog. Cognition's cloud + // returns an opaque `permission_denied: "an internal error occurred (trace + // ID: ...)"` for every chat call that targets a model not enabled on the + // caller's tier — issue #14. The catalog's `disabled` flag is the + // authoritative source for "can this account run this UID"; we surface a + // named error here so the user knows why instead of guessing. + // + // Best-effort: if the catalog fetch fails (network, auth, schema drift) we + // pass through to the chat call. The cloud will still surface its own + // error and the trailer-error path below enriches the message in-place. + // Treat an empty catalog (schema drift / unexpected response) as "no catalog" + // so chat passes through instead of failing every request. + const catalog = await getCachedCatalog(req.apiKey, host, req.signal).catch(() => null); + if (catalog && catalog.byUid.size > 0) { + const entry = catalog.byUid.get(req.modelUid); + if (!entry) { + throw new ModelNotAvailableError(req.modelUid, req.modelUid, 'not_listed'); + } + if (entry.disabled) { + throw new ModelNotAvailableError(req.modelUid, entry.label, 'disabled'); + } + } + + // Reuse session + cascade ids across calls for the same (apiKey, host). + // Without this, every turn looks like a brand-new server-side session + // and the cloud's prompt cache never hits — significant cost regression + // for long conversations. + const sessionIds = getOrAllocateSessionIds(req.apiKey, host, req.cascadeId); + + const proto = buildGetChatMessageRequest({ + apiKey: req.apiKey, + userJwt, + modelUid: req.modelUid, + messages: req.messages, + tools: req.tools, + cascadeId: sessionIds.cascadeId, + sessionId: sessionIds.sessionId, + requestId: BigInt(Date.now()), + triggerId: crypto.randomUUID(), + requestType: req.requestType, + completionOpts: req.completionOpts, + }); + // The request envelope goes up uncompressed. A gzipped GetChatMessage frame is + // rejected with the same opaque `invalid_argument: an internal error occurred` + // the short fingerprint produces, and it is one of three things that have to be + // right together — the other two are the doubled Basic credential and the + // 732-character Metadata #31. + const framed = frameConnectStream(proto, false); + const body = new Blob([new Uint8Array(framed)], { type: "application/connect+proto" }); + + // Compose caller signal with a TTFB timeout. If the cloud takes longer + // than CLOUD_STREAM_TTFB_MS to start the response, abort. Once any byte + // arrives we cancel the TTFB timer and start the per-chunk idle timer + // inside the read loop instead. + const ttfbController = new AbortController(); + const ttfbTimer = setTimeout(() => ttfbController.abort(new Error(`cloud-direct: time-to-first-byte timeout (${CLOUD_STREAM_TTFB_MS}ms)`)), CLOUD_STREAM_TTFB_MS); + const ttfbSignal = ttfbController.signal; + // Compose req.signal + ttfbSignal. AbortSignal.any was added in Node + // 20.3 / Bun 1.0; our `engines` allows Node ≥18, so on Node 18-20.2 the + // built-in is missing. The previous fallback `req.signal ?? ttfbSignal` + // silently discarded one of the two signals (TTFB if caller passed + // one), defeating the timeout guard. anySignal() is a real polyfill. + const composed = req.signal ? anySignal([req.signal, ttfbSignal]) : undefined; + const initialSignal: AbortSignal = composed?.signal ?? ttfbSignal; + + let resp: Response; + try { + resp = await fetch(`${host}/exa.api_server_pb.ApiServerService/GetChatMessage`, { + method: 'POST', + headers: { + 'Content-Type': 'application/connect+proto', + 'Connect-Protocol-Version': '1', + 'Connect-Accept-Encoding': 'gzip', + // The credential is the session token doubled and dash-joined. A single + // copy is refused with permission_denied. The protobuf body keeps one + // copy, in Metadata #3. + Authorization: `Basic ${req.apiKey}-${req.apiKey}`, + 'User-Agent': 'connect-es/2.0.0', + Accept: '*/*', + }, + body, + redirect: 'error', + signal: initialSignal, + }); + } finally { + clearTimeout(ttfbTimer); + // The composed signal only guards the headers hop; the body is cancelled + // through cancelBodyOnAbort below. Detaching here keeps a long-lived caller + // signal from collecting one listener per turn. + composed?.cleanup(); + } + + if (!resp.ok) { + // The body is not echoed into the message. This error reaches the adapter's + // error event and /api/logs, and a Connect error can quote the request that + // produced it - which is the request holding the api_key. + throw new CloudChatError(`GetChatMessage failed (HTTP ${resp.status})`, undefined); + } + if (!resp.body) { + throw new CloudChatError('GetChatMessage response had no body stream'); + } + + // Cancel the body when the client goes away. Without this the read loop never + // observes req.signal after headers arrive: the turn keeps draining until the + // idle timer fires, and the stream then ends without an EOS trailer, which + // this function would report as a truncated upstream response rather than as + // the cancellation it actually was. + const detachBodyCancel = cancelBodyOnAbort(resp.body, req.signal); + + // Incremental parsing. We previously did `pending = Buffer.concat([pending, + // chunk])` per chunk — O(n²) over a long stream because every chunk copies + // every buffered byte again. Now we keep a queue of arriving chunks with a + // running offset; we only `Buffer.concat` when a frame straddles a chunk + // boundary, and we slice/drop fully-consumed chunks immediately. For + // typical 50-200KB responses this is ~5x faster and produces zero waste. + const chunkQueue: Buffer[] = []; + let queuedBytes = 0; + // Bun + Node ReadableStream readers diverge on the type-level shape + // (Bun's includes a `readMany` method); both work the same at runtime. + const reader = resp.body.getReader() as ReadableStreamDefaultReader; + let trailerError: { code?: string; message: string; traceId?: string } | null = null; + let sawEos = false; + + /** + * Try to read the next `n` bytes from the chunk queue WITHOUT consuming + * them. Returns null if not enough buffered. + */ + function peek(n: number): Buffer | null { + if (queuedBytes < n) return null; + if (chunkQueue.length === 1 && chunkQueue[0].length >= n) { + return chunkQueue[0].slice(0, n); + } + // Cross-chunk peek — concat just the prefix we need. + const parts: Buffer[] = []; + let remaining = n; + for (const c of chunkQueue) { + if (remaining <= 0) break; + if (c.length <= remaining) { + parts.push(c); + remaining -= c.length; + } else { + parts.push(c.slice(0, remaining)); + remaining = 0; + } + } + return Buffer.concat(parts, n); + } + + /** Drop the first `n` bytes from the chunk queue. */ + function drop(n: number): void { + queuedBytes -= n; + let remaining = n; + while (remaining > 0 && chunkQueue.length > 0) { + const head = chunkQueue[0]; + if (head.length <= remaining) { + chunkQueue.shift(); + remaining -= head.length; + } else { + chunkQueue[0] = head.slice(remaining); + remaining = 0; + } + } + } + + // Track the idle timer at outer scope so the finally block can clear it + // regardless of how we exit the read loop (clean done, throw, etc). + // Previously this lived inside `try { ... }` and was only cleared on + // normal exit — an error path left a 120s timer in the event loop and + // the process refused to exit promptly. + let idleTimer: ReturnType | null = null; + try { + const resetIdle = (): Promise<{ value?: Uint8Array; done: boolean }> => { + if (idleTimer) clearTimeout(idleTimer); + const idleController = new AbortController(); + idleTimer = setTimeout( + () => idleController.abort(new Error(`cloud-direct: idle timeout (${CLOUD_STREAM_IDLE_MS}ms with no bytes)`)), + CLOUD_STREAM_IDLE_MS, + ); + // Race the reader.read() against idle abort. When abort wins, we + // also actively `cancel()` the underlying body stream so the + // pending read() resolves promptly with done=true instead of + // hanging on the now-dead TCP socket until the OS notices. + // + // Promise-handling carefully: the reader.read() promise can settle + // AFTER the outer race rejects (we cancelled, the read eventually + // sees the cancellation and either resolves with done=true or + // rejects with an abort error). We attach an explicit `.catch(()=>{})` + // on the read promise so any post-race rejection doesn't surface as + // an unhandled-rejection warning in the host runtime. + return new Promise((resolve, reject) => { + let settled = false; + const settle = (fn: () => void): void => { + if (settled) return; + settled = true; + fn(); + }; + const readP = reader.read(); + // Defensive: swallow any post-race rejection. If the outer promise + // already settled via the abort listener, we still need a handler + // attached to readP or Node logs an unhandledRejection. + readP.catch(() => { /* swallowed; outer promise already rejected */ }); + + idleController.signal.addEventListener('abort', () => { + try { void resp.body?.cancel(idleController.signal.reason ?? new Error('idle abort')); } catch { /* */ } + settle(() => reject(idleController.signal.reason ?? new Error('idle abort'))); + }, { once: true }); + + readP.then( + (v) => settle(() => resolve(v)), + (e) => settle(() => reject(e)), + ); + }); + }; + + while (true) { + const { value, done } = await resetIdle(); + if (done) break; + if (value) { + chunkQueue.push(Buffer.from(value)); + queuedBytes += value.length; + } + + // Drain every complete frame currently buffered. + while (queuedBytes >= 5) { + const header = peek(5); + if (!header) break; + const flags = header[0]; + const len = header.readUInt32BE(1); + // Cap frame length to prevent memory exhaustion from a corrupt/malicious + // length prefix. 16MB is well above any legitimate Connect-RPC frame. + if (len > MAX_FRAME_LEN) { + throw new CloudChatError(`Connect frame length ${len} exceeds ${MAX_FRAME_LEN} byte cap`); + } + if (queuedBytes < 5 + len) break; // frame still arriving + drop(5); + const raw = peek(len) ?? Buffer.alloc(0); + drop(len); + + let payload = raw; + if (flags & 0x01) { + try { + // MAX_FRAME_LEN caps the COMPRESSED frame, so without an output cap + // a 16 MiB gzip frame can still inflate to gigabytes. The inbound + // request path (src/server/request-decompress.ts) already bounds + // decompression the same way. + payload = zlib.gunzipSync(raw, { maxOutputLength: MAX_FRAME_LEN }); + } catch (gzipErr) { + const code = (gzipErr as NodeJS.ErrnoException).code; + if (code === 'ERR_BUFFER_TOO_LARGE') { + throw new CloudChatError(`Connect frame inflates past the ${MAX_FRAME_LEN} byte cap`, 'frame_too_large'); + } + // Corrupt compressed frame — surface as a CloudChatError instead + // of falling through and re-parsing raw gzip bytes as proto + // (which used to misparse silently downstream). + throw new CloudChatError(`Connect frame gunzip failed: ${(gzipErr as Error).message}`); + } + } + const eos = (flags & 0x02) !== 0; + + if (eos) { + sawEos = true; + // Trailer: {} on success, {"error":{code,message}} on failure. + const text = payload.toString('utf8'); + if (text && text.includes('"error"')) { + let code: string | undefined; + let message = text; + try { + const j = JSON.parse(text) as { error?: { code?: string; message?: string } }; + code = j.error?.code; + if (j.error?.message) message = j.error.message; + } catch { /* keep raw */ } + const traceMatch = message.match(TRACE_ID_RE); + trailerError = { code, message, traceId: traceMatch?.[1] }; + } + continue; + } + yield* decodeChatFrame(payload); + } + } + } finally { + // Always clear the idle timer. The previous "clear on normal exit + // only" path leaked a 120s setTimeout into the event loop on any + // throw (idle timeout, gunzip error, trailer error, etc), keeping + // the process from exiting promptly. + if (idleTimer) clearTimeout(idleTimer); + // Cancel the underlying body stream on any non-clean exit so the TCP + // connection is released. `releaseLock` alone leaves the body in a + // dangling state; we have to call `cancel` on the response body + // itself (cancel-via-reader requires holding the lock). Fire and + // forget — there's nothing meaningful to do if cancel rejects. + try { reader.releaseLock(); } catch { /* */ } + try { void resp.body?.cancel(); } catch { /* */ } + } + + if (trailerError) { + // Cognition uses `permission_denied: "an internal error occurred (trace + // ID: …)"` as a catch-all for "your account can't run this model" — same + // root cause issue #14 reported. The pre-flight above catches this when + // the catalog disagrees with the call, but the catalog can lag (a model + // that was enabled at fetch time may have been gated between then and + // now) or be missing (network failure caused a fall-through). When the + // raw trailer is this exact shape, swap in a message that names the + // model and explains the likely cause rather than re-passing + // Cognition's opaque text. The cloud's original message is appended in + // parens so users (and bug reports) still have it verbatim. + // Both codes carry this shape. Cognition uses `invalid_argument` for a + // request it could not accept and `permission_denied` for one it would not, + // and the message body is the same opaque sentence either way. + const isOpaqueDenial = + (trailerError.code === 'permission_denied' || trailerError.code === 'invalid_argument') && + /an internal error occurred/i.test(trailerError.message); + if (isOpaqueDenial) { + const enriched = + `Cognition denied this request for model "${req.modelUid}" with the opaque ` + + `"an internal error occurred" message, which it uses for both a malformed ` + + `request and a refused one. In practice this has meant the request, not ` + + `the account: the same sentence came back for every turn until the ` + + `CompletionConfiguration tag map was corrected, and a temperature of ` + + `exactly 0 still produces it. Check the request before the plan — ` + + `tests/providers/devin-hardening.test.ts pins the field layout the ` + + `service accepts. If the request is unchanged and this is new, the ` + + `account's model access is the next thing to check. ` + + `(cloud trace ID: ${trailerError.traceId ?? 'n/a'})`; + throw new CloudChatError(enriched, trailerError.code, trailerError.traceId); + } + // Cognition also returns `permission_denied` when a tool description + // contains a blocklisted phrase that the sanitizer above did not catch + // (e.g. Cognition added a new phrase). Surface a clear message so the + // user knows to check tool descriptions rather than suspect auth/tier. + // Only blame the blocklist when tools were actually sent. Asserting it for + // every permission_denied sent users to inspect a tool table that had + // nothing to do with an ordinary ACL or tier denial. + if (trailerError.code === 'permission_denied' && (req.tools?.length ?? 0) > 0) { + const enriched = + `Cognition denied this request (permission_denied). If tool descriptions ` + + `are present, a blocklisted phrase may have triggered this — see the ` + + `COGNITION_BLOCKLIST_REWRITES table in cloud-direct/chat.ts. ` + + `(cloud trace ID: ${trailerError.traceId ?? 'n/a'})`; + throw new CloudChatError(enriched, trailerError.code, trailerError.traceId); + } + throw new CloudChatError(trailerError.message, trailerError.code, trailerError.traceId); + } + // Truncation detection: the cloud always terminates a successful stream + // with an EOS trailer. If we hit `done` from the body reader without one, + // the connection dropped mid-frame and any bytes still in the queue are + // garbage. Previously those leftover bytes were silently discarded and + // the consumer saw a clean stop with no error — looked like the model + // had finished. Now we surface it. + detachBodyCancel(); + if (req.signal?.aborted) { + // The caller cancelled. The missing EOS trailer is the expected consequence + // of that cancellation, not evidence that the cloud dropped the response. + return; + } + if (!sawEos) { + throw new CloudChatError( + `Cloud stream ended without EOS trailer (${queuedBytes} bytes orphaned). ` + + `Connection likely dropped mid-response.`, + 'truncated_stream', + ); + } +} + +/** + * Back-compat: yield text content only (drops tool calls). The plugin uses + * streamChatEvents directly when it needs to surface tool_calls. + */ +export async function* streamChat(req: CloudChatRequest): AsyncGenerator { + for await (const ev of streamChatEvents(req)) { + if (ev.kind === 'text') yield ev.text; + } +} + +// `parseConnectFrames` is no longer needed by streamChat itself, but exported +// from wire.ts for one-shot callers + tests. +void parseConnectFrames; diff --git a/src/adapters/devin/cloud-direct/index.ts b/src/adapters/devin/cloud-direct/index.ts new file mode 100644 index 0000000000..98dfcea9de --- /dev/null +++ b/src/adapters/devin/cloud-direct/index.ts @@ -0,0 +1,65 @@ +/* + * Derived from rsvedant/opencode-windsurf-auth (src/cloud-direct/), MIT licensed. + * + * MIT License + * Copyright (c) 2026 Vedant + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +/** + * Public surface of the cloud-direct module. + * + * Usage: + * import { streamChat } from './cloud-direct/index.js'; + * + * for await (const delta of streamChat({ + * apiKey: creds.apiKey, + * apiServerUrl: creds.apiServerUrl, + * modelUid: 'swe-1-6', + * messages: [{ role: 'user', content: 'hi' }], + * })) { + * process.stdout.write(delta); + * } + */ + +export { + streamChat, + streamChatEvents, + allocateCascadeId, + CloudChatError, + type CloudChatRequest, + type ChatHistoryItem, + type CloudChatEvent, + type ToolDef, +} from './chat.js'; + +export { + mintUserJwt, + getCachedUserJwt, + clearCachedUserJwt, + CloudAuthError, +} from './auth.js'; + +export { + getCachedCatalog, + clearCachedCatalog, + ModelNotAvailableError, + type ModelCatalogEntry, + type CacheEntry, +} from './catalog.js'; diff --git a/src/adapters/devin/cloud-direct/metadata.ts b/src/adapters/devin/cloud-direct/metadata.ts new file mode 100644 index 0000000000..b371abe06a --- /dev/null +++ b/src/adapters/devin/cloud-direct/metadata.ts @@ -0,0 +1,134 @@ +/* + * Derived from rsvedant/opencode-windsurf-auth (src/cloud-direct/), MIT licensed, + * Copyright (c) 2026 Vedant. The full notice is in ./index.ts. + */ +/** + * `exa.codeium_common_pb.Metadata` proto builder. + * + * Field numbers come from src/plugin/discovery.ts (which reads the bundled + * extension.js for live numbers). For cloud-direct we hard-code the canonical + * set of fields the LS always populates — the IDE-extracted dynamic numbers + * would help if Windsurf renumbers, but we don't have a way to refresh those + * without the bundled extension.js path being present. + * + * Captured from real LS upstream traffic via mitm reverse-proxy. See + * docs/CLOUD_DIRECT.md → "The exact captured request body (annotated)". + */ + +import { + encodeMessage, + encodeString, + encodeTimestampBody, + encodeVarintField, +} from './wire.js'; +import { randomBytes } from 'node:crypto'; + +/** + * extension_version + ide_version sent to the cloud. It MUST be a string the + * cloud recognizes as a real client release: an unknown version comes back as + * an opaque "an internal error occurred", with no hint that the version is what + * it objected to. + * + * Pinned to the version the shipped desktop client reports + * (`product.json` -> `windsurfVersion`) rather than to anything of ours. The + * previous pin of "2.0.0" predates the Devin rebrand and no longer chats. + * `OPENCODEX_DEVIN_CLIENT_VERSION` overrides it, which is the escape hatch when + * Cognition retires a version before this constant is updated. + */ +const WINDSURF_VERSION_STRING = process.env.OPENCODEX_DEVIN_CLIENT_VERSION?.trim() || '3.9.19'; + +/** + * Identity the hosted chat RPC expects, which is not the desktop client's. + * GetChatMessage is calibrated against a different client name and version, and + * sending the IDE's own strings is one of the ways the request comes back as an + * opaque "an internal error occurred". + */ +const CLOUD_CHAT_CLIENT_NAME = 'chisel'; +const CLOUD_CHAT_CLIENT_VERSION = process.env.OPENCODEX_DEVIN_CHAT_CLIENT_VERSION?.trim() || '2026.8.18'; +const CLOUD_CHAT_OS = 'windows'; + +/** + * Metadata #31 is a device fingerprint, and the server checks its shape rather + * than its value: 732 hex characters (366 bytes). Anything shorter — including + * absent — is rejected with the same opaque internal error, and a fresh random + * value per request is accepted, so nothing here identifies the machine. + */ +const DEVICE_FINGERPRINT_BYTES = 366; + +export interface MetadataInput { + /** Persistent api_key from OAuth (`devin-session-token$`). */ + apiKey: string; + /** + * Fresh user_jwt from GetUserJwt. The catalog RPC uses it; the hosted chat + * path does not need it and only sends it when an operator opts in. + */ + userJwt?: string; + /** UUID — one per opencode session is fine. */ + sessionId: string; + /** Monotonic, milliseconds since epoch. */ + requestId: bigint; + /** UUID — one per RPC call. */ + triggerId: string; + /** Optional override for the version string. Cosmetic. */ + windsurfVersion?: string; + /** Optional override for the host OS string. */ + osName?: string; + /** + * Emit the exact field set the hosted chat RPC accepts. + * + * GetChatMessage validates this message far more strictly than + * GetCascadeModelConfigs does, which is why the catalog has always worked + * while chat did not. The shape is seven identity fields, the optional + * user_jwt, and the fingerprint — the telemetry fields this module otherwise + * sends (request_id, session_id, ls_timestamp, trigger_id, plan_name, + * ide_type) are not part of it. + */ + cloudChatShape?: boolean; + /** Override for Metadata #31; a random fingerprint is generated when absent. */ + deviceHex?: string; +} + +function osString(): string { + switch (process.platform) { + case 'darwin': return 'darwin'; + case 'linux': return 'linux'; + case 'win32': return 'windows'; + default: return String(process.platform); + } +} + +export function buildMetadata(input: MetadataInput): Buffer { + const version = input.windsurfVersion ?? WINDSURF_VERSION_STRING; + const os = input.osName ?? osString(); + if (input.cloudChatShape) { + const clientVersion = input.windsurfVersion ?? CLOUD_CHAT_CLIENT_VERSION; + return Buffer.concat([ + encodeString(1, CLOUD_CHAT_CLIENT_NAME), + encodeString(2, clientVersion), + encodeString(3, input.apiKey), + encodeString(4, 'en'), + encodeString(5, input.osName ?? CLOUD_CHAT_OS), + encodeString(7, clientVersion), + encodeString(12, CLOUD_CHAT_CLIENT_NAME), + ...(input.userJwt ? [encodeString(21, input.userJwt)] : []), + encodeString(31, input.deviceHex ?? randomBytes(DEVICE_FINGERPRINT_BYTES).toString('hex')), + ]); + } + const parts: Buffer[] = [ + encodeString(1, 'windsurf'), // ide_name + encodeString(2, version), // extension_version + encodeString(3, input.apiKey), // api_key + encodeString(4, 'en'), // locale + encodeString(5, os), // os + encodeString(7, version), // ide_version + encodeVarintField(9, input.requestId), // request_id (uint64 monotonic) + encodeString(10, input.sessionId), // session_id + encodeString(12, 'windsurf'), // extension_name + encodeMessage(16, encodeTimestampBody()), // ls_timestamp (google.protobuf.Timestamp) + encodeString(25, input.triggerId), // trigger_id + encodeString(26, 'Unset'), // plan_name + encodeString(28, 'windsurf'), // ide_type + ]; + if (input.userJwt) parts.push(encodeString(21, input.userJwt)); // user_jwt + return Buffer.concat(parts); +} diff --git a/src/adapters/devin/cloud-direct/wire.ts b/src/adapters/devin/cloud-direct/wire.ts new file mode 100644 index 0000000000..cc8b732eb1 --- /dev/null +++ b/src/adapters/devin/cloud-direct/wire.ts @@ -0,0 +1,206 @@ +/* + * Derived from rsvedant/opencode-windsurf-auth (src/cloud-direct/), MIT licensed, + * Copyright (c) 2026 Vedant. The full notice is in ./index.ts. + */ +/** + * Manual protobuf + Connect-RPC streaming envelope helpers. + * + * Connect-RPC streaming wire format (HTTPS POST body): + * ┌─────────────┬────────────────┬──────────────┐ + * │ flags 1byte │ length 4B BE │ payload │ + * └─────────────┴────────────────┴──────────────┘ + * flags bit 0x01 = payload is gzip-compressed + * flags bit 0x02 = end-of-stream (trailer frame — JSON {error} or empty {}) + * + * All `Get*` methods on `exa.api_server_pb.ApiServerService` that the + * language_server calls upstream use this format, content-type + * `application/connect+proto`, with `Connect-Protocol-Version: 1`. + * + * Kept tiny and dependency-free — same philosophy as src/plugin/protobuf.ts. + */ + +import * as zlib from 'zlib'; + +// ---------------------------------------------------------------------------- +// Proto wire encode +// ---------------------------------------------------------------------------- + +export function encodeVarint(value: number | bigint): Buffer { + const v0 = BigInt(value); + // Reject negatives at the boundary. Proto3 spec encodes signed types as + // 10-byte sign-extended varints; we don't support that here and the + // current call sites never need it (tags, lengths, request ids — all + // strictly positive). The old loop body would have terminated with + // `Number(-1n)` = -1, producing a malformed single 0xFF byte that the + // server would misparse silently. Throw instead so future regressions + // surface immediately. + if (v0 < 0n) { + throw new RangeError(`encodeVarint: negative input not supported (got ${value})`); + } + const bytes: number[] = []; + let v = v0; + while (v > 127n) { + bytes.push(Number(v & 0x7fn) | 0x80); + v >>= 7n; + } + bytes.push(Number(v)); + return Buffer.from(bytes); +} + +export function encodeTag(fieldNum: number, wire: number): Buffer { + return encodeVarint((fieldNum << 3) | wire); +} + +export function encodeString(fieldNum: number, s: string): Buffer { + const buf = Buffer.from(s, 'utf8'); + return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(buf.length), buf]); +} + +export function encodeMessage(fieldNum: number, body: Buffer): Buffer { + return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(body.length), body]); +} + +export function encodeVarintField(fieldNum: number, v: number | bigint): Buffer { + return Buffer.concat([encodeTag(fieldNum, 0), encodeVarint(v)]); +} + +export function encodeFixed64Field(fieldNum: number, v: number): Buffer { + const b = Buffer.alloc(8); + b.writeDoubleLE(v, 0); + return Buffer.concat([encodeTag(fieldNum, 1), b]); +} + +export function encodeTimestampBody(): Buffer { + const now = Date.now(); + const seconds = Math.floor(now / 1000); + const nanos = (now % 1000) * 1_000_000; + return Buffer.concat([ + encodeVarintField(1, seconds), + nanos > 0 ? encodeVarintField(2, nanos) : Buffer.alloc(0), + ]); +} + +// ---------------------------------------------------------------------------- +// Proto wire decode +// ---------------------------------------------------------------------------- + +export function decodeVarint(buf: Buffer, offset: number): [bigint, number] { + let res = 0n; + let shift = 0n; + let i = offset; + while (i < buf.length) { + const b = buf[i++]; + res |= BigInt(b & 0x7f) << shift; + if (!(b & 0x80)) return [res, i]; + shift += 7n; + } + throw new Error('truncated varint'); +} + +export interface ProtoField { + num: number; + wire: number; + /** varint → bigint, fixed → 8/4 byte Buffer, length-delim → payload Buffer. */ + value: bigint | Buffer; +} + +export function* iterFields(buf: Buffer): Generator { + let i = 0; + while (i < buf.length) { + const [tagBig, ai] = decodeVarint(buf, i); + i = ai; + const tag = Number(tagBig); + const num = tag >> 3; + const wire = tag & 0x7; + if (wire === 0) { + const [v, bi] = decodeVarint(buf, i); + i = bi; + yield { num, wire, value: v }; + } else if (wire === 1) { + // Bounds-check: a truncated frame mustn't yield a short fixed64 slice + // that downstream readers treat as a full 8-byte value. Stop iterating + // cleanly instead. + if (i + 8 > buf.length) return; + yield { num, wire, value: buf.slice(i, i + 8) }; + i += 8; + } else if (wire === 2) { + const [n, ci] = decodeVarint(buf, i); + i = ci; + const len = Number(n); + // Bounds-check: when the declared length runs past the buffer, the + // frame is corrupt or truncated. Returning short-buffered slices to + // downstream parsers used to misparse silently (M12). + if (len < 0 || i + len > buf.length) return; + yield { num, wire, value: buf.slice(i, i + len) }; + i += len; + } else if (wire === 5) { + if (i + 4 > buf.length) return; + yield { num, wire, value: buf.slice(i, i + 4) }; + i += 4; + } else if (wire === 3 || wire === 4) { + // Wire types 3 (start group) and 4 (end group) are deprecated in + // proto3 but show up in some Codeium server-generated messages. They + // carry no length info; the safe behavior is to stop iterating + // gracefully rather than tear down the whole frame parse. + return; + } else { + // Unknown wire type — bail rather than misalign. + return; + } + } +} + +// ---------------------------------------------------------------------------- +// Connect-streaming envelope +// ---------------------------------------------------------------------------- + +/** + * Wrap `body` (a serialized proto message) in a Connect-streaming envelope. + * If `compress` is true, gzip the payload and set the 0x01 flag. + */ +export function frameConnectStream(body: Buffer, compress = true): Buffer { + let payload = body; + let flags = 0; + if (compress) { + payload = zlib.gzipSync(body); + flags |= 0x01; + } + const header = Buffer.alloc(5); + header[0] = flags; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +export interface ConnectFrame { + flags: number; + /** Decompressed payload (gzip handled here if flags & 0x01). */ + payload: Buffer; + /** Frame is the trailer (end-of-stream). */ + eos: boolean; +} + +/** + * Parse all Connect-streaming frames out of a response body. + * + * Returns array of decoded frames. Each frame's payload is already gzip-decoded + * if the compression flag was set. + */ +export function parseConnectFrames(buf: Buffer): ConnectFrame[] { + const out: ConnectFrame[] = []; + let i = 0; + while (i + 5 <= buf.length) { + const flags = buf[i]; + const len = buf.readUInt32BE(i + 1); + if (i + 5 + len > buf.length) break; + let payload = buf.slice(i + 5, i + 5 + len); + if (flags & 0x01) { + // Compressed frame. If gunzip fails the frame is genuinely corrupt + // — surfacing as a thrown error beats parsing raw gzip bytes as proto + // (which previously produced misleading "yielded bad wire type" downstream). + payload = zlib.gunzipSync(payload); + } + out.push({ flags, payload, eos: (flags & 0x02) !== 0 }); + i += 5 + len; + } + return out; +} diff --git a/src/adapters/devin/live-models.ts b/src/adapters/devin/live-models.ts new file mode 100644 index 0000000000..de44ae0a27 --- /dev/null +++ b/src/adapters/devin/live-models.ts @@ -0,0 +1,97 @@ +/** + * Live Devin / Cognition model discovery via GetCascadeModelConfigs. + * + * The live catalog is the source of truth for the model roster. The endpoint + * returns effort-suffixed variants (e.g. `gpt-5-6-sol-high`); we collapse those + * to base ids so the picker stays clean and the adapter appends the effort + * suffix at request time. `DEVIN_STATIC_MODELS` is only a degraded-mode + * fallback for when there is no API key or discovery fails. + */ +import { getCachedCatalog, type ModelCatalogEntry } from "./cloud-direct"; + +const DEFAULT_HOST = "https://server.codeium.com"; + +/** + * Degraded-mode fallback shown when there is no API key or live discovery + * fails. The live catalog overrides this whenever discovery succeeds. + */ +export const DEVIN_STATIC_MODELS = [ + "swe-1-7", + "swe-1-7-lightning", + "gpt-5-6-sol", + "gpt-5-6-luna", + "gpt-5-6-terra", + "claude-opus-4-8", + "claude-fable-5-1", + "claude-sonnet-5", + "glm-5-2", + "kimi-k2-7", + "grok-4-5", +] as const; + +/** Per-model context windows for Devin/Cognition models. Source: Cognition model catalog. */ +export const DEVIN_MODEL_CONTEXT_WINDOWS: Record = { + "swe-1-7": 256_000, + "swe-1-7-lightning": 256_000, + "gpt-5-6-sol": 1_050_000, + "gpt-5-6-luna": 1_050_000, + "gpt-5-6-terra": 1_050_000, + "claude-opus-4-8": 200_000, + "claude-fable-5-1": 200_000, + "claude-sonnet-5": 200_000, + "glm-5-2": 200_000, + "kimi-k2-7": 256_000, + "grok-4-5": 256_000, +}; + +/** + * Trailing tokens that the Cognition catalog appends as effort/variant + * suffixes. Stripped to collapse suffixed UIDs to their base id. + */ +const EFFORT_TOKENS = new Set([ + "low", "medium", "high", "xhigh", "max", "none", "fast", "priority", "1m", +]); + +/** Collapse an effort-suffixed UID to its base id (e.g. `gpt-5-6-sol-high` → `gpt-5-6-sol`). */ +export function collapseDevinModelUid(uid: string): string { + const parts = uid.split("-"); + while (parts.length > 1 && EFFORT_TOKENS.has(parts[parts.length - 1]!)) { + parts.pop(); + } + return parts.join("-"); +} + +export type DevinUsableModelsResult = + | { ok: true; models: string[] } + | { ok: false; error: "auth" | "http" | "empty" | "unknown"; detail?: string }; + +/** + * Fetch the live model roster from Cognition's `GetCascadeModelConfigs` and + * collapse effort-suffixed variants to base ids. The returned list is the + * authoritative model roster for the signed-in account. + */ +export async function fetchDevinUsableModels(opts: { + apiKey: string; + baseUrl?: string; + signal?: AbortSignal; +}): Promise { + try { + const host = (opts.baseUrl || DEFAULT_HOST).replace(/\/$/, ""); + const catalog = await getCachedCatalog(opts.apiKey, host, opts.signal); + if (!catalog) return { ok: false, error: "empty" }; + const bases = new Set(); + for (const entry of catalog.byUid.values()) { + if (entry.disabled) continue; + // Skip internal enum constants (e.g. MODEL_GPT_5_2_LOW, MODEL_PRIVATE_*). + // Real chat model UIDs are lowercase dashed strings (swe-1-7, gpt-5-6-sol). + if (entry.modelUid.startsWith("MODEL_")) continue; + bases.add(collapseDevinModelUid(entry.modelUid)); + } + if (bases.size === 0) return { ok: false, error: "empty" }; + return { ok: true, models: [...bases].sort() }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/unauth|401|invalid token|login/i.test(message)) return { ok: false, error: "auth", detail: message }; + return { ok: false, error: "unknown", detail: message }; + } +} diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index 6ed9674c9f..2fe1464a2f 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -7,6 +7,7 @@ import { createQoderAdapter } from "./qoder/adapter"; import { createCommandCodeAdapter } from "./command-code"; import { createCursorAdapter } from "./cursor"; import { createDevinCliAdapter } from "./devin-cli/adapter"; +import { createDevinAdapter } from "./devin"; import { createGoogleAdapter } from "./google"; import { createKiroAdapter } from "./kiro"; import { createMimoFreeAdapter } from "./mimo-free"; @@ -32,7 +33,8 @@ export type AdapterWire = | "google" | "kiro" | "cursor" - | "devin-cli"; + | "devin-cli" + | "devin"; export type AdapterMutationContract = | "codex-owned" @@ -119,6 +121,11 @@ export const ADAPTER_REGISTRY = { mutation: "codex-owned", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createDevinCliAdapter(provider), }, + devin: { + wire: "devin", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createDevinAdapter(provider), + }, "mimo-free": { contractParent: "openai-chat", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createMimoFreeAdapter(provider), diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index cf4e4dddef..cb85ef36d2 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -54,6 +54,7 @@ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; import { fetchQoderModels } from "../../adapters/qoder/live-models"; import { resolveQoderProfile } from "../../adapters/qoder/profiles"; +import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { COMBO_NAMESPACE, @@ -1699,6 +1700,50 @@ async function fetchProviderModelsWithAuth( stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured, ), "degraded"); } + if (prov.adapter === "devin") { + if (!apiKey) return observed(configured, "degraded"); + const cachedDevin = getFreshCached(name, ttlMs); + if (cachedDevin) { + return observed( + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin)), + "authoritative", + ); + } + if (isModelsFetchCoolingDown(name)) { + const cooling = getStaleCached(name); + return observed( + withConfiguredRetention( + cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured, + ), + "degraded", + ); + } + const liveResult = await fetchDevinUsableModels({ apiKey, baseUrl: prov.baseUrl }); + if (liveResult.ok) { + // Live catalog is the source of truth — use the discovered base models + // directly, not a filtered subset of the static seed. + const result = liveResult.models.map((id) => ({ + id, + provider: name, + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + }) as CatalogModel); + const forCache = withConfiguredRetention(result, { retainComboTargets: false }); + if (!setCached(name, forCache, Date.now(), cacheGeneration)) { + return observed(withConfiguredRetention(configured), "degraded"); + } + markProviderDiscoveryOk(name, liveResult.models.length); + return observed(withConfiguredRetention(forCache), "authoritative"); + } + if (isCurrentCacheGeneration()) { + markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, { reason: liveResult.error === "auth" ? "provider" : "invalid_response" }); + } + const stale = getStaleCached(name); + return observed( + withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured), + "degraded", + ); + } if (prov.adapter === "cursor") { if (!apiKey) return observed(configured, "degraded"); // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed diff --git a/src/lib/abort.ts b/src/lib/abort.ts index e42a4b89b6..79f638e811 100644 --- a/src/lib/abort.ts +++ b/src/lib/abort.ts @@ -144,3 +144,39 @@ export function cancelBodyOnAbort(body: ReadableStream | null, signa signal.addEventListener("abort", onAbort, { once: true }); return () => signal.removeEventListener("abort", onAbort); } + +/** + * Compose multiple AbortSignals into a single signal that aborts when ANY input + * aborts. Uses `AbortSignal.any` when available (Node >=20.3 / Bun >=1.0); + * falls back to a manual implementation for older runtimes. + * + * The caller gets a `cleanup` alongside the signal and must call it once the + * work it guards has settled. `{ once: true }` only removes a listener that + * actually fired, so on the polyfill path a long-lived parent signal - a proxy + * session's, say - accumulates one listener per request until it aborts or the + * process exits. `signalWithTimeout` in this file has always had that + * discipline; this function did not. + */ +export function anySignal(signals: AbortSignal[]): { signal: AbortSignal; cleanup: () => void } { + const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any; + if (typeof builtin === "function") return { signal: builtin(signals), cleanup: () => {} }; + const controller = new AbortController(); + const listeners: Array<[AbortSignal, () => void]> = []; + const cleanup = (): void => { + for (const [source, handler] of listeners.splice(0)) source.removeEventListener("abort", handler); + }; + const onAbort = (reason: unknown): void => { + if (!controller.signal.aborted) controller.abort(reason); + cleanup(); + }; + for (const s of signals) { + if (s.aborted) { + onAbort(s.reason); + break; + } + const handler = () => onAbort(s.reason); + listeners.push([s, handler]); + s.addEventListener("abort", handler); + } + return { signal: controller.signal, cleanup }; +} diff --git a/src/oauth/devin.ts b/src/oauth/devin.ts new file mode 100644 index 0000000000..f4a046aee2 --- /dev/null +++ b/src/oauth/devin.ts @@ -0,0 +1,166 @@ +/** + * Devin / Cognition OAuth. + * + * Login opens the Auth0 browser sign-in flow (windsurf.com/windsurf/signin + * with redirect_uri=show-auth-token), then exchanges the pasted Firebase ID + * token via Cognition's RegisterUser for a long-lived API key. + */ +import { randomUUID } from "node:crypto"; +import type { OAuthController, OAuthCredentials } from "./types"; +import { DEFAULT_REGION, type WindsurfRegion } from "./devin/types"; +import { registerUser } from "./devin/register-user"; +import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiBaseUrl, validateDevinApiBaseUrl } from "./devin/api-base"; +import { getCredential } from "./store"; + +export { DEVIN_DEFAULT_API_SERVER } from "./devin/api-base"; + +/** + * The api-server host this account must talk to. + * + * RegisterUser hands EU and FedStart tenants a host of their own and it is kept + * on the credential, so the signed-in account decides the destination. The + * configured provider baseUrl is the fallback, and the US default is the last + * resort; both are re-validated because neither is trusted more than the + * network value. + */ +export function resolveDevinApiServer(configuredBaseUrl?: string): string { + return ( + validateDevinApiBaseUrl(getCredential("devin")?.apiBaseUrl) ?? + validateDevinApiBaseUrl(configuredBaseUrl) ?? + DEVIN_DEFAULT_API_SERVER + ); +} + +function decodeJwtPayload(token: string): Record | undefined { + const parts = token.split("."); + const payload = parts[1]; + if (parts.length < 2 || !payload) return undefined; + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record; + } catch { + return undefined; + } +} + +function identityFromApiKey(apiKey: string): { accountId?: string; email?: string } { + const jwtPart = apiKey.includes("$") ? apiKey.slice(apiKey.indexOf("$") + 1) : apiKey; + const payload = decodeJwtPayload(jwtPart); + const email = typeof payload?.email === "string" && payload.email.length > 0 ? payload.email : undefined; + const sub = typeof payload?.sub === "string" && payload.sub.length > 0 ? payload.sub : undefined; + const authUid = typeof payload?.auth_uid === "string" && payload.auth_uid.length > 0 ? payload.auth_uid : undefined; + return { ...(email ? { email } : {}), ...(sub || authUid ? { accountId: sub ?? authUid } : {}) }; +} + +function credentialsFromApiKey( + apiKey: string, + apiBaseUrl: string, + source: OAuthCredentials["source"] = "oauth", +): OAuthCredentials { + const identity = identityFromApiKey(apiKey); + return { + access: apiKey, + // Cognition issues a durable key and exposes no refresh endpoint. Carrying + // the key here rather than "" is the house pattern for durable-key + // providers: an empty refresh makes detectOAuthWarning report + // stale_credentials for every Devin account from the moment it logs in. + refresh: apiKey, + // No expiry to model. A synthetic one-year deadline only produces a + // refresh attempt against an endpoint that does not exist. + expires: Number.MAX_SAFE_INTEGER, + source, + apiBaseUrl, + ...identity, + }; +} + +function buildSignInUrl(region: WindsurfRegion): string { + const params = new URLSearchParams({ + response_type: "token", + client_id: region.oauthClientId, + redirect_uri: "show-auth-token", + state: randomUUID(), + prompt: "login", + }); + return region.website + "/windsurf/signin?" + params.toString(); +} + +/** + * Shape of the value the sign-in page hands back. + * + * It is not always a JWT. A live free-tier sign-in against + * windsurf.com/windsurf/signin returns a 47-character one-time token of the + * form `ott$`, and RegisterUser accepts it; an earlier JWT-only + * check here would have rejected every real login. So this is deliberately a + * shape check for "one opaque credential-looking word" rather than a format + * check: the point is to tell a token from a pasted URL or a sentence, not to + * second-guess what the vendor mints. + */ +const TOKEN_SHAPE = /^[A-Za-z0-9._$~+/=-]{20,4096}$/; + +const TOKEN_PARAM_NAMES = ["firebase_id_token", "access_token", "id_token", "token"] as const; + +/** + * Turn whatever the user pasted into the Firebase ID token RegisterUser expects. + * + * The sign-in page shows a bare token, but a user who copies the address bar + * instead hands us a callback URL whose fragment carries it. Posting that URL + * as `firebase_id_token` produces an opaque server-side rejection, so pull the + * token out and refuse a paste that has none rather than sending something that + * cannot work. + */ +export function parseDevinAuthPaste(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) throw new Error("No auth token pasted; cannot complete Devin sign-in."); + if (/^https?:\/\//i.test(trimmed)) { + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error("That paste is not a usable Devin auth token or sign-in URL."); + } + const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; + for (const params of [new URLSearchParams(hash), url.searchParams]) { + for (const name of TOKEN_PARAM_NAMES) { + const value = params.get(name)?.trim(); + if (value && TOKEN_SHAPE.test(value)) return value; + } + } + throw new Error("That sign-in URL carries no auth token. Paste the token shown on the Windsurf page instead."); + } + if (TOKEN_SHAPE.test(trimmed)) return trimmed; + throw new Error("That paste is not a Devin auth token. Copy the token shown on the Windsurf sign-in page."); +} + +async function loginDevinBrowser(ctrl: OAuthController, region: WindsurfRegion): Promise { + const url = buildSignInUrl(region); + ctrl.onAuth?.({ + url, + instructions: "Sign in with your Cognition/Devin account, then paste the on-screen auth token here.", + }); + ctrl.onProgress?.("Waiting for the pasted auth token..."); + const pasted = (await ctrl.onManualCodeInput?.())?.trim(); + if (!pasted) throw new Error("No auth token pasted; cannot complete Devin sign-in."); + const firebaseIdToken = parseDevinAuthPaste(pasted); + const result = await registerUser(firebaseIdToken, region, ctrl.signal); + const credentials = credentialsFromApiKey(result.apiKey, resolveDevinApiBaseUrl(result.apiServerUrl), "oauth"); + // The display name is not an identity. Use it only when the key carried no + // email, otherwise reauth compares a label against an address and mismatches. + if (!credentials.email && result.name) credentials.email = result.name; + return credentials; +} + +export async function loginDevin(ctrl: OAuthController): Promise { + return loginDevinBrowser(ctrl, DEFAULT_REGION); +} + +export async function refreshDevinToken( + _refreshToken: string, + _signal?: AbortSignal, + _credential?: OAuthCredentials, +): Promise { + // Cognition has no refresh endpoint. Extending the stored expiry here is what + // the carried implementation did, and it makes a revoked key look valid + // forever. Throwing lets the request path mark the account needsReauth the + // first time a forced refresh happens. + throw new Error("invalid_grant: Devin API keys do not refresh. Run ocx login devin again."); +} diff --git a/src/oauth/devin/api-base.ts b/src/oauth/devin/api-base.ts new file mode 100644 index 0000000000..684ed632ce --- /dev/null +++ b/src/oauth/devin/api-base.ts @@ -0,0 +1,63 @@ +/** + * Allowlist for the Cognition/Devin api-server origin. + * + * RegisterUser returns the tenant's api-server host, and that host then receives + * GetUserJwt, GetCascadeModelConfigs and GetChatMessage - the first of which + * carries the long-lived api_key. A host taken from the network without + * validation turns a spoofed or compromised RegisterUser response into + * credential exfiltration, so every value that reaches a request URL or the + * credential store passes through here first. + * + * This lives in its own module rather than in `../devin.ts` because the + * credential store imports the validator and `../devin.ts` imports the store's + * sibling types; a shared leaf keeps that from becoming a cycle. + */ + +export const DEVIN_DEFAULT_API_SERVER = "https://server.codeium.com"; + +/** + * Return the normalized api-server base URL, or undefined when the input is not + * an allowlisted Cognition host. + * + * Unlike the Copilot equivalent this keeps the path. EU and FedStart tenants are + * reached at `https://eu.windsurf.com/_route/api_server`, so the path prefix is + * part of the address rather than decoration, and normalizing to the origin + * would silently point those accounts at the wrong service. + */ +export function validateDevinApiBaseUrl(raw: string | undefined | null): string | undefined { + if (raw === undefined || raw === null) return undefined; + const trimmed = String(raw).trim(); + if (!trimmed) return undefined; + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return undefined; + } + if (parsed.protocol !== "https:") return undefined; + if (parsed.username || parsed.password) return undefined; + if (parsed.port && parsed.port !== "443") return undefined; + if (parsed.search || parsed.hash) return undefined; + const host = parsed.hostname.toLowerCase(); + if (host === "localhost" || host.endsWith(".localhost")) return undefined; + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return undefined; + const allowed = + host === "server.codeium.com" || + // The shipped client (Devin Desktop 3.9.19, + // Contents/Resources/app/extensions/windsurf/dist/extension.js) also names + // these two, and a beta account's RegisterUser can return one. + host === "server-staging.codeium.com" || + host === "server-beta.codeium.com" || + host === "windsurf.com" || + host.endsWith(".windsurf.com") || + host === "windsurf.fedstart.com"; + if (!allowed) return undefined; + const path = parsed.pathname.replace(/\/+$/, ""); + if (path && !/^(\/[A-Za-z0-9._-]+)+$/.test(path)) return undefined; + return `https://${host}${path}`; +} + +/** Same check, falling back to the default US host when the input is unusable. */ +export function resolveDevinApiBaseUrl(raw: string | undefined | null): string { + return validateDevinApiBaseUrl(raw) ?? DEVIN_DEFAULT_API_SERVER; +} diff --git a/src/oauth/devin/login.ts b/src/oauth/devin/login.ts new file mode 100644 index 0000000000..67d4672ea3 --- /dev/null +++ b/src/oauth/devin/login.ts @@ -0,0 +1 @@ +export { loginDevin } from "../devin"; diff --git a/src/oauth/devin/register-user.ts b/src/oauth/devin/register-user.ts new file mode 100644 index 0000000000..248e822789 --- /dev/null +++ b/src/oauth/devin/register-user.ts @@ -0,0 +1,186 @@ +/** + * Exchange a Firebase ID token for a long-lived Cognition/Devin API key. + * + * This calls the same Connect-RPC endpoint the Devin desktop client uses + * after browser sign-in completes: + * + * POST https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser + * Content-Type: application/json + * Body: { "firebase_id_token": "" } + * + * Connect-RPC happily accepts plain JSON over HTTPS (no gRPC framing required), + * so we skip @connectrpc/connect entirely and use `fetch`. The response shape + * matches `exa.seat_management_pb.RegisterUserResponse`: + * + * { api_key, name, api_server_url, redirect_url, team_options[] } + */ + +import type { OAuthLoginResult, WindsurfRegion } from './types.js'; +import { anySignal } from '../../lib/abort.js'; +import { validateDevinApiBaseUrl } from './api-base.js'; + +interface RegisterUserResponseJson { + api_key?: string; + name?: string; + api_server_url?: string; + redirect_url?: string; + team_options?: unknown[]; +} + +interface ConnectErrorJson { + code?: string; + message?: string; +} + +export class WindsurfRegistrationError extends Error { + readonly status: number; + readonly connectCode?: string; + readonly traceId?: string; + + constructor(message: string, status: number, connectCode?: string, traceId?: string) { + super(message); + this.name = 'WindsurfRegistrationError'; + this.status = status; + this.connectCode = connectCode; + this.traceId = traceId; + } +} + +const TRACE_ID_RE = /\(trace ID: ([0-9a-f]+)\)/i; + +/** + * Connect error codes that are safe to repeat to the user. + * + * The message body is not: a Connect error can echo the request, and the + * request here is the Firebase ID token. That message reaches CLI output and + * /api/logs, and redactSecretString does not recognise a bare JWT, so the code + * is the only part of an error body that leaves this function. + */ +const SAFE_CONNECT_CODES = new Set([ + 'canceled', 'unknown', 'invalid_argument', 'deadline_exceeded', 'not_found', 'already_exists', + 'permission_denied', 'resource_exhausted', 'failed_precondition', 'aborted', 'out_of_range', + 'unimplemented', 'internal', 'unavailable', 'data_loss', 'unauthenticated', +]); + +function safeConnectCode(value: unknown): string | undefined { + return typeof value === 'string' && SAFE_CONNECT_CODES.has(value) ? value : undefined; +} + +/** + * Exchange the Firebase ID token for a Windsurf API key. + * + * `firebaseIdToken` is the `access_token` (or `firebase_id_token`) value the + * Windsurf sign-in page returns in the OAuth callback URL — we treat it as + * opaque. + */ +export async function registerUser( + firebaseIdToken: string, + region: WindsurfRegion, + abortSignal?: AbortSignal, +): Promise { + if (!firebaseIdToken) { + throw new WindsurfRegistrationError('Empty firebase_id_token', 0, 'invalid_argument'); + } + + // The register host reaches the network holding the Firebase ID token, so it + // passes the same allowlist as the api-server host rather than being trusted + // because it came from a config object. + const registerBase = validateDevinApiBaseUrl(region.registerApiServerUrl); + if (!registerBase) { + throw new WindsurfRegistrationError( + 'Refusing to send the sign-in token to a non-Cognition register host.', + 0, + 'permission_denied', + ); + } + const url = `${registerBase}/exa.seat_management_pb.SeatManagementService/RegisterUser`; + + // 30s internal timeout — RegisterUser responds in ~200ms in steady state. + // CLI users on flaky networks need bounded waits or the sign-in command + // hangs forever. Compose with the caller's signal via a small polyfill + // (`anySignal`) because Node 18 / older Bun lack AbortSignal.any; the + // previous fallback `combinedSignal = abortSignal` would drop the + // timeout entirely on those runtimes. + const timeoutSignal = AbortSignal.timeout(30_000); + const composed = abortSignal ? anySignal([abortSignal, timeoutSignal]) : undefined; + const combinedSignal: AbortSignal = composed?.signal ?? timeoutSignal; + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Connect protocol version header — not strictly required for JSON, but + // matches what the official Connect clients send and avoids accidental + // routing into a non-Connect HTTP handler. + 'Connect-Protocol-Version': '1', + }, + body: JSON.stringify({ firebase_id_token: firebaseIdToken }), + // A 307/308 would replay this POST, and its body is the sign-in token, at + // whatever host Location names. Fail instead of following. + redirect: 'error', + signal: combinedSignal, + }); + } finally { + // Detach from the caller's signal; it can outlive this one exchange. + composed?.cleanup(); + } + + const text = await response.text(); + + if (!response.ok) { + let connectCode: string | undefined; + let traceId: string | undefined; + try { + const errJson = JSON.parse(text) as ConnectErrorJson; + connectCode = safeConnectCode(errJson.code); + // The trace id is an opaque server identifier and is the one part of the + // message worth keeping for a support conversation. + traceId = typeof errJson.message === 'string' ? errJson.message.match(TRACE_ID_RE)?.[1] : undefined; + } catch { + // Non-JSON error body. It stays unread; only the status is reported. + } + const message = `RegisterUser failed (HTTP ${response.status}${connectCode ? `, ${connectCode}` : ''}${traceId ? `, trace ${traceId}` : ''})`; + throw new WindsurfRegistrationError(message, response.status, connectCode, traceId); + } + + let parsed: RegisterUserResponseJson; + try { + parsed = JSON.parse(text) as RegisterUserResponseJson; + } catch { + throw new WindsurfRegistrationError( + // The body is not echoed: a 200 that fails to parse can still contain the + // key or the token that produced it. + `RegisterUser returned 200 with a body that is not JSON (${text.length} bytes)`, + response.status, + 'internal', + ); + } + + const apiKey = parsed.api_key; + // Empty `api_server_url` is normal for single-tenant accounts — the desktop + // extension's `getApiServerUrl` helper falls back to the configured default + // when this is empty/missing. We mirror that behavior here. + const apiServerUrl = parsed.api_server_url && parsed.api_server_url.length > 0 + ? parsed.api_server_url + : 'https://server.codeium.com'; + + if (!apiKey) { + throw new WindsurfRegistrationError( + 'RegisterUser returned 200 but api_key was empty', + response.status, + 'malformed_response', + ); + } + // `name` is optional in the response — default it instead of failing login. + // src/oauth/devin.ts uses it only as a display label for the account email. + const name = parsed.name && parsed.name.length > 0 ? parsed.name : 'Devin account'; + + return { + apiKey, + name, + apiServerUrl, + redirectUrl: parsed.redirect_url, + }; +} diff --git a/src/oauth/devin/types.ts b/src/oauth/devin/types.ts new file mode 100644 index 0000000000..07b317eb0d --- /dev/null +++ b/src/oauth/devin/types.ts @@ -0,0 +1,71 @@ +/** + * Shared types for the OAuth login flow + persisted credentials. + * + * Two distinct token shapes appear in this codebase: + * + * - `firebaseIdToken` — the short-lived JWT minted by Auth0 / Firebase Auth + * during browser sign-in. Lives in the OAuth callback URL fragment/query. + * Treated as opaque and discarded once exchanged. + * + * - `apiKey` — the long-lived credential returned by + * `SeatManagementService.RegisterUser`. Used inside every Cascade RPC's + * `Metadata.api_key` field. Format is provider-defined: + * * Cognition era: `devin-session-token$` + * * Codeium classic: bare UUID v4 + * * Older Windsurf: `sk-ws-01-<...>` / `cog_<...>` + * The plugin treats it as an opaque string — only the cloud cares about format. + */ + +export interface OAuthLoginResult { + /** The opaque API key used as `Metadata.api_key` in every Cascade RPC. */ + apiKey: string; + /** Human-readable account name (`Satvik Kapoor`). */ + name: string; + /** + * Cloud API server (`https://server.codeium.com`, `https://eu.windsurf.com/_route/api_server`, + * `https://windsurf.fedstart.com/_route/api_server`). Driven by the user's + * tenant — language_server needs this as `--api_server_url`. + */ + apiServerUrl: string; + /** Optional cleanup redirect URL returned by RegisterUser. Informational. */ + redirectUrl?: string; +} + +export interface PersistedCredentials extends OAuthLoginResult { + /** ISO timestamp the credentials were minted at — purely informational. */ + issuedAt: string; + /** Optional tag tracking the OAuth client id used (so a future client rotation can invalidate). */ + oauthClientId: string; + /** + * True when these credentials were written as part of the + * `opencode auth login` → authorize() flow (so opencode's auth.json is the + * authoritative copy and `opencode auth logout windsurf` should mirror-clear + * this file). False / absent for credentials written by our standalone + * `opencode-windsurf-auth login` CLI; those survive opencode auth state + * changes. + */ + syncedViaOpencodeAuth?: boolean; +} + +export interface WindsurfRegion { + /** Where to send users for browser sign-in. */ + website: string; + /** Where to POST RegisterUser. */ + registerApiServerUrl: string; + /** Auth0 client id passed in the OAuth URL. */ + oauthClientId: string; +} + +/** + * The single tenant (free / personal) configuration. EU, FedStart, and arbitrary + * portal URLs override `website` + `registerApiServerUrl` at runtime when the + * user passes `--portal-url` to the login command. + */ +export const DEFAULT_REGION: WindsurfRegion = { + website: 'https://windsurf.com', + registerApiServerUrl: 'https://register.windsurf.com', + // From /Applications/Windsurf.app/.../extension.js — the public Windsurf + // Auth0 client. If Windsurf rotates this, sign-in will start failing until + // we re-extract it. + oauthClientId: '3GUryQ7ldAeKEuD2obYnppsnmj58eP5u', +}; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index ed8af01af9..f98854cc3e 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -38,6 +38,7 @@ import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, Re import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; +import { loginDevin, refreshDevinToken } from "./devin"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { loginCommandCode, refreshCommandCodeToken } from "./command-code"; import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse"; @@ -308,6 +309,13 @@ export const OAUTH_PROVIDERS: Record = { providerConfig: oauthConfig("cursor"), defaultModel: oauthDefaultModel("cursor"), }, + devin: { + login: (ctrl) => loginDevin(ctrl), + refresh: refreshDevinToken, + providerConfig: oauthConfig("devin"), + defaultModel: oauthDefaultModel("devin"), + defaultRefreshPolicy: "disabled", + }, "github-copilot": { login: (ctrl) => loginGithubCopilot(ctrl), refresh: (rt, signal) => refreshGithubCopilotToken(rt, signal), diff --git a/src/oauth/store.ts b/src/oauth/store.ts index e641b81be7..011247dde3 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -29,6 +29,7 @@ import { type GenerationContext, } from "../lib/state-store-sweeper"; import { validateCopilotApiBaseUrl } from "./github-copilot"; +import { validateDevinApiBaseUrl } from "./devin/api-base"; import type { OAuthAccountSelection, OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types"; export type AuthStore = Record; @@ -459,9 +460,12 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null { if (isCredentialSource(candidate.source)) normalized.source = candidate.source; if (typeof candidate.projectId === "string" && candidate.projectId.length > 0) normalized.projectId = candidate.projectId; if (typeof candidate.apiBaseUrl === "string" && candidate.apiBaseUrl.length > 0) { - // Persist only allowlisted Copilot origins; drop anything else so auth.json cannot - // become an SSRF springboard across reloads. - const validated = validateCopilotApiBaseUrl(candidate.apiBaseUrl); + // Persist only allowlisted origins; drop anything else so auth.json cannot + // become an SSRF springboard across reloads. Copilot and Devin are the two + // providers whose host comes back from the network, and each owns its own + // allowlist. + const validated = + validateCopilotApiBaseUrl(candidate.apiBaseUrl) ?? validateDevinApiBaseUrl(candidate.apiBaseUrl); if (validated) normalized.apiBaseUrl = validated; } if (candidate.kiro && typeof candidate.kiro === "object") { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f72bb7650b..9450a071e9 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2,6 +2,7 @@ import type { CodexAccountMode, FastWire, OcxProviderConfig } from "../types"; import { fastWireDeclarationError } from "./fastwire"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; import { DEVIN_CLI_DEFAULT_MODEL, DEVIN_CLI_MODELS } from "../adapters/devin-cli/models"; +import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "./antigravity-models"; import type { ProviderBaseUrlChoice } from "./base-url-choices"; import { @@ -1298,6 +1299,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ models: [...DEVIN_CLI_MODELS], defaultModel: DEVIN_CLI_DEFAULT_MODEL, }, + { + id: "devin", + label: "Cognition (Devin/Windsurf)", + adapter: "devin", + baseUrl: "https://server.codeium.com", + authKind: "oauth", + featured: false, + dashboardPreset: false, + note: "Experimental unofficial Cognition/Devin bridge. ocx login devin opens Auth0 browser sign-in, then exchanges the token via Cognition's RegisterUser for a long-lived API key.", + models: ["swe-1-7", "swe-1-7-lightning", "gpt-5-6-sol", "gpt-5-6-luna", "gpt-5-6-terra", "claude-opus-4-8", "claude-fable-5-1", "claude-sonnet-5", "glm-5-2", "kimi-k2-7", "grok-4-5"], + liveModels: true, + defaultModel: "swe-1-7", + modelContextWindows: DEVIN_MODEL_CONTEXT_WINDOWS, + }, { id: "xai", label: "xAI Grok", diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index 3ce81accbf..a853fa4813 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -15,6 +15,7 @@ export function upstreamProtocolForAdapter(adapter: string): string { case "command-code": case "cursor": case "devin-cli": + case "devin": case "azure": case "azure-openai": case "kiro": diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index e2ba5a2029..bad8212a5d 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -255,6 +255,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); clearAccountQuotaCache(provider); + if (provider === "devin") { + // The cached user_jwt's payload contains the api_key, and the catalog is + // keyed by that key. Without this they outlive the credential in process + // memory until the JWT's own ~24 minute expiry. + const { clearCachedUserJwt, clearCachedCatalog } = await import("../../adapters/devin/cloud-direct"); + clearCachedUserJwt(); + clearCachedCatalog(); + } return jsonResponse({ success: true }); } diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 6c92aad3ae..c77db6cbc0 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -50,6 +50,7 @@ import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/ import { capEstimateAtContextWindow } from "../lib/token-estimate"; import { inferCursorContextWindow } from "../adapters/cursor/discovery"; import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models"; +import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models"; import { modelRecordValue } from "../reasoning-effort"; export interface RequestLogContext { @@ -1190,6 +1191,9 @@ function contextWindowForModel(adapter: string, modelId: string | undefined): nu if (adapter === "cursor" || adapter.startsWith("cursor-")) { return inferCursorContextWindow(modelId); } + if (adapter === "devin") { + return modelRecordValue(DEVIN_MODEL_CONTEXT_WINDOWS, modelId); + } return undefined; } diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 1ae7440a76..dd98190184 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -18,6 +18,11 @@ Some adapters share another adapter's routed-tool semantics while retaining inde so `buildRequest` returns a placeholder and `parseStream` is disabled. Its registry `baseUrl` is a canonical identity URL rather than a destination anything connects to, which is what keeps the generated configuration loadable: `providerBaseUrlConfigError` accepts only `http(s)` schemes. +- `devin` is the cloud half of the same family and is also direct. It streams Cognition's + `ApiServerService/GetChatMessage` over Connect-RPC from `runTurn` with hand-written protobuf + framing, so like Cursor and `devin-cli` it never travels the `buildRequest`/`parseStream` path. + The two share a name and nothing else: separate transports, separate credentials, separate + adapters. The registry records those relationships with `contractParent`. A parent relationship does **not** mean the registry recursively constructs a parent adapter and injects it into the child. Azure and MiMo keep owning their existing internal composition. This avoids making production constructors depend on test/conformance needs and keeps this authority refactor behavior-neutral. diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index e7e8f60b4f..08103c43f2 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -22,6 +22,7 @@ const EXPECTED_ADAPTER_NAMES = { "azure-openai": "azure-openai", cursor: "cursor", "devin-cli": "devin-cli", + devin: "devin", "mimo-free": "mimo-free", qoder: "qoder", } as const; diff --git a/tests/adapters/adapter-tool-conformance.test.ts b/tests/adapters/adapter-tool-conformance.test.ts index 284add7992..5b8fa543f7 100644 --- a/tests/adapters/adapter-tool-conformance.test.ts +++ b/tests/adapters/adapter-tool-conformance.test.ts @@ -420,9 +420,10 @@ describe("registry-derived routed tool conformance", () => { }); const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder"]); - // devin-cli drives a local CLI over ACP stdio: buildRequest returns a - // placeholder and tools never travel the wire path. - const RUN_TURN_ONLY_WIRES = new Set(["devin-cli"]); + // Both Devin providers are runTurn-only: devin-cli drives a local CLI over ACP + // stdio and devin streams Connect-RPC from runTurn, so for both of them + // buildRequest returns a placeholder and tools never travel the wire path. + const RUN_TURN_ONLY_WIRES = new Set(["devin-cli", "devin"]); test("every registered adapter keeps the nested apply_patch helper in its final request", async () => { for (const [adapterId] of adapterDefinitions()) { @@ -457,10 +458,10 @@ describe("registry-derived routed tool conformance", () => { if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; - if (!driver.streamingToolCall) { + if (!driver?.streamingToolCall) { // OpenAI Responses is a normal passthrough here and only parses routed compaction; // Cursor's proprietary runTurn stream has focused parser coverage elsewhere. - expect(["openai-responses", "cursor", "devin-cli"]).toContain(contract.wire); + expect(["openai-responses", "cursor"]).toContain(contract.wire); continue; } expect(await restoredStreamInput(adapterId, contract.wire), adapterId).toBe(PATCH); @@ -472,7 +473,7 @@ describe("registry-derived routed tool conformance", () => { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); - if (contract.wire === "openai-responses" || contract.wire === "cursor" || contract.wire === "devin-cli") { + if (contract.wire === "openai-responses" || contract.wire === "cursor") { // Native Responses passthrough and Cursor's protobuf transport do not use the routed // adapter tool declaration surface exercised by this registry-wide check. continue; @@ -488,7 +489,7 @@ describe("registry-derived routed tool conformance", () => { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); - if (contract.wire === "openai-responses" || contract.wire === "cursor" || contract.wire === "devin-cli") continue; + if (contract.wire === "openai-responses" || contract.wire === "cursor") continue; const parsed = namespacedCollisionParsed(contract.wire); // parseRequest rejects this shape for real inbound traffic; keeping the policy mutation here // also proves each adapter remains fail-closed when a caller reaches it with a prebuilt AST. @@ -516,8 +517,8 @@ describe("registry-derived routed tool conformance", () => { if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; - if (!driver.streamingToolCall || !driver.extractWireToolName) { - expect(["openai-responses", "cursor", "devin-cli"]).toContain(contract.wire); + if (!driver?.streamingToolCall || !driver?.extractWireToolName) { + expect(["openai-responses", "cursor"]).toContain(contract.wire); continue; } @@ -559,6 +560,7 @@ describe("registry-derived routed tool conformance", () => { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); + // Devin is a runTurn-only adapter; continuation replay is not expressed on buildRequest. const body = await outbound(adapterId, continuationParsed(contract.wire)); expect(continuationInput(contract.wire, body), adapterId).toBe(PATCH); } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b06cf54d29..8b3d85d403 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -454,7 +454,9 @@ "desktop-profile.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", + "devin-adapter.test.ts": "providers", "devin-cli-adapter.test.ts": "providers", + "devin-hardening.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts new file mode 100644 index 0000000000..7ce0efe994 --- /dev/null +++ b/tests/providers/devin-adapter.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { createDevinAdapter, mapOcxMessagesToDevin, mapOcxToolsToDevin } from "../../src/adapters/devin"; +import { sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; +import { DEVIN_STATIC_MODELS, collapseDevinModelUid } from "../../src/adapters/devin/live-models"; +import { OAUTH_PROVIDERS } from "../../src/oauth"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import type { OcxParsedRequest } from "../../src/types"; + +describe("devin adapter", () => { + test("is registered as an oauth provider and adapter", () => { + expect(OAUTH_PROVIDERS.devin.defaultModel).toBe("swe-1-7"); + const entry = PROVIDER_REGISTRY.find((row) => row.id === "devin"); + expect(entry?.adapter).toBe("devin"); + expect(entry?.authKind).toBe("oauth"); + expect(entry?.liveModels).toBe(true); + expect(createDevinAdapter({ adapter: "devin", baseUrl: "https://server.codeium.com" }).name).toBe("devin"); + }); + + test("maps user/assistant/tool history and tools", () => { + const parsed: OcxParsedRequest = { + modelId: "swe-1-7", + stream: true, + context: { + systemPrompt: ["be brief"], + messages: [ + { role: "user", content: "hi", timestamp: 1 }, + { + role: "assistant", + content: [ + { type: "text", text: "calling" }, + { type: "toolCall", id: "c1", name: "lookup", arguments: { q: "x" } }, + ], + timestamp: 2, + }, + { role: "toolResult", toolCallId: "c1", toolName: "lookup", content: "ok", isError: false, timestamp: 3 }, + ], + tools: [{ name: "lookup", description: "lookup", parameters: { type: "object" } }], + }, + options: {}, + }; + const history = mapOcxMessagesToDevin(parsed); + expect(history[0]).toEqual({ role: "system", content: "be brief" }); + expect(history[1]).toEqual({ role: "user", content: "hi" }); + expect(history[2]?.role).toBe("assistant"); + expect(history[2]?.tool_calls?.[0]?.id).toBe("c1"); + expect(history[3]).toEqual({ role: "tool", content: "ok", tool_call_id: "c1" }); + expect(mapOcxToolsToDevin(parsed.context.tools)?.[0]?.name).toBe("lookup"); + }); + + test("collapseDevinModelUid strips effort suffixes to base ids", () => { + expect(collapseDevinModelUid("swe-1-7")).toBe("swe-1-7"); + expect(collapseDevinModelUid("swe-1-7-medium")).toBe("swe-1-7"); + expect(collapseDevinModelUid("swe-1-7-lightning")).toBe("swe-1-7-lightning"); + expect(collapseDevinModelUid("swe-1-7-lightning-medium")).toBe("swe-1-7-lightning"); + expect(collapseDevinModelUid("gpt-5-6-sol-high")).toBe("gpt-5-6-sol"); + expect(collapseDevinModelUid("gpt-5-6-sol-high-priority")).toBe("gpt-5-6-sol"); + expect(collapseDevinModelUid("glm-5-2-max-1m")).toBe("glm-5-2"); + expect(collapseDevinModelUid("claude-opus-4-8-high-fast")).toBe("claude-opus-4-8"); + expect(collapseDevinModelUid("claude-fable-5-1-high")).toBe("claude-fable-5-1"); + expect(collapseDevinModelUid("grok-4-5-medium")).toBe("grok-4-5"); + }); + + test("loginDevin is browser-only (no local import option)", () => { + // The devin OAuth entry must not accept importLocal/forceLogin opts — + // login is always the Auth0 browser flow. + const entry = OAUTH_PROVIDERS.devin; + expect(entry.login.length).toBeLessThanOrEqual(1); + }); + + test("rewrites the Cognition blocklist trigger phrase in tool descriptions", () => { + // The exact 7-word phrase (capital T, single spaces) triggers Cognition's + // permission_denied content filter. The rewrite must break the exact match + // while preserving meaning. + const trigger = "Takes a task_id parameter identifying the task"; + expect(sanitizeToolDescriptionForCognitionForTests(trigger)).toBe("Accepts a task_id parameter identifying the task"); + // Case-sensitive: lowercase first letter is NOT rewritten (it doesn't trigger) + expect(sanitizeToolDescriptionForCognitionForTests("takes a task_id parameter identifying the task")) + .toBe("takes a task_id parameter identifying the task"); + // Substring match: the phrase embedded in a larger description is rewritten + const full = "- Retrieves output from a running or completed task\n- Takes a task_id parameter identifying the task\n- Returns the task output"; + const rewritten = sanitizeToolDescriptionForCognitionForTests(full); + expect(rewritten).not.toContain("Takes a task_id parameter identifying the task"); + expect(rewritten).toContain("Accepts a task_id parameter identifying the task"); + // Surrounding text is preserved + expect(rewritten).toContain("- Retrieves output from a running or completed task"); + expect(rewritten).toContain("- Returns the task output"); + // Descriptions without the trigger pass through unchanged + expect(sanitizeToolDescriptionForCognitionForTests("A benign description.")).toBe("A benign description."); + }); +}); + diff --git a/tests/providers/devin-hardening.test.ts b/tests/providers/devin-hardening.test.ts new file mode 100644 index 0000000000..07a5302ce7 --- /dev/null +++ b/tests/providers/devin-hardening.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeDevinModelId } from "../../src/adapters/devin"; +import { parseDevinAuthPaste, refreshDevinToken } from "../../src/oauth/devin"; +import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiBaseUrl, validateDevinApiBaseUrl } from "../../src/oauth/devin/api-base"; +import { registerUser } from "../../src/oauth/devin/register-user"; +import { anySignal } from "../../src/lib/abort"; +import { buildGetChatMessageRequestForTests } from "../../src/adapters/devin/cloud-direct/chat"; +import { iterFields } from "../../src/adapters/devin/cloud-direct/wire"; + +const FAKE_TOKEN = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEifQ.c2lnbmF0dXJl"; + +describe("devin api-server allowlist", () => { + test("accepts the default host and a tenant path, keeping the path", () => { + expect(validateDevinApiBaseUrl("https://server.codeium.com")).toBe("https://server.codeium.com"); + expect(validateDevinApiBaseUrl("https://server.codeium.com/")).toBe("https://server.codeium.com"); + // EU and FedStart tenants live under a path prefix, so normalizing to the + // origin the way the Copilot validator does would point them at the wrong + // service rather than merely losing decoration. + expect(validateDevinApiBaseUrl("https://eu.windsurf.com/_route/api_server")).toBe( + "https://eu.windsurf.com/_route/api_server", + ); + expect(validateDevinApiBaseUrl("https://windsurf.fedstart.com/_route/api_server")).toBe( + "https://windsurf.fedstart.com/_route/api_server", + ); + }); + + test("rejects every shape that would redirect a credential-bearing POST", () => { + for (const hostile of [ + "http://server.codeium.com", + "https://attacker.example.com", + "https://server.codeium.com.attacker.example", + // Assembled rather than written out: a literal userinfo URL reads as an + // email address to the privacy scanner. + `https://user:secret${"@"}server.codeium.com`, + "https://server.codeium.com:8443", + "https://127.0.0.1", + "https://localhost", + "https://10.0.0.5", + "https://server.codeium.com/path?next=https://evil.example", + "https://server.codeium.com/path#frag", + "not a url", + "", + ]) { + expect(validateDevinApiBaseUrl(hostile)).toBeUndefined(); + } + expect(resolveDevinApiBaseUrl("https://attacker.example.com")).toBe(DEVIN_DEFAULT_API_SERVER); + }); +}); + +describe("devin auth paste", () => { + test("accepts a bare token and pulls one out of a callback URL", () => { + expect(parseDevinAuthPaste(` ${FAKE_TOKEN} `)).toBe(FAKE_TOKEN); + expect(parseDevinAuthPaste(`https://windsurf.com/callback#access_token=${FAKE_TOKEN}&state=abc`)).toBe(FAKE_TOKEN); + expect(parseDevinAuthPaste(`https://windsurf.com/cb?firebase_id_token=${FAKE_TOKEN}`)).toBe(FAKE_TOKEN); + }); + + test("accepts the one-time token shape a live sign-in actually returns", () => { + // Measured, not assumed: a free-tier sign-in on 2026-09-12 returned a + // 47-character `ott$…` value, and RegisterUser exchanged it successfully. + // A JWT-only check here would reject every real login. + const oneTime = "ott$lLA_RUkVq3nB7xYz0aQpMdT4sWgEhJcK-TjATkAk"; + expect(parseDevinAuthPaste(oneTime)).toBe(oneTime); + expect(parseDevinAuthPaste(` ${oneTime}\n`)).toBe(oneTime); + }); + + test("refuses a paste with no token instead of posting it as the token", () => { + expect(() => parseDevinAuthPaste("https://windsurf.com/windsurf/signin?prompt=login")).toThrow(/no auth token/i); + expect(() => parseDevinAuthPaste("this is not a token")).toThrow(/not a Devin auth token/i); + expect(() => parseDevinAuthPaste("short")).toThrow(/not a Devin auth token/i); + expect(() => parseDevinAuthPaste(" ")).toThrow(/No auth token pasted/i); + }); +}); + +describe("devin credential lifecycle", () => { + test("refresh fails closed rather than extending a possibly revoked key", async () => { + // The carried implementation returned an extended expiry, which made a + // revoked key look valid forever. Throwing is what marks needsReauth. + await expect(refreshDevinToken("whatever")).rejects.toThrow(/invalid_grant/); + }); +}); + +describe("devin model ids", () => { + test("dotted version numbers collapse to the hyphenated catalog spelling", () => { + expect(normalizeDevinModelId("swe-1.6")).toBe("swe-1-6"); + expect(normalizeDevinModelId("claude-opus-4.7-max")).toBe("claude-opus-4-7-max"); + expect(normalizeDevinModelId("swe-1-7")).toBe("swe-1-7"); + }); +}); + +describe("registerUser error reporting", () => { + const withFetch = async (impl: typeof fetch, run: () => Promise) => { + const original = globalThis.fetch; + globalThis.fetch = impl; + try { + await run(); + } finally { + globalThis.fetch = original; + } + }; + const region = { + website: "https://windsurf.com", + registerApiServerUrl: "https://register.windsurf.com", + oauthClientId: "test-client", + }; + + test("an error body that echoes the token never reaches the message", async () => { + await withFetch( + (async () => + new Response(JSON.stringify({ code: "invalid_argument", message: `bad firebase_id_token ${FAKE_TOKEN}` }), { + status: 400, + })) as typeof fetch, + async () => { + const error = await registerUser(FAKE_TOKEN, region).catch((e: Error) => e); + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).not.toContain(FAKE_TOKEN); + expect(message).toContain("HTTP 400"); + expect(message).toContain("invalid_argument"); + }, + ); + }); + + test("a 200 with an unparseable body reports its size, not its contents", async () => { + await withFetch( + (async () => new Response(`${FAKE_TOKEN}`, { status: 200 })) as typeof fetch, + async () => { + const error = await registerUser(FAKE_TOKEN, region).catch((e: Error) => e); + expect((error as Error).message).not.toContain(FAKE_TOKEN); + expect((error as Error).message).toMatch(/not JSON/i); + }, + ); + }); + + test("a register host outside the allowlist is refused before the token is sent", async () => { + let called = false; + await withFetch( + (async () => { + called = true; + return new Response("{}", { status: 200 }); + }) as typeof fetch, + async () => { + const error = await registerUser(FAKE_TOKEN, { ...region, registerApiServerUrl: "https://evil.example" }).catch( + (e: Error) => e, + ); + expect((error as Error).message).toMatch(/non-Cognition register host/i); + expect(called).toBe(false); + }, + ); + }); +}); + +describe("anySignal", () => { + test("cleanup detaches from a parent signal that never aborts", () => { + const parent = new AbortController(); + let added = 0; + let removed = 0; + const realAdd = parent.signal.addEventListener.bind(parent.signal); + const realRemove = parent.signal.removeEventListener.bind(parent.signal); + // Exercise the polyfill branch explicitly: on Bun the builtin + // AbortSignal.any is used and owns its own teardown. + const builtin = (AbortSignal as unknown as { any?: unknown }).any; + (AbortSignal as unknown as { any?: unknown }).any = undefined; + parent.signal.addEventListener = ((...args: Parameters) => { + added += 1; + return realAdd(...args); + }) as typeof realAdd; + parent.signal.removeEventListener = ((...args: Parameters) => { + removed += 1; + return realRemove(...args); + }) as typeof realRemove; + try { + const composed = anySignal([parent.signal, AbortSignal.timeout(60_000)]); + expect(composed.signal.aborted).toBe(false); + composed.cleanup(); + expect(added).toBe(1); + expect(removed).toBe(1); + } finally { + (AbortSignal as unknown as { any?: unknown }).any = builtin; + } + }); + + test("aborts as soon as any input aborts", () => { + const a = new AbortController(); + const b = new AbortController(); + const composed = anySignal([a.signal, b.signal]); + expect(composed.signal.aborted).toBe(false); + b.abort(new Error("stop")); + expect(composed.signal.aborted).toBe(true); + composed.cleanup(); + }); +}); + +describe("devin cloud request shape", () => { + // The bug this guards: #2 and #3 were swapped, so a caller asking for 32 + // output tokens wrote 32 into the context-window field and Cognition answered + // every single turn with an opaque "an internal error occurred" - on free and + // paid accounts alike. Verified on 2026-09-12 by building the same turn with a + // working client and diffing the encoded messages field by field. + function fields(buf: Buffer) { + const out: Record = {}; + for (const f of iterFields(buf)) out[f.num] = { wire: f.wire, value: f.value }; + return out; + } + const build = (completionOpts?: Record) => + buildGetChatMessageRequestForTests({ + apiKey: "devin-session-token$test", + sessionId: "11111111-1111-1111-1111-111111111111", + requestId: 1n, + triggerId: "22222222-2222-2222-2222-222222222222", + cascadeId: "33333333-3333-3333-3333-333333333333", + modelUid: "swe-2-high", + messages: [{ role: "user", content: "hi" }], + ...(completionOpts ? { completionOpts } : {}), + }); + + test("the output cap lands in #2 and the context window in #3", () => { + const outer = fields(build({ maxOutputTokens: 64, maxInputTokens: 200_000 })); + const completion = outer[8]?.value as Buffer; + const inner = fields(completion); + expect(inner[2]).toEqual({ wire: 0, value: 64n }); + expect(inner[3]).toEqual({ wire: 0, value: 200_000n }); + // #6 and #11 are not part of the message the service accepts. + expect(inner[6]).toBeUndefined(); + expect(inner[11]).toBeUndefined(); + }); + + test("temperature zero is clamped, because the service refuses exactly zero", () => { + const inner = fields(fields(build({ temperature: 0 }))[8]?.value as Buffer); + const raw = inner[5]?.value as Buffer; + const temperature = Buffer.from(raw).readDoubleLE(0); + expect(temperature).toBeGreaterThan(0); + expect(temperature).toBeLessThan(0.01); + }); + + test("the outer request carries the verified tag set", () => { + const outer = fields(build()); + // Present: metadata, system prompt, one prompt, request type, completion + // config, session model config, session id, the #20 marker and the model. + for (const tag of [1, 2, 3, 7, 8, 15, 16, 20, 21]) expect(outer[tag], `#${tag}`).toBeDefined(); + // #22 only appears from the second turn onward and is reused across that + // turn's tool loop, so a fresh per-request uuid matches neither shape. + expect(outer[22]).toBeUndefined(); + }); + + test("metadata carries the fingerprint the service checks the length of", () => { + const metadata = fields(fields(build())[1]?.value as Buffer); + expect((metadata[31]?.value as Buffer).length).toBe(732); + }); +}); From a594f7e52d0e8340e1fc3ca4c27972b09a0033a7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:42:21 +0900 Subject: [PATCH 109/231] docs(devlog): plan the GUI pool client merge --- .../050_phase5_surface_consolidation.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md index 2067e70a53..9714373731 100644 --- a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -339,3 +339,54 @@ right discriminator; the unified GET must NOT copy the mixed pin+failover+pool D `GET /api/codex-auth/active` returns; and CORS, the Vite `/api` proxy, OpenAPI and the management-auth enumeration are not gates for a new path. +## wp5b plan — one GUI pool client + +The last phase. wp5c gave the server one contract; this points the dashboard at it. + +### What "two surfaces" means in the GUI + +Not two screens. Two independent client implementations of the same idea: + +| Surface | File | Talks to | Reads | +|---|---|---|---| +| Codex threshold | `gui/src/codex-auto-switch.ts` | `PUT /api/codex-auth/auto-switch` | bare `{ threshold }` | +| Codex strategy/sticky | `gui/src/account-pool-strategy.ts` | `PUT /api/codex-auth/pool-strategy` | `accountPoolStrategy`, `accountPoolStickyLimit` | +| Anthropic pool | `gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx` | `GET`/`PUT /api/oauth/accounts/pool` | `strategy`, `stickyLimit`, `quotaWindow` | + +Three fetchers, three response shapes, two prefix conventions for the same two fields. The +components on top are legitimately different — a Codex pool card is not an Anthropic pool card — +so this phase merges the CLIENT, not the presentation. Merging the rendering would be a visual +redesign nobody asked for; merging the transport is the duplication the objective names. + +### Change surface + +NEW `gui/src/pool-settings.ts` — one client for `/api/pool/settings`: +`getPoolSettings(apiBase, provider)` and `putPoolSettings(apiBase, provider, fields)`, both +returning the unified DTO with its `supported` list. The existing normalizers in +`account-pool-strategy.ts` stay where they are and are reused; this adds a transport, not a +second copy of the value rules. + +MODIFY `codex-auto-switch.ts` `putAutoSwitchThreshold` and `account-pool-strategy.ts` +`putCodexPoolStrategy` to delegate, keeping their exported signatures so no component changes +shape. The `accountPoolStrategy`/`accountPoolStickyLimit` response handling disappears with the +prefixed keys — the unified DTO is neutral for every kind. + +MODIFY `AnthropicAccountPoolSettings.tsx` to read and write through the same client. + +### The screenshot + +`enforce-target` requires a screenshot embed in the description of any PR whose title or +description mentions `gui`, waivable only by a maintainer label. So: `bun run build:gui`, start +the proxy, open the dashboard, capture the pool settings, and commit the PNG under the plan unit +so the description can embed it from the branch. A committed asset is the only route that does +not depend on a browser drag-and-drop. + +### Acceptance + +- No GUI file references `/api/codex-auth/auto-switch`, `/api/codex-auth/pool-strategy` or + `/api/oauth/accounts/pool` any more; one grep proves the consolidation rather than an + argument about it. +- `bun run lint:gui` passes and the GUI suites covering these modules pass. +- The three server routes still work — they have their own goldens and are not touched. +- The PR description embeds a real screenshot of the rendered pool settings. + From 38a4bded0798b181d58ca61065963eb4cd108766 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:42:21 +0900 Subject: [PATCH 110/231] fix(zai): declare glm-5.3-flash image input on the Chat rows noVisionModels only said what Flash is not, so the catalog fell through to the text floor and client exports advertised a native VLM as text-only. Closes #4296. --- src/providers/registry.ts | 26 +++++++++++++++++++ .../provider-registry-parity.test.ts | 15 +++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f72bb7650b..421498aeeb 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -444,6 +444,30 @@ const ZAI_GLM_5X_MODELS = [...ZAI_GLM_53_MODELS, ...ZAI_GLM_52_MODELS]; * `preserveReasoningContentModels`, where flash DOES belong. */ const ZAI_GLM_5X_SIDECAR_VISION_MODELS = ZAI_GLM_5X_MODELS.filter(id => id !== "glm-5.3-flash"); +/** + * Positive input-modality declaration for the Chat-path GLM rows. + * + * `noVisionModels` already keeps Flash out of the vision sidecar, but that is a NEGATIVE + * statement: it stops a detour without telling the catalog what the model can read. With + * no `modelInputModalities` entry, `configuredInputModalities` returns undefined and the + * catalog falls through to the `["text"]` floor, so every client export (ZCode, Pi, OMP) + * listed a native VLM as text-only and its picker refused to attach an image. + * + * The Responses sibling row below already declares this positively, so the same model was + * described two different ways in one registry. + * + * Authoritative source: `GET https://api.z.ai/api/v1/models` returns `input_modalities: + * ["text"]` for glm-5.3 and `["text", "image"]` for glm-5.3-flash (captured in + * devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json). + * docs.z.ai/devpack/latest-model says the same in prose: "GLM-5.3 is a text-only model... + * GLM-5.3-FLASH is a multimodal model". Upstream also lists video and file for Flash; + * neither the internal vocabulary nor the export vocabulary can express them, so `image` + * is where this stops. + */ +const ZAI_GLM_5X_INPUT_MODALITIES: Record = { + ...Object.fromEntries(ZAI_GLM_5X_SIDECAR_VISION_MODELS.map(id => [id, ["text"]])), + "glm-5.3-flash": ["text", "image"], +}; const ZAI_GLM_52_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; /** * GLM-5.3 does NOT share 5.2's five-tier ladder. docs.z.ai/devpack/latest-model folds every @@ -2604,6 +2628,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Z.AI's OpenAI path returns 400 code 1211 for bracketed model ids. modelSuffixBracketStrip: true, noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, + modelInputModalities: ZAI_GLM_5X_INPUT_MODALITIES, modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS, modelDefaultReasoningEfforts: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, "max"])), modelMaxOutputTokens: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, 131_072])), @@ -2685,6 +2710,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelContextWindows: { "glm-5.3": 1_000_000, "glm-5.3[1m]": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }, modelSuffixBracketStrip: true, noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, + modelInputModalities: ZAI_GLM_5X_INPUT_MODALITIES, modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS, modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_5X_MODELS.map(id => [id, true])), preserveReasoningContentModels: ZAI_GLM_5X_MODELS, diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 586c8a7735..96be6cc6ab 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -482,6 +482,21 @@ describe("provider registry parity", () => { // The sibling it is most often confused with stays text-only, so the assertion above // cannot pass by making every GLM row a VLM. expect(zai?.noVisionModels ?? []).toContain("glm-5.3"); + // The global loop above accepts an ABSENT declaration, which is exactly how the Chat + // rows shipped: Flash was kept out of the sidecar but never told the catalog it could + // read an image, so `configuredInputModalities` returned undefined and every client + // export listed a native VLM as text-only. Pin the positive declaration so the two + // Chat rows cannot drift back to describing Flash only by what it is not. + for (const id of ["zai", "zhipu-bigmodel-coding"] as const) { + const row = PROVIDER_REGISTRY.find(entry => entry.id === id); + expect(row?.modelInputModalities?.["glm-5.3-flash"]).toEqual(["text", "image"]); + expect(row?.modelInputModalities?.["glm-5.3"]).toEqual(["text"]); + // The bracketed aliases are looked up by their exact catalog id, not a stripped one, + // so they need their own text-only entries. + expect(row?.modelInputModalities?.["glm-5.3[1m]"]).toEqual(["text"]); + expect(row?.modelInputModalities?.["glm-5.2[1m]"]).toEqual(["text"]); + expect(row?.noVisionModels ?? []).not.toContain("glm-5.3-flash"); + } // `glm-5.3-flash` belongs in all three maps. It was seeded into the model list // and the context map alone, so it advertised a 1M window with no effort ladder, // no default effort and no output cap - and this assertion pinned that gap in From a054ba444e58b9b5c7ac46f2390d2ab20a86a93a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:47:15 +0900 Subject: [PATCH 111/231] docs(devlog): fold the wp5b audit; the silent-no-op trap a third time --- .../050_phase5_surface_consolidation.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md index 9714373731..bb68857a93 100644 --- a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -390,3 +390,45 @@ not depend on a browser drag-and-drop. - The three server routes still work — they have their own goldens and are not touched. - The PR description embeds a real screenshot of the rendered pool settings. +### wp5b plan audit — FAIL, folded + +**Blocker 1 — the request adapter, again.** This is the third time this exact shape has been +caught in this unit, and it is the most dangerous instance. `putAutoSwitchThreshold` sends +`{ threshold }`; the unified route reads `{ provider, autoSwitchThreshold }`. A URL swap alone +either 400s, or — with `provider` added and `threshold` left alone — returns **200 while writing +nothing**, because the route ignores an unknown field. And the function only inspects +`response.ok`, so the dashboard would report success on every save and change no setting. + +Silent success is worse than a visible failure, so the client owns an explicit request mapping: +`threshold` becomes `autoSwitchThreshold`, `provider` is always sent, and Codex is addressed as +`provider: "openai"`. The strategy body keys already match and need no mapping; only the +response did, which is what the original plan named and why the request side slipped past it. + +**Major 2 — the read path is a different route, and the plan mislabeled it.** The table called +the write bodies "Reads". The GUI actually reads the Codex threshold and strategy from +`GET /api/codex-auth/active` via `extractAutoSwitchThresholdPayload`. That read STAYS: `/active` +is a mixed pin + failover + pool payload the dashboard needs in one request, and wp5c +deliberately did not have the unified GET copy it. Stated rather than left implicit, because a +future reader would otherwise see a half-migrated client and assume it was unfinished. + +This narrows the acceptance grep: no GUI file may reference the three legacy pool WRITE +contracts. `/api/codex-auth/active` legitimately remains, and the grep says so. + +**Major 3 — four GUI test files pin the old URLs and payloads:** +`gui/tests/account-pool-strategy.test.tsx`, `anthropic-pool-quota-window.test.tsx`, +`codex-account-auto-switch.test.tsx` and `codex-auto-switch-controller.test.tsx`. They move with +the client. `CodexPoolStrategySetting` reads `result.strategy`/`stickyLimit` from the wrapper, +so it survives untouched as long as the wrapper maps the DTO; `putAutoSwitchThreshold` callers +never read the body. + +**Minor 4 recorded, not fixed:** `ProviderAuthPanel` still gates the pool card on +`item.name === "anthropic"`, so a generic OAuth provider has a contract and no UI, and the new +`supported`/`enabledEffective` fields are not yet rendered. That is a feature the objective does +not ask for; naming it is better than silently leaving a reader to wonder whether it was missed. + +**Screenshot — the gate is stricter than the plan assumed.** It fires on `gui/` PATH CHANGES, +not on a title cue, so it applies here regardless of wording. A committed PNG alone does not +satisfy it: the description must contain a rendered embed. A relative path passes the regex but +renders nothing on GitHub, so the description uses an absolute `raw.githubusercontent.com` URL +pointing at the committed file on this branch. The waiver is a maintainer COMMENT, not a label. + From e0ecab1bfbf0b57bf5850ce5999d79115588249f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:50:37 +0900 Subject: [PATCH 112/231] docs(devlog): amend the wp5b spec instead of only recording the audit --- .../050_phase5_surface_consolidation.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md index bb68857a93..08953085b7 100644 --- a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -432,3 +432,60 @@ satisfy it: the description must contain a rendered embed. A relative path passe renders nothing on GitHub, so the description uses an absolute `raw.githubusercontent.com` URL pointing at the committed file on this branch. The waiver is a maintainer COMMENT, not a label. +### wp5b SPEC — supersedes "Change surface", "The screenshot" and "Acceptance" above + +Those three sections predate the audit and disagree with it. This is the spec. + +**Change surface.** + +NEW `gui/src/pool-settings.ts`, one client for `/api/pool/settings`: + +- `getPoolSettings(apiBase, provider)` — `GET ?provider=`, returns the unified DTO. +- `putPoolSettings(apiBase, provider, fields)` — `PUT`, and it owns an explicit REQUEST + mapping rather than forwarding whatever it is handed: + - `provider` is ALWAYS sent, and Codex is addressed as `provider: "openai"`. + - the Codex threshold field `threshold` becomes `autoSwitchThreshold`. + - `strategy` and `stickyLimit` already match and pass through unmapped. + + Without that mapping a URL swap returns 200 and writes nothing, because the route ignores an + unknown field — and the caller only inspects `response.ok`, so the dashboard would report + success on every save. That is the specific failure this mapping exists to prevent. + +MODIFY `gui/src/codex-auto-switch.ts` `putAutoSwitchThreshold` and +`gui/src/account-pool-strategy.ts` `putCodexPoolStrategy`: same exported signatures, bodies +delegating through the client, and the `accountPoolStrategy`/`accountPoolStickyLimit` response +parsing replaced by the DTO's neutral keys. + +MODIFY `gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx`: read and write +through the client. + +MOVE WITH IT — four test files pin the old URLs and payloads and are part of this change, not +collateral: `gui/tests/account-pool-strategy.test.tsx`, +`gui/tests/anthropic-pool-quota-window.test.tsx`, `gui/tests/codex-account-auto-switch.test.tsx`, +`gui/tests/codex-auto-switch-controller.test.tsx`. + +UNCHANGED ON PURPOSE — `GET /api/codex-auth/active`. The dashboard reads the Codex threshold and +strategy from that mixed pin + failover + pool payload in one request, and wp5c deliberately did +not have the unified GET copy it. This phase migrates the three pool WRITE contracts, not that +read. + +**Acceptance.** + +- `rg` over `gui/` returns no hit for `/api/codex-auth/auto-switch`, + `/api/codex-auth/pool-strategy` or `/api/oauth/accounts/pool` — the three legacy WRITE + contracts. `/api/codex-auth/active` is expected to remain and is not part of this grep. +- The four test files above assert the unified path and the mapped request body, including + `autoSwitchThreshold` rather than `threshold`. +- `bun run lint:gui` passes and the GUI suites pass. +- Red control: with the request mapping removed, the auto-switch save test must fail — the point + is that it would otherwise pass silently. + +**The screenshot.** + +The gate fires on `gui/` PATH CHANGES, not on a title cue, so it applies. A committed PNG alone +does NOT satisfy it. The description must carry a rendered embed — `![alt](url)`, +``, or a reference form — outside comments and fences. A relative path passes the +regex but renders nothing, so the PNG is committed under the plan unit and the description +embeds its absolute `raw.githubusercontent.com` URL on this branch. The only waiver is a +maintainer COMMENT, which is not something this cycle can issue for itself. + From f85f6ac76b7013542021462950ea8bcdad6e5925 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 03:59:53 +0900 Subject: [PATCH 113/231] feat(gui): one pool client for every kind Three fetchers spoke three shapes for the same two fields: the Codex threshold write, the Codex strategy write with accountPool-prefixed response keys, and the Anthropic pool read/write. They now share gui/src/pool-settings.ts against the unified contract. The client owns the request mapping rather than forwarding what it is handed. The route ignores an unknown field, and putAutoSwitchThreshold only inspects response.ok, so a body that still said threshold would have returned 200 and written nothing while the dashboard reported success on every save. GET /api/codex-auth/active is unchanged on purpose: it is a mixed pin, failover and pool payload the dashboard reads in one request, and the unified GET deliberately does not copy it. --- gui/src/account-pool-strategy.ts | 33 ++--- gui/src/codex-auto-switch.ts | 24 ++-- .../AnthropicAccountPoolSettings.tsx | 42 +++--- gui/src/pool-settings.ts | 121 ++++++++++++++++++ gui/tests/account-pool-strategy.test.tsx | 16 ++- .../anthropic-pool-quota-window.test.tsx | 4 +- gui/tests/codex-account-auto-switch.test.tsx | 7 +- .../codex-auto-switch-controller.test.tsx | 14 +- 8 files changed, 184 insertions(+), 77 deletions(-) create mode 100644 gui/src/pool-settings.ts diff --git a/gui/src/account-pool-strategy.ts b/gui/src/account-pool-strategy.ts index 4dbc7b9e2b..b1ce2aa5ed 100644 --- a/gui/src/account-pool-strategy.ts +++ b/gui/src/account-pool-strategy.ts @@ -61,26 +61,15 @@ export async function putCodexPoolStrategy( fetchImpl: PoolStrategyFetch = (input, init) => fetch(input, init), ): Promise<{ ok: true; strategy: AccountPoolStrategy; stickyLimit: number } | { ok: false }> { if (body.strategy === undefined && body.stickyLimit === undefined) return { ok: false }; - try { - const response = await fetchImpl(`${apiBase}/api/codex-auth/pool-strategy`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...(body.strategy !== undefined ? { strategy: body.strategy } : {}), - ...(body.stickyLimit !== undefined ? { stickyLimit: body.stickyLimit } : {}), - }), - }); - if (!response.ok) return { ok: false }; - const json = await response.json() as { - accountPoolStrategy?: unknown; - accountPoolStickyLimit?: unknown; - }; - return { - ok: true, - strategy: normalizeAccountPoolStrategy(json.accountPoolStrategy ?? body.strategy), - stickyLimit: normalizeAccountPoolStickyLimit(json.accountPoolStickyLimit ?? body.stickyLimit), - }; - } catch { - return { ok: false }; - } + // The prefixed `accountPoolStrategy`/`accountPoolStickyLimit` response keys are gone with + // the Codex-only route: the unified contract answers with neutral keys for every kind. + const { CODEX_POOL_PROVIDER, putPoolSettings } = await import("./pool-settings"); + const settings = await putPoolSettings( + apiBase, + CODEX_POOL_PROVIDER, + { strategy: body.strategy, stickyLimit: body.stickyLimit }, + (input, init) => fetchImpl(input, init as RequestInit), + ); + if (!settings) return { ok: false }; + return { ok: true, strategy: settings.strategy, stickyLimit: settings.stickyLimit }; } diff --git a/gui/src/codex-auto-switch.ts b/gui/src/codex-auto-switch.ts index ed7d7168da..eac76dbbfc 100644 --- a/gui/src/codex-auto-switch.ts +++ b/gui/src/codex-auto-switch.ts @@ -1,5 +1,7 @@ export const DEFAULT_AUTO_SWITCH_THRESHOLD = 80; +import { CODEX_POOL_PROVIDER, putPoolSettings } from "./pool-settings"; + const AUTO_SWITCH_PUT_TIMEOUT_MS = 10_000; export type AutoSwitchFetch = (input: string, init: RequestInit) => Promise; @@ -78,15 +80,15 @@ export async function putAutoSwitchThreshold( timeoutMs = AUTO_SWITCH_PUT_TIMEOUT_MS, ): Promise { if (!Number.isInteger(threshold) || threshold < 0 || threshold > 100) return false; - try { - const response = await fetchImpl(`${apiBase}/api/codex-auth/auto-switch`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ threshold }), - signal: AbortSignal.timeout(timeoutMs), - }); - return response.ok; - } catch { - return false; - } + // Through the shared client, which maps `threshold` onto the contract's + // `autoSwitchThreshold` and sends the provider. This function reports only ok/not-ok, so a + // body the route silently ignored would read here as a successful save that changed nothing. + const settings = await putPoolSettings( + apiBase, + CODEX_POOL_PROVIDER, + { threshold }, + (input, init) => fetchImpl(input, init as RequestInit), + { signal: AbortSignal.timeout(timeoutMs) }, + ); + return settings !== null; } diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx index 4d7c66b970..84120b54c4 100644 --- a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -4,6 +4,7 @@ */ import { useCallback, useEffect, useState } from "react"; import { useT } from "../../i18n/shared"; +import { getPoolSettings, putPoolSettings } from "../../pool-settings"; import { ACCOUNT_POOL_QUOTA_WINDOWS, DEFAULT_ACCOUNT_POOL_QUOTA_WINDOW, @@ -60,16 +61,11 @@ export default function AnthropicAccountPoolSettings({ // mount-then-unmount dropped the request entirely. The abort controller already covers // in-flight cancellation, which is the part that actually needs to be cancellable. void Promise.resolve() - .then(() => fetch(`${apiBase}/api/oauth/accounts/pool?provider=anthropic`, { signal: ac.signal })) - .then(res => { - if (!res.ok) throw new Error("load"); - return res.json() as Promise<{ - enabled?: boolean; - autoSwitchThreshold?: number; - strategy?: unknown; - stickyLimit?: unknown; - quotaWindow?: unknown; - }>; + // Through the shared pool client, which speaks the one contract every kind answers on. + .then(() => getPoolSettings(apiBase, "anthropic", (input, init) => fetch(input, init), { signal: ac.signal })) + .then(settings => { + if (!settings) throw new Error("load"); + return settings; }) .then(json => { if (cancelled) return; @@ -114,24 +110,16 @@ export default function AnthropicAccountPoolSettings({ setSaving(true); setError(null); try { - const res = await fetch(`${apiBase}/api/oauth/accounts/pool`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - provider: "anthropic", - enabled: next.enabled, - autoSwitchThreshold: next.threshold, - strategy: next.strategy, - stickyLimit: next.stickyLimit, - quotaWindow: next.quotaWindow, - }), + // The client owns the field mapping: `threshold` becomes `autoSwitchThreshold` and the + // provider is always sent, so no call site can forget either. + const json = await putPoolSettings(apiBase, "anthropic", { + enabled: next.enabled, + threshold: next.threshold, + strategy: next.strategy, + stickyLimit: next.stickyLimit, + quotaWindow: next.quotaWindow, }); - if (!res.ok) throw new Error("save"); - const json = await res.json().catch(() => null) as { - strategy?: unknown; - stickyLimit?: unknown; - quotaWindow?: unknown; - } | null; + if (!json) throw new Error("save"); const savedStrategy = normalizeAccountPoolStrategy(json?.strategy ?? next.strategy); const savedSticky = normalizeAccountPoolStickyLimit(json?.stickyLimit ?? next.stickyLimit); const savedWindow = normalizeAccountPoolQuotaWindow(json?.quotaWindow ?? next.quotaWindow); diff --git a/gui/src/pool-settings.ts b/gui/src/pool-settings.ts new file mode 100644 index 0000000000..57cc65628e --- /dev/null +++ b/gui/src/pool-settings.ts @@ -0,0 +1,121 @@ +/** + * One GUI client for the unified pool-settings contract. + * + * Before this, three fetchers spoke three shapes for the same two fields: the Codex threshold + * write, the Codex strategy write with `accountPool`-prefixed response keys, and the Anthropic + * pool read/write. `/api/pool/settings` answers identically for every kind, so the transport + * collapses to this module and the components above keep their own presentation. + */ +import { + normalizeAccountPoolQuotaWindow, + normalizeAccountPoolStickyLimit, + normalizeAccountPoolStrategy, + type AccountPoolQuotaWindow, + type AccountPoolStrategy, +} from "./account-pool-strategy"; + +/** The Codex pool is addressed by its provider id like any other kind. */ +export const CODEX_POOL_PROVIDER = "openai"; + +export type PoolSettingsFetch = (input: string, init?: RequestInit) => Promise; + +export interface PoolSettings { + provider: string; + kind: "codex" | "anthropic" | "generic"; + supported: string[]; + enabled: boolean | null; + enabledEffective: boolean; + strategy: AccountPoolStrategy; + stickyLimit: number; + autoSwitchThreshold: number | null; + quotaWindow: AccountPoolQuotaWindow | null; +} + +/** Fields a caller may write. Named in GUI terms; mapped to the wire below. */ +export interface PoolSettingsWrite { + enabled?: boolean; + strategy?: AccountPoolStrategy; + stickyLimit?: number; + /** GUI callers say "threshold"; the contract says autoSwitchThreshold. */ + threshold?: number; + quotaWindow?: AccountPoolQuotaWindow; +} + +function toDto(json: unknown, provider: string, fallback?: PoolSettingsWrite): PoolSettings { + const raw = (json ?? {}) as Record; + const threshold = raw.autoSwitchThreshold ?? fallback?.threshold; + return { + provider, + kind: raw.kind === "codex" || raw.kind === "anthropic" ? raw.kind : "generic", + supported: Array.isArray(raw.supported) ? raw.supported.filter((f): f is string => typeof f === "string") : [], + enabled: typeof raw.enabled === "boolean" ? raw.enabled : null, + enabledEffective: raw.enabledEffective === true, + // Fall back to what was asked for when the response omits a field. A management write may + // answer 204, and reporting the normalizer default there would silently show the operator + // a different value than the one they just saved. + strategy: normalizeAccountPoolStrategy(raw.strategy ?? fallback?.strategy), + stickyLimit: normalizeAccountPoolStickyLimit(raw.stickyLimit ?? fallback?.stickyLimit), + autoSwitchThreshold: typeof threshold === "number" ? threshold : null, + quotaWindow: (raw.quotaWindow ?? fallback?.quotaWindow) === undefined || raw.quotaWindow === null + ? null + : normalizeAccountPoolQuotaWindow(raw.quotaWindow ?? fallback?.quotaWindow), + }; +} + +/** + * Map GUI field names onto the wire, and ALWAYS send `provider`. + * + * This is not ceremony. The route ignores a field it does not know, so a body that still said + * `threshold` would return 200 and write nothing -- and `putAutoSwitchThreshold` only inspects + * `response.ok`, so every save would report success while changing no setting. Silent success + * is worse than a visible failure, which is why the mapping lives here rather than at each + * call site where one of three could forget it. + */ +export function poolSettingsRequestBody(provider: string, fields: PoolSettingsWrite): Record { + return { + provider, + ...(fields.enabled !== undefined ? { enabled: fields.enabled } : {}), + ...(fields.strategy !== undefined ? { strategy: fields.strategy } : {}), + ...(fields.stickyLimit !== undefined ? { stickyLimit: fields.stickyLimit } : {}), + ...(fields.threshold !== undefined ? { autoSwitchThreshold: fields.threshold } : {}), + ...(fields.quotaWindow !== undefined ? { quotaWindow: fields.quotaWindow } : {}), + }; +} + +export async function getPoolSettings( + apiBase: string, + provider: string, + fetchImpl: PoolSettingsFetch = (input, init) => fetch(input, init), + init?: RequestInit, +): Promise { + try { + const response = await fetchImpl(`${apiBase}/api/pool/settings?provider=${encodeURIComponent(provider)}`, init); + if (!response.ok) return null; + return toDto(await response.json().catch(() => ({})), provider); + } catch { + return null; + } +} + +export async function putPoolSettings( + apiBase: string, + provider: string, + fields: PoolSettingsWrite, + fetchImpl: PoolSettingsFetch = (input, init) => fetch(input, init), + init?: RequestInit, +): Promise { + try { + const response = await fetchImpl(`${apiBase}/api/pool/settings`, { + ...init, + method: "PUT", + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + body: JSON.stringify(poolSettingsRequestBody(provider, fields)), + }); + if (!response.ok) return null; + // A 2xx with no parseable body is still a successful write; the old per-route clients + // only inspected response.ok and a management PUT may answer 204. + return toDto(await response.json().catch(() => ({})), provider, fields); + } catch { + return null; + } +} diff --git a/gui/tests/account-pool-strategy.test.tsx b/gui/tests/account-pool-strategy.test.tsx index 856c44af8c..5f537b73e6 100644 --- a/gui/tests/account-pool-strategy.test.tsx +++ b/gui/tests/account-pool-strategy.test.tsx @@ -133,9 +133,11 @@ describe("account pool strategy helpers", () => { ); expect(result).toEqual({ ok: true, strategy: "round-robin", stickyLimit: 3 }); expect(calls).toHaveLength(1); - expect(calls[0]!.url).toBe("http://proxy/api/codex-auth/pool-strategy"); + expect(calls[0]!.url).toBe("http://proxy/api/pool/settings"); expect(calls[0]!.init.method).toBe("PUT"); expect(JSON.parse(String(calls[0]!.init.body))).toEqual({ + // The Codex pool is addressed by provider id like every other kind now. + provider: "openai", strategy: "round-robin", stickyLimit: 3, }); @@ -281,12 +283,12 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => { if (url.endsWith("/api/codex-auth/active") && (!init || init.method === undefined)) { return active.promise; } - if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") { + if (url.endsWith("/api/pool/settings") && init?.method === "PUT") { puts.push(init.body ? JSON.parse(String(init.body)) : null); return new Response(JSON.stringify({ ok: true, - accountPoolStrategy: "round-robin", - accountPoolStickyLimit: 1, + strategy: "round-robin", + stickyLimit: 1, }), { status: 200 }); } throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); @@ -331,7 +333,7 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => { accountPoolStickyLimit: 1, }), { status: 200 }); } - if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") { + if (url.endsWith("/api/pool/settings") && init?.method === "PUT") { return put.promise; } throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); @@ -380,7 +382,7 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => { accountPoolStickyLimit: 1, }), { status: 200 }); } - if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") { + if (url.endsWith("/api/pool/settings") && init?.method === "PUT") { return new Response("fail", { status: 500 }); } throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); @@ -416,7 +418,7 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); - if (url.endsWith("/api/codex-auth/pool-strategy") && init?.method === "PUT") { + if (url.endsWith("/api/pool/settings") && init?.method === "PUT") { return put.promise; } throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); diff --git a/gui/tests/anthropic-pool-quota-window.test.tsx b/gui/tests/anthropic-pool-quota-window.test.tsx index 03043813d8..c8b2a86c8f 100644 --- a/gui/tests/anthropic-pool-quota-window.test.tsx +++ b/gui/tests/anthropic-pool-quota-window.test.tsx @@ -62,7 +62,7 @@ function stubPool(initial: PoolPayload): Record[] { const puts: Record[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); - if (url.includes("/api/oauth/accounts/pool") && init?.method === "PUT") { + if (url.includes("/api/pool/settings") && init?.method === "PUT") { const body = init.body ? JSON.parse(String(init.body)) as Record : {}; puts.push(body); return new Response(JSON.stringify({ @@ -71,7 +71,7 @@ function stubPool(initial: PoolPayload): Record[] { quotaWindow: body.quotaWindow, }), { status: 200 }); } - if (url.includes("/api/oauth/accounts/pool")) { + if (url.includes("/api/pool/settings")) { return new Response(JSON.stringify(initial), { status: 200 }); } throw new Error(`unexpected fetch: ${url} ${init?.method ?? "GET"}`); diff --git a/gui/tests/codex-account-auto-switch.test.tsx b/gui/tests/codex-account-auto-switch.test.tsx index c9b4f19dca..695f62bd62 100644 --- a/gui/tests/codex-account-auto-switch.test.tsx +++ b/gui/tests/codex-account-auto-switch.test.tsx @@ -245,9 +245,12 @@ describe("Codex account auto-switch threshold", () => { }; expect(await putAutoSwitchThreshold("http://localhost:10100", 95, fetchImpl)).toBe(true); - expect(request?.input).toBe("http://localhost:10100/api/codex-auth/auto-switch"); + expect(request?.input).toBe("http://localhost:10100/api/pool/settings"); expect(request?.init.method).toBe("PUT"); - expect(request?.init.body).toBe(JSON.stringify({ threshold: 95 })); + // Mapped, not forwarded: the contract field is autoSwitchThreshold and the provider is + // always sent. A body that still said `threshold` would be ignored and the save would + // report success while changing nothing. + expect(request?.init.body).toBe(JSON.stringify({ provider: "openai", autoSwitchThreshold: 95 })); }); test("reports HTTP and network failures without accepting the write", async () => { diff --git a/gui/tests/codex-auto-switch-controller.test.tsx b/gui/tests/codex-auto-switch-controller.test.tsx index 287ceffdaf..126510a9a2 100644 --- a/gui/tests/codex-auto-switch-controller.test.tsx +++ b/gui/tests/codex-auto-switch-controller.test.tsx @@ -156,9 +156,11 @@ async function mountHarness(): Promise { accountPoolStickyLimit: 1, }); } - if (url.endsWith("/api/codex-auth/auto-switch") && method === "PUT") { - const body = JSON.parse(String(init?.body)) as { threshold: number }; - writes.push(body.threshold); + if (url.endsWith("/api/pool/settings") && method === "PUT") { + // The unified contract field, not the GUI one: the client maps it, and a harness + // still reading `threshold` would record undefined for every save. + const body = JSON.parse(String(init?.body)) as { autoSwitchThreshold: number }; + writes.push(body.autoSwitchThreshold); const response = putResponses.shift(); if (!response) throw new Error("unexpected auto-switch write"); return await response; @@ -321,9 +323,9 @@ describe("Codex auto-switch controller interactions", () => { accountPoolStickyLimit: 1, }); } - if (url.endsWith("/api/codex-auth/auto-switch") && method === "PUT") { - const body = JSON.parse(String(init?.body)) as { threshold: number }; - writes.push(body.threshold); + if (url.endsWith("/api/pool/settings") && method === "PUT") { + const body = JSON.parse(String(init?.body)) as { autoSwitchThreshold: number }; + writes.push(body.autoSwitchThreshold); const response = putResponses.shift(); if (!response) throw new Error("unexpected auto-switch write"); return await response; From 610dad658b87827a960d02b86c2c1d5339dd5c61 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 04:01:06 +0900 Subject: [PATCH 114/231] refactor(gui): move the Codex strategy write beside the client it uses account-pool-strategy.ts owns the value normalizers pool-settings.ts imports, so keeping the transport there too made the two modules import each other. The first draft hid that behind a dynamic import and the bundler reported INEFFECTIVE_DYNAMIC_IMPORT - the cycle telling on itself. account-pool-strategy.ts is a pure value module again. --- gui/src/account-pool-strategy.ts | 21 ----------------- .../components/CodexPoolStrategySetting.tsx | 3 ++- gui/src/pool-settings.ts | 23 +++++++++++++++++++ gui/tests/account-pool-strategy.test.tsx | 3 ++- 4 files changed, 27 insertions(+), 23 deletions(-) diff --git a/gui/src/account-pool-strategy.ts b/gui/src/account-pool-strategy.ts index b1ce2aa5ed..b2532b0fc7 100644 --- a/gui/src/account-pool-strategy.ts +++ b/gui/src/account-pool-strategy.ts @@ -52,24 +52,3 @@ export function parseAccountPoolStickyLimitDraft(value: string): number | null { const n = Number(trimmed); return n >= MIN_ACCOUNT_POOL_STICKY_LIMIT && n <= MAX_ACCOUNT_POOL_STICKY_LIMIT ? n : null; } - -export type PoolStrategyFetch = (input: string, init: RequestInit) => Promise; - -export async function putCodexPoolStrategy( - apiBase: string, - body: { strategy?: AccountPoolStrategy; stickyLimit?: number }, - fetchImpl: PoolStrategyFetch = (input, init) => fetch(input, init), -): Promise<{ ok: true; strategy: AccountPoolStrategy; stickyLimit: number } | { ok: false }> { - if (body.strategy === undefined && body.stickyLimit === undefined) return { ok: false }; - // The prefixed `accountPoolStrategy`/`accountPoolStickyLimit` response keys are gone with - // the Codex-only route: the unified contract answers with neutral keys for every kind. - const { CODEX_POOL_PROVIDER, putPoolSettings } = await import("./pool-settings"); - const settings = await putPoolSettings( - apiBase, - CODEX_POOL_PROVIDER, - { strategy: body.strategy, stickyLimit: body.stickyLimit }, - (input, init) => fetchImpl(input, init as RequestInit), - ); - if (!settings) return { ok: false }; - return { ok: true, strategy: settings.strategy, stickyLimit: settings.stickyLimit }; -} diff --git a/gui/src/components/CodexPoolStrategySetting.tsx b/gui/src/components/CodexPoolStrategySetting.tsx index b0acdab3f4..e575baed0c 100644 --- a/gui/src/components/CodexPoolStrategySetting.tsx +++ b/gui/src/components/CodexPoolStrategySetting.tsx @@ -1,3 +1,4 @@ +import { putCodexPoolStrategy } from "../pool-settings"; import { useCallback, useEffect, useRef, useState } from "react"; import { useT } from "../i18n/shared"; import { @@ -6,7 +7,7 @@ import { normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, parseAccountPoolStickyLimitDraft, - putCodexPoolStrategy, + type AccountPoolStrategy, } from "../account-pool-strategy"; import AccountPoolStrategyControls from "./AccountPoolStrategyControls"; diff --git a/gui/src/pool-settings.ts b/gui/src/pool-settings.ts index 57cc65628e..660176c224 100644 --- a/gui/src/pool-settings.ts +++ b/gui/src/pool-settings.ts @@ -119,3 +119,26 @@ export async function putPoolSettings( return null; } } + +/** + * Codex strategy/sticky write, kept as a named helper because three call sites use it. + * + * It lives HERE rather than in `account-pool-strategy.ts` for a structural reason: that module + * owns the value normalizers this one imports, so putting the transport there too would make the + * two modules import each other. The first draft papered over that with a dynamic import and the + * bundler called it out as ineffective, which was the cycle telling on itself. + */ +export async function putCodexPoolStrategy( + apiBase: string, + body: { strategy?: AccountPoolStrategy; stickyLimit?: number }, + fetchImpl: PoolSettingsFetch = (input, init) => fetch(input, init), +): Promise<{ ok: true; strategy: AccountPoolStrategy; stickyLimit: number } | { ok: false }> { + if (body.strategy === undefined && body.stickyLimit === undefined) return { ok: false }; + const settings = await putPoolSettings(apiBase, CODEX_POOL_PROVIDER, { + strategy: body.strategy, + stickyLimit: body.stickyLimit, + }, fetchImpl); + if (!settings) return { ok: false }; + return { ok: true, strategy: settings.strategy, stickyLimit: settings.stickyLimit }; +} + diff --git a/gui/tests/account-pool-strategy.test.tsx b/gui/tests/account-pool-strategy.test.tsx index 5f537b73e6..5f98969f99 100644 --- a/gui/tests/account-pool-strategy.test.tsx +++ b/gui/tests/account-pool-strategy.test.tsx @@ -1,3 +1,4 @@ +import { putCodexPoolStrategy } from "../src/pool-settings"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; @@ -9,7 +10,7 @@ import { normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, parseAccountPoolStickyLimitDraft, - putCodexPoolStrategy, + } from "../src/account-pool-strategy"; import AccountPoolStrategyControls from "../src/components/AccountPoolStrategyControls"; import CodexPoolStrategySetting from "../src/components/CodexPoolStrategySetting"; From 3dc69c475e675b4bea039ebf9cf9852cbaca51c1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 04:07:52 +0900 Subject: [PATCH 115/231] docs(devlog): capture the pool settings surface for wp5b Rendered from an isolated OPENCODEX_HOME with the Codex pool strategy set to round-robin and a sticky count of 3, so the card shows the controls the unified /api/pool/settings client now writes. --- .../assets/wp5b-pool-settings.png | Bin 0 -> 196254 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 devlog/_plan/260911_account_pool_unification/assets/wp5b-pool-settings.png diff --git a/devlog/_plan/260911_account_pool_unification/assets/wp5b-pool-settings.png b/devlog/_plan/260911_account_pool_unification/assets/wp5b-pool-settings.png new file mode 100644 index 0000000000000000000000000000000000000000..f6c6be47435dfeb780db38abba0e74621334205d GIT binary patch literal 196254 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

    {t('Search & vision sidecars', '검색 & 비전 사이드카', '搜索与视觉边车', 'Сайдкары поиска и зрения', '検索 & ビジョンサイドカー', '搜尋與視覺邊車', 'Arama & görme sidecar’ları')}

    -

    {t('Give non-OpenAI models real web search and image understanding through a gpt-5.4-mini sidecar.', 'gpt-5.4-mini 사이드카로 비 OpenAI 모델에 실제 웹 검색과 이미지 이해를 붙입니다.', '通过 gpt-5.4-mini 边车,让非 OpenAI 模型获得真实的网页搜索与图像理解能力。', 'Дайте моделям не от OpenAI настоящий веб-поиск и понимание изображений через сайдкар gpt-5.4-mini.', 'gpt-5.4-mini サイドカーで非 OpenAI モデルに本物のウェブ検索と画像理解を提供します。', '透過 gpt-5.4-mini 邊車,讓非 OpenAI 模型獲得真實的網頁搜尋與圖像理解能力。', 'gpt-5.4-mini sidecar’ı sayesinde OpenAI harici modellere gerçek web araması ve görsel anlama yeteneği kazandırın.')}

    +

    {t('Give non-OpenAI models real web search and image understanding through a gpt-5.6-luna sidecar.', 'gpt-5.6-luna 사이드카로 비 OpenAI 모델에 실제 웹 검색과 이미지 이해를 붙입니다.', '通过 gpt-5.6-luna 边车,让非 OpenAI 模型获得真实的网页搜索与图像理解能力。', 'Дайте моделям не от OpenAI настоящий веб-поиск и понимание изображений через сайдкар gpt-5.6-luna.', 'gpt-5.6-luna サイドカーで非 OpenAI モデルに本物のウェブ検索と画像理解を提供します。', '透過 gpt-5.6-luna 邊車,讓非 OpenAI 模型獲得真實的網頁搜尋與圖像理解能力。', 'gpt-5.6-luna sidecar’ı sayesinde OpenAI harici modellere gerçek web araması ve görsel anlama yeteneği kazandırın.')}

    {t('Quickstart', '퀵스타트', '快速开始', 'Быстрый старт', 'クイックスタート', '快速入門', 'Hızlı Başlangıç')}

    diff --git a/docs-site/src/content/docs/fr/getting-started/quickstart.md b/docs-site/src/content/docs/fr/getting-started/quickstart.md index 79cce13afa..984294b476 100644 --- a/docs-site/src/content/docs/fr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md @@ -74,7 +74,7 @@ codex -m "ollama-cloud/glm-5.2" "Write a SQL migration" ## Choisissez des modèles de sous-agents (facultatif) Une nouvelle configuration propose cinq modèles natifs dans le sélecteur de sous-agents de Codex : `gpt-5.5`, -`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` et `gpt-5.4-mini`. Ouvrez `ocx gui` pour remplacer ou +`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` et `gpt-6-astra`. Ouvrez `ocx gui` pour remplacer ou réorganiser jusqu’à cinq modèles natifs ou routés. Le tableau de bord peut également définir un modèle de sous-agent préféré et un effort de raisonnement. Consultez [Interface des sous-agents](/fr/guides/sub-agent-surface/) pour choisir v1, base ou v2 et comprendre quand s’appliquent les instructions, les valeurs natives par défaut et les replis. diff --git a/docs-site/src/content/docs/fr/guides/codex-app-models.md b/docs-site/src/content/docs/fr/guides/codex-app-models.md index fb81b9597e..10dce00320 100644 --- a/docs-site/src/content/docs/fr/guides/codex-app-models.md +++ b/docs-site/src/content/docs/fr/guides/codex-app-models.md @@ -130,8 +130,8 @@ service OpenAI. ## Couverture stable actuelle des modèles -L'ensemble natif de secours comprend `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.3-codex-spark` et GPT-5.6 -Sol/Terra/Luna. Pour la famille GPT-5.5/5.4, opencodex conserve les entrées dynamiques plus riches du +L'ensemble natif de secours comprend `gpt-5.5`, `gpt-5.3-codex-spark` et GPT-5.6 +Sol/Terra/Luna. Pour la famille GPT-5.5, opencodex conserve les entrées dynamiques plus riches du catalogue Codex installé et ne synthétise qu'une entrée manquante. L'instantané amont fourni n'est employé que pour GPT-5.6, auquel il apporte l'identité et les métadonnées réelles de chaque modèle plutôt qu'une approximation fondée sur un ancien modèle d'entrée. diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index e8a4deae4f..fa2457395d 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -375,7 +375,7 @@ délégation v1/base/v2 et de ses mécanismes de repli. ## Préchauffage des comptes Codex -L’ajout ou la réauthentification vérifie normalement le compte avant son enregistrement par une petite requête attendant `response.completed`. Le modèle par défaut est `gpt-5.4-mini`, avec un essai sur `gpt-5.5` et `gpt-5.6-luna` en cas de HTTP 400 ou HTTP 404. Les erreurs publiques contiennent des catégories fixes, sans corps de réponse brut. +L’ajout ou la réauthentification vérifie normalement le compte avant son enregistrement par une petite requête attendant `response.completed`. Le modèle par défaut est `gpt-5.6-luna`, avec un essai sur `gpt-5.5` en cas de HTTP 400 ou HTTP 404. Les erreurs publiques contiennent des catégories fixes, sans corps de réponse brut. Si la lecture authentifiée des quotas avec le nouveau jeton OAuth confirme un quota de 5 heures, hebdomadaire ou mensuel épuisé, le compte est enregistré sans appel au modèle et affiche **Validation en attente**. Il reste exclu du routage après un redémarrage ou un renouvellement du jeton. Après récupération du quota, actualisez les quotas : une lecture récente et complète avec de la capacité disponible permet une petite requête de validation. Seule sa réussite active le compte. Tout échec conserve la restriction. Les lectures passives ne déclenchent pas cette requête. Un quota inconnu à l’inscription conserve la vérification habituelle. @@ -397,7 +397,7 @@ Un renouvellement du compte principal qui n'aboutit pas répond toujours `503` a ocx config set codexPool '{"excludedPlans":["free"]}' ``` -C'est une politique de sélection, pas un blocage. Un compte écarté conserve ses identifiants, son historique de quota et son affinité de thread, reste visible dans la liste des comptes et demeure joignable par sélection explicite comme `work/gpt-5.4`. Seule la rotation automatique cesse de le choisir, y compris lorsqu'il est déjà le compte actif ou déjà lié à un thread — l'état exact que laisse un abonnement expiré. +C'est une politique de sélection, pas un blocage. Un compte écarté conserve ses identifiants, son historique de quota et son affinité de thread, reste visible dans la liste des comptes et demeure joignable par sélection explicite comme `work/gpt-5.5`. Seule la rotation automatique cesse de le choisir, y compris lorsqu'il est déjà le compte actif ou déjà lié à un thread — l'état exact que laisse un abonnement expiré. Deux limites volontaires. Le compte Codex principal n'est jamais écarté par forfait, car le routage en mode sélection seule ne lit pas son forfait dans les identifiants natifs protégés ; une règle le couvrant se contredirait. Et lorsqu'il ne reste aucun compte non écarté, le compte écarté répond quand même au lieu d'échouer : mettre tous les comptes en pause reste le moyen d'arrêter complètement le service. Il n'existe pas de `minimumPlan`, car classer les forfaits ChatGPT entre eux exige un ordre total qui n'existe pas ici. diff --git a/docs-site/src/content/docs/fr/guides/sidecars.md b/docs-site/src/content/docs/fr/guides/sidecars.md index 27bcda75cb..367b98d9c3 100644 --- a/docs-site/src/content/docs/fr/guides/sidecars.md +++ b/docs-site/src/content/docs/fr/guides/sidecars.md @@ -93,7 +93,7 @@ modèle couvert par le sidecar. Les combos annoncent l'entrée image seulement l nativement ou via un sidecar, et que le paramètre `imageInput` du combo n'est pas désactivé, afin que des clients comme l'application Codex autorisent les pièces jointes au lieu de les bloquer avant l'exécution du sidecar. Lorsque `visionSidecar.model` est absent ou vide, le chemin d'exécution OpenAI, le tableau de bord et l'API de gestion -utilisent le modèle de repli `gpt-5.4-mini`. Au démarrage, une ancienne valeur `gpt-5.4-mini` explicitement enregistrée +utilisent le modèle de repli `gpt-5.6-luna`. Au démarrage, une ancienne valeur `gpt-5.6-luna` explicitement enregistrée est toujours migrée vers `gpt-5.6-luna` ; cette migration s'applique à une valeur stockée, et non à l'absence du champ du modèle. diff --git a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md index 14bf8bb0d3..a5b172a4e4 100644 --- a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md @@ -191,7 +191,7 @@ Utilisez `ocx agent` pour les paramètres de délégation, de liste, de plafond ocx agent status ocx agent injection set --model anthropic/claude-sonnet-5 --effort xhigh ocx agent subagents set gpt-5.6-sol,anthropic/claude-sonnet-5 -ocx agent fallback set gpt-5.4-mini,xai/grok-4.5 --poll-ms 60000 +ocx agent fallback set gpt-5.6-luna,xai/grok-4.5 --poll-ms 60000 ocx agent effort set --subagent max ``` diff --git a/docs-site/src/content/docs/fr/reference/configuration/agents.md b/docs-site/src/content/docs/fr/reference/configuration/agents.md index 4f04a20d44..03e289e3e9 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/fr/reference/configuration/agents.md @@ -67,9 +67,9 @@ opencodex ignore les candidats désactivés, non routables, en mauvais état, en "injectionModel": "gpt-5.5", "injectionEffort": "high", "syncCodexSubagentDefaults": true, - "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallback": ["gpt-5.6-luna"], "subagentModelFallbackByModel": { - "gpt-5.5": ["gpt-5.4-mini"] + "gpt-5.5": ["gpt-5.6-luna"] }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 492548c30f..576b13c1bb 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -253,7 +253,7 @@ Les entrées `codexAccounts[]` exigent `id`, `email` et `isMain` ; `plan`, | `failureBackoffMaxSeconds?` | `number` | `3600` | Plafond du délai d'attente et délai après un échec permanent. | | `codexWarmupEnabled?` | `boolean` | `false` | Active la validation synthétique des comptes du pool Codex. | | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | Revalidez un compte après 8 jours. | -| `codexWarmupModel?` | `string` | `gpt-5.4-mini` | Modèle natif utilisé pour l'échauffement facultatif. | +| `codexWarmupModel?` | `string` | `gpt-5.6-luna` | Modèle natif utilisé pour l'échauffement facultatif. | ## Points de terminaison du fournisseur fixes diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index 27fd80b36a..d957d9b50f 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -240,7 +240,7 @@ une garde d'inactivité, pas un délai de génération total. | --- | --- | --- | --- | | `enabled?` | `boolean` | activé lorsqu'il est utilisable | Commutateur principal de description d'images. | | `backend?` | `"openai" \| "anthropic"` | automatique | La valeur explicite prévaut ; si elle est omise, un identifiant OAuth Anthropic stocké et utilisable est privilégié, sinon `openai`. | -| `model?` | `string` | dépendant du backend | `gpt-5.4-mini` pour OpenAI ou `claude-sonnet-5` pour Anthropic. | +| `model?` | `string` | dépendant du backend | `gpt-5.6-luna` pour OpenAI ou `claude-sonnet-5` pour Anthropic. | | `maxDescriptionsPerTurn?` | `number` | `8` | Nouvelles descriptions des ratés du cache admises par tour principal. `0` désactive les appels ; les valeurs non valides utilisent la valeur par défaut. | | `timeoutMs?` | `number` | `45000` | Délai d'expiration de la récupération par le service auxiliaire. Entier 1–2147483647. | diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 867a06cec1..137b532118 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -114,7 +114,7 @@ codex -m "ollama-cloud/glm-5.2" "Write a SQL migration" ## Choose sub-agent models (optional) A fresh config features five native models in Codex's sub-agent picker: `gpt-5.5`, -`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-5.4-mini`. Open `ocx gui` to replace or +`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-6-astra`. Open `ocx gui` to replace or reorder up to five native or routed models. The dashboard can also set one preferred sub-agent model and reasoning effort. See [Sub-agent Surface](/guides/sub-agent-surface/) to choose v1/base/v2 and understand when guidance, native defaults, and fallback apply. diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index b30240bf0e..f99fbc5fd8 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -188,8 +188,8 @@ including OpenAI service-tier metadata. ## Current stable model coverage -The native fallback set includes `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, -`gpt-5.3-codex-spark`, and GPT-5.6 Sol/Terra/Luna. For the GPT-5.5/5.4 family, opencodex preserves +The native fallback set includes `gpt-5.5`, `gpt-5.3-codex-spark`, and GPT-5.6 Sol/Terra/Luna. +For the GPT-5.5 family, opencodex preserves the installed Codex catalog's richer live entries and only synthesizes a missing entry. The bundled upstream snapshot is used only for GPT-5.6, where it supplies the real per-model identity and metadata instead of an older-template approximation. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index a99b880fc1..ffc383ac8c 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -706,7 +706,7 @@ Catalog sync makes the selected sub-agent models available to Codex; see [Codex ## Codex account warmup -When a ChatGPT account is added or reauthenticated, OpenCodex normally verifies it before saving with a small streaming request to the Codex Responses backend. It waits for `response.completed`, defaults to `gpt-5.4-mini`, and retries with `gpt-5.5` and `gpt-5.6-luna` on HTTP 400 or HTTP 404. Public errors contain fixed failure categories rather than raw upstream response bodies. +When a ChatGPT account is added or reauthenticated, OpenCodex normally verifies it before saving with a small streaming request to the Codex Responses backend. It waits for `response.completed`, defaults to `gpt-5.6-luna`, and retries with `gpt-5.5` on HTTP 400 or HTTP 404. Public errors contain fixed failure categories rather than raw upstream response bodies. If the new OAuth credential's authenticated usage lookup confirms an exhausted 5-hour, weekly, or monthly quota, the account is saved without this model request and shows **Validation pending**. It cannot serve pool requests, even after a restart or token refresh. Once quota recovers, **Refresh quotas** finishes validation: a fresh, complete usage reading with headroom permits one small model request, and only a completed response enables the account. Failed or incomplete readings and failed validation preserve the restriction. Passive account polling does not trigger deferred validation. Unknown usage during initial registration retains the normal warmup gate. @@ -728,7 +728,7 @@ A main-account refresh that does not complete still answers `503` with `Retry-Af ocx config set codexPool '{"excludedPlans":["free"]}' ``` -This is a selection policy, not a block. An excluded account keeps its credential, quota history, and thread affinity, stays visible on the account surface, and is still reachable by explicit account selection such as `work/gpt-5.4`. What changes is that automatic rotation stops choosing it, including when it is already the active account or already bound to a thread — which is the state a lapsed subscription leaves behind. +This is a selection policy, not a block. An excluded account keeps its credential, quota history, and thread affinity, stays visible on the account surface, and is still reachable by explicit account selection such as `work/gpt-5.5`. What changes is that automatic rotation stops choosing it, including when it is already the active account or already bound to a thread — which is the state a lapsed subscription leaves behind. Two deliberate limits. The main Codex account is never excluded by plan, because selection-only routing withholds its plan rather than reading the fenced native credential, so a rule covering it would disagree with itself. And when no unexcluded account remains, the excluded one still answers rather than failing closed; pausing every account is still the way to stop serving entirely. There is no `minimumPlan` counterpart, because ranking ChatGPT plans against each other needs a total ordering that does not exist here. diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 543fe49e96..cee0da3f07 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -132,7 +132,7 @@ Combos advertise image input only when every member accepts images, either nativ sidecar, and the combo's `imageInput` setting is not disabled, so clients such as the Codex app allow attachments instead of blocking them before the sidecar runs. When `visionSidecar.model` is absent or blank, the OpenAI execution path, Dashboard, and management API -use the `gpt-5.4-mini` fallback. Startup still migrates an explicitly persisted legacy +use the `gpt-5.6-luna` fallback. Startup still migrates an explicitly persisted legacy `gpt-5.4-mini` value to `gpt-5.6-luna`; that migration applies to a stored value, not to an absent model field. diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 8c9a838e77..49faeb9370 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -225,7 +225,7 @@ Use `ocx agent` for delegation, roster, effort-cap, and fallback settings: ocx agent status ocx agent injection set --model anthropic/claude-sonnet-5 --effort xhigh ocx agent subagents set gpt-5.6-sol,anthropic/claude-sonnet-5 -ocx agent fallback set gpt-5.4-mini,xai/grok-4.5 --poll-ms 60000 +ocx agent fallback set gpt-5.6-luna,xai/grok-4.5 --poll-ms 60000 ocx effort set --subagent max ``` diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index 4d251fc1d7..dddd327cbb 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -67,7 +67,7 @@ codex -m "ollama-cloud/glm-5.2" "Write a SQL migration" ## サブエージェント モデルの選択 (オプション) -新しい設定には、Codex のサブエージェント ピッカーの 5 つのネイティブ モデル、`gpt-5.5`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`、および `gpt-5.4-mini` が含まれています。 `ocx gui` を開いて、最大 5 つのネイティブ モデルまたはルーティング モデルを置換または並べ替えます。ダッシュボードでは、優先サブエージェント モデルと推論負荷を 1 つ設定することもできます。 v1/base/v2 を選択し、ガイダンス、ネイティブのデフォルト、およびフォールバックがいつ適用されるかを理解するには、[サブエージェントサーフェス](/guides/sub-agent-surface/) を参照してください。 +新しい設定には、Codex のサブエージェント ピッカーの 5 つのネイティブ モデル、`gpt-5.5`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`、および `gpt-6-astra` が含まれています。 `ocx gui` を開いて、最大 5 つのネイティブ モデルまたはルーティング モデルを置換または並べ替えます。ダッシュボードでは、優先サブエージェント モデルと推論負荷を 1 つ設定することもできます。 v1/base/v2 を選択し、ガイダンス、ネイティブのデフォルト、およびフォールバックがいつ適用されるかを理解するには、[サブエージェントサーフェス](/guides/sub-agent-surface/) を参照してください。 ## キーを貼り付ける代わりにログインする diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index 93385300ec..13f9b6517b 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -48,7 +48,7 @@ visibility = "list" ## 現在の安定したモデルの範囲 -ネイティブ フォールバック セットには、`gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.3-codex-spark`、および GPT-5.6 Sol/Terra/Luna が含まれます。 GPT-5.5/5.4 ファミリの場合、opencodex は、インストールされている Codex カタログの豊富なライブ エントリを保存し、欠落しているエントリのみを合成します。バンドルされたアップストリーム スナップショットは GPT-5.6 でのみ使用され、古いテンプレートの近似値の代わりに実際のモデルごとの ID とメタデータが提供されます。 +ネイティブ フォールバック セットには、`gpt-5.5`、`gpt-5.3-codex-spark`、および GPT-5.6 Sol/Terra/Luna が含まれます。 GPT-5.5 ファミリの場合、opencodex は、インストールされている Codex カタログの豊富なライブ エントリを保存し、欠落しているエントリのみを合成します。バンドルされたアップストリーム スナップショットは GPT-5.6 でのみ使用され、古いテンプレートの近似値の代わりに実際のモデルごとの ID とメタデータが提供されます。 |ルート |ピッカー ID とカタログのメタデータ | | --- | --- | diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 4ce54074d3..b1a499dcbc 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -241,7 +241,7 @@ ocx service install # persistent: auto-starts on login and respawns on crash ## Codex アカウントのウォームアップ -アカウントの追加・再認証では通常、保存前に小さなモデルリクエストで `response.completed` を確認します。既定モデルは `gpt-5.4-mini` で、HTTP 400 または HTTP 404 の場合は `gpt-5.5` および `gpt-5.6-luna` で再試行します。公開エラーには固定の分類のみを表示し、生の応答本文は公開しません。 +アカウントの追加・再認証では通常、保存前に小さなモデルリクエストで `response.completed` を確認します。既定モデルは `gpt-5.6-luna` で、HTTP 400 または HTTP 404 の場合は `gpt-5.5` で再試行します。公開エラーには固定の分類のみを表示し、生の応答本文は公開しません。 新しい OAuth トークンによる使用量取得で5時間・週次・月次の上限到達が確認された場合、モデルを呼ばずに保存し、**検証待ち**と表示します。再起動やトークン更新後も使用できません。上限回復後に使用量を更新すると、十分な空き容量を示す完全な最新情報を確認してから小さなモデルリクエストを送り、完了した場合のみ使用可能になります。取得や検証の失敗では待機状態を維持します。通常の状態ポーリングは検証リクエストを送りません。初回登録時の使用量が不明な場合は通常の検証が必要です。 @@ -263,7 +263,7 @@ ocx service install # persistent: auto-starts on login and respawns on crash ocx config set codexPool '{"excludedPlans":["free"]}' ``` -これはブロックではなく選択ポリシーです。除外されたアカウントも資格情報・使用量履歴・スレッドアフィニティを保持し、アカウント一覧に表示され、`work/gpt-5.4` のような明示的な指定では引き続き利用できます。変わるのは自動ローテーションが選ばなくなる点で、すでにアクティブなアカウントやスレッドに紐づいている場合も含みます。サブスクリプションが失効した直後は、まさにその状態です。 +これはブロックではなく選択ポリシーです。除外されたアカウントも資格情報・使用量履歴・スレッドアフィニティを保持し、アカウント一覧に表示され、`work/gpt-5.5` のような明示的な指定では引き続き利用できます。変わるのは自動ローテーションが選ばなくなる点で、すでにアクティブなアカウントやスレッドに紐づいている場合も含みます。サブスクリプションが失効した直後は、まさにその状態です。 意図的な制限が2つあります。メインの Codex アカウントはプランによって除外されません。選択のみのルーティングは保護されたネイティブ資格情報を読まずにプランを伏せるため、メインを対象にすると挙動が食い違うからです。また、除外されていないアカウントが1つも残らない場合は、失敗させずに除外済みのアカウントが応答します。完全に停止したい場合は従来どおり全アカウントを一時停止してください。`minimumPlan` に相当する設定はありません。ChatGPT のプランを順位付けするには、ここに存在しない全順序が必要になるためです。 diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index 8d2fd18ef1..30a7f5a404 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -76,7 +76,7 @@ stall は全体生成 timeout ではありません。SSE 開始前の失敗は サイドカー対象の各モデルに画像入力を広告します。コンボは、すべてのメンバーがネイティブまたはサイドカーを 通じて画像を受け入れ、かつコンボの `imageInput` 設定が無効でない場合にのみ画像入力を広告します。これにより Codex アプリなどのクライアントは、サイドカー実行前に添付をブロックせず許可できます。`visionSidecar.model` が未設定または空の場合、 -OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.4-mini` をフォールバックとして使います。起動時には +OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.6-luna` をフォールバックとして使います。起動時には 明示的に保存された旧 `gpt-5.4-mini` 値を引き続き `gpt-5.6-luna` にマイグレーションしますが、この マイグレーションは保存済みの値だけが対象で、モデルフィールドがない場合には適用されません。 diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index 8d2be3b2e8..c8d4e06b54 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -119,7 +119,7 @@ ocx v2 threads 8 ocx agent status ocx agent injection set --model anthropic/claude-sonnet-5 --effort xhigh ocx agent subagents set gpt-5.6-sol,anthropic/claude-sonnet-5 -ocx agent fallback set gpt-5.4-mini,xai/grok-4.5 --poll-ms 60000 +ocx agent fallback set gpt-5.6-luna,xai/grok-4.5 --poll-ms 60000 ocx agent effort set --subagent max ``` diff --git a/docs-site/src/content/docs/ja/reference/configuration/agents.md b/docs-site/src/content/docs/ja/reference/configuration/agents.md index b4a5106b81..def092132c 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -72,9 +72,9 @@ opencodex は、無効、ルーティング不能、異常、冷却期間、ま "injectionModel": "gpt-5.5", "injectionEffort": "high", "syncCodexSubagentDefaults": true, - "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallback": ["gpt-5.6-luna"], "subagentModelFallbackByModel": { - "gpt-5.5": ["gpt-5.4-mini"] + "gpt-5.5": ["gpt-5.6-luna"] }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 118ea830b9..5523430c80 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -219,7 +219,7 @@ Anthropic アカウント ポリシーのリスクを理解していない限り | `failureBackoffMaxSeconds?` | `number` | `3600` |バックオフの上限と永続的な障害による遅延。 | | `codexWarmupEnabled?` | `boolean` | `false` |合成 Codex プールアカウント検証をオプトインします。 | | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | 8 日後にアカウントを再認証します。 | -| `codexWarmupModel?` | `string` | `gpt-5.4-mini` |オプションのウォームアップに使用されるネイティブ モデル。 | +| `codexWarmupModel?` | `string` | `gpt-5.6-luna` |オプションのウォームアップに使用されるネイティブ モデル。 | ## 固定プロバイダーエンドポイント diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index b1dd316c7f..0dd9cf59e4 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -153,7 +153,7 @@ OpenAI バックエンドには、ChatGPT ログインと有効な ChatGPT `forw | --- | --- | --- | --- | | `enabled?` | `boolean` |使用可能な場合はオン |マスターイメージと説明のスイッチ。 | | `backend?` | `"openai" \| "anthropic"` |自動 | 明示的な値が優先されます。未設定の場合、使用可能な保存済み Anthropic OAuth 認証情報が優先され、それ以外は `openai` になります。 | -| `model?` | `string` |バックエンド依存 | OpenAI の場合は `gpt-5.4-mini`、Anthropic の場合は `claude-sonnet-5`。 | +| `model?` | `string` |バックエンド依存 | OpenAI の場合は `gpt-5.6-luna`、Anthropic の場合は `claude-sonnet-5`。 | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses の推論負荷。Anthropic は無視します。 | | `maxDescriptionsPerTurn?` | `number` | `8` |新しい説明のキャッシュミスはメインターンごとに許可されます。 `0` は通話を無効にします。無効な値にはデフォルトが使用されます。 | | `timeoutMs?` | `number` | `45000` |サイドカーのフェッチタイムアウト。整数 1–2147483647。 | diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index 971c15785e..bc83df1b21 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -65,7 +65,7 @@ codex -m "ollama-cloud/glm-5.2" "Write a SQL migration" ## Sub-agent 모델 선택(선택 사항) -새 구성에는 Codex의 sub-agent 선택기에 네이티브 모델 다섯 개인 `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini`가 표시됩니다. `ocx gui`를 열어 네이티브 또는 라우팅 모델을 최대 다섯 개까지 바꾸거나 순서를 다시 정할 수 있습니다. 대시보드에서는 선호하는 sub-agent 모델과 추론 강도도 설정할 수 있습니다. [Sub-agent Surface](/guides/sub-agent-surface/)에서 v1/base/v2를 고르고, guidance, 네이티브 기본값, fallback이 언제 적용되는지 확인합니다. +새 구성에는 Codex의 sub-agent 선택기에 네이티브 모델 다섯 개인 `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-6-astra`가 표시됩니다. `ocx gui`를 열어 네이티브 또는 라우팅 모델을 최대 다섯 개까지 바꾸거나 순서를 다시 정할 수 있습니다. 대시보드에서는 선호하는 sub-agent 모델과 추론 강도도 설정할 수 있습니다. [Sub-agent Surface](/guides/sub-agent-surface/)에서 v1/base/v2를 고르고, guidance, 네이티브 기본값, fallback이 언제 적용되는지 확인합니다. ## 키를 붙여넣는 대신 로그인하기 diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 0642712082..23dfd131c6 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -97,8 +97,8 @@ visibility = "list" ## 현재 안정 모델 범위 -네이티브 폴백 목록에는 `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, -`gpt-5.3-codex-spark`, 그리고 GPT-5.6 Sol/Terra/Luna가 들어 있습니다. GPT-5.5/5.4 계열은 설치된 +네이티브 폴백 목록에는 `gpt-5.5`, +`gpt-5.3-codex-spark`, 그리고 GPT-5.6 Sol/Terra/Luna가 들어 있습니다. GPT-5.5 계열은 설치된 Codex 카탈로그의 더 풍부한 실시간 항목을 보존하고, 빠진 항목만 합성합니다. 번들 업스트림 스냅샷은 GPT-5.6에만 사용합니다. 오래된 템플릿으로 근사하지 않고 모델별 실제 식별 정보와 메타데이터를 제공하기 위해서입니다. diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 584e77adb0..0dc6021bd6 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -252,7 +252,7 @@ catalog sync는 선택된 서브에이전트 모델을 Codex가 쓸 수 있게 ## Codex 계정 워밍업 -ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로 저장 전에 작은 모델 요청으로 확인합니다. `gpt-5.4-mini`의 `response.completed`를 기다리며 HTTP 400 또는 HTTP 404이면 `gpt-5.5`와 `gpt-5.6-luna`로 재시도합니다. 오류에는 고정된 실패 분류만 표시하고 원본 응답 본문은 노출하지 않습니다. +ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로 저장 전에 작은 모델 요청으로 확인합니다. `gpt-5.6-luna`의 `response.completed`를 기다리며 HTTP 400 또는 HTTP 404이면 `gpt-5.5`로 재시도합니다. 오류에는 고정된 실패 분류만 표시하고 원본 응답 본문은 노출하지 않습니다. 새 OAuth 토큰으로 인증된 사용량 조회에서 5시간·주간·월간 한도 소진이 확인되면 모델 요청 없이 계정을 저장하고 **검증 대기**로 표시합니다. 재시작이나 토큰 갱신 후에도 요청에 사용되지 않습니다. 한도 회복 후 **사용량 새로고침**을 실행하면, 여유가 있는 완전한 최신 사용량을 확인한 뒤 작은 모델 요청을 보내고 완료 응답을 받아야 계정을 사용할 수 있습니다. 조회나 검증 실패 시 대기 상태를 유지합니다. 일반적인 화면 상태 조회는 이 모델 요청을 보내지 않습니다. 최초 등록 때 사용량이 불명확하면 기존 워밍업 검증이 필요합니다. @@ -274,7 +274,7 @@ ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로 ocx config set codexPool '{"excludedPlans":["free"]}' ``` -차단이 아니라 선택 정책입니다. 제외된 계정도 자격 증명과 사용량 기록, 스레드 어피니티를 그대로 유지하고 계정 목록에도 계속 보이며 `work/gpt-5.4` 같은 명시적 지정으로는 여전히 쓸 수 있습니다. 달라지는 것은 자동 로테이션이 그 계정을 고르지 않는다는 점이고, 이미 활성 계정이거나 스레드에 묶여 있는 경우도 포함합니다. 구독이 만료된 계정이 바로 그 상태입니다. +차단이 아니라 선택 정책입니다. 제외된 계정도 자격 증명과 사용량 기록, 스레드 어피니티를 그대로 유지하고 계정 목록에도 계속 보이며 `work/gpt-5.5` 같은 명시적 지정으로는 여전히 쓸 수 있습니다. 달라지는 것은 자동 로테이션이 그 계정을 고르지 않는다는 점이고, 이미 활성 계정이거나 스레드에 묶여 있는 경우도 포함합니다. 구독이 만료된 계정이 바로 그 상태입니다. 의도한 제한이 두 가지 있습니다. 메인 Codex 계정은 플랜으로 제외하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않고 플랜을 감추기 때문에, 메인까지 적용하면 상황에 따라 판정이 어긋납니다. 그리고 제외되지 않은 계정이 하나도 남지 않으면 실패시키지 않고 제외된 계정이 그대로 응답합니다. 완전히 멈추려면 지금처럼 모든 계정을 일시 중지하면 됩니다. `minimumPlan`에 해당하는 설정은 없습니다. ChatGPT 플랜에 순위를 매기려면 여기 존재하지 않는 전순서가 필요합니다. diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index c07e8c589d..b3d4309dbf 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -78,7 +78,7 @@ stall은 전체 생성 timeout이 아닙니다. SSE가 시작되기 전 실패 콤보는 모든 멤버가 네이티브로 또는 사이드카를 통해 이미지를 수용하고 콤보의 `imageInput` 설정이 비활성화되지 않은 경우에만 image input을 알립니다. 따라서 Codex 앱 같은 클라이언트는 사이드카가 실행되기 전에 첨부를 차단하지 않고 허용합니다. `visionSidecar.model`이 없거나 빈 값이면 -OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.4-mini`를 폴백으로 사용합니다. 시작 시 명시적으로 +OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.6-luna`를 폴백으로 사용합니다. 시작 시 명시적으로 저장된 기존 `gpt-5.4-mini` 값은 계속 `gpt-5.6-luna`로 마이그레이션되지만, 이 마이그레이션은 저장된 값에만 적용되고 모델 필드가 없는 경우에는 적용되지 않습니다. diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index 1372ed7c15..c0273412ec 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -117,7 +117,7 @@ ocx v2 threads 8 ocx agent status ocx agent injection set --model anthropic/claude-sonnet-5 --effort xhigh ocx agent subagents set gpt-5.6-sol,anthropic/claude-sonnet-5 -ocx agent fallback set gpt-5.4-mini,xai/grok-4.5 --poll-ms 60000 +ocx agent fallback set gpt-5.6-luna,xai/grok-4.5 --poll-ms 60000 ocx agent effort set --subagent max ``` diff --git a/docs-site/src/content/docs/ko/reference/configuration/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index da3b95fbd5..5393052849 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -72,9 +72,9 @@ opencodex는 비활성, 라우팅 불가, 비정상, 쿨다운 중, 또는 할 "injectionModel": "gpt-5.5", "injectionEffort": "high", "syncCodexSubagentDefaults": true, - "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallback": ["gpt-5.6-luna"], "subagentModelFallbackByModel": { - "gpt-5.5": ["gpt-5.4-mini"] + "gpt-5.5": ["gpt-5.6-luna"] }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index dfda7297e8..c63dfc9bc7 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -223,7 +223,7 @@ Anthropic 계정 정책 위험을 이해하지 못한다면 이 기능은 꺼두 | `failureBackoffMaxSeconds?` | `number` | `3600` | backoff 상한이자 영구 실패 지연입니다. | | `codexWarmupEnabled?` | `boolean` | `false` | 합성 Codex 풀 계정 검증을 선택적으로 켭니다. | | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | 8일 후 계정을 다시 검증합니다. | -| `codexWarmupModel?` | `string` | `gpt-5.4-mini` | 선택적 워밍업에 쓰는 네이티브 모델입니다. | +| `codexWarmupModel?` | `string` | `gpt-5.6-luna` | 선택적 워밍업에 쓰는 네이티브 모델입니다. | ## 고정 공급자 엔드포인트 diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 163685a645..577379fed9 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -201,7 +201,7 @@ OpenAI 백엔드는 ChatGPT 로그인과 활성화된 ChatGPT `forward` provider | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | 주 이미지 설명 스위치입니다. | | `backend?` | `"openai" \| "anthropic"` | auto | 명시값이 우선하며, 미설정 시 사용 가능한 저장된 Anthropic OAuth 자격 증명을 우선하고 없으면 `openai`를 사용합니다. | -| `model?` | `string` | backend-dependent | OpenAI는 `gpt-5.4-mini`, Anthropic은 `claude-sonnet-5`입니다. | +| `model?` | `string` | backend-dependent | OpenAI는 `gpt-5.6-luna`, Anthropic은 `claude-sonnet-5`입니다. | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses 추론 강도입니다. Anthropic은 무시합니다. | | `maxDescriptionsPerTurn?` | `number` | `8` | 메인 턴당 허용되는 새 설명 캐시 미스 수입니다. `0`이면 호출이 비활성화되며, 잘못된 값은 기본값을 사용합니다. | | `timeoutMs?` | `number` | `45000` | 사이드카 fetch 제한 시간입니다. 정수 1–2147483647. | diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 8fd5db5080..24dd9dec16 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -153,9 +153,9 @@ on a mid-thread model switch. "injectionModel": "gpt-5.5", "injectionEffort": "high", "syncCodexSubagentDefaults": true, - "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallback": ["gpt-5.6-luna"], "subagentModelFallbackByModel": { - "gpt-5.5": ["gpt-5.4-mini"] + "gpt-5.5": ["gpt-5.6-luna"] }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 8553d8a899..d9e25f347c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -643,7 +643,7 @@ provider in question. | `failureBackoffMaxSeconds?` | `number` | `3600` | Backoff ceiling and permanent-failure delay. | | `codexWarmupEnabled?` | `boolean` | `false` | Opt into synthetic Codex pool-account validation. | | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | Revalidate an account after 8 days. | -| `codexWarmupModel?` | `string` | `gpt-5.4-mini` | Native model used for optional warmup. | +| `codexWarmupModel?` | `string` | `gpt-5.6-luna` | Native model used for optional warmup. | ## Fixed provider endpoints diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index e9a3faa914..108d2e0925 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -510,7 +510,7 @@ an inactivity guard, not a total generation deadline. | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | Master image-description switch. | | `backend?` | `"openai" \| "anthropic"` | auto | Explicit wins; unset prefers a usable stored Anthropic OAuth credential, else `openai`. | -| `model?` | `string` | backend-dependent | `gpt-5.4-mini` for OpenAI or `claude-sonnet-5` for Anthropic. | +| `model?` | `string` | backend-dependent | `gpt-5.6-luna` for OpenAI or `claude-sonnet-5` for Anthropic. | | `maxDescriptionsPerTurn?` | `number` | `8` | New description cache misses admitted per main turn. `0` disables calls; invalid values use default. | | `timeoutMs?` | `number` | `45000` | Sidecar fetch timeout. Integer 1–2147483647. | diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index c0f87f1986..20460589f9 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -79,7 +79,7 @@ codex -m "ollama-cloud/glm-5.2" "Write a SQL migration" ## Выбор моделей подагентов (опционально) В свежей конфигурации в селекторе подагентов Codex представлены пять нативных моделей: `gpt-5.5`, -`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` и `gpt-5.4-mini`. Откройте `ocx gui`, чтобы +`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` и `gpt-6-astra`. Откройте `ocx gui`, чтобы заменить или переупорядочить до пяти нативных или маршрутизируемых моделей. В панели управления также можно задать одну предпочитаемую модель подагента и уровень рассуждений. Раздел [Поверхность подагентов](/guides/sub-agent-surface/) поможет выбрать v1/base/v2 и понять, когда diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index e208ab4b0b..5f8c458184 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -77,8 +77,8 @@ visibility = "list" ## Текущее покрытие стабильных моделей -Нативный fallback-набор включает `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, -`gpt-5.3-codex-spark` и GPT-5.6 Sol/Terra/Luna. Для семейства GPT-5.5/5.4 opencodex сохраняет +Нативный fallback-набор включает `gpt-5.5`, +`gpt-5.3-codex-spark` и GPT-5.6 Sol/Terra/Luna. Для семейства GPT-5.5 opencodex сохраняет более богатые живые записи установленного каталога Codex и синтезирует только отсутствующую запись. Bundled upstream-snapshot используется только для GPT-5.6, где он даёт настоящую per-model identity и метаданные вместо приближения по старому шаблону. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index e695830370..4e5acb9ee6 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -368,7 +368,7 @@ v1/base/v2 при делегировании и fallback — в ## Прогрев аккаунтов Codex -При добавлении или повторной аутентификации аккаунт обычно проверяется до сохранения небольшим запросом, ожидающим `response.completed`. По умолчанию используется `gpt-5.4-mini`, при HTTP 400 или HTTP 404 — повтор с `gpt-5.5` и `gpt-5.6-luna`. Публичные ошибки содержат фиксированные категории без исходного тела ответа. +При добавлении или повторной аутентификации аккаунт обычно проверяется до сохранения небольшим запросом, ожидающим `response.completed`. По умолчанию используется `gpt-5.6-luna`, при HTTP 400 или HTTP 404 — повтор с `gpt-5.5`. Публичные ошибки содержат фиксированные категории без исходного тела ответа. Если запрос квоты с новым OAuth-токеном подтверждает исчерпание пятичасовой, недельной или месячной квоты, аккаунт сохраняется без вызова модели со статусом **Ожидает проверки**. Перезапуск и обновление токена не включают маршрутизацию. После восстановления квоты обновите её: полные свежие данные с доступной ёмкостью разрешают небольшой проверочный запрос. Только успешное завершение включает аккаунт. Ошибки сохраняют ограничение. Пассивный опрос не отправляет такой запрос. Неизвестная квота при регистрации требует обычной проверки. @@ -390,7 +390,7 @@ v1/base/v2 при делегировании и fallback — в ocx config set codexPool '{"excludedPlans":["free"]}' ``` -Это политика выбора, а не блокировка. Исключённый аккаунт сохраняет учётные данные, историю квот и привязку к треду, остаётся видимым в списке и по-прежнему доступен при явном выборе вроде `work/gpt-5.4`. Меняется только то, что автоматическая ротация перестаёт его выбирать — в том числе когда он уже активен или уже привязан к треду, а именно это состояние остаётся после истёкшей подписки. +Это политика выбора, а не блокировка. Исключённый аккаунт сохраняет учётные данные, историю квот и привязку к треду, остаётся видимым в списке и по-прежнему доступен при явном выборе вроде `work/gpt-5.5`. Меняется только то, что автоматическая ротация перестаёт его выбирать — в том числе когда он уже активен или уже привязан к треду, а именно это состояние остаётся после истёкшей подписки. Два намеренных ограничения. Основной аккаунт Codex никогда не исключается по тарифу: маршрутизация в режиме «только выбор» скрывает его тариф, чтобы не читать защищённые нативные учётные данные, и правило для него противоречило бы само себе. А если не осталось ни одного неисключённого аккаунта, исключённый всё равно отвечает вместо отказа; чтобы остановить обслуживание полностью, по-прежнему нужно поставить на паузу все аккаунты. Аналога `minimumPlan` нет: чтобы ранжировать тарифы ChatGPT, нужен полный порядок, которого здесь не существует. diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index 57b437c1e6..54cf37020e 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -88,7 +88,7 @@ opencodex описывает каждое изображение **до** осн объявляют вход изображений только если каждый участник принимает изображения нативно или через сайдкар и параметр комбо `imageInput` не отключён; поэтому такие клиенты, как приложение Codex, разрешают вложения вместо их блокировки до запуска сайдкара. Если `visionSidecar.model` отсутствует или пуст, путь выполнения OpenAI, дашборд и API управления -используют фолбэк `gpt-5.4-mini`. При запуске явно сохранённое устаревшее значение +используют фолбэк `gpt-5.6-luna`. При запуске явно сохранённое устаревшее значение `gpt-5.4-mini` по-прежнему мигрирует на `gpt-5.6-luna`; миграция применяется только к сохранённому значению, а не к отсутствующему полю модели. diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index 7dfd7ea94c..edfb1fe75c 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -169,7 +169,7 @@ ocx v2 threads 8 ocx agent status ocx agent injection set --model anthropic/claude-sonnet-5 --effort xhigh ocx agent subagents set gpt-5.6-sol,anthropic/claude-sonnet-5 -ocx agent fallback set gpt-5.4-mini,xai/grok-4.5 --poll-ms 60000 +ocx agent fallback set gpt-5.6-luna,xai/grok-4.5 --poll-ms 60000 ocx agent effort set --subagent max ``` diff --git a/docs-site/src/content/docs/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index 5f8c0eef35..831f49bcf9 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -114,9 +114,9 @@ native ChatGPT-target'ами и прямыми key-auth Responses-маршрут "injectionModel": "gpt-5.5", "injectionEffort": "high", "syncCodexSubagentDefaults": true, - "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallback": ["gpt-5.6-luna"], "subagentModelFallbackByModel": { - "gpt-5.5": ["gpt-5.4-mini"] + "gpt-5.5": ["gpt-5.6-luna"] }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 6c76dd556b..9d132b0e94 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -262,7 +262,7 @@ backoff и может переключить аккаунт уже внутри | `failureBackoffMaxSeconds?` | `number` | `3600` | Верхний предел backoff'а и задержки после permanent-failure. | | `codexWarmupEnabled?` | `boolean` | `false` | Включить synthetic validation для аккаунтов пула Codex. | | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | Повторно валидировать аккаунт через 8 дней. | -| `codexWarmupModel?` | `string` | `gpt-5.4-mini` | Нативная модель, используемая для необязательного warmup'а. | +| `codexWarmupModel?` | `string` | `gpt-5.6-luna` | Нативная модель, используемая для необязательного warmup'а. | ## Фиксированные endpoint'ы провайдеров diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index f8bc9f2a25..84b3caf183 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -193,7 +193,7 @@ routed-model и hosted-search timeout. Эффективный watchdog мост | --- | --- | --- | --- | | `enabled?` | `boolean` | on when usable | Главный переключатель описания изображений. | | `backend?` | `"openai" \| "anthropic"` | auto | Явное значение имеет приоритет; если оно не задано, предпочтение отдаётся пригодным сохранённым учётным данным Anthropic OAuth, иначе используется `openai`. | -| `model?` | `string` | backend-dependent | `gpt-5.4-mini` для OpenAI или `claude-sonnet-5` для Anthropic. | +| `model?` | `string` | backend-dependent | `gpt-5.6-luna` для OpenAI или `claude-sonnet-5` для Anthropic. | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | Уровень рассуждений OpenAI Responses. Anthropic его игнорирует. | | `maxDescriptionsPerTurn?` | `number` | `8` | Максимум новых промахов description-cache за один main turn. `0` отключает вызовы; некорректные значения возвращают дефолт. | | `timeoutMs?` | `number` | `45000` | Таймаут запроса sidecar'а. Целое число 1–2147483647. | diff --git a/docs-site/src/content/docs/tr/getting-started/quickstart.md b/docs-site/src/content/docs/tr/getting-started/quickstart.md index 0ae770231f..dfff3c6104 100644 --- a/docs-site/src/content/docs/tr/getting-started/quickstart.md +++ b/docs-site/src/content/docs/tr/getting-started/quickstart.md @@ -84,7 +84,7 @@ codex -m "ollama-cloud/glm-5.2" "Bir SQL geçişi yaz" ## Alt ajan modellerini seçin (isteğe bağlı) Yeni bir yapılandırma, Codex'in alt ajan seçicisinde beş yerel model sunar: -`gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` ve `gpt-5.4-mini`. En +`gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` ve `gpt-6-astra`. En fazla beş yerel veya yönlendirilmiş modeli değiştirmek veya yeniden sıralamak için `ocx gui`'yi açın. Kontrol paneli ayrıca tercih edilen bir alt ajan modelini ve akıl yürütme çabasını ayarlayabilir. v1/base/v2 seçmek ve rehberlik, diff --git a/docs-site/src/content/docs/tr/guides/codex-app-models.md b/docs-site/src/content/docs/tr/guides/codex-app-models.md index 2ddd5bd63a..d26343e9eb 100644 --- a/docs-site/src/content/docs/tr/guides/codex-app-models.md +++ b/docs-site/src/content/docs/tr/guides/codex-app-models.md @@ -144,8 +144,8 @@ yerel yetenekleri kaldırır. ## Mevcut kararlı model kapsamı -Yerel geri dönüş kümesi `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, -`gpt-5.3-codex-spark` ve GPT-5.6 Sol/Terra/Luna modellerini içerir. GPT-5.5/5.4 +Yerel geri dönüş kümesi `gpt-5.5`, +`gpt-5.3-codex-spark` ve GPT-5.6 Sol/Terra/Luna modellerini içerir. GPT-5.5 ailesi için opencodex, kurulu Codex kataloğunun daha zengin canlı girdilerini korur ve yalnızca eksik bir girdiyi sentezler. Paketlenmiş yukarı akış anlık görüntüsü yalnızca eski şablon yaklaşımı yerine gerçek model başına kimliği ve diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index 23b9b87f2e..09493150a4 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -425,7 +425,7 @@ Arayüzü](/tr/guides/sub-agent-surface/) sayfasına bakın. ## Codex hesap ısınması -Hesap ekleme veya yeniden kimlik doğrulama, normalde kaydetmeden önce `response.completed` bekleyen küçük bir model isteğiyle doğrulanır. Varsayılan model `gpt-5.4-mini` olup HTTP 400 veya HTTP 404 durumunda `gpt-5.5` ve `gpt-5.6-luna` denenir. Genel hatalar ham yanıt gövdesi yerine sabit hata kategorilerini içerir. +Hesap ekleme veya yeniden kimlik doğrulama, normalde kaydetmeden önce `response.completed` bekleyen küçük bir model isteğiyle doğrulanır. Varsayılan model `gpt-5.6-luna` olup HTTP 400 veya HTTP 404 durumunda `gpt-5.5` denenir. Genel hatalar ham yanıt gövdesi yerine sabit hata kategorilerini içerir. Yeni OAuth belirteciyle yapılan kota sorgusu 5 saatlik, haftalık veya aylık kotanın tükendiğini doğrularsa hesap model çağrısı olmadan kaydedilir ve **Doğrulama bekleniyor** gösterilir. Yeniden başlatma veya belirteç yenileme yönlendirmeyi açmaz. Kota geri geldiğinde kotaları yenileyin: kullanılabilir kapasite gösteren eksiksiz güncel veri küçük bir doğrulama isteğine izin verir. Yalnızca tamamlanan yanıt hesabı etkinleştirir. Hatalarda kısıtlama korunur. Pasif sorgulama bu isteği göndermez. İlk kayıtta bilinmeyen kota normal doğrulamayı gerektirir. @@ -447,7 +447,7 @@ Tamamlanmayan bir ana hesap yenilemesi, yeniden denemede başarılı olabileceğ ocx config set codexPool '{"excludedPlans":["free"]}' ``` -Bu bir engelleme değil, seçim politikasıdır. Dışarıda bırakılan hesap kimlik bilgisini, kota geçmişini ve iş parçacığı bağını korur, hesap listesinde görünmeye devam eder ve `work/gpt-5.4` gibi açık bir seçimle hâlâ erişilebilir. Değişen tek şey, otomatik rotasyonun onu artık seçmemesidir; hesap zaten etkin olsa ya da bir iş parçacığına bağlı olsa bile. Süresi dolan bir abonelik tam olarak bu durumu bırakır. +Bu bir engelleme değil, seçim politikasıdır. Dışarıda bırakılan hesap kimlik bilgisini, kota geçmişini ve iş parçacığı bağını korur, hesap listesinde görünmeye devam eder ve `work/gpt-5.5` gibi açık bir seçimle hâlâ erişilebilir. Değişen tek şey, otomatik rotasyonun onu artık seçmemesidir; hesap zaten etkin olsa ya da bir iş parçacığına bağlı olsa bile. Süresi dolan bir abonelik tam olarak bu durumu bırakır. İki kasıtlı sınır var. Ana Codex hesabı plana göre hiçbir zaman dışarıda bırakılmaz: yalnızca-seçim yönlendirmesi korunan yerel kimlik bilgisini okumamak için planını saklar, dolayısıyla ana hesabı kapsayan bir kural kendisiyle çelişirdi. Ayrıca dışarıda bırakılmamış hiçbir hesap kalmadığında, dışarıda bırakılan hesap başarısız olmak yerine yine yanıt verir; hizmeti tamamen durdurmak için hâlâ tüm hesapları duraklatmak gerekir. `minimumPlan` karşılığı yoktur, çünkü ChatGPT planlarını sıralamak burada bulunmayan bir tam sıralama gerektirir. diff --git a/docs-site/src/content/docs/tr/guides/sidecars.md b/docs-site/src/content/docs/tr/guides/sidecars.md index 6dc32cd15d..19c7f16ee8 100644 --- a/docs-site/src/content/docs/tr/guides/sidecars.md +++ b/docs-site/src/content/docs/tr/guides/sidecars.md @@ -113,7 +113,7 @@ her üye görselleri yerel olarak veya bir sidecar üzerinden kabul ettiğinde v `imageInput` ayarı devre dışı olmadığında görsel girdisini bildirir; böylece Codex uygulaması gibi istemciler, sidecar çalışmadan önce ekleri engellemek yerine kabul eder. `visionSidecar.model` olmadığında veya boş -olduğunda, OpenAI yürütme yolu, Kontrol Paneli ve yönetim API'si `gpt-5.4-mini` +olduğunda, OpenAI yürütme yolu, Kontrol Paneli ve yönetim API'si `gpt-5.6-luna` geri dönüşünü kullanır. Başlangıç hala açıkça kalıcı hale getirilmiş eski bir `gpt-5.4-mini` değerini `gpt-5.6-luna`'ya geçirir; bu geçiş, bulunmayan bir model alanına değil, saklanan bir değere uygulanır. diff --git a/docs-site/src/content/docs/tr/guides/sub-agent-surface.md b/docs-site/src/content/docs/tr/guides/sub-agent-surface.md index c03f23ba44..1367d66c36 100644 --- a/docs-site/src/content/docs/tr/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/tr/guides/sub-agent-surface.md @@ -214,7 +214,7 @@ kullanın: ocx agent status ocx agent injection set --model anthropic/claude-sonnet-5 --effort xhigh ocx agent subagents set gpt-5.6-sol,anthropic/claude-sonnet-5 -ocx agent fallback set gpt-5.4-mini,xai/grok-4.5 --poll-ms 60000 +ocx agent fallback set gpt-5.6-luna,xai/grok-4.5 --poll-ms 60000 ocx agent effort set --subagent max ``` diff --git a/docs-site/src/content/docs/tr/reference/configuration/agents.md b/docs-site/src/content/docs/tr/reference/configuration/agents.md index fa181b1328..128ac3920a 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/tr/reference/configuration/agents.md @@ -136,9 +136,9 @@ kurtarır. "injectionModel": "gpt-5.5", "injectionEffort": "high", "syncCodexSubagentDefaults": true, - "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallback": ["gpt-5.6-luna"], "subagentModelFallbackByModel": { - "gpt-5.5": ["gpt-5.4-mini"] + "gpt-5.5": ["gpt-5.6-luna"] }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 9fce4f0ba9..3d72f129a7 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -284,7 +284,7 @@ yönetilir. | `failureBackoffMaxSeconds?` | `number` | `3600` | Geri çekilme tavanı ve kalıcı arıza gecikmesi. | | `codexWarmupEnabled?` | `boolean` | `false` | Sentetik Codex havuz hesabı doğrulamasına dahil olun. | | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | 8 gün sonra bir hesabı yeniden doğrulayın. | -| `codexWarmupModel?` | `string` | `gpt-5.4-mini` | İsteğe bağlı ısınma için kullanılan yerel model. | +| `codexWarmupModel?` | `string` | `gpt-5.6-luna` | İsteğe bağlı ısınma için kullanılan yerel model. | ## Sabit sağlayıcı uç noktaları diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index 3b6ee8a9f3..439cd70606 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -267,7 +267,7 @@ hareketsizlik korumasıdır, toplam bir üretim süresi sınırı değildir. | --- | --- | --- | --- | | `enabled?` | `boolean` | kullanılabilir olduğunda açık | Ana görsel açıklama anahtarı. | | `backend?` | `"openai" \| "anthropic"` | auto | Açık değer önceliklidir; ayarlanmadığında kullanılabilir kayıtlı bir Anthropic OAuth kimlik bilgisi tercih edilir, aksi halde `openai` kullanılır. | -| `model?` | `string` | arka uca bağlı | OpenAI için `gpt-5.4-mini` veya Anthropic için `claude-sonnet-5`. | +| `model?` | `string` | arka uca bağlı | OpenAI için `gpt-5.6-luna` veya Anthropic için `claude-sonnet-5`. | | `maxDescriptionsPerTurn?` | `number` | `8` | Ana tur başına kabul edilen yeni açıklama önbellek ıskalamaları. `0` çağrıları devre dışı bırakır; geçersiz değerler varsayılanı kullanır. | | `timeoutMs?` | `number` | `45000` | Sidecar getirme zaman aşımı. Tamsayı 1–2147483647. | diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index d3495a3ce4..f4aeabc2d7 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -65,7 +65,7 @@ codex -m "ollama-cloud/glm-5.2" "Write a SQL migration" ## 选择 sub-agent 模型(可选) -全新配置会在 Codex 的 sub-agent 选择器中提供五个原生模型:`gpt-5.5`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 和 `gpt-5.4-mini`。打开 `ocx gui`,可以替换或重新排序最多五个原生或已路由模型。仪表盘还可以设置一个首选 sub-agent 模型和 reasoning effort。参见 [Sub-agent Surface](/guides/sub-agent-surface/) 以选择 v1/base/v2,并了解何时适用 guidance、原生默认值和 fallback。 +全新配置会在 Codex 的 sub-agent 选择器中提供五个原生模型:`gpt-5.5`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 和 `gpt-6-astra`。打开 `ocx gui`,可以替换或重新排序最多五个原生或已路由模型。仪表盘还可以设置一个首选 sub-agent 模型和 reasoning effort。参见 [Sub-agent Surface](/guides/sub-agent-surface/) 以选择 v1/base/v2,并了解何时适用 guidance、原生默认值和 fallback。 ## 登录而非粘贴 key diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 241b812252..d47a080ac5 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -61,7 +61,7 @@ visibility = "list" ## 当前稳定模型覆盖 -原生回退集合包含 `gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.3-codex-spark` 以及 GPT-5.6 Sol/Terra/Luna。对于 GPT-5.5/5.4 家族,opencodex 会保留已安装 Codex 目录中更丰富的实时条目,只在缺失时才合成条目。内置的上游快照只用于 GPT-5.6,因为它提供的是每个模型真实的身份和元数据,而不是较旧模板的近似版本。 +原生回退集合包含 `gpt-5.5`、`gpt-5.3-codex-spark` 以及 GPT-5.6 Sol/Terra/Luna。对于 GPT-5.5 家族,opencodex 会保留已安装 Codex 目录中更丰富的实时条目,只在缺失时才合成条目。内置的上游快照只用于 GPT-5.6,因为它提供的是每个模型真实的身份和元数据,而不是较旧模板的近似版本。 | 路由 | 选择器 id 与目录元数据 | | --- | --- | diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 71d2b81473..1e4cea1d43 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -312,7 +312,7 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 ## Codex 账号预热 -添加或重新认证账号时,通常会在保存前发送一个小型模型请求并等待 `response.completed`。默认使用 `gpt-5.4-mini`,HTTP 400 或 HTTP 404 时改用 `gpt-5.5` 与 `gpt-5.6-luna` 重试。公开错误仅包含固定分类,不包含原始响应正文。 +添加或重新认证账号时,通常会在保存前发送一个小型模型请求并等待 `response.completed`。默认使用 `gpt-5.6-luna`,HTTP 400 或 HTTP 404 时改用 `gpt-5.5` 重试。公开错误仅包含固定分类,不包含原始响应正文。 如果新 OAuth 凭据的已认证用量查询确认5小时、每周或每月额度耗尽,则不调用模型而直接保存账号,显示**等待验证**。重启或刷新令牌也不会使其可用。额度恢复后刷新额度:只有完整的最新用量显示有余额,才会发送一个小型验证请求;请求完成后账号才可用于路由。查询或验证失败将保留等待状态。普通状态轮询不会发送该请求。初次注册时用量未知仍需常规预热验证。 @@ -334,7 +334,7 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 ocx config set codexPool '{"excludedPlans":["free"]}' ``` -这是选择策略,不是封禁。被排除的账号保留凭据、用量历史和线程亲和性,仍显示在账号列表中,也仍可通过 `work/gpt-5.4` 这类显式选择使用。改变的只是自动轮换不再选它,包括它已经是活跃账号或已绑定线程的情况——订阅到期后留下的正是这种状态。 +这是选择策略,不是封禁。被排除的账号保留凭据、用量历史和线程亲和性,仍显示在账号列表中,也仍可通过 `work/gpt-5.5` 这类显式选择使用。改变的只是自动轮换不再选它,包括它已经是活跃账号或已绑定线程的情况——订阅到期后留下的正是这种状态。 有两处刻意的限制。主 Codex 账号不会因套餐被排除:仅选择模式的路由不读取受保护的原生凭据而隐去其套餐,覆盖主账号的规则会自相矛盾。另外,当没有未被排除的账号时,被排除的账号仍会应答而不是失败;要彻底停止服务,仍然是暂停全部账号。没有对应的 `minimumPlan`,因为给 ChatGPT 套餐排序需要一个这里并不存在的全序。 diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index 9d148c85b8..2ffeb8ff1e 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -69,7 +69,7 @@ OAuth 账户时使用 `anthropic`,否则使用 `openai`。显式选择 `anthro 如果没有可用 plan,原始图像会被移除,而不会继续转发给纯文本后端。模型目录会为每个由 sidecar 覆盖的模型声明图像输入。 只有当每个 combo 成员都能原生或通过 sidecar 接受图像、且 combo 的 `imageInput` 设置未禁用时,combo 才会声明图像输入; 这样 Codex 应用等客户端会允许附件,而不会在 sidecar 运行前阻止它们。当 `visionSidecar.model` 缺失或为空时,OpenAI 执行路径、 -Dashboard 和管理 API 都使用 `gpt-5.4-mini` 作为回退。启动时仍会把明确保存的旧 +Dashboard 和管理 API 都使用 `gpt-5.6-luna` 作为回退。启动时仍会把明确保存的旧 `gpt-5.4-mini` 值迁移到 `gpt-5.6-luna`;该迁移只作用于已保存值,不适用于缺失的 model 字段。 - 图像可以来自 user、developer 和 tool-result message,也包括 Codex 的 `view_image` 结果。 diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index c8c6bcba3d..b0d1dc6536 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -117,7 +117,7 @@ ocx v2 threads 8 ocx agent status ocx agent injection set --model anthropic/claude-sonnet-5 --effort xhigh ocx agent subagents set gpt-5.6-sol,anthropic/claude-sonnet-5 -ocx agent fallback set gpt-5.4-mini,xai/grok-4.5 --poll-ms 60000 +ocx agent fallback set gpt-5.6-luna,xai/grok-4.5 --poll-ms 60000 ocx agent effort set --subagent max ``` diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index 51558e2e11..094e6df720 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -71,9 +71,9 @@ opencodex 会跳过已禁用、不可路由、不健康、处于冷却中,或 "injectionModel": "gpt-5.5", "injectionEffort": "high", "syncCodexSubagentDefaults": true, - "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallback": ["gpt-5.6-luna"], "subagentModelFallbackByModel": { - "gpt-5.5": ["gpt-5.4-mini"] + "gpt-5.5": ["gpt-5.6-luna"] }, "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 2e287e14e4..9d0ed2dd76 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -217,7 +217,7 @@ affinity。这些策略不能规避 provider enforcement。 | `failureBackoffMaxSeconds?` | `number` | `3600` | 退避上限和永久故障延迟。 | | `codexWarmupEnabled?` | `boolean` | `false` | 启用合成的 Codex 池账户验证。 | | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | 8 天后重新验证账户。 | -| `codexWarmupModel?` | `string` | `gpt-5.4-mini` | 用于可选预热的原生模型。 | +| `codexWarmupModel?` | `string` | `gpt-5.6-luna` | 用于可选预热的原生模型。 | ## 固定提供者端点 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 211e141650..4031f9ff19 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -167,7 +167,7 @@ routed 重放会把主 ChatGPT 认证注入内部请求。Anthropic 后端使用 | --- | --- | --- | --- | | `enabled?` | `boolean` | 在可用时启用 | 图像描述总开关。 | | `backend?` | `"openai" \| "anthropic"` | auto | 显式值优先;未设置时优先使用可用的已保存 Anthropic OAuth 凭据,否则使用 `openai`。 | -| `model?` | `string` | 依后端而定 | OpenAI 使用 `gpt-5.4-mini`,Anthropic 使用 `claude-sonnet-5`。 | +| `model?` | `string` | 依后端而定 | OpenAI 使用 `gpt-5.6-luna`,Anthropic 使用 `claude-sonnet-5`。 | | `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses 推理强度;Anthropic 会忽略该项。 | | `maxDescriptionsPerTurn?` | `number` | `8` | 每个主轮次允许的新增描述缓存未命中次数。`0` 会禁用调用;无效值会使用默认值。 | | `timeoutMs?` | `number` | `45000` | 侧车获取超时。整数 1–2147483647。 | diff --git a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md index a7d0ca4e87..8236a6f511 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/quickstart.md @@ -73,7 +73,7 @@ codex -m "ollama-cloud/glm-5.2" "Write a SQL migration" ## 選擇 sub-agent 模型(可選) 新設定會讓 Codex 的 sub-agent 選擇器包含五個原生模型:`gpt-5.5`、`gpt-5.6-sol`、 -`gpt-5.6-terra`、`gpt-5.6-luna` 和 `gpt-5.4-mini`。開啟 `ocx gui` 即可替換或重新排序最多五個 +`gpt-5.6-terra`、`gpt-5.6-luna` 和 `gpt-6-astra`。開啟 `ocx gui` 即可替換或重新排序最多五個 原生或已路由模型。儀表板也可以設定一個首選 sub-agent 模型及 reasoning effort。參見 [子代理介面](/zh-tw/guides/sub-agent-surface/) 選擇 v1/base/v2,並了解指引、原生預設與回退 何時生效。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md b/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md index 260ab7ad36..efe0dfb6ee 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md @@ -80,8 +80,8 @@ visibility = "list" ## 目前穩定模型涵蓋範圍 -原生回退列表包含 `gpt-5.5`、`gpt-5.4`、`gpt-5.4-mini`、 -`gpt-5.3-codex-spark` 以及 GPT-5.6 Sol/Terra/Luna。對於 GPT-5.5/5.4 系列,opencodex 會 +原生回退列表包含 `gpt-5.5`、 +`gpt-5.3-codex-spark` 以及 GPT-5.6 Sol/Terra/Luna。對於 GPT-5.5 系列,opencodex 會 保留已安裝 Codex 目錄中資訊更完整的即時條目,僅在條目缺失時才合成。內建的上游快照只用於 GPT-5.6,以便提供每個模型真實的身份和後設資料,而不是套用舊模板近似生成。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index c9f8090987..a17997b848 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -319,7 +319,7 @@ ocx service install # 常駐:登入時自動啟動,崩潰後自動重新 ## Codex 帳號預熱 -新增或重新驗證帳號時,通常會在儲存前傳送小型模型請求並等待 `response.completed`。預設使用 `gpt-5.4-mini`,HTTP 400 或 HTTP 404 時改用 `gpt-5.5` 與 `gpt-5.6-luna` 重試。公開錯誤僅包含固定分類,不包含原始回應本文。 +新增或重新驗證帳號時,通常會在儲存前傳送小型模型請求並等待 `response.completed`。預設使用 `gpt-5.6-luna`,HTTP 400 或 HTTP 404 時改用 `gpt-5.5` 重試。公開錯誤僅包含固定分類,不包含原始回應本文。 若新 OAuth 憑證的已驗證用量查詢確認5小時、每週或每月額度耗盡,則不呼叫模型而直接儲存帳號,顯示**等待驗證**。重新啟動或更新權杖也不會使其可用。額度恢復後重新整理額度:只有完整的最新用量顯示有餘額,才會傳送小型驗證請求;請求完成後帳號才可用於路由。查詢或驗證失敗將保留等待狀態。一般狀態輪詢不會傳送該請求。首次註冊時用量未知仍需一般預熱驗證。 @@ -341,7 +341,7 @@ ocx service install # 常駐:登入時自動啟動,崩潰後自動重新 ocx config set codexPool '{"excludedPlans":["free"]}' ``` -這是選擇策略,不是封鎖。被排除的帳號保留憑證、用量紀錄與執行緒親和性,仍顯示在帳號清單中,也仍可透過 `work/gpt-5.4` 這類明確選擇使用。改變的只是自動輪換不再挑它,包括它已經是使用中帳號或已綁定執行緒的情況——訂閱到期後留下的正是這種狀態。 +這是選擇策略,不是封鎖。被排除的帳號保留憑證、用量紀錄與執行緒親和性,仍顯示在帳號清單中,也仍可透過 `work/gpt-5.5` 這類明確選擇使用。改變的只是自動輪換不再挑它,包括它已經是使用中帳號或已綁定執行緒的情況——訂閱到期後留下的正是這種狀態。 有兩處刻意的限制。主 Codex 帳號不會因方案被排除:僅選擇模式的路由不讀取受保護的原生憑證而隱去其方案,涵蓋主帳號的規則會自相矛盾。此外,當沒有未被排除的帳號時,被排除的帳號仍會回應而不是失敗;要完全停止服務,仍然是暫停所有帳號。沒有對應的 `minimumPlan`,因為為 ChatGPT 方案排序需要一個這裡並不存在的全序。 diff --git a/docs-site/src/content/docs/zh-tw/guides/sidecars.md b/docs-site/src/content/docs/zh-tw/guides/sidecars.md index 6462a5df2d..97c073d3ba 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-tw/guides/sidecars.md @@ -70,7 +70,7 @@ OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthro 只有當每個 combo 成員都能原生或透過 sidecar 接受圖像,且 combo 的 `imageInput` 設定未停用時,combo 才會宣告圖像輸入; 如此 Codex 應用程式等用戶端會允許附件,而不會在 sidecar 執行前阻擋它們。Dashboard 和管理 API 目前顯示的預設值是 `gpt-5.6-luna`,啟動時也會把明確儲存的舊 `gpt-5.4-mini` 值遷移到 Luna。只有在 -`visionSidecar.model` 欄位不存在或為空字串時,vision 執行路徑才會使用程式碼中的 `gpt-5.4-mini` 回退值。 +`visionSidecar.model` 欄位不存在或為空字串時,vision 執行路徑才會使用程式碼中的 `gpt-5.6-luna` 回退值。 - 圖像可以來自 user、developer 和 tool-result message,也包括 Codex 的 `view_image` 結果。 - 每張圖像會以 `reasoning.effort: "low"` 傳送給設定的原生 vision 模型,描述結果會就地替換 diff --git a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md index 59be70f24d..b18e2d34c1 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md @@ -215,7 +215,7 @@ opencodex 會將磁碟上的模型目錄與目前使用者擁有的每個 Codex | 模型 | 線路上的 `max` | 選擇 `ultra` 後的線路值 | | --- | --- | --- | -| gpt-5.5、gpt-5.4、gpt-5.4-mini | xhigh | xhigh(先轉為 max,再經 `nativeEffortClamp`) | +| gpt-5.5 | xhigh | xhigh(先轉為 max,再經 `nativeEffortClamp`) | | gpt-5.6-sol、gpt-5.6-terra | max | max | | gpt-5.6-luna | max | 其精確上游階梯不提供該選項 | | 路由模型 | 由適配器對映或限制 | 先轉為 max,再由適配器對映或限制 | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index e979fa35b6..0e3bbe4b78 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -280,7 +280,7 @@ Preview 建置使用 `/native-main-profiles`。該配置絕不 | `provider ` | `--json` | 在單次寫入中啟用或停用一個供應商的所有模型。 | | `selected ` | `--set `, `--clear`, `--json` | 讀取或替換供應商模型允許清單。`--clear` 移除允許清單,使每個模型都被提供。 | | `context \|provider \|all >` | `--json` | 讀取或設定 context-window 上限,全域或 per 供應商。 | -| `shadow [model\|-]` | `--enabled `, `--json` | 讀取或設定 Codex 背景 helper 呼叫的替換模型。`-` 清除模型。`status` 亦回報 `sourceModels`,即代理攔截的 helper slug(預設:`gpt-5.4-mini` 與 `gpt-5.6-luna`)。 | +| `shadow [model\|-]` | `--enabled `, `--json` | 讀取或設定 Codex 背景 helper 呼叫的替換模型。`-` 清除模型。`status` 亦回報 `sourceModels`,即代理攔截的 helper slug(預設:`gpt-5.6-luna`;0.144.x 以前的用戶端使用已退役的 `gpt-5.4-mini`,可透過 `sourceModels` 還原)。 | ```bash ocx models live --json # Codex 目前實際可見的模型 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md b/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md index 4fb07c6d29..d3fdf7059b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md @@ -59,7 +59,7 @@ opencodex 會跳過已停用、不可路由、不健康、冷卻中或達到配 "injectionModel": "gpt-5.5", "injectionEffort": "high", "syncCodexSubagentDefaults": true, - "subagentModelFallback": ["gpt-5.4-mini"], + "subagentModelFallback": ["gpt-5.6-luna"], "subagentModelFallbackPollMs": 60000, "subagentEffortCap": "high" } diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 4303ca73aa..ff67793990 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -182,7 +182,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 | `failureBackoffMaxSeconds?` | `number` | `3600` | Backoff 上限與永久失敗延遲。 | | `codexWarmupEnabled?` | `boolean` | `false` | 選擇加入合成 Codex 池帳號驗證。 | | `codexWarmupMaxAgeSeconds?` | `number` | `691200` | 8 天後重新驗證帳號。 | -| `codexWarmupModel?` | `string` | `gpt-5.4-mini` | 用於可選暖機的原生模型。 | +| `codexWarmupModel?` | `string` | `gpt-5.6-luna` | 用於可選暖機的原生模型。 | ## 固定供應商端點 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index 4649b51f97..402015f060 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -188,7 +188,7 @@ OpenAI backend 需要 ChatGPT 登入與啟用的 ChatGPT `forward` 供應商。C | --- | --- | --- | --- | | `enabled?` | `boolean` | 可用時開啟 | 主圖片描述開關。 | | `backend?` | `"openai" \| "anthropic"` | 自動 | 明確值優先;未設定時優先使用可用的已儲存 Anthropic OAuth 憑證,否則使用 `openai`。 | -| `model?` | `string` | 視 backend 而定 | OpenAI 為 `gpt-5.4-mini` 或 Anthropic 為 `claude-sonnet-5`。 | +| `model?` | `string` | 視 backend 而定 | OpenAI 為 `gpt-5.6-luna` 或 Anthropic 為 `claude-sonnet-5`。 | | `maxDescriptionsPerTurn?` | `number` | `8` | 每個主回合允許的新描述快取未命中。`0` 停用呼叫;無效值使用預設。 | | `timeoutMs?` | `number` | `45000` | Sidecar 擷取逾時。整數 1–2147483647。 | diff --git a/docs/codex-app-model-catalog.md b/docs/codex-app-model-catalog.md index a6cd01da27..2e8c7e3c63 100644 --- a/docs/codex-app-model-catalog.md +++ b/docs/codex-app-model-catalog.md @@ -107,8 +107,8 @@ The recognized Codex effort ladder is `low < medium < high < xhigh < max < ultra `prefer_websockets`/`supports_websockets` follow the central websocket gate. A future `gpt-5.6-*` slug the snapshot predates falls back to template synthesis plus `ensureGpt56ReasoningLevels` (appends `max`+`ultra`). -- Snapshot scope is deliberately gpt-5.6-only: the bundled upstream entries for - `gpt-5.5`/`gpt-5.4` are staler than the installed catalog's live entries (e.g. snapshot +- Snapshot scope is deliberately gpt-5.6-only: the bundled upstream entries for older natives + such as `gpt-5.5` are staler than the installed catalog's live entries (e.g. snapshot gpt-5.5 carries `tool_mode: null`), so substituting them would downgrade real data. On-disk sync also self-heals fallback-quality 5.6 entries (display_name stamped with the bare slug) by upgrading them to the snapshot entry; genuine entries from a newer installed codex are diff --git a/docs/shadow-call-intercept.md b/docs/shadow-call-intercept.md index d0e7248734..dc271588b4 100644 --- a/docs/shadow-call-intercept.md +++ b/docs/shadow-call-intercept.md @@ -14,9 +14,11 @@ its configured reasoning effort. The helper model is not stable across client versions. Codex used `gpt-5.4-mini` up to 0.144.x and moved to `gpt-5.6-luna` in 0.145.0, which silently disabled a single-literal intercept ([#311](https://github.com/lidge-jun/opencodex/issues/311)). The intercept therefore matches a -**set** of source-model prefixes — `gpt-5.4-mini` and `gpt-5.6-luna` by default — so a client bump -does not quietly turn the feature off. Routed ids (`provider/model`) are never matched: a shadow -call is always a bare native slug, and an explicit routed selection must not be hijacked. +**set** of source-model prefixes — `gpt-5.6-luna` by default — so a client bump does not quietly +turn the feature off. `gpt-5.4-mini` is retired upstream and is no longer a default prefix, but it +remains a valid `sourceModels` entry for anyone still serving 0.144.x clients. Routed ids +(`provider/model`) are never matched: a shadow call is always a bare native slug, and an explicit +routed selection must not be hijacked. ## The problem diff --git a/gui/src/pages/api-keys-panels.tsx b/gui/src/pages/api-keys-panels.tsx index 54dce72098..661dd006e4 100644 --- a/gui/src/pages/api-keys-panels.tsx +++ b/gui/src/pages/api-keys-panels.tsx @@ -319,7 +319,7 @@ export function ApiKeysUsagePanel({ -H "x-opencodex-api-key: ocx_YOUR_KEY_HERE" \\ -H "Content-Type: application/json" \\ -d '{ - "model": "gpt-5.4", + "model": "gpt-5.6-luna", "messages": [{"role": "user", "content": ${sampleInput}}] }'`; @@ -327,7 +327,7 @@ export function ApiKeysUsagePanel({ -H "x-opencodex-api-key: ocx_YOUR_KEY_HERE" \\ -H "Content-Type: application/json" \\ -d '{ - "model": "gpt-5.4", + "model": "gpt-5.6-luna", "input": ${sampleInput} }'`; diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index c71687acb9..a62bc47eea 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -444,7 +444,7 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { shadowCall, shadowCallSaving, shadowCallHelpTriggerRef, shadowCallHelpOpen, setShadowCallHelpOpen, saveShadowCall, } = d; const visionEnabled = sidecar?.vision.enabled !== false; - const visionModel = visionEnabled ? (sidecar?.vision.model ?? "gpt-5.4-mini") : ""; + const visionModel = visionEnabled ? (sidecar?.vision.model ?? "gpt-5.6-luna") : ""; const persistedVisionReasoning = sidecar?.vision.reasoning ?? "low"; const visionLadder = visionReasoningLadder(models, visionModel); const visionReasoning = clampVisionReasoningToLadder(visionLadder, persistedVisionReasoning); diff --git a/gui/tests/api-access-models.test.ts b/gui/tests/api-access-models.test.ts index 8fb404ebcb..8fe582a117 100644 --- a/gui/tests/api-access-models.test.ts +++ b/gui/tests/api-access-models.test.ts @@ -6,9 +6,9 @@ import { describe("classifyExternalModel", () => { test("keeps bare native OpenAI ids and marks them native via owned_by", () => { - expect(classifyExternalModel({ id: "gpt-5.4", owned_by: "openai" })).toEqual({ - id: "gpt-5.4", - displayName: "gpt-5.4", + expect(classifyExternalModel({ id: "gpt-5.5", owned_by: "openai" })).toEqual({ + id: "gpt-5.5", + displayName: "gpt-5.5", provider: "openai", native: true, custom: false, diff --git a/gui/tests/apikeys-actions.test.tsx b/gui/tests/apikeys-actions.test.tsx index ae3bffecef..02015a3b1d 100644 --- a/gui/tests/apikeys-actions.test.tsx +++ b/gui/tests/apikeys-actions.test.tsx @@ -237,7 +237,7 @@ test("a failed delete keeps the detail pane open", async () => { test("without a fresh key the protocol chips are disabled, not silently passing", async () => { const container = await mount({ - filteredModels: [{ id: "gpt-5.4", displayName: "gpt-5.4", provider: "openai", native: true }], + filteredModels: [{ id: "gpt-5.5", displayName: "gpt-5.5", provider: "openai", native: true }], modelCount: 1, canTestModels: false, }); @@ -286,10 +286,10 @@ test("rotation start, one-time secret, commit, and abort stay explicit", async ( test("a protocol result belongs to its own chip", async () => { const container = await mount({ - filteredModels: [{ id: "gpt-5.4", displayName: "gpt-5.4", provider: "openai", native: true }], + filteredModels: [{ id: "gpt-5.5", displayName: "gpt-5.5", provider: "openai", native: true }], modelCount: 1, canTestModels: true, - modelTests: { "gpt-5.4": { chat: { state: "error", detail: "boom" } } }, + modelTests: { "gpt-5.5": { chat: { state: "error", detail: "boom" } } }, }); const notes = [...container.querySelectorAll(".api-test-note")]; // Exactly one result rendered, announced, and attached to the chat chip only. diff --git a/gui/tests/apikeys-model-test-wire.test.tsx b/gui/tests/apikeys-model-test-wire.test.tsx index 943d4d6f3d..c62cca7e20 100644 --- a/gui/tests/apikeys-model-test-wire.test.tsx +++ b/gui/tests/apikeys-model-test-wire.test.tsx @@ -89,7 +89,7 @@ function installFetch(sent: SentRequest[], dataPlaneStatus = 200): void { const url = String(input); const method = (init?.method ?? "GET").toUpperCase(); if (url.endsWith("/v1/models") && method === "GET") { - return Response.json({ data: [{ id: "gpt-5.4", owned_by: "openai" }] }); + return Response.json({ data: [{ id: "gpt-5.5", owned_by: "openai" }] }); } if (url.endsWith("/api/keys") && method === "GET") return Response.json(KEYS_OK); if (url.endsWith("/api/keys") && method === "POST") return Response.json({ key: ONE_TIME_KEY }); @@ -163,11 +163,11 @@ test("each protocol chip posts its own endpoint and body, carrying the one-time // Each protocol speaks its own wire. A chat body posted at /v1/responses // would be rejected for its shape, not for the key, and the green chip // would then be lying about what it proved. - expect(sent[0]!.body).toMatchObject({ model: "gpt-5.4", input: "ping", stream: false }); + expect(sent[0]!.body).toMatchObject({ model: "gpt-5.5", input: "ping", stream: false }); expect(sent[0]!.body).not.toHaveProperty("messages"); - expect(sent[1]!.body).toMatchObject({ model: "gpt-5.4", max_tokens: 1, stream: false }); + expect(sent[1]!.body).toMatchObject({ model: "gpt-5.5", max_tokens: 1, stream: false }); expect(sent[1]!.body).toHaveProperty("messages"); - expect(sent[2]!.body).toMatchObject({ model: "gpt-5.4", max_tokens: 1 }); + expect(sent[2]!.body).toMatchObject({ model: "gpt-5.5", max_tokens: 1 }); expect(sent[2]!.body).not.toHaveProperty("stream"); // The dedicated header is the only one every data-plane endpoint accepts; diff --git a/gui/tests/apikeys-models-states.test.tsx b/gui/tests/apikeys-models-states.test.tsx index ef98e24fa6..017dba1a71 100644 --- a/gui/tests/apikeys-models-states.test.tsx +++ b/gui/tests/apikeys-models-states.test.tsx @@ -135,11 +135,11 @@ test("an empty catalog says the catalog is empty, with no query in the sentence" test("a query matching nothing names the query, and does not claim the catalog is empty", async () => { const counter = { gets: 0 }; installFetch(() => Response.json({ - data: [{ id: "gpt-5.4", owned_by: "openai" }, { id: "claude/opus-4-6", owned_by: "anthropic" }], + data: [{ id: "gpt-5.5", owned_by: "openai" }, { id: "claude/opus-4-6", owned_by: "anthropic" }], }), counter); const { container, root } = await mountPage(); try { - expect(container.textContent).toContain("gpt-5.4"); + expect(container.textContent).toContain("gpt-5.5"); await typeQuery(container, "nothing-matches-this"); await tick(); @@ -158,7 +158,7 @@ test("a failed cold load offers a retry that really refetches, and no false empt installFetch( () => (fail ? new Response("upstream unavailable", { status: 503 }) - : Response.json({ data: [{ id: "gpt-5.4", owned_by: "openai" }] })), + : Response.json({ data: [{ id: "gpt-5.5", owned_by: "openai" }] })), counter, ); const { container, root } = await mountPage(); @@ -176,7 +176,7 @@ test("a failed cold load offers a retry that really refetches, and no false empt await tick(); expect(counter.gets).toBe(2); - expect(container.textContent).toContain("gpt-5.4"); + expect(container.textContent).toContain("gpt-5.5"); expect(container.textContent).not.toContain("Could not load the external model catalog."); } finally { await act(async () => { root.unmount(); }); diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index 37f2df193b..c04edc1bae 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -40,7 +40,7 @@ const OPENCODE_ENVELOPE_BASE = { npm: "@ai-sdk/openai-compatible", name: "OpenCodex", options: { baseURL: "http://127.0.0.1:10100/v1", apiKey: "{env:OPENCODEX_OPENCODE_API_KEY}" }, - models: { "gpt-5.4": { name: "gpt-5.4 (native)" } }, + models: { "gpt-5.5": { name: "gpt-5.5 (native)" } }, }, }, }, @@ -57,7 +57,7 @@ const PI_ENVELOPE_BASE = { format: "json", mediaType: "application/json", // Pi keys its models as an ARRAY — the shape swap is what proves a real refetch. - config: { providers: { opencodex: { models: [{ id: "gpt-5.4" }, { id: "claude-sonnet-4-6" }] } } }, + config: { providers: { opencodex: { models: [{ id: "gpt-5.5" }, { id: "claude-sonnet-4-6" }] } } }, }; /** diff --git a/gui/tests/subagents-fallback.test.tsx b/gui/tests/subagents-fallback.test.tsx index bf838d63ca..71001416ed 100644 --- a/gui/tests/subagents-fallback.test.tsx +++ b/gui/tests/subagents-fallback.test.tsx @@ -99,7 +99,7 @@ beforeEach(() => { model: preferredModel, effort: null, available: [ - { provider: "openai", model: "gpt-5.4", namespaced: "gpt-5.4" }, + { provider: "openai", model: "gpt-5.5", namespaced: "gpt-5.5" }, { provider: "anthropic", model: "claude-sonnet-4-6", namespaced: "anthropic/claude-sonnet-4-6" }, ], efforts: [], @@ -735,7 +735,7 @@ const compatibilityCases: Array<{ keepNative: boolean; warning: boolean; }> = [ - { name: "native preferred model", model: "gpt-5.4", enabled: true, mode: "v2", keepNative: false, warning: false }, + { name: "native preferred model", model: "gpt-5.5", enabled: true, mode: "v2", keepNative: false, warning: false }, { name: "routed preferred model on the default surface", model: "anthropic/claude-sonnet-4-6", enabled: false, mode: "default", keepNative: false, warning: true }, { name: "routed preferred model on V1", model: "anthropic/claude-sonnet-4-6", enabled: false, mode: "v1", keepNative: false, warning: false }, { name: "forced V2 preserving native V1 with global V2 disabled", model: "anthropic/claude-sonnet-4-6", enabled: false, mode: "v2", keepNative: true, warning: false }, diff --git a/gui/tests/vision-reasoning-contract.test.ts b/gui/tests/vision-reasoning-contract.test.ts index ca6d2699c5..40fb449b90 100644 --- a/gui/tests/vision-reasoning-contract.test.ts +++ b/gui/tests/vision-reasoning-contract.test.ts @@ -12,15 +12,15 @@ import { test("vision reasoning uses advertised model ladders and clamps unsupported persisted values", () => { const models: ModelInfo[] = [ { id: "gpt-5.6-luna", provider: "openai", namespaced: "gpt-5.6-luna", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"] }, - { id: "gpt-5.4-mini", provider: "openai", namespaced: "gpt-5.4-mini", reasoningEfforts: ["low", "medium", "high", "xhigh"] }, + { id: "gpt-5.5", provider: "openai", namespaced: "gpt-5.5", reasoningEfforts: ["low", "medium", "high", "xhigh"] }, ]; expect(visionReasoningLadder(models, "gpt-5.6-luna")).toEqual(VISION_REASONING_LEVELS); - const mini = visionReasoningLadder(models, "gpt-5.4-mini"); - expect(mini).toEqual(["low", "medium", "high", "xhigh"]); - expect(clampVisionReasoningToLadder(mini, "max")).toBe("xhigh"); - expect(clampVisionReasoningToLadder(mini, "high")).toBe("high"); - expect(visionReasoningOptionsFor(mini, "max")).toEqual(mini); + const shorter = visionReasoningLadder(models, "gpt-5.5"); + expect(shorter).toEqual(["low", "medium", "high", "xhigh"]); + expect(clampVisionReasoningToLadder(shorter, "max")).toBe("xhigh"); + expect(clampVisionReasoningToLadder(shorter, "high")).toBe("high"); + expect(visionReasoningOptionsFor(shorter, "max")).toEqual(shorter); }); test("vision reasoning clamp matches the server for non-prefix ladders", () => { diff --git a/gui/tests/vision-sidecar-dashboard.test.tsx b/gui/tests/vision-sidecar-dashboard.test.tsx index fd41cc89fd..5a2c7cec3b 100644 --- a/gui/tests/vision-sidecar-dashboard.test.tsx +++ b/gui/tests/vision-sidecar-dashboard.test.tsx @@ -36,7 +36,7 @@ const initialSidecar: SidecarData = { }, visionModels: [ { value: "gpt-5.6-luna", label: "gpt-5.6-luna", backend: "openai", baseline: true }, - { value: "gpt-5.4-mini", label: "gpt-5.4-mini", backend: "openai", baseline: true }, + { value: "gpt-5.6-terra", label: "gpt-5.6-terra", backend: "openai", baseline: true }, ], }; @@ -104,7 +104,7 @@ function harness(sidecar: SidecarData = initialSidecar) { visionModels: sidecar.visionModels ?? [], models: [ { id: "gpt-5.6-luna", provider: "openai", namespaced: "gpt-5.6-luna", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"] }, - { id: "gpt-5.4-mini", provider: "openai", namespaced: "gpt-5.4-mini", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"] }, + { id: "gpt-5.6-terra", provider: "openai", namespaced: "gpt-5.6-terra", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"] }, ], saveSidecar, shadowCall: { enabled: false, model: "" }, @@ -304,11 +304,11 @@ test("choosing a model from Off sends enabled:true plus that model and backend", const { d, patches } = harness({ ...initialSidecar, vision: { ...initialSidecar.vision, enabled: false } }); await mount(d); await act(async () => { modelTrigger().click(); }); - const next = pickOption("gpt-5.4-mini"); + const next = pickOption("gpt-5.6-terra"); expect(next).toBeTruthy(); await act(async () => { next!.click(); }); expect(patches).toEqual([ - { vision: { model: "gpt-5.4-mini", backend: "openai", reasoning: "medium", enabled: true } }, + { vision: { model: "gpt-5.6-terra", backend: "openai", reasoning: "medium", enabled: true } }, ]); }); @@ -369,12 +369,12 @@ test("model and reasoning saves still omit enabled, limit, and timeout", async ( ) as HTMLButtonElement; await act(async () => { modelTrigger.click(); }); - const nextModel = pickOption("gpt-5.4-mini"); + const nextModel = pickOption("gpt-5.6-terra"); expect(nextModel).toBeTruthy(); await act(async () => { nextModel!.click(); }); expect(patches).toHaveLength(1); expect(patches[0]).toEqual({ - vision: { model: "gpt-5.4-mini", backend: "openai", reasoning: "medium" }, + vision: { model: "gpt-5.6-terra", backend: "openai", reasoning: "medium" }, }); assertVisionControlFieldsOmitted(patches[0]!); diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts index df68ed2b07..76a364a980 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -1134,7 +1134,7 @@ async function main(argv: string[]): Promise { console.error("✗ polish --base-url must be https: or a loopback http: host (the API key must not travel in plaintext)"); process.exit(1); } - const model = args.get("model") ?? process.env.OPENAI_MODEL ?? "gpt-5.4"; + const model = args.get("model") ?? process.env.OPENAI_MODEL ?? "gpt-5.6-luna"; if (!(await Bun.file(inputPath).exists())) { console.error(`✗ polish input not found: ${inputPath}`); diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index b06ef38d15..a063602b59 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -144,8 +144,8 @@ function normalizeVisionConfig(config: OcxConfig): OcxConfig { const vision = config.visionSidecar; if (!vision || vision.reasoning === undefined) return config; // Keep CLI import/set semantics aligned with the execution path: an omitted or blank model means - // the bounded OpenAI vision default, gpt-5.4-mini, not the Dashboard's web-search default. - const model = vision.model || "gpt-5.4-mini"; + // the bounded OpenAI vision default, gpt-5.6-luna, not the Dashboard's web-search default. + const model = vision.model || "gpt-5.6-luna"; const normalized = normalizeVisionReasoningForModel(model, vision.reasoning); if (normalized === undefined) delete vision.reasoning; else vision.reasoning = normalized; diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index f54792b2ec..7aa0f006dc 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -59,9 +59,10 @@ export function nativeEffortClamp(slug: string, effort: string | undefined): str : []; if (levels.length === 0) { // Not snapshot-covered. gpt-5.6 natives have a REAL max rung (ensureGpt56ReasoningLevels - // restores it even off-snapshot) -> never clamp. Every other bare native (gpt-5.5/5.4/ - // 5.4-mini/5.3-codex-spark and future old-ladder slugs) really stops at xhigh — the - // ChatGPT backend error names exactly none..xhigh — so clamp the synthetic top tier. + // restores it even off-snapshot) -> never clamp. Every other bare native (gpt-5.5, + // 5.3-codex-spark, a retired slug a client still asks for, and future old-ladder slugs) + // really stops at xhigh — the ChatGPT backend error names exactly none..xhigh — so clamp + // the synthetic top tier. return isGpt56NativeSlug(slug) ? null : "xhigh"; } const supported = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []); diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index f239ce48b1..d3ebf109a0 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -120,8 +120,7 @@ export function isUnsupportedOpenAiNativeSlug(slug: string): boolean { * * This is an OPERATING CAP, not the hard ceiling — the same shape upstream uses. The live * catalog reports `context_window: 272000` against a `max_context_window: 872000` for these - * slugs, and gpt-5.4 runs 272,000 against 1,000,000: the advertised window is always well - * inside what the model can take. + * slugs: the advertised window is always well inside what the model can take. * * The hard ceiling here was measured on 2026-08-17 against a real Codex-login account: * `POST /backend-api/codex/responses` admitted 921,508 input tokens and refused 922,013 with @@ -162,7 +161,6 @@ const NATIVE_GPT56_FAMILY = new Set([ export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record = { "gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 }, - "gpt-5.4": { contextWindow: 1_000_000, maxContextWindow: 1_000_000 }, "gpt-5.3-codex-spark": { contextWindow: 100_000, maxContextWindow: 100_000 }, "gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, @@ -537,7 +535,7 @@ function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined { const sourceSlug = nativeOpenAiCapabilitySourceSlug(slug); // A self-described native returns its OWN pinned row; the alias-cloning branch below stays // reserved for slugs that genuinely borrow another model's identity. The allowlist is explicit - // rather than "has a pinned entry", which would also admit gpt-5.5/gpt-5.4/gpt-5.4-mini into + // rather than "has a pinned entry", which would also admit gpt-5.5/gpt-5.2/codex-auto-review into // the sync-replacement authority this map carries. if (!sourceSlug.startsWith("gpt-5.6-") && !SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(slug)) { return undefined; diff --git a/src/codex/catalog/native-models.ts b/src/codex/catalog/native-models.ts index 691849fbdd..fb14a64911 100644 --- a/src/codex/catalog/native-models.ts +++ b/src/codex/catalog/native-models.ts @@ -72,9 +72,11 @@ const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly> = Objec * * Membership authorizes `upstreamNativeEntryForSlug` to return the pinned entry directly. It is * an explicit list, not a structural `PINNED_UPSTREAM_MODELS.has(slug)` predicate: the pin also - * holds `gpt-5.5`, `gpt-5.4` and `gpt-5.4-mini`, and admitting those into + * holds `gpt-5.5`, `gpt-5.2` and `codex-auto-review`, and admitting those into * `UPSTREAM_NATIVE_ENTRIES` would newly authorize replacing their persisted catalog rows during - * sync — an invariant that map's own comment reserves for the GPT-5.6 family. + * sync — an invariant that map's own comment reserves for the GPT-5.6 family. The snapshot + * keeps rows this runtime does not expose, which is exactly why presence in the pin cannot be + * the predicate: `gpt-5.4` and `gpt-5.4-mini` are still pinned after their retirement. */ export const SELF_DESCRIBED_NATIVE_OPENAI_MODELS: ReadonlySet = new Set([ NATIVE_GPT6_ASTRA_MODEL, @@ -153,7 +155,7 @@ export function nativeOpenAiAliasPresentation(slug: string): { displayName: stri * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 §4-bis. */ export const NATIVE_OPENAI_MODELS = [ - "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", + "gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT6_ASTRA_MODEL, @@ -172,7 +174,7 @@ export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS); * flipped false — letting a drain silently rewrite the operator's configured subagent model. * * It is an explicit list rather than `SUPPORTED_NATIVE_OPENAI_SLUGS`, which would have widened - * the sentinel to `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini` and `gpt-5.3-codex-spark` as well. Those + * the sentinel to `gpt-5.5` and `gpt-5.3-codex-spark` as well. Those * models were never covered, and widening would turn "fell back and answered" into a * maintenance error for the most commonly configured fallback slug in the repo. Membership is * the set the drain behaviour was actually reasoned about: the account-gated natives plus the diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 6ddf6512e6..06747a8553 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -518,7 +518,8 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ } // providerContextCaps.openai is a ceiling for native OpenAI rows regardless of where the // advertised window came from (#1430): preserved rows without a hardcoded override (e.g. - // gpt-5.4-mini) must stay under the cap too, and auto-compaction follows the capped window. + // gpt-5.3-codex-spark) must stay under the cap too, and auto-compaction follows the capped + // window. // The per-model window narrows the same rows for the same reason. const currentContext = typeof entry.context_window === "number" ? entry.context_window : undefined; const cappedContext = narrowNativeMaxContextWindow(nativeSlug, currentContext, limits); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 074d9ddef0..2f756663ad 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -260,7 +260,7 @@ export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, con if (priority !== 9) clone.priority = priority; applyNativeOpenAiContextOverride(clone, contextCap); // GPT-5.6 natives keep their exact upstream ladders (e.g. luna has max but no ultra). - // Older natives (gpt-5.5 / 5.4 / 5.4-mini / 5.3-codex-spark) get mock max + ultra + // Older natives (gpt-5.5 / 5.3-codex-spark) get mock max + ultra // (wire-clamped to xhigh). Ultra is always advertised regardless of v2 toggle. if (!isGpt56NativeSlug(String(clone.slug ?? ""))) ensureUltraReasoningLevel(clone); return ensureStrictCatalogFields(normalizeServiceTiers(clone)); diff --git a/src/codex/warmup.ts b/src/codex/warmup.ts index 2fbd87d582..7be16980c6 100644 --- a/src/codex/warmup.ts +++ b/src/codex/warmup.ts @@ -27,8 +27,8 @@ export interface CodexWarmupOptions { } const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; -const DEFAULT_MODEL = "gpt-5.4-mini"; -const FALLBACK_MODELS = ["gpt-5.5", "gpt-5.6-luna"]; +const DEFAULT_MODEL = "gpt-5.6-luna"; +const FALLBACK_MODELS = ["gpt-5.5"]; const isRetryableWarmupStatus = (status?: number): boolean => status === 400 || status === 404; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_TIMEOUT_MS = 0x7fff_ffff; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index f98854cc3e..866496cd84 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -328,7 +328,7 @@ export const OAUTH_PROVIDERS: Record = { login: (ctrl, opts) => loginChatGPT(ctrl, { forceLogin: opts?.forceLogin, flow: opts?.flow }), refresh: (rt) => refreshChatGPTToken(rt), providerConfig: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, - defaultModel: "gpt-5.4", + defaultModel: "gpt-5.6-luna", }, }; diff --git a/src/oauth/token-guardian.ts b/src/oauth/token-guardian.ts index ff87c1f532..6815e83a3b 100644 --- a/src/oauth/token-guardian.ts +++ b/src/oauth/token-guardian.ts @@ -52,7 +52,7 @@ const DEFAULTS = { failureBackoffBaseSeconds: 300, failureBackoffMaxSeconds: 3600, codexWarmupMaxAgeSeconds: 691_200, // 8d — matches Codex managed-auth last_refresh cadence. - codexWarmupModel: "gpt-5.4-mini", + codexWarmupModel: "gpt-5.6-luna", }; interface BackoffEntry { diff --git a/src/server/index.ts b/src/server/index.ts index e1aa2be2bd..6c74f1e704 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -683,8 +683,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server= SIDECAR_MIGRATION_CUTOFF) { @@ -697,6 +699,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server; /** Hosted image_generation tool config stashed for the image bridge sidecar (see src/images). */ diff --git a/src/types/tools.ts b/src/types/tools.ts index fcc0689819..7a4cdc4ebb 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -13,7 +13,7 @@ export interface OcxTool { loadedFromToolSearch?: boolean; /** Cursor-only synthetic exact-match edit tool; never inferred from the wire name. */ cursorStructuredEdit?: true; - /** Synthetic web_search tool: the model's call is executed by the gpt-5.4-mini sidecar, not relayed to Codex. */ + /** Synthetic web_search tool: the model's call is executed by the gpt-5.6-luna sidecar, not relayed to Codex. */ webSearch?: boolean; /** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */ imageGeneration?: boolean; diff --git a/src/vision/plan.ts b/src/vision/plan.ts index 3cb0b2f3e2..408c88533d 100644 --- a/src/vision/plan.ts +++ b/src/vision/plan.ts @@ -11,7 +11,7 @@ import { resolveSidecarAuth } from "../sidecar/auth"; import { DEFAULT_VISION_TIMEOUT_MS, MAX_VISION_TIMEOUT_MS, MIN_VISION_TIMEOUT_MS } from "./timeout-bounds"; import { carriesImages } from "./image-rewrite"; -const DEFAULT_VISION_MODEL = "gpt-5.4-mini"; +const DEFAULT_VISION_MODEL = "gpt-5.6-luna"; const DEFAULT_ANTHROPIC_VISION_MODEL = "claude-sonnet-5"; const DEFAULT_REASONING: VisionReasoningEffort = "low"; export const DEFAULT_MAX_DESCRIPTIONS_PER_TURN = 8; diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index c6b35fbad8..e0bc5c856f 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -126,7 +126,7 @@ this document owns is which module holds which area and what invariant that area | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; it does not widen a Remote Hub management ingress to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | -| Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend (web-search union: openai/anthropic/xai/gemini/exa; xAI is live through stored Grok OAuth, while Gemini/Exa remain inert until their executors ship) plus validated `webSearch.xSearch`, optional `webSearch.exaApiKey` (write/clear only — never echoed by GET or the PUT response; redact.ts strips it from logs), `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Provider/OAuth credentials live in their stores; `exaApiKey` is the one sidecar-owned secret and follows the write-only contract above. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.4-mini` + `gpt-5.6-luna`), so no client hard-codes a helper slug that a Codex release can invalidate. | +| Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend (web-search union: openai/anthropic/xai/gemini/exa; xAI is live through stored Grok OAuth, while Gemini/Exa remain inert until their executors ship) plus validated `webSearch.xSearch`, optional `webSearch.exaApiKey` (write/clear only — never echoed by GET or the PUT response; redact.ts strips it from logs), `webSearch.reasoning`, `vision.reasoning`, `vision.enabled`, `vision.maxDescriptionsPerTurn`, and `vision.timeoutMs`; the read and PUT-response payload reports model, backend, reasoning, enabled, the vision per-turn limit, and timeout. `timeoutMs` is validated against the runtime integer bounds in `src/vision/timeout-bounds.ts`. Provider/OAuth credentials live in their stores; `exaApiKey` is the one sidecar-owned secret and follows the write-only contract above. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.6-luna`; the retired `gpt-5.4-mini` stays available as an explicit `sourceModels` entry for 0.144.x clients), so no client hard-codes a helper slug that a Codex release can invalidate. | | Storage | `src/server/management/logs-usage-routes.ts` — `GET /api/storage`, `POST /api/storage/cleanup/preview` and `/api/storage/cleanup`, `GET /api/storage/trash`, `POST /api/storage/trash/restore`, and `GET/PUT /api/storage/cleanup-policy` plus `POST /api/storage/cleanup-policy/run`. `GET /api/storage/cleanup-policy/test-stream` and `GET /api/storage/trash/restore/test-stream` exist for progress-stream testing. Cleanup takes an explicit `mode`: `quarantine` moves to trash and is restorable, `permanent` is not. The caller must name the mode — there is no default that silently deletes. | | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 31319b28de..dd5f58e345 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -49,7 +49,7 @@ Gemini and Exa remain inert until their executors ship. Selection differs per si | Sidecar | Backend selection | Default model | Activation | | --- | --- | --- | --- | | `web-search/` | Explicit configuration only: unset always resolves to the OpenAI forward path. No backend — Anthropic or otherwise — is auto-selected from credential availability (doing so once sent OpenAI model ids to the Anthropic API). Explicit xAI requires usable stored Grok OAuth and may add hosted `x_search`; explicit Gemini/Exa remain fail-closed until their executors land. | `gpt-5.6-luna` (OpenAI), `claude-sonnet-5` (Anthropic), `grok-4.6` (xAI) | Hosted `web_search` requested by a non-passthrough routed model. | -| `vision/` | Explicit configuration wins for both backends. Only an unset backend auto-selects: Anthropic when a usable Anthropic OAuth provider exists, otherwise the OpenAI forward authority. An explicitly selected backend whose authority is unavailable produces no plan rather than falling back. | `claude-sonnet-5` (Anthropic), `gpt-5.4-mini` (OpenAI) | Input contains images for a model listed in `noVisionModels`. | +| `vision/` | Explicit configuration wins for both backends. Only an unset backend auto-selects: Anthropic when a usable Anthropic OAuth provider exists, otherwise the OpenAI forward authority. An explicitly selected backend whose authority is unavailable produces no plan rather than falling back. | `claude-sonnet-5` (Anthropic), `gpt-5.6-luna` (OpenAI) | Input contains images for a model listed in `noVisionModels`. | The asymmetry is in the unset case only: vision may describe an image with whichever model can see it, while a hosted search tool is tied to a provider-specific tool contract, so search never infers diff --git a/tests/claude-integration/claude-context-windows.test.ts b/tests/claude-integration/claude-context-windows.test.ts index d411fce9aa..f22c418cb2 100644 --- a/tests/claude-integration/claude-context-windows.test.ts +++ b/tests/claude-integration/claude-context-windows.test.ts @@ -21,12 +21,15 @@ describe("claude context-window map (devlog 260712 B2)", () => { }); test("registers native slugs (bare + desktop alias + legacy alias)", () => { - const map = buildClaudeContextWindows(["gpt-5.6-sol", "gpt-5.4"], []); - // Authoritative native overrides: gpt-5.6 natives follow Codex 272k, gpt-5.4 native 1M. + const map = buildClaudeContextWindows(["gpt-5.6-sol", "gpt-5.3-codex-spark", "gpt-5.4"], []); + // Authoritative native overrides: gpt-5.6 natives follow Codex 272k, spark 100k. + // gpt-5.4 was the only 1M native override; that window is gone, so a retired + // slug passed here does not register. expect(map["gpt-5.6-sol"]).toBe(272_000); expect(map[desktop3pAlias("native", "gpt-5.6-sol")]).toBe(272_000); expect(map["claude-ocx-native--gpt-5.6-sol"]).toBe(272_000); - expect(map["gpt-5.4"]).toBe(1_000_000); + expect(map["gpt-5.3-codex-spark"]).toBe(100_000); + expect(map["gpt-5.4"]).toBeUndefined(); }); test("first-wins on alias collisions (registry policy)", () => { diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index a89f9c928b..3db1a4a627 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -739,7 +739,7 @@ describe("#3922 translated tools carry the source strict intent", () => { additionalProperties: false, }; const request = (tool: Record) => ({ - model: "openai/gpt-5.4", + model: "openai/gpt-5.6-luna", max_tokens: 32, messages: [{ role: "user", content: "Run a local agent." }], tools: [tool], @@ -795,7 +795,7 @@ describe("#3922 translated tools carry the source strict intent", () => { [agent({ strict: false }), false], ] as const) { const expectedSchema = structuredClone(tool.input_schema); - const parsed = parseRequest({ ...anthropicToResponsesBody(request(tool)), model: "gpt-5.4" }); + const parsed = parseRequest({ ...anthropicToResponsesBody(request(tool)), model: "gpt-5.6-luna" }); expect(parsed.context.tools?.[0]?.strict).toBe(expected); const outbound = await adapter.buildRequest(parsed); diff --git a/tests/claude-integration/claude-model-info.test.ts b/tests/claude-integration/claude-model-info.test.ts index 34a49be617..b8648a0a68 100644 --- a/tests/claude-integration/claude-model-info.test.ts +++ b/tests/claude-integration/claude-model-info.test.ts @@ -61,7 +61,7 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => }); test("native effective ladder only advertises clamp-identity rungs (audit R4#1)", () => { - for (const slug of ["gpt-5.5", "gpt-5.4", "gpt-5.6-sol"]) { + for (const slug of ["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.6-sol"]) { for (const rung of nativeEffectiveLadder(slug)) { expect(rung).not.toBe("ultra"); const clamped = nativeEffortClamp(slug, rung); @@ -96,13 +96,11 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => expect(String(lunaBase)).toBeDefined(); }); - test("[1m] variants cover 1M NATIVES too (audit R1#1) — and skip sub-1M natives", () => { - // gpt-5.4 is the only authoritative 1M native. gpt-5.6-sol advertises 922k — a cap held - // under its measured ceiling — so it stays out, and so does gpt-5.5 at 272k. - const infos = buildAnthropicModelInfos(["gpt-5.4", "gpt-5.6-sol", "gpt-5.5"], []); - const variants = infos.filter(i => i.id.endsWith("[1m]")); - expect(variants).toHaveLength(1); - expect(variants[0]!.display_name.includes("gpt-5.4")).toBe(true); + test("[1m] variants skip natives — none have a >=1M window after gpt-5.4 retirement", () => { + // gpt-5.4 was the only authoritative 1M native; that override is gone. gpt-5.6-sol + // advertises 922k under its measured ceiling, gpt-5.5 is 272k, spark is 100k. + const infos = buildAnthropicModelInfos(["gpt-5.6-sol", "gpt-5.5", "gpt-5.3-codex-spark"], []); + expect(infos.filter(i => i.id.endsWith("[1m]"))).toHaveLength(0); }); test("native OpenAI rows carry max_input_tokens so Claude Code skips the 200k fallback (#1218)", () => { @@ -146,17 +144,15 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => test("no [1m] rows for sub-1M models, even with auto-context enabled (#854 contract)", () => { const auto = { enabled: true, compactWindow: 350_000 }; - const infos = buildAnthropicModelInfos(["gpt-5.4", "gpt-5.5"], [ + const infos = buildAnthropicModelInfos(["gpt-5.5", "gpt-5.6-sol"], [ { provider: "mock", id: "small-model", contextWindow: 128_000 }, { provider: "mock", id: "mid-model", contextWindow: 300_000 }, // < compact window: unsafe, no row ], auto); const variants = infos.filter(i => i.id.endsWith("[1m]")); - // The [1m] marker makes Claude Code account 1e6 tokens: only the - // authoritative 1M model may carry it — never the 272K gpt-5.5 route. - expect(variants).toHaveLength(1); - expect(variants[0]!.display_name.includes("gpt-5.4")).toBe(true); - expect(variants[0]!.display_name.endsWith("· 1M")).toBe(true); - expect(variants[0]!.max_input_tokens).toBe(1_000_000); + // The [1m] marker makes Claude Code account 1e6 tokens. No surviving native + // is >=1M, and auto-context must not mint the marker for 272k natives or + // sub-compact-window mocks (#854). + expect(variants).toHaveLength(0); }); test("auto-context never widens anthropic passthrough rows (audit 021 #3)", () => { diff --git a/tests/claude-integration/claude-models-discovery.test.ts b/tests/claude-integration/claude-models-discovery.test.ts index 608a1ffa80..5c29fd7ef0 100644 --- a/tests/claude-integration/claude-models-discovery.test.ts +++ b/tests/claude-integration/claude-models-discovery.test.ts @@ -325,6 +325,10 @@ test("Codex discovery restores account rows for supported natives hidden on disk expect(listCatalogNativeSlugs()).toContain("gpt-5.5"); expect(listCatalogNativeSlugs()).not.toContain("gpt-99-internal"); expect(listCatalogNativeSlugs()).not.toContain("provider/gpt-5.5"); + // Retired slugs may still sit in a custom catalog or the upstream pin; membership + // does not follow either of those. + expect(listCatalogNativeSlugs()).not.toContain("gpt-5.4"); + expect(listCatalogNativeSlugs()).not.toContain("gpt-5.4-mini"); expect(visibleNativeSlugs(config)).toContain("gpt-5.5"); expect(visibleNativeSlugs({ ...config, disabledModels: ["gpt-5.5"] })).not.toContain("gpt-5.5"); @@ -332,6 +336,7 @@ test("Codex discovery restores account rows for supported natives hidden on disk try { const plain = await fetch(new URL("/v1/models", server.url)) .then(response => response.json()) as { data: Array<{ id: string }> }; + expect(plain.data.some(model => model.id === "gpt-5.4")).toBe(false); expect(plain.data.some(model => model.id === "gpt-5.4-mini")).toBe(false); const catalog = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)) @@ -344,10 +349,11 @@ test("Codex discovery restores account rows for supported natives hidden on disk } config.codexAccountNamespaces = { team: "@main" }; - config.disabledModels = ["gpt-5.4"]; + config.disabledModels = ["gpt-5.6-sol"]; saveConfig(config); resetCatalogRuntimeStateForTests(); expect(visibleNativeSlugs(config)).toContain("gpt-5.5"); + expect(visibleNativeSlugs(config)).not.toContain("gpt-5.6-sol"); expect(visibleNativeSlugs(config)).not.toContain("gpt-5.4"); server = startServer(0); try { @@ -362,10 +368,10 @@ test("Codex discovery restores account rows for supported natives hidden on disk expect(plain.data.some(model => model.id === "team/gpt-5.4")).toBe(false); // Activating account selectors makes both bare and qualified discovery mirror the complete // enabled supported set, even when a partial custom catalog omitted this native. - expect(plain.data.find(model => model.id === "gpt-5.4-mini")?.reasoning_efforts) + expect(plain.data.find(model => model.id === "gpt-5.6-luna")?.reasoning_efforts) .toBeArray(); - expect(plain.data.find(model => model.id === "team/gpt-5.4-mini")?.reasoning_efforts) - .toEqual(plain.data.find(model => model.id === "gpt-5.4-mini")?.reasoning_efforts); + expect(plain.data.find(model => model.id === "team/gpt-5.6-luna")?.reasoning_efforts) + .toEqual(plain.data.find(model => model.id === "gpt-5.6-luna")?.reasoning_efforts); const catalog = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)) .then(response => response.json()) as { @@ -380,9 +386,11 @@ test("Codex discovery restores account rows for supported natives hidden on disk visibility: "list", opencodex_catalog_kind: "account-selector-v1", }); - expect(catalog.models.find(model => model.slug === "team/gpt-5.4")?.visibility) + expect(catalog.models.find(model => model.slug === "team/gpt-5.6-sol")?.visibility) .toBe("hide"); - expect(catalog.models.find(model => model.slug === "team/gpt-5.4-mini")?.visibility) + expect(catalog.models.find(model => model.slug === "team/gpt-5.4")?.visibility) + .toBeUndefined(); + expect(catalog.models.find(model => model.slug === "team/gpt-5.6-luna")?.visibility) .toBe("list"); } finally { await server.stop(true); diff --git a/tests/clients/desktop-3p.test.ts b/tests/clients/desktop-3p.test.ts index 912c2c18af..cc116cb837 100644 --- a/tests/clients/desktop-3p.test.ts +++ b/tests/clients/desktop-3p.test.ts @@ -212,13 +212,21 @@ describe("Claude Desktop 3P models", () => { }); test("an openai context cap reaches the Desktop writer, not just the dashboard", () => { - // gpt-5.4 is the authoritative 1M native, so it earns supports1m. Capping the provider - // at 272k has to take that away here too, or the written Desktop config promises a - // window the proxy will not serve (#854's effective-window contract). - const uncapped = generateDesktop3pModels(["gpt-5.4"], []); - expect(uncapped[0]).toMatchObject({ supports1m: true, prefer1m: true }); + // No surviving native advertises a 1M window (gpt-5.4 was the last). Sol's + // opt-in ceiling is 922k, so even a 1M provider cap must not invent + // supports1m — nativeOpenAiContextWindow clamps it under the threshold. + // A 272k cap has to take the same path, or the written Desktop config + // would promise a window the proxy will not serve (#854's effective-window + // contract). + const uncapped = generateDesktop3pModels(["gpt-5.6-sol"], []); + expect(uncapped[0]!.supports1m).toBeUndefined(); + expect(uncapped[0]!.prefer1m).toBeUndefined(); - const capped = generateDesktop3pModels(["gpt-5.4"], [], undefined, 272_000); + const optedIn = generateDesktop3pModels(["gpt-5.6-sol"], [], undefined, 1_000_000); + expect(optedIn[0]!.supports1m).toBeUndefined(); + expect(optedIn[0]!.prefer1m).toBeUndefined(); + + const capped = generateDesktop3pModels(["gpt-5.6-sol"], [], undefined, 272_000); expect(capped[0]!.supports1m).toBeUndefined(); expect(capped[0]!.prefer1m).toBeUndefined(); }); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 2956c2135e..580f6d15bd 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -1755,7 +1755,7 @@ describe("Codex auth context", () => { }); // Spark owns a separate quota, so Terra can use the same account. - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); @@ -1763,12 +1763,12 @@ describe("Codex auth context", () => { recordCodexUpstreamOutcome(cfg, "pool-a", 429, { now, resetAt, - modelId: "gpt-5.4", + modelId: "gpt-5.6-terra", }); // Terra and Luna stay in the shared native quota group, while Spark keeps // its independent cooldown instead of being overwritten by Terra's 429. - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4-mini" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-luna" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); @@ -1780,7 +1780,7 @@ describe("Codex auth context", () => { retryAfter: "60", modelId: "gpt-5.3-codex-spark", }); - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); } finally { Date.now = originalNow; @@ -1813,7 +1813,7 @@ describe("Codex auth context", () => { Date.now = () => now; // Establish the shared-scope binding first. The Spark fallback below must // create a second binding rather than replacing this one. - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); recordCodexUpstreamOutcome(cfg, "pool-a", 429, { @@ -1825,7 +1825,7 @@ describe("Codex auth context", () => { await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); expect(cfg.activeCodexAccountId).toBe("pool-a"); - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); // This second Spark request proves routing retained the peer choice for // the Spark affinity instead of relying on an auth-layer substitution. @@ -1858,7 +1858,7 @@ describe("Codex auth context", () => { recordCodexUpstreamOutcome(cfg, "pool-a", 429, { now, resetAt, - modelId: "gpt-5.4", + modelId: "gpt-5.6-terra", }); const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS; @@ -1881,7 +1881,7 @@ describe("Codex auth context", () => { Date.now = () => probeAt + 1; await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4-mini" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-luna" })) .resolves.toMatchObject({ kind: "pool", probeQuotaScope: "shared" }); } finally { Date.now = originalNow; diff --git a/tests/codex-integration/codex-catalog-golden.test.ts b/tests/codex-integration/codex-catalog-golden.test.ts index 35eeb82bbc..2219440790 100644 --- a/tests/codex-integration/codex-catalog-golden.test.ts +++ b/tests/codex-integration/codex-catalog-golden.test.ts @@ -38,7 +38,7 @@ describe("codex-catalog golden (pure buildCatalogEntries oracle)", () => { const entries = buildCatalogEntries( template() as unknown as Parameters[0], - ["gpt-5.5", "gpt-5.4"], + ["gpt-5.5", "gpt-5.3-codex-spark"], goModels, ["gpt-5.5", "kiro/claude-opus-4.6"], false, @@ -73,7 +73,7 @@ describe("codex-catalog golden (pure buildCatalogEntries oracle)", () => { // Full structural snapshot (the oracle): exact slug set + priority + ws projection. expect(projection.map(p => `${p.slug}@${p.priority}`).sort()).toEqual([ - "gpt-5.4@9", + "gpt-5.3-codex-spark@9", "gpt-5.5@0", "kiro/claude-opus-4.6@1", "opencode-go/glm-5.2@5", diff --git a/tests/codex-integration/codex-catalog-model-picker-order.test.ts b/tests/codex-integration/codex-catalog-model-picker-order.test.ts index 1bc57c76cc..2d1fb6cc73 100644 --- a/tests/codex-integration/codex-catalog-model-picker-order.test.ts +++ b/tests/codex-integration/codex-catalog-model-picker-order.test.ts @@ -150,10 +150,10 @@ describe("modelPickerOrder (#1649)", () => { test("the builder leaves a bare native row unchanged before the complete-order pass", () => { const entries = buildCatalogEntriesFromObservedState({ template: template() as never, - gptSlugs: ["gpt-5.5", "gpt-5.4"], + gptSlugs: ["gpt-5.5", "gpt-5.3-codex-spark"], goModels: [{ id: "glm-5.2", provider: "jd-chat", owned_by: "jd" }] as unknown as CatalogModel[], featured: [], - modelPickerOrder: ["gpt-5.4", "jd-chat/glm-5.2"], + modelPickerOrder: ["gpt-5.3-codex-spark", "jd-chat/glm-5.2"], wsEnabled: false, multiAgentMode: "default", exactComboSlugs: new Set(), @@ -164,7 +164,7 @@ describe("modelPickerOrder (#1649)", () => { }); const p = Object.fromEntries((entries as Record[]).map(e => [e.slug as string, e.priority as number])); // The native row keeps its native priority (9), untouched by modelPickerOrder. - expect(p["gpt-5.4"]).toBe(9); + expect(p["gpt-5.3-codex-spark"]).toBe(9); // The routed row IS placed in the high picker tier. expect(p["jd-chat/glm-5.2"]).toBeGreaterThanOrEqual(1000); }); diff --git a/tests/codex-integration/codex-catalog-restore.test.ts b/tests/codex-integration/codex-catalog-restore.test.ts index c268a55d19..ee215b823b 100644 --- a/tests/codex-integration/codex-catalog-restore.test.ts +++ b/tests/codex-integration/codex-catalog-restore.test.ts @@ -113,12 +113,12 @@ describe("Codex catalog restore", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ - disabledModels: ["gpt-5.4", "desktop/gpt-5.5"], + disabledModels: ["gpt-5.6-luna", "desktop/gpt-5.5"], }), "utf8"); writeFileSync(catalogPath, JSON.stringify({ models: [ { slug: "gpt-5.5", visibility: "hide", priority: 7 }, - { slug: "gpt-5.4", visibility: "hide" }, + { slug: "gpt-5.6-luna", visibility: "hide" }, { slug: "gpt-5.3-codex-spark", visibility: "hide" }, { slug: "user-native", visibility: "hide" }, { @@ -132,7 +132,7 @@ describe("Codex catalog restore", () => { opencodex_catalog_kind: "account-selector-v1", }, { - slug: "team/gpt-5.4", + slug: "team/gpt-5.6-luna", visibility: "list", opencodex_catalog_kind: "account-selector-v1", }, @@ -158,7 +158,7 @@ describe("Codex catalog restore", () => { visibility: "list", priority: 7, }); - expect(restored.find(model => model.slug === "gpt-5.4")?.visibility).toBe("hide"); + expect(restored.find(model => model.slug === "gpt-5.6-luna")?.visibility).toBe("hide"); expect(restored.find(model => model.slug === "gpt-5.3-codex-spark")?.visibility).toBe("hide"); expect(restored.find(model => model.slug === "user-native")?.visibility).toBe("hide"); expect(restored.some(model => String(model.slug).includes("/"))).toBe(false); @@ -198,15 +198,15 @@ describe("Codex catalog restore", () => { const backupPath = backupPathForTestCatalog(codexHome, opencodexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(backupPath, JSON.stringify({ - models: [{ slug: "gpt-5.4", visibility: "hide", priority: 50 }], + models: [{ slug: "gpt-5.6-luna", visibility: "hide", priority: 50 }], }, null, 2) + "\n"); writeFileSync(catalogPath, JSON.stringify({ models: [ - { slug: "gpt-5.4", visibility: "hide", priority: 0 }, + { slug: "gpt-5.6-luna", visibility: "hide", priority: 0 }, { slug: "gpt-5.5", visibility: "hide", priority: 7 }, { slug: "gpt-5.3-codex-spark", visibility: "hide" }, { - slug: "team/gpt-5.4", + slug: "team/gpt-5.6-luna", visibility: "list", opencodex_catalog_kind: "account-selector-v1", }, @@ -237,7 +237,7 @@ describe("Codex catalog restore", () => { expect(JSON.parse(r.stdout)).toMatchObject({ removed: 4, kept: 3 }); const restored = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; expect(restored).toEqual([ - { slug: "gpt-5.4", visibility: "hide", priority: 50 }, + { slug: "gpt-5.6-luna", visibility: "hide", priority: 50 }, { slug: "gpt-5.5", visibility: "list", priority: 7 }, { slug: "gpt-5.3-codex-spark", visibility: "hide" }, ]); @@ -310,7 +310,7 @@ describe("Codex catalog restore", () => { writeFileSync(catalogPath, JSON.stringify({ models: [ { slug: "gpt-5.5", priority: 50, base_instructions: "native", visibility: "list" }, - { slug: "gpt-5.4", priority: 0, base_instructions: "native", visibility: "list" }, + { slug: "gpt-5.3-codex-spark", priority: 0, base_instructions: "native", visibility: "list" }, ], }, null, 2) + "\n"); @@ -333,7 +333,7 @@ describe("Codex catalog restore", () => { expect(JSON.parse(r.stdout)).toMatchObject({ added: 0 }); const synced = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; expect(synced.find(m => m.slug === "gpt-5.5")?.priority).toBe(0); - expect(synced.find(m => m.slug === "gpt-5.4")?.priority).toBeGreaterThan(100); + expect(synced.find(m => m.slug === "gpt-5.3-codex-spark")?.priority).toBeGreaterThan(100); }, { timeout: 15_000 }); test("sync advertises documented Codex-native additions omitted by the bundled catalog", () => { @@ -383,7 +383,7 @@ describe("Codex catalog restore", () => { port: 10100, providers: {}, defaultProvider: "openai", - subagentModels: ["gpt-5.5", "gpt-5.4", "gpt-5.3-codex-spark", "gpt-5.6-sol"], + subagentModels: ["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.6-sol"], }); console.log(JSON.stringify(result)); })(); @@ -395,6 +395,8 @@ describe("Codex catalog restore", () => { expect(synced.map(m => m.slug)).toContain("gpt-5.6-sol"); expect(synced.map(m => m.slug)).toContain("gpt-5.6-terra"); expect(synced.map(m => m.slug)).toContain("gpt-5.6-luna"); - expect(synced.find(m => m.slug === "gpt-5.4")?.max_context_window).toBe(1_000_000); + // gpt-5.4 is no longer a native catalog member, and no surviving native has a 1M window. + expect(synced.map(m => m.slug)).not.toContain("gpt-5.4"); + expect(synced.find(m => m.slug === "gpt-5.3-codex-spark")?.max_context_window).toBe(100_000); }, { timeout: 15_000 }); }); diff --git a/tests/codex-integration/codex-catalog-sync-hardening.test.ts b/tests/codex-integration/codex-catalog-sync-hardening.test.ts index 21c60d5835..2521067764 100644 --- a/tests/codex-integration/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-integration/codex-catalog-sync-hardening.test.ts @@ -115,8 +115,8 @@ describe("Codex catalog sync hardening", () => { writeFileSync(catalogPath, JSON.stringify({ models: [ nativeEntry("gpt-5.5", 0), - nativeEntry("gpt-5.4", 1), - nativeEntry("gpt-5.4-mini", 2), + nativeEntry("gpt-5.4", 1), // retired -> drop + nativeEntry("gpt-5.4-mini", 2), // retired -> drop nativeEntry("gpt-5.3-codex-spark", 3), nativeEntry("gpt-5.6-sol", 4), nativeEntry("gpt-5.6-terra", 5), @@ -136,8 +136,10 @@ describe("Codex catalog sync hardening", () => { const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); expect(slugs).toContain("gpt-5.5"); - expect(slugs).toContain("gpt-5.4"); - expect(slugs).toContain("gpt-5.4-mini"); + // Retired from NATIVE_OPENAI_MODELS. A pinned upstream snapshot row is not catalog + // membership, so these drop with the other unsupported gpt-/codex- natives. + expect(slugs).not.toContain("gpt-5.4"); + expect(slugs).not.toContain("gpt-5.4-mini"); expect(slugs).toContain("gpt-5.3-codex-spark"); // This isolated fixture has no authenticated ChatGPT roster. The flagship natives list // anyway (owner decision 2026-09-04): asking upstream under an adequate client version @@ -272,13 +274,13 @@ describe("Codex catalog sync hardening", () => { auto_compact_token_limit: 115_200, }, { - ...nativeEntry("gpt-5.4", 1), - comp_hash: "native-5.4-hash", - base_instructions: "Native 5.4 instructions", - model_messages: { instructions_template: "Native 5.4 instructions" }, + ...nativeEntry("gpt-5.3-codex-spark", 1), + comp_hash: "native-spark-hash", + base_instructions: "Native spark instructions", + model_messages: { instructions_template: "Native spark instructions" }, tool_mode: "code_mode_only", }, - nativeEntry("gpt-5.4-mini", 2), + nativeEntry("gpt-5.6-luna", 2), routedEntry("vendor/stable-model", 5), { ...routedEntry("foreign/gpt-5.5", 6), description: "Foreign provider description" }, { @@ -370,10 +372,10 @@ describe("Codex catalog sync hardening", () => { expect(team?.description).toBe(bare?.description); expect(rows.filter(row => row.slug === "team/gpt-5.5")).toHaveLength(1); for (const selector of ["desktop", "team"]) { - expect(rows.some(row => row.slug === `${selector}/gpt-5.4`)).toBe(true); - expect(rows.some(row => row.slug === `${selector}/gpt-5.4-mini`)).toBe(true); + expect(rows.some(row => row.slug === `${selector}/gpt-5.3-codex-spark`)).toBe(true); + expect(rows.some(row => row.slug === `${selector}/gpt-5.6-luna`)).toBe(true); } - for (const nativeSlug of ["gpt-5.5", "gpt-5.4"]) { + for (const nativeSlug of ["gpt-5.5", "gpt-5.3-codex-spark"]) { const native = rows.find(row => row.slug === nativeSlug); const qualified = rows.find(row => row.slug === `team/${nativeSlug}`); expect(qualified).toMatchObject({ @@ -726,7 +728,7 @@ describe("Codex catalog sync hardening", () => { writeFileSync(catalogPath, JSON.stringify({ models: [ { ...nativeEntry("gpt-5.5", 0), visibility: "hide" }, - nativeEntry("gpt-5.4", 1), + nativeEntry("gpt-5.3-codex-spark", 1), ], }, null, 2) + "\n"); @@ -740,7 +742,7 @@ describe("Codex catalog sync hardening", () => { liveModels: false } }, - disabledModels: ["gpt-5.4", "team/gpt-5.5"], + disabledModels: ["gpt-5.3-codex-spark", "team/gpt-5.5"], codexAccounts: [{ id: "stored-side-account", isMain: false }], codexAccountNamespaces: { desktop: "@main", team: "stored-side-account" } }).then(res => console.log(JSON.stringify(res))); @@ -762,7 +764,7 @@ describe("Codex catalog sync hardening", () => { visibility: "list", opencodex_catalog_kind: "account-selector-v1", }); - expect(rows.find(row => row.slug === "team/gpt-5.4")?.visibility).toBe("hide"); + expect(rows.find(row => row.slug === "team/gpt-5.3-codex-spark")?.visibility).toBe("hide"); }); test("default catalog path merges from disk instead of replacing it with bundled rows", () => { diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 7c92d0af5d..cc94f48085 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -3456,18 +3456,28 @@ describe("Codex catalog routed normalization", () => { expect(routed?.auto_compact_token_limit).toBe(115_200); }); - test("native gpt-5.4 uses its 1M context window override", () => { + test("retired gpt-5.4 no longer has a 1M native context override", () => { + expect(NATIVE_OPENAI_MODELS).not.toContain("gpt-5.4"); + expect(NATIVE_OPENAI_MODELS).not.toContain("gpt-5.4-mini"); + expect(nativeOpenAiContextWindow("gpt-5.4")).toBeUndefined(); + expect(nativeOpenAiContextWindow("gpt-5.4-mini")).toBeUndefined(); + + // gpt-5.4 was the only native 1M override. Nothing replaces it: remaining + // natives keep their own windows even when cloned from a 1M template or + // given a 2M cap large enough to raise a long-window family. const template = { ...nativeTemplate(), context_window: 272_000, max_context_window: 1_000_000, }; - const entries = buildCatalogEntries(template, ["gpt-5.4"], []); - const native = entries.find(e => e.slug === "gpt-5.4"); - - expect(native?.context_window).toBe(1_000_000); - expect(native?.max_context_window).toBe(1_000_000); - expect(native?.auto_compact_token_limit).toBe(900_000); + const entries = buildCatalogEntries(template, [...NATIVE_OPENAI_MODELS], []); + for (const slug of NATIVE_OPENAI_MODELS) { + const native = entries.find(e => e.slug === slug); + expect(native?.context_window).toBeDefined(); + expect(native!.context_window as number).toBeLessThan(1_000_000); + expect(native!.max_context_window as number).toBeLessThan(1_000_000); + expect(nativeOpenAiContextWindow(slug, 2_000_000)).toBeLessThan(1_000_000); + } }); test("native gpt-5.3-codex-spark uses its 100k context window instead of inherited codex max", () => { @@ -3637,11 +3647,14 @@ describe("Codex catalog routed normalization", () => { expect(luna?.auto_compact_token_limit).toBe(244_800); }); - test("preserved gpt-5.4-mini rows get the openai cap without a hardcoded override (#1430)", () => { + test("preserved old-ladder native rows get the openai cap; retired gpt-5.4-mini is dropped (#1430)", () => { const cap = 200_000; const template = nativeTemplate(); - // gpt-5.4-mini has no NATIVE_OPENAI_CONTEXT_OVERRIDES entry; its windows come - // from the preserved disk row and must still be capped on merge. + // gpt-5.4-mini is no longer a supported native, so merge drops it + // (CANONICAL_NATIVE_CATALOG_CONTENT_POLICY.unsupportedNativeEntries = "drop"). + // The #1430 cap still applies to a preserved old-ladder native without a + // long-window opt-in: gpt-5.5's hardcoded override is 272k/272k, so a 200k + // cap must still win. const genuine54Mini = { ...template, slug: "gpt-5.4-mini", @@ -3650,8 +3663,16 @@ describe("Codex catalog routed normalization", () => { max_context_window: 272_000, auto_compact_token_limit: 244_800, }; + const genuine55 = { + ...template, + slug: "gpt-5.5", + display_name: "GPT-5.5", + context_window: 272_000, + max_context_window: 272_000, + auto_compact_token_limit: 244_800, + }; const merged = mergeCatalogEntriesForSync( - [genuine54Mini], + [genuine54Mini, genuine55], [], new Map(), [], @@ -3669,10 +3690,11 @@ describe("Codex catalog routed normalization", () => { new Set(), cap, ); - const mini = merged.find(e => e.slug === "gpt-5.4-mini"); - expect(mini?.context_window).toBe(cap); - expect(mini?.max_context_window).toBe(cap); - expect(mini?.auto_compact_token_limit).toBe(180_000); + expect(merged.find(e => e.slug === "gpt-5.4-mini")).toBeUndefined(); + const gpt55 = merged.find(e => e.slug === "gpt-5.5"); + expect(gpt55?.context_window).toBe(cap); + expect(gpt55?.max_context_window).toBe(cap); + expect(gpt55?.auto_compact_token_limit).toBe(180_000); }); test("nativeOpenAiContextWindow applies the openai cap as a ceiling only when provided", () => { @@ -3682,8 +3704,11 @@ describe("Codex catalog routed normalization", () => { expect(nativeOpenAiContextWindow("gpt-5.6-sol", 500_000)).toBe(500_000); // A cap ABOVE the native value is a ceiling, not a floor. expect(nativeOpenAiContextWindow("gpt-5.6-sol", 2_000_000)).toBe(922_000); - // Non-5.6 natives are capped the same way. - expect(nativeOpenAiContextWindow("gpt-5.4", 272_000)).toBe(272_000); + // Non-5.6 natives have no long-window opt-in: a cap may only lower. + expect(nativeOpenAiContextWindow("gpt-5.5", 200_000)).toBe(200_000); + expect(nativeOpenAiContextWindow("gpt-5.5", 2_000_000)).toBe(272_000); + // The retired 1M native is gone; a cap cannot invent a window for it. + expect(nativeOpenAiContextWindow("gpt-5.4", 272_000)).toBeUndefined(); }); // Owner decision (devlog 260816_.../011 §4-bis): Daybreak Blue is now a GLOBALLY @@ -4335,41 +4360,43 @@ describe("Codex catalog routed normalization", () => { base_instructions: "installed native instructions", genuine_marker: "installed-native", }; - const nativeMini = { + // The second native is a surviving slug: a retired one would be dropped as an + // unsupported native before this test could say anything about adoption. + const nativeSpark = { ...nativeTemplate(), - slug: "gpt-5.4-mini", - display_name: "gpt-5.4-mini", + slug: "gpt-5.3-codex-spark", + display_name: "gpt-5.3-codex-spark", priority: 6, }; const routedCursorRows = buildCatalogEntries(nativeTemplate(), [], [ { provider: "cursor", id: "gpt-5.5", owned_by: "cursor" }, - { provider: "cursor", id: "gpt-5.4-mini", owned_by: "cursor" }, + { provider: "cursor", id: "gpt-5.3-codex-spark", owned_by: "cursor" }, ]); const merged = mergeCatalogEntriesForSync( - [native, nativeMini, { slug: "cursor/old", visibility: "list" }], + [native, nativeSpark, { slug: "cursor/old", visibility: "list" }], routedCursorRows, new Map([ ["gpt-5.5", 9], - ["gpt-5.4-mini", 10], + ["gpt-5.3-codex-spark", 10], ]), [], false, - new Set(["gpt-5.5", "gpt-5.4-mini"]), + new Set(["gpt-5.5", "gpt-5.3-codex-spark"]), ); const slugs = merged.map(entry => entry.slug); expect(slugs).toContain("gpt-5.5"); - expect(slugs).toContain("gpt-5.4-mini"); + expect(slugs).toContain("gpt-5.3-codex-spark"); expect(slugs).toContain("cursor/gpt-5.5"); - expect(slugs).toContain("cursor/gpt-5.4-mini"); + expect(slugs).toContain("cursor/gpt-5.3-codex-spark"); expect(slugs).not.toContain("cursor/old"); expect(merged.find(entry => entry.slug === "gpt-5.5")?.priority).toBe(9); expect(merged.find(entry => entry.slug === "gpt-5.5")?.base_instructions) .toBe("installed native instructions"); expect(merged.find(entry => entry.slug === "gpt-5.5")?.genuine_marker) .toBe("installed-native"); - expect(merged.find(entry => entry.slug === "gpt-5.4-mini")?.priority).toBe(10); + expect(merged.find(entry => entry.slug === "gpt-5.3-codex-spark")?.priority).toBe(10); }); test("buildCatalogEntries advertises supports_websockets only on explicit opt-in", () => { @@ -6951,7 +6978,7 @@ describe("native slug allowlist", () => { ]; expect(filterSupportedNativeSlugs(liveModels)).toEqual([ - "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", + "gpt-5.5", "gpt-5.3-codex-spark", ]); }); diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index ceb34ef6a2..1586978e46 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -140,13 +140,13 @@ function generatedRoutedEntry(slug: string, marker?: string): RawEntry { } function nativeMetadataEntry( - slug: "gpt-5.5" | "gpt-5.4", + slug: "gpt-5.5" | "gpt-5.6-luna", baseInstructions: string, priority: number, ): RawEntry { return { slug, - display_name: slug === "gpt-5.5" ? "GPT-5.5 Live" : "GPT-5.4 Live", + display_name: slug === "gpt-5.5" ? "GPT-5.5 Live" : "GPT-5.6-Luna Live", description: `${slug} installed metadata`, priority, visibility: "list", @@ -201,7 +201,7 @@ function writeAutoReviewModel(value?: string): void { function autoReviewSeed(routeOverride: string | null = "stale-override"): RawEntry[] { return [ - { ...nativeEntry(), slug: "gpt-5.4", auto_review_model_override: "native-upstream" }, + { ...nativeEntry(), slug: "gpt-5.5", auto_review_model_override: "native-upstream" }, { ...generatedRoutedEntry("static/deepseek-v4-flash"), auto_review_model_override: routeOverride, @@ -693,7 +693,7 @@ test("retained and convergence writers resolve, clear, reject, and recover auto- writeAutoReviewModel(); writeCatalog(autoReviewSeed()); catalog = await write(autoReviewConfig(["deepseek-v4-flash"])); - expect(catalog.models?.find(entry => entry.slug === "gpt-5.4")) + expect(catalog.models?.find(entry => entry.slug === "gpt-5.5")) .toHaveProperty("auto_review_model_override", "native-upstream"); expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")) .toHaveProperty("auto_review_model_override", null); @@ -994,19 +994,19 @@ test("generated account rows silently win freshly gathered provider collisions", test("qualified rows retain the matching installed metadata for each native model", async () => { const gpt55Instructions = "Installed instructions unique to GPT-5.5."; - const gpt54Instructions = "Installed instructions unique to GPT-5.4."; + const lunaInstructions = "Installed instructions unique to GPT-5.6-Luna."; writeCatalog([ nativeMetadataEntry("gpt-5.5", gpt55Instructions, 3), - nativeMetadataEntry("gpt-5.4", gpt54Instructions, 4), + nativeMetadataEntry("gpt-5.6-luna", lunaInstructions, 4), ]); const catalog = await convergeCatalog(config(true)); const models = catalog.models ?? []; expect(models.find(entry => entry.slug === "gpt-5.5")?.base_instructions).toBe(gpt55Instructions); - expect(models.find(entry => entry.slug === "gpt-5.4")?.base_instructions).toBe(gpt54Instructions); + expect(models.find(entry => entry.slug === "gpt-5.6-luna")?.base_instructions).toBe(lunaInstructions); expect(models.find(entry => entry.slug === "team/gpt-5.5")?.base_instructions).toBe(gpt55Instructions); - expect(models.find(entry => entry.slug === "team/gpt-5.4")?.base_instructions).toBe(gpt54Instructions); + expect(models.find(entry => entry.slug === "team/gpt-5.6-luna")?.base_instructions).toBe(lunaInstructions); }); test("a missing supported native is backfilled and restored when the picker is disabled", async () => { diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index c03d03eb8c..ee70d4829e 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -23,11 +23,11 @@ const poolAuthContext = { function minimalParsed(): OcxParsedRequest { return { - modelId: "gpt-5.4", + modelId: "gpt-5.6-luna", context: { messages: [] }, stream: false, options: {}, - _rawBody: { model: "gpt-5.4", input: [] }, + _rawBody: { model: "gpt-5.6-luna", input: [] }, }; } @@ -184,7 +184,7 @@ describe("Codex request transport metadata", () => { test("canonical adapter forwards Lite through selected auth and derives the final wire tier/model", async () => { const parsed = minimalParsed(); - parsed.modelId = "gpt-5.4"; + parsed.modelId = "gpt-5.6-luna"; parsed._rawBody = { model: "gpt-5.6-sol", input: [], service_tier: "flex" }; parsed.options.tierDecision = { kind: "set", value: "priority" }; const before = JSON.stringify(parsed._rawBody); @@ -298,7 +298,7 @@ describe("Codex request transport metadata", () => { for (const lite of [undefined, "yes", "1", "TRUE", "true, false"]) { const headers = new Headers({ "openai-beta": "responses_websockets=existing" }); if (lite !== undefined) headers.set(liteHeader, lite); - const prepared = prepareCodexWsRequest(url, { headers, body: JSON.stringify({ model: "gpt-5.4", + const prepared = prepareCodexWsRequest(url, { headers, body: JSON.stringify({ model: "gpt-5.6-luna", client_metadata: { [liteKey]: "false", thread_id: "thread-fixture" }, stream: true, }) })!; expect(JSON.parse(prepared.frameText).client_metadata).toEqual({ [liteKey]: "false", thread_id: "thread-fixture" }); @@ -306,10 +306,10 @@ describe("Codex request transport metadata", () => { expect(new Headers(prepared.headers).has("originator")).toBe(false); expect(new Headers(prepared.headers).has("user-agent")).toBe(false); } - const absent = prepareCodexWsRequest(url, { body: '{"model":"gpt-5.4","stream":true}' })!; + const absent = prepareCodexWsRequest(url, { body: '{"model":"gpt-5.6-luna","stream":true}' })!; expect(JSON.parse(absent.frameText).client_metadata).toBeUndefined(); const explicit = prepareCodexWsRequest(url, { - headers: { [liteHeader]: "true" }, body: '{"model":"gpt-5.4","stream":true}', + headers: { [liteHeader]: "true" }, body: '{"model":"gpt-5.6-luna","stream":true}', })!; expect(JSON.parse(explicit.frameText).client_metadata).toEqual({ [liteKey]: "true" }); }); @@ -336,7 +336,7 @@ describe("Codex request transport metadata", () => { expect(prepareCodexWsRequest(url, { body })).toBeNull(); } for (const client_metadata of [null, [], true, 1, "text", { unrelated: false }, { [liteKey]: true }]) { - const init = { body: JSON.stringify({ model: "gpt-5.4", client_metadata }), headers: { [liteHeader]: "true" } }; + const init = { body: JSON.stringify({ model: "gpt-5.6-luna", client_metadata }), headers: { [liteHeader]: "true" } }; const before = JSON.stringify(init); expect(prepareCodexWsRequest(url, init)).toBeNull(); expect(JSON.stringify(init)).toBe(before); @@ -347,8 +347,8 @@ describe("Codex request transport metadata", () => { const { applyCodexRoutingHint } = await import("../../src/codex/forward-transport-headers"); const invalid = ["", " ", "model;service_tier=priority", "model=tier", "a b", "a\t", "a\n", "a\r", "a\0", "a\x7f", "é", null, 42]; for (const body of [null, [], "text", {}, ...invalid.map(model => ({ model })), - ...invalid.map(service_tier => ({ model: "gpt-5.4", service_tier })), - { model: "m".repeat(257) }, { model: "gpt-5.4", service_tier: "t".repeat(65) }]) { + ...invalid.map(service_tier => ({ model: "gpt-5.6-luna", service_tier })), + { model: "m".repeat(257) }, { model: "gpt-5.6-luna", service_tier: "t".repeat(65) }]) { const headers = new Headers({ [hintHeader]: "model=stale;service_tier=priority", originator: "unchanged" }); const before = JSON.stringify(body); applyCodexRoutingHint(headers, body); @@ -359,7 +359,7 @@ describe("Codex request transport metadata", () => { const headers = new Headers(); applyCodexRoutingHint(headers, { model: "m".repeat(256), service_tier: "t".repeat(64) }); expect(headers.get(hintHeader)).toBe(`model=${"m".repeat(256)};tier=${"t".repeat(64)}`); - applyCodexRoutingHint(headers, { model: "gpt-5.4" }); - expect(headers.get(hintHeader)).toBe("model=gpt-5.4"); + applyCodexRoutingHint(headers, { model: "gpt-5.6-luna" }); + expect(headers.get(hintHeader)).toBe("model=gpt-5.6-luna"); }); }); diff --git a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts index 88080d5a4d..204436b3a8 100644 --- a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts @@ -287,7 +287,7 @@ describe("quota auto-refresh native-main admission", () => { }); await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); expect(calls).toEqual([responsesUrl, responsesUrl]); - expect(models).toEqual(["gpt-5.4-mini", "gpt-5.5"]); + expect(models).toEqual(["gpt-5.6-luna", "gpt-5.5"]); expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(RESET_MILLISECONDS); expect(getNativeMainProfileRequestCount()).toBe(0); }); diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts index 39ec9a342c..7b9ce035ae 100644 --- a/tests/codex-integration/codex-v2-gate.test.ts +++ b/tests/codex-integration/codex-v2-gate.test.ts @@ -1890,8 +1890,8 @@ describe("3-state multi-agent mode", () => { }]; const accountBoundEntries = [{ ...template(), - slug: "team/gpt-5.4", - display_name: "team / GPT-5.4", + slug: "team/gpt-5.6-luna", + display_name: "team / GPT-5.6 Luna", opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, service_tier: "fast", }]; diff --git a/tests/codex-integration/codex-warmup.test.ts b/tests/codex-integration/codex-warmup.test.ts index d186fb7221..9339920918 100644 --- a/tests/codex-integration/codex-warmup.test.ts +++ b/tests/codex-integration/codex-warmup.test.ts @@ -35,7 +35,7 @@ describe("codex warmup", () => { expect(requests).toBe(1); }); - test("posts a minimal gpt-5.4-mini Responses stream request and accepts response.completed", async () => { + test("posts a minimal gpt-5.6-luna Responses stream request and accepts response.completed", async () => { let body: Record | undefined; let auth: string | null = null; let account: string | null = null; @@ -52,7 +52,7 @@ describe("codex warmup", () => { expect(auth).toBe("Bearer access-test"); expect(account).toBe("acct-test"); expect(body).toMatchObject({ - model: "gpt-5.4-mini", + model: "gpt-5.6-luna", instructions: "Reply with OK.", input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], stream: true, diff --git a/tests/codex-integration/effort-policy.test.ts b/tests/codex-integration/effort-policy.test.ts index 2f1e65c10c..0d3ac76018 100644 --- a/tests/codex-integration/effort-policy.test.ts +++ b/tests/codex-integration/effort-policy.test.ts @@ -278,7 +278,7 @@ describe("supportedLadderFor (real routeModel routes)", () => { tempCodexHome = mkdtempSync(join(tmpdir(), "ocx-effort-catalog-")); process.env.CODEX_HOME = tempCodexHome; writeFileSync(join(tempCodexHome, "opencodex-catalog.json"), JSON.stringify({ - models: [{ slug: "gpt-5.4", display_name: "gpt-5.4", supported_reasoning_levels: [ + models: [{ slug: "gpt-5.5", display_name: "gpt-5.5", supported_reasoning_levels: [ { effort: "low", description: "low" }, { effort: "medium", description: "medium" }, ] }], })); @@ -286,13 +286,13 @@ describe("supportedLadderFor (real routeModel routes)", () => { providers: { selfhosted: { adapter: "openai-responses", baseUrl: "https://example.com/v1", authMode: "key", - apiKey: "k", models: ["gpt-5.4"], + apiKey: "k", models: ["gpt-5.5"], }, }, defaultProvider: "selfhosted", } as Partial); - expect(() => routeModel(config, "gpt-5.4")).toThrow(NoEnabledOpenAiProviderError); - const namespaced = routeModel(config, "selfhosted/gpt-5.4"); + expect(() => routeModel(config, "gpt-5.5")).toThrow(NoEnabledOpenAiProviderError); + const namespaced = routeModel(config, "selfhosted/gpt-5.5"); expect(namespaced.providerName).toBe("selfhosted"); expect(supportedLadderFor(namespaced)).toBeUndefined(); }); @@ -301,7 +301,7 @@ describe("supportedLadderFor (real routeModel routes)", () => { tempCodexHome = mkdtempSync(join(tmpdir(), "ocx-effort-catalog-")); process.env.CODEX_HOME = tempCodexHome; writeFileSync(join(tempCodexHome, "opencodex-catalog.json"), JSON.stringify({ - models: [{ slug: "gpt-5.4", display_name: "gpt-5.4", supported_reasoning_levels: [ + models: [{ slug: "gpt-5.5", display_name: "gpt-5.5", supported_reasoning_levels: [ { effort: "low", description: "low" }, { effort: "medium", description: "medium" }, { effort: "high", description: "high" }, { effort: "xhigh", description: "xhigh" }, ] }], @@ -312,7 +312,7 @@ describe("supportedLadderFor (real routeModel routes)", () => { }, defaultProvider: "openai", } as Partial); - const route = routeModel(config, "gpt-5.4"); + const route = routeModel(config, "gpt-5.5"); expect(supportedLadderFor(route)).toEqual(["low", "medium", "high", "xhigh"]); }); }); @@ -432,11 +432,11 @@ describe("cap composition with downstream clamps", () => { }); test("synthetic native top rung is still lowered by nativeEffortClamp after the cap block", () => { - // gpt-5.4's real ladder stops at xhigh: an uncapped (or xhigh-capped) max/ultra + // gpt-5.5's real ladder stops at xhigh: an uncapped (or xhigh-capped) max/ultra // arrival is repaired by the native clamp that runs AFTER applyEffortCap. - expect(nativeEffortClamp("gpt-5.4", "max")).toBe("xhigh"); - expect(nativeEffortClamp("gpt-5.4", "ultra")).toBe("xhigh"); - expect(nativeEffortClamp("gpt-5.4", "medium")).toBeNull(); + expect(nativeEffortClamp("gpt-5.5", "max")).toBe("xhigh"); + expect(nativeEffortClamp("gpt-5.5", "ultra")).toBe("xhigh"); + expect(nativeEffortClamp("gpt-5.5", "medium")).toBeNull(); }); }); diff --git a/tests/codex-integration/model-visibility-management-api.test.ts b/tests/codex-integration/model-visibility-management-api.test.ts index e11ebc2428..9fef0adefe 100644 --- a/tests/codex-integration/model-visibility-management-api.test.ts +++ b/tests/codex-integration/model-visibility-management-api.test.ts @@ -373,12 +373,12 @@ test("configured manual OpenAI rows can be toggled alongside native rows", async const config = loadConfig(); config.providers.openai = {adapter:"openai-responses",authMode:"forward",baseUrl:"https://chatgpt.com/backend-api/codex",liveModels:false}; config.customModels = [{id:"manual-gpt",provider:"openai",modelId:"gpt-5.5",contextWindow:128_000}]; - config.disabledModels = ["openai/gpt-5.5", "gpt-5.4"]; + config.disabledModels = ["openai/gpt-5.5", "gpt-5.6-luna"]; expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"gpt-5.5",native:false}],enabled:true},config)).status).toBe(200); - expect(config.disabledModels).toEqual(["gpt-5.4"]); - expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"gpt-5.5",native:false},{id:"gpt-5.4",native:true}],enabled:false},config)).status).toBe(200); + expect(config.disabledModels).toEqual(["gpt-5.6-luna"]); + expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"gpt-5.5",native:false},{id:"gpt-5.6-luna",native:true}],enabled:false},config)).status).toBe(200); expect(config.disabledModels).toContain("openai/gpt-5.5"); - expect(config.disabledModels).toContain("gpt-5.4"); + expect(config.disabledModels).toContain("gpt-5.6-luna"); expect((await putWithConfig({scope:"models",provider:"openai",targets:[{id:"not-configured",native:false}],enabled:true},config)).status).toBe(400); }); @@ -391,15 +391,15 @@ test("provider-group toggles persist mixed native and manual OpenAI targets toge config.customModels = [{ id: "manual-gpt", provider: "openai", modelId: "gpt-5.5" }]; const unrelatedDisabled = [...config.disabledModels!]; const unrelatedProvider = structuredClone(config.providers["google-antigravity"]); - const targets = [{ id: "gpt-5.5", native: false }, { id: "gpt-5.4", native: true }]; + const targets = [{ id: "gpt-5.5", native: false }, { id: "gpt-5.6-luna", native: true }]; saveConfig(config); const disabled = await putWithConfig({ scope: "provider", provider: "openai", targets, enabled: false }, config); expect(disabled.status).toBe(200); expect(await disabled.json()).toMatchObject({ ok: true, scope: "provider", provider: "openai", enabled: false }); - expect(config.disabledModels).toEqual([...unrelatedDisabled, "openai/gpt-5.5", "gpt-5.4"]); + expect(config.disabledModels).toEqual([...unrelatedDisabled, "openai/gpt-5.5", "gpt-5.6-luna"]); expect(config.providers.openai.selectedModels).toEqual(["gpt-5.5"]); - expect(loadConfig().disabledModels).toEqual([...unrelatedDisabled, "openai/gpt-5.5", "gpt-5.4"]); + expect(loadConfig().disabledModels).toEqual([...unrelatedDisabled, "openai/gpt-5.5", "gpt-5.6-luna"]); expect(loadConfig().providers.openai.selectedModels).toEqual(["gpt-5.5"]); expect(loadConfig().providers["google-antigravity"]).toEqual(unrelatedProvider); expect(refreshes).toBe(1); @@ -426,14 +426,14 @@ test("an invalid trailing target leaves a mixed OpenAI provider-group update ato for (const enabled of [false, true]) { // Both valid targets would change state before the final invalid target is reached. - config.disabledModels = enabled ? ["other/keep", "openai/gpt-5.5", "gpt-5.4"] : ["other/keep"]; + config.disabledModels = enabled ? ["other/keep", "openai/gpt-5.5", "gpt-5.6-luna"] : ["other/keep"]; saveConfig(config); const before = structuredClone(config); const persistedBefore = loadConfig(); for (const invalid of [{ id: "not-configured", native: false }, { id: "gpt-9.9-imaginary", native: true }]) { const response = await putWithConfig({ scope: "provider", provider: "openai", enabled, - targets: [{ id: "gpt-5.5", native: false }, { id: "gpt-5.4", native: true }, invalid], + targets: [{ id: "gpt-5.5", native: false }, { id: "gpt-5.6-luna", native: true }, invalid], }, config); expect(response.status).toBe(400); expect(await response.json()).toMatchObject({ error: "invalid model visibility target" }); diff --git a/tests/codex-integration/native-model-toggle.test.ts b/tests/codex-integration/native-model-toggle.test.ts index 0ac18f1ad2..7e90a6f05c 100644 --- a/tests/codex-integration/native-model-toggle.test.ts +++ b/tests/codex-integration/native-model-toggle.test.ts @@ -68,8 +68,8 @@ function nativeTemplate(): Record { describe("native GPT model toggles (bare slugs in disabledModels)", () => { test("disabledNativeSlugs picks bare ids only; routed namespaced ids are ignored", () => { - const set = disabledNativeSlugs({ disabledModels: ["gpt-5.4", "kiro/claude-opus-4.6", "gpt-5.6-luna"] }); - expect([...set].sort()).toEqual(["gpt-5.4", "gpt-5.6-luna"]); + const set = disabledNativeSlugs({ disabledModels: ["gpt-5.5", "kiro/claude-opus-4.6", "gpt-5.6-luna"] }); + expect([...set].sort()).toEqual(["gpt-5.5", "gpt-5.6-luna"]); }); test("visibleNativeSlugs omits disabled natives from the bare availability list", () => { @@ -230,8 +230,12 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { }); test("the on-disk catalog preserves a lower retained native compaction threshold", () => { + // A retired slug is no longer a valid subject: nativeOpenAiAutoCompactTokenLimit + // requires a known native window, so a configured lowering would not apply to + // gpt-5.4-mini after its override and membership were removed. gpt-5.5 is the + // surviving old-ladder native whose 272k window matches this retained row. const retained = { - slug: "gpt-5.4-mini", + slug: "gpt-5.5", context_window: 272_000, max_context_window: 272_000, auto_compact_token_limit: 100_000, @@ -240,7 +244,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(retained.auto_compact_token_limit).toBe(100_000); const configured = { - providers: { openai: { modelAutoCompactTokenLimits: { "gpt-5.4-mini": 80_000 } } }, + providers: { openai: { modelAutoCompactTokenLimits: { "gpt-5.5": 80_000 } } }, } as never; const lowered = { ...retained }; applyNativeOpenAiContextOverride(lowered as never, nativeContextLimits(configured)); @@ -296,7 +300,10 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { const over = nativeModelRows({ providerContextCaps: { openai: 2_000_000 } }); expect(over.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(922_000); expect(raised.find(r => r.slug === "gpt-5.5")?.contextWindow).toBe(272_000); - expect(raised.find(r => r.slug === "gpt-5.4")?.contextWindow).toBe(922_000); + // gpt-5.4 was the only native with a 1M override. Retirement deleted that + // membership and the override; nothing else inherits a 1M window. + expect(raised.find(r => r.slug === "gpt-5.4")).toBeUndefined(); + expect(raised.every(r => (r.contextWindow ?? 0) <= 922_000)).toBe(true); }); test("nativeModelRows applies providerContextCaps.openai as a ceiling (#1430)", () => { diff --git a/tests/codex-integration/token-guardian.test.ts b/tests/codex-integration/token-guardian.test.ts index f007211340..3bb215d7dc 100644 --- a/tests/codex-integration/token-guardian.test.ts +++ b/tests/codex-integration/token-guardian.test.ts @@ -250,7 +250,7 @@ describe("token guardian", () => { expect(res.refreshed).toEqual([]); expect(res.warmed).toContain("codex:acct-warm"); - expect(mock.body()).toMatchObject({ model: "gpt-5.4-mini", input: WARMUP_INPUT, stream: true, store: false }); + expect(mock.body()).toMatchObject({ model: "gpt-5.6-luna", input: WARMUP_INPUT, stream: true, store: false }); expect(readCodexAccountRecord("acct-warm")?.lastCodexValidationStatus).toBe("ok"); expect(readCodexAccountRecord("acct-warm")?.lastCodexValidatedAt).toBeGreaterThan(Date.now() - 30_000); }); diff --git a/tests/codex-integration/warmup.test.ts b/tests/codex-integration/warmup.test.ts index 5590e62ae1..1c25005860 100644 --- a/tests/codex-integration/warmup.test.ts +++ b/tests/codex-integration/warmup.test.ts @@ -94,7 +94,7 @@ describe("codex warmup improvements", () => { const body = JSON.parse(String(init?.body)) as Record; parsedBodies.push(body); - if (body.model === "gpt-5.4-mini") { + if (body.model === "gpt-5.6-luna") { return new Response(JSON.stringify({ detail: "unknown model" }), { status: 400 }); } @@ -110,23 +110,18 @@ describe("codex warmup improvements", () => { } expect(fetchMock).toHaveBeenCalledTimes(2); - expect(parsedBodies.map(body => body.model)).toEqual(["gpt-5.4-mini", "gpt-5.5"]); + expect(parsedBodies.map(body => body.model)).toEqual(["gpt-5.6-luna", "gpt-5.5"]); }); - test("warmCodexAccount retries FALLBACK_MODELS on HTTP 404 and falls through to gpt-5.6-luna", async () => { + test("warmCodexAccount retries FALLBACK_MODELS on HTTP 404", async () => { const parsedBodies: Record[] = []; const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { const body = JSON.parse(String(init?.body)) as Record; parsedBodies.push(body); - if (body.model === "gpt-5.4-mini") { - return new Response(JSON.stringify({ detail: "model not found" }), { status: 404 }); - } - if (body.model === "gpt-5.5") { - return new Response(JSON.stringify({ detail: "model not supported for free tier" }), { status: 400 }); - } if (body.model === "gpt-5.6-luna") { - return sseResponse(); + return new Response(JSON.stringify({ detail: "model not found" }), { status: 404 }); } + if (body.model === "gpt-5.5") return sseResponse(); return new Response("unexpected model", { status: 500 }); }); const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch); @@ -137,8 +132,8 @@ describe("codex warmup improvements", () => { fetchSpy.mockRestore(); } - expect(fetchMock).toHaveBeenCalledTimes(3); - expect(parsedBodies.map(body => body.model)).toEqual(["gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna"]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(parsedBodies.map(body => body.model)).toEqual(["gpt-5.6-luna", "gpt-5.5"]); }); test("warmCodexAccount does not retry on 401 and immediately fails", async () => { diff --git a/tests/providers/cyber-policy-error-fidelity.test.ts b/tests/providers/cyber-policy-error-fidelity.test.ts index f949634b3c..bff86a295a 100644 --- a/tests/providers/cyber-policy-error-fidelity.test.ts +++ b/tests/providers/cyber-policy-error-fidelity.test.ts @@ -273,7 +273,7 @@ describe("cyber_policy error fidelity", () => { }); expect(events.find(e => e.type === "error")).toMatchObject({ errorType: "invalid_request" }); - const frames = await collectSse(bridgeToResponsesSSE(replay(events), "openai/gpt-5.4")); + const frames = await collectSse(bridgeToResponsesSSE(replay(events), "openai/gpt-5.6-luna")); const failed = frames.find(frame => frame.event === "response.failed")?.data.response as Record; expect(failed.error).toMatchObject({ type: "invalid_request", @@ -287,7 +287,7 @@ describe("cyber_policy error fidelity", () => { test("message-only cyber adapter error still classifies (no silent 502)", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "error", message: SECRET_CYBER_MESSAGE, retryable: true }, - ]), "openai/gpt-5.4")); + ]), "openai/gpt-5.6-luna")); const failed = frames.find(frame => frame.event === "response.failed")?.data.response as Record; expect(failed.error).toMatchObject({ type: CYBER_POLICY_ERROR_CODE, @@ -299,7 +299,7 @@ describe("cyber_policy error fidelity", () => { const buffered = buildResponseJSON([ { type: "error", message: SECRET_CYBER_MESSAGE, retryable: true }, - ], "openai/gpt-5.4"); + ], "openai/gpt-5.6-luna"); expect(buffered).toMatchObject({ status: "failed", retryable: false, @@ -311,7 +311,7 @@ describe("cyber_policy error fidelity", () => { async function* throwingEvents(): AsyncGenerator { throw new Error(SECRET_CYBER_MESSAGE); } - const frames = await collectSse(bridgeToResponsesSSE(throwingEvents(), "openai/gpt-5.4")); + const frames = await collectSse(bridgeToResponsesSSE(throwingEvents(), "openai/gpt-5.6-luna")); const failed = frames.find(frame => frame.event === "response.failed")?.data.response as Record; expect(failed).toMatchObject({ status: "failed", @@ -386,7 +386,7 @@ describe("cyber_policy error fidelity", () => { controller.close(); }, }), - "gpt-5.4", + "gpt-5.6-luna", ); const frames = await collectSse(chatSse); const errorFrame = frames.find(frame => frame.data.error); diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index 1edfe9284f..bf3552d8c0 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -127,9 +127,9 @@ describe("ocx opencode provider block", () => { }); test("native slugs pick up authoritative context windows from the resolver", () => { - const block = buildOpencodeProviderBlock(10100, ["gpt-5.4", "unknown-native"], [], slug => - slug === "gpt-5.4" ? 1_000_000 : undefined); - expect(block.models["gpt-5.4"]?.limit).toEqual({ context: 1_000_000, output: SCHEMA_REQUIRED_OUTPUT_BUDGET }); + const block = buildOpencodeProviderBlock(10100, ["gpt-5.6-luna", "unknown-native"], [], slug => + slug === "gpt-5.6-luna" ? 1_000_000 : undefined); + expect(block.models["gpt-5.6-luna"]?.limit).toEqual({ context: 1_000_000, output: SCHEMA_REQUIRED_OUTPUT_BUDGET }); expect(block.models["unknown-native"]?.limit).toBeUndefined(); }); diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index 38f0368d54..6f649c52d3 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -1412,7 +1412,7 @@ describe("server combo failover 030 activation matrix", () => { authMode: "forward", codexAccountMode: "pool", }, - }, [{ provider: "openai", model: "gpt-5.4" }]); + }, [{ provider: "openai", model: "gpt-5.6-luna" }]); config.codexAccounts = [{ id: rawAccountId, email: "pool@example.test", @@ -1427,7 +1427,7 @@ describe("server combo failover 030 activation matrix", () => { expiresAt: Date.now() + 300_000, chatgptAccountId: "acct-pool-safe", }); - customTransientResponse = async () => Response.json(responsesSuccess("pool success", "gpt-5.4")); + customTransientResponse = async () => Response.json(responsesSuccess("pool success", "gpt-5.6-luna")); const response = await postLogged(config); expect(response.status).toBe(200); @@ -1459,7 +1459,7 @@ describe("server combo failover 030 activation matrix", () => { authMode: "forward", codexAccountMode: "pool", }, - }, [{ provider: "openai", model: "gpt-5.4" }]); + }, [{ provider: "openai", model: "gpt-5.6-luna" }]); config.codexAccounts = [{ id: rawAccountId, email: "combo-terminal@example.test", @@ -1504,7 +1504,7 @@ describe("server combo failover 030 activation matrix", () => { }, }, [ { provider: "openai", model: "gpt-5.3-codex-spark" }, - { provider: "openai", model: "gpt-5.4" }, + { provider: "openai", model: "gpt-5.6-luna" }, ]); config.codexAccounts = [{ id: rawAccountId, @@ -1531,7 +1531,7 @@ describe("server combo failover 030 activation matrix", () => { headers: { "x-codex-primary-reset-at": String(Math.floor(Date.now() / 1000) + 3600) }, }, ) - : Response.json(responsesSuccess("model fallback succeeded", "gpt-5.4")); + : Response.json(responsesSuccess("model fallback succeeded", "gpt-5.6-luna")); }; const response = await postLogged(config); @@ -1552,7 +1552,7 @@ describe("server combo failover 030 activation matrix", () => { }, }, [ { provider: "openai", model: "gpt-5.3-codex-spark" }, - { provider: "openai", model: "gpt-5.4" }, + { provider: "openai", model: "gpt-5.6-luna" }, ]); config.codexAccounts = [{ id: rawAccountId, @@ -1582,7 +1582,7 @@ describe("server combo failover 030 activation matrix", () => { }, }, ) - : Response.json(responsesSuccess("must not reach second upstream", "gpt-5.4")); + : Response.json(responsesSuccess("must not reach second upstream", "gpt-5.6-luna")); }; const response = await postLogged(config); @@ -3801,7 +3801,7 @@ describe("combo compact failover", () => { return chatStream("compact backup"); }); const { config } = canonicalPoolConfig([ - { provider: "openai-apikey", model: "gpt-5.4" }, + { provider: "openai-apikey", model: "gpt-5.6-luna" }, { provider: "backup", model: "m1" }, ], baseUrl(b)); globalThis.fetch = (async (input: unknown, init?: RequestInit) => { @@ -3857,7 +3857,7 @@ describe("combo compact failover", () => { test("combo compact runs the synthetic turn as SSE so a canonical child can serve it", async () => { const bodies: Array> = []; - const { config } = canonicalPoolConfig([{ provider: "openai-apikey", model: "gpt-5.4" }]); + const { config } = canonicalPoolConfig([{ provider: "openai-apikey", model: "gpt-5.6-luna" }]); globalThis.fetch = (async (input: unknown, init?: RequestInit) => { const url = typeof input === "object" && input !== null && "url" in input ? String((input as Request).url) @@ -3908,7 +3908,7 @@ describe("combo compact failover", () => { }); test("native compact rejects an empty ciphertext item", async () => { - const { config } = canonicalPoolConfig([{ provider: "openai-apikey", model: "gpt-5.4" }]); + const { config } = canonicalPoolConfig([{ provider: "openai-apikey", model: "gpt-5.6-luna" }]); globalThis.fetch = (async (input: unknown, init?: RequestInit) => { const url = typeof input === "object" && input !== null && "url" in input ? String((input as Request).url) diff --git a/tests/vision/sidecar-abort.test.ts b/tests/vision/sidecar-abort.test.ts index 348de2a69a..9e2c35ebdf 100644 --- a/tests/vision/sidecar-abort.test.ts +++ b/tests/vision/sidecar-abort.test.ts @@ -161,7 +161,7 @@ describe("sidecar abort propagation", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, abortSignal: turn.signal, }); @@ -189,7 +189,7 @@ describe("sidecar abort propagation", () => { { type: "web_search" }, forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, turn.signal, value => recorded.push(value), ); @@ -211,7 +211,7 @@ describe("sidecar abort propagation", () => { { type: "web_search" }, forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, undefined, outcome => recorded.push(outcome), ); @@ -230,7 +230,7 @@ describe("sidecar abort propagation", () => { { type: "web_search" }, forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, lateAbort.signal, outcome => recorded.push(outcome), ); @@ -246,7 +246,7 @@ describe("sidecar abort propagation", () => { { type: "web_search" }, forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, ); expect(outcome.error).toBe("sidecar HTTP 401: upstream echoed Bearer [REDACTED]"); }); @@ -288,7 +288,7 @@ describe("sidecar abort propagation", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, recordSidecarOutcome: outcome => recorded.push(outcome), }); @@ -314,7 +314,7 @@ describe("sidecar abort propagation", () => { "inspect screenshot", forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", timeoutMs: 30_000 }, turn.signal, value => recorded.push(value), ); @@ -336,7 +336,7 @@ describe("sidecar abort propagation", () => { { type: "web_search" }, forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, webTurn.signal, value => webRecorded.push(value), ); @@ -358,7 +358,7 @@ describe("sidecar abort propagation", () => { "inspect screenshot", forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", timeoutMs: 30_000 }, visionTurn.signal, value => visionRecorded.push(value), ); @@ -382,7 +382,7 @@ describe("sidecar abort propagation", () => { { type: "web_search" }, forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, webTurn.signal, value => webRecorded.push(value), ); @@ -402,7 +402,7 @@ describe("sidecar abort propagation", () => { "inspect screenshot", forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", timeoutMs: 30_000 }, visionTurn.signal, value => visionRecorded.push(value), ); @@ -424,7 +424,7 @@ describe("sidecar abort propagation", () => { "inspect screenshot", forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", timeoutMs: 30_000 }, turn.signal, value => recorded.push(value), ); @@ -443,7 +443,7 @@ describe("sidecar abort propagation", () => { { type: "web_search" }, forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, undefined, value => webRecorded.push(value), ); @@ -458,7 +458,7 @@ describe("sidecar abort propagation", () => { "inspect screenshot", forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", timeoutMs: 30_000 }, undefined, value => visionRecorded.push(value), ); @@ -476,7 +476,7 @@ describe("sidecar abort propagation", () => { "inspect screenshot", forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", timeoutMs: 30_000 }, undefined, outcome => recorded.push(outcome), ); @@ -496,7 +496,7 @@ describe("sidecar abort propagation", () => { "inspect screenshot", forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", timeoutMs: 30_000 }, lateAbort.signal, outcome => recorded.push(outcome), ); @@ -513,7 +513,7 @@ describe("sidecar abort propagation", () => { { type: "web_search" }, forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 1 }, + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 1 }, undefined, outcome => webRecorded.push(outcome), ); @@ -528,7 +528,7 @@ describe("sidecar abort propagation", () => { "inspect screenshot", forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", timeoutMs: 1 }, + { model: "gpt-5.6-luna", timeoutMs: 1 }, undefined, outcome => visionRecorded.push(outcome), ); @@ -544,7 +544,7 @@ describe("sidecar abort propagation", () => { "inspect screenshot", forwardProvider, new Headers({ authorization: "Bearer token" }), - { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", timeoutMs: 30_000 }, ); expect(outcome.error).toBe("vision sidecar HTTP 403: upstream echoed Bearer [REDACTED]"); }); @@ -568,7 +568,7 @@ describe("sidecar abort propagation", () => { { type: "web_search" }, forwardProvider, selectedHeaders, - { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, ); expect(seenAuthorization).toBe("Bearer pool-token"); @@ -595,7 +595,7 @@ describe("sidecar abort propagation", () => { "inspect screenshot", forwardProvider, selectedHeaders, - { model: "gpt-5.4-mini", timeoutMs: 30_000 }, + { model: "gpt-5.6-luna", timeoutMs: 30_000 }, ); expect(seenAuthorization).toBe("Bearer pool-token"); diff --git a/tests/vision/sidecar-settings-vision-controls.test.ts b/tests/vision/sidecar-settings-vision-controls.test.ts index a57b646f78..1ac5670d06 100644 --- a/tests/vision/sidecar-settings-vision-controls.test.ts +++ b/tests/vision/sidecar-settings-vision-controls.test.ts @@ -86,7 +86,7 @@ describe("sidecar-settings remaining vision controls", () => { expect(unset.status).toBe(200); expect((await unset.json() as { vision: Record }).vision).toMatchObject({ enabled: true, - model: "gpt-5.4-mini", + model: "gpt-5.6-luna", reasoning: "low", maxDescriptionsPerTurn: resolveMaxDescriptionsPerTurn(undefined), timeoutMs: DEFAULT_VISION_TIMEOUT_MS, diff --git a/tests/vision/sidecar-settings-vision-filter.test.ts b/tests/vision/sidecar-settings-vision-filter.test.ts index 0582f0cff0..948ab49585 100644 --- a/tests/vision/sidecar-settings-vision-filter.test.ts +++ b/tests/vision/sidecar-settings-vision-filter.test.ts @@ -134,7 +134,7 @@ describe("sidecar-settings vision model filter", () => { const body = await response.json() as { vision: { model: string } }; // Empty string clears the override; the effective reported model is the fallback. expect(config.visionSidecar?.model).toBeUndefined(); - expect(body.vision.model).toBe("gpt-5.4-mini"); + expect(body.vision.model).toBe("gpt-5.6-luna"); }); test("6. catalog failure degrades to baselines", async () => { @@ -186,7 +186,7 @@ describe("sidecar-settings vision model filter", () => { test("9. GET reports the effective Anthropic default for an explicitly selected backend", async () => { // Reports what the runtime WOULD use for this backend. No OAuth account is set up // here, so no plan would run; the point is that the projection stops answering - // gpt-5.4-mini for a configuration the OpenAI describer does not own. + // gpt-5.6-luna for a configuration the OpenAI describer does not own. const config = emptyConfig({ visionSidecar: { backend: "anthropic" } }); const response = await getSidecarSettings(config); expect(response.status).toBe(200); diff --git a/tests/vision/vision-anthropic.test.ts b/tests/vision/vision-anthropic.test.ts index f956cf6a4d..a42a238db9 100644 --- a/tests/vision/vision-anthropic.test.ts +++ b/tests/vision/vision-anthropic.test.ts @@ -416,7 +416,7 @@ describe("Anthropic vision planning and management config", () => { expect(clearBody.webSearch).toEqual({ enabled: true, model: "gpt-5.6-luna", streamRoutedModelOutput: false }); expect(clearBody.vision).toEqual({ enabled: true, - model: "gpt-5.4-mini", + model: "gpt-5.6-luna", reasoning: "low", maxDescriptionsPerTurn: 4, timeoutMs: 45_000, diff --git a/tests/vision/vision-eligibility.test.ts b/tests/vision/vision-eligibility.test.ts index 2344fc4d69..25fdc63d42 100644 --- a/tests/vision/vision-eligibility.test.ts +++ b/tests/vision/vision-eligibility.test.ts @@ -222,7 +222,7 @@ describe("vision eligibility core", () => { test("14. a non-native row's explicit text-only modality wins over a colliding native slug", () => { expect(modelAcceptsImageInput(emptyConfig, { provider: "custom-openai-compatible", - id: "gpt-5.4-mini", + id: "gpt-5.6-luna", inputModalities: ["text"], })).toBe(false); }); diff --git a/tests/vision/vision-reasoning-contract.test.ts b/tests/vision/vision-reasoning-contract.test.ts index 38d8378996..b55a4008b4 100644 --- a/tests/vision/vision-reasoning-contract.test.ts +++ b/tests/vision/vision-reasoning-contract.test.ts @@ -68,7 +68,7 @@ describe("vision reasoning capability contracts", () => { | { reasoningEfforts?: string[] } | undefined)?.reasoningEfforts; - expect(efforts("gpt-5.4-mini")).toEqual(["low", "medium", "high", "xhigh"]); + expect(efforts("gpt-5.5")).toEqual(["low", "medium", "high", "xhigh"]); expect(efforts("gpt-5.6-luna")).toEqual(["low", "medium", "high", "xhigh", "max"]); expect(efforts("gpt-5.6-sol")).toEqual(["low", "medium", "high", "xhigh", "max"]); expect(efforts("gpt-5.6-sol")).not.toContain("ultra"); @@ -85,7 +85,7 @@ describe("vision reasoning capability contracts", () => { const response = await getVision(config); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ - vision: { model: "gpt-5.4-mini", reasoning: "xhigh" }, + vision: { model: "gpt-5.6-luna", reasoning: "max" }, }); // Reads report effective execution state without mutating a hand-edited config in memory. expect(config.visionSidecar?.reasoning).toBe("max"); @@ -99,10 +99,10 @@ describe("vision reasoning capability contracts", () => { try { const direct = { port: 10100, defaultProvider: "none", providers: {} } as OcxConfig; - let response = await putVision(direct, { model: "gpt-5.4-mini", reasoning: "max" }); + let response = await putVision(direct, { model: "gpt-5.5", reasoning: "max" }); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ - vision: { model: "gpt-5.4-mini", reasoning: "xhigh" }, + vision: { model: "gpt-5.5", reasoning: "xhigh" }, }); expect(direct.visionSidecar?.reasoning).toBe("xhigh"); @@ -110,7 +110,7 @@ describe("vision reasoning capability contracts", () => { port: 10100, defaultProvider: "none", providers: {}, - visionSidecar: { model: "gpt-5.4-mini", reasoning: "low" }, + visionSidecar: { model: "gpt-5.5", reasoning: "low" }, } as OcxConfig; response = await putVision(reasoningOnly, { reasoning: "max" }); expect(response.status).toBe(200); @@ -125,10 +125,10 @@ describe("vision reasoning capability contracts", () => { response = await putVision(unsetModel, { reasoning: "max" }); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ - vision: { model: "gpt-5.4-mini", reasoning: "xhigh" }, + vision: { model: "gpt-5.6-luna", reasoning: "max" }, }); expect(unsetModel.visionSidecar?.model).toBeUndefined(); - expect(unsetModel.visionSidecar?.reasoning).toBe("xhigh"); + expect(unsetModel.visionSidecar?.reasoning).toBe("max"); const modelOnly = { port: 10100, @@ -136,23 +136,23 @@ describe("vision reasoning capability contracts", () => { providers: {}, visionSidecar: { model: "gpt-5.6-luna", reasoning: "max" }, } as OcxConfig; - response = await putVision(modelOnly, { model: "gpt-5.4-mini" }); + response = await putVision(modelOnly, { model: "gpt-5.5" }); expect(response.status).toBe(200); - expect(modelOnly.visionSidecar).toMatchObject({ model: "gpt-5.4-mini", reasoning: "xhigh" }); + expect(modelOnly.visionSidecar).toMatchObject({ model: "gpt-5.5", reasoning: "xhigh" }); const reset = { port: 10100, defaultProvider: "none", providers: {}, - visionSidecar: { model: "gpt-5.4-mini", reasoning: "max" }, + visionSidecar: { model: "gpt-5.5", reasoning: "max" }, } as OcxConfig; response = await putVision(reset, { model: "" }); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ - vision: { model: "gpt-5.4-mini", reasoning: "xhigh" }, + vision: { model: "gpt-5.6-luna", reasoning: "max" }, }); expect(reset.visionSidecar?.model).toBeUndefined(); - expect(reset.visionSidecar?.reasoning).toBe("xhigh"); + expect(reset.visionSidecar?.reasoning).toBe("max"); const custom = { port: 10100, defaultProvider: "none", providers: {} } as OcxConfig; response = await putVision(custom, { model: "custom-vision", reasoning: "max" }); @@ -175,14 +175,14 @@ describe("vision reasoning capability contracts", () => { writeFileSync(importPath, JSON.stringify(validCliConfig({ reasoning: "max" }))); expect(await handleConfigCommand(["import", importPath, "--yes", "--json"])).toBe(0); let persisted = JSON.parse(readFileSync(join(isolatedHome, "config.json"), "utf8")); - expect(persisted.visionSidecar).toMatchObject({ reasoning: "xhigh" }); + expect(persisted.visionSidecar).toMatchObject({ reasoning: "max" }); expect(persisted.visionSidecar.model).toBeUndefined(); writeFileSync(importPath, JSON.stringify(validCliConfig({ model: "", reasoning: "max" }))); expect(await handleConfigCommand(["import", importPath, "--yes", "--json"])).toBe(0); persisted = JSON.parse(readFileSync(join(isolatedHome, "config.json"), "utf8")); - expect(persisted.visionSidecar).toMatchObject({ model: "", reasoning: "xhigh" }); - expect(resolveOpenAiVisionModel({ visionSidecar: persisted.visionSidecar })).toBe("gpt-5.4-mini"); + expect(persisted.visionSidecar).toMatchObject({ model: "", reasoning: "max" }); + expect(resolveOpenAiVisionModel({ visionSidecar: persisted.visionSidecar })).toBe("gpt-5.6-luna"); } finally { if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -198,18 +198,18 @@ describe("vision reasoning capability contracts", () => { port: 10100, defaultProvider: "none", providers: {}, - visionSidecar: { model: "gpt-5.4-mini", reasoning: "high", maxDescriptionsPerTurn: 8 }, + visionSidecar: { model: "gpt-5.6-luna", reasoning: "high", maxDescriptionsPerTurn: 8 }, } as OcxConfig; try { let response = await putVision(config, { reasoning: "ultra" }); expect(response.status).toBe(400); - expect(config.visionSidecar).toMatchObject({ model: "gpt-5.4-mini", reasoning: "high" }); + expect(config.visionSidecar).toMatchObject({ model: "gpt-5.6-luna", reasoning: "high" }); response = await putVision(config, { maxDescriptionsPerTurn: 4 }); expect(response.status).toBe(200); expect(config.visionSidecar).toMatchObject({ - model: "gpt-5.4-mini", + model: "gpt-5.6-luna", reasoning: "high", maxDescriptionsPerTurn: 4, }); diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index c116743d86..81a06febfa 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -952,7 +952,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, onRequestBuilt: request => reasoningLogs.push(request.reasoningLog), on429: async retryAfter => { @@ -1023,7 +1023,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, retryOn429Policy: { enabled: true, attempts: 2, intervalMs: 120, maxIntervalMs: 60_000, respectRetryAfter: false }, on429: () => { @@ -1076,7 +1076,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, stallTimeoutSec: 1, retryOn429Policy: { enabled: true, attempts: 1, intervalMs: 1_500, maxIntervalMs: 60_000, respectRetryAfter: false }, @@ -1118,7 +1118,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, connectTimeoutMs: 100, retryOn429Policy: { enabled: true, attempts: 1, intervalMs: 150, maxIntervalMs: 60_000, respectRetryAfter: false }, @@ -1175,7 +1175,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, retryOn429Policy: { enabled: true, attempts: 1, intervalMs: 50, maxIntervalMs: 60_000, respectRetryAfter: false }, on429: () => { @@ -1208,7 +1208,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, on429: () => null, }); @@ -1231,7 +1231,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, connectTimeoutMs: 100, }); @@ -1269,7 +1269,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, connectTimeoutMs: 100, on429: () => rotatedAdapter, @@ -1300,7 +1300,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, connectTimeoutMs: 30_000, abortSignal: parent.signal, @@ -1372,7 +1372,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 2, }); await collectSse(response.body!); @@ -1437,7 +1437,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 2, }); await collectSse(response.body!); @@ -1507,7 +1507,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 2, }); await collectSse(response.body!); @@ -1581,7 +1581,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 2, }); await collectSse(response.body!); @@ -1620,7 +1620,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, }); @@ -1652,7 +1652,7 @@ describe("web-search sidecar native web_search_call emission", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, }); @@ -1725,7 +1725,7 @@ describe("web-search forced-answer nudge", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, }); // Iteration 2 (the forced-answer pass) runs live inside the SSE body — drain it so it executes. @@ -1764,7 +1764,7 @@ describe("web-search forced-answer nudge", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, }); await drain(response.body!); @@ -1804,7 +1804,7 @@ describe("web-search live spinner ordering", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, }); @@ -1881,7 +1881,7 @@ describe("web-search batched queries", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 3, }); @@ -1935,7 +1935,7 @@ describe("web-search sources -> url_citation annotations", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, }); @@ -1978,7 +1978,7 @@ describe("web-search sources -> url_citation annotations", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, }); @@ -2005,7 +2005,7 @@ describe("web-search sources -> url_citation annotations", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, }); const frames = await collectSse(response.body!); @@ -2048,7 +2048,7 @@ describe("web-search batched sources -> url_citation annotations", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 3, }); @@ -2094,7 +2094,7 @@ describe("web-search batched sources -> url_citation annotations", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 3, }); @@ -2172,7 +2172,7 @@ describe("web-search stall deadline", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 600_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 600_000 }, maxSearches: 1, // Bridge clamps to >= 1s and checks on its 2s tick: the hung search dies on the first // silent tick (~4s), proving deps.stallTimeoutSec actually reaches bridgeToResponsesSSE. @@ -2207,7 +2207,7 @@ describe("#398 sidecar failure degradation", () => { forwardProvider, hostedTool: { type: "web_search" }, selectedForwardHeaders: new Headers({ authorization: "Bearer token" }), - settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, + settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, }); From 4ad0d93229e9d87d8d1797e2f1e6f9936598d670 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 11:15:19 +0900 Subject: [PATCH 138/231] feat(gui): read and redeem Grok reset coupons from the xAI account rows The coupon routes shipped in #4306 with a CLI verb and no dashboard surface. Each xAI OAuth row now carries a ticket badge with its remaining coupon count, and the badge opens a dialog that lists validity windows and redeems the coupon closest to expiry. Three behaviours are deliberate rather than incidental: - Redemption truth is the settled ledger code, not HTTP 200. The route replays a settled failure as 200 with replayed: true and the original code, so reading only that flag would announce a failed redemption as a completed reset. - The roster epoch and the per-account request token are separate, so one row's retry cannot discard a sibling row's in-flight read. - An aborted redemption stops posting. The route re-executes a redemption whose journal record is still open, so a retry after a timeout can spend a second coupon; the dialog holds its operation id, reports the outcome as unknown, and offers only a re-read. Reads are bounded to three in flight and cover only the accounts of the open provider. --- .../provider-workspace/GrokResetCoupons.tsx | 310 +++++++++++++++++ .../provider-workspace/ProviderAuthPanel.tsx | 51 ++- gui/src/hooks/useGrokResetCoupons.ts | 207 ++++++++++++ gui/src/i18n/en.ts | 39 +++ gui/tests/grok-reset-coupons.test.tsx | 318 ++++++++++++++++++ 5 files changed, 919 insertions(+), 6 deletions(-) create mode 100644 gui/src/components/provider-workspace/GrokResetCoupons.tsx create mode 100644 gui/src/hooks/useGrokResetCoupons.ts create mode 100644 gui/tests/grok-reset-coupons.test.tsx diff --git a/gui/src/components/provider-workspace/GrokResetCoupons.tsx b/gui/src/components/provider-workspace/GrokResetCoupons.tsx new file mode 100644 index 0000000000..d0deca4fd0 --- /dev/null +++ b/gui/src/components/provider-workspace/GrokResetCoupons.tsx @@ -0,0 +1,310 @@ +/** + * Grok reset-coupon badge and redemption dialog for xAI OAuth account rows. + * + * The dialog is deliberately conservative about the one irreversible thing it + * does. It always names the coupon it is spending, it holds one client-minted + * operation id per confirmation, and when a redemption aborts it stops posting + * entirely: the route re-executes a redemption whose journal record is still + * open, so a retry after a timeout can spend a second coupon. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useI18n, type Locale, type TFn, type TKey } from "../../i18n/shared"; +import { IconAlert, IconTicket } from "../../icons"; +import { daysUntil, formatCreditDate, formatCreditDateTime } from "../codex-account-pool-utils"; +import type { GrokCouponEntry, GrokResetCoupon, GrokResetCouponController } from "../../hooks/useGrokResetCoupons"; + +function couponsOf(entry: GrokCouponEntry | undefined): GrokResetCoupon[] { + return entry?.status === "ready" ? entry.coupons : []; +} + +function newOperationId(): string | undefined { + const api = globalThis.crypto; + if (api && typeof api.randomUUID === "function") return api.randomUUID(); + if (api && typeof api.getRandomValues === "function") { + const bytes = api.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join(""); + return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)].join("-"); + } + // Without an id the journal cannot recognise a repeat, so the dialog refuses + // rather than letting the route mint a fresh id per attempt. + return undefined; +} + +const FAILURE_KEYS: Record = { + auth_failed: "grokCoupon.authFailed", + no_account: "grokCoupon.noAccount", + no_coupons_available: "grokCoupon.noneAvailable", + operation_id_owned_by_another_account: "grokCoupon.identityMismatch", + capacity: "grokCoupon.capacity", + unavailable: "grokCoupon.capacity", + network: "grokCoupon.networkError", + redeem_failed: "grokCoupon.redeemFailed", +}; + +/** Ticket badge on an xAI account row. Muted at zero, amber when redeemable. */ +export function GrokCouponBadge({ entry, onClick, t }: { + entry: GrokCouponEntry | undefined; + onClick: () => void; + t: TFn; +}) { + if (entry === undefined || entry.status === "loading") { + // Reserve the width so the row does not shift when the count lands. Same + // aria-hidden placeholder the Codex ticket badge uses. + return ( + + ); + } + const count = entry.status === "ready" ? entry.coupons.length : null; + const label = count === null + ? t("grokCoupon.badgeErrorAria") + : t("grokCoupon.badgeAria", { count: String(count) }); + return ( + + ); +} + +function GrokCouponItem({ coupon, index, isNext, locale, t }: { + coupon: GrokResetCoupon; + index: number; + isNext: boolean; + locale: Locale; + t: TFn; +}) { + const days = coupon.validityEnd ? daysUntil(coupon.validityEnd) : null; + return ( +
    +
    + + + {isNext ? t("grokCoupon.couponNext") : t("grokCoupon.couponLabel", { n: String(index + 1) })} + + {isNext && ( + + {t("grokCoupon.couponNextBadge")} + + )} +
    +
    + {coupon.validityStart && {t("grokCoupon.validFrom", { date: formatCreditDate(coupon.validityStart, locale) })}} + {days !== null && ( + + {t("grokCoupon.expires", { date: formatCreditDateTime(coupon.validityEnd, locale), days: String(days) })} + + )} +
    +
    + ); +} + +type Outcome = { tone: "ok" | "warn"; key: TKey }; + +export function GrokResetCouponModal({ accountId, accountLabel, entry, controller, onClose }: { + accountId: string; + accountLabel: string; + entry: GrokCouponEntry | undefined; + controller: GrokResetCouponController; + onClose: () => void; +}) { + const { locale, t } = useI18n(); + const dialogRef = useRef(null); + const redeemRef = useRef(null); + const [confirming, setConfirming] = useState(false); + const [redeeming, setRedeeming] = useState(false); + const [checking, setChecking] = useState(false); + /** Set by an aborted redemption; while it holds, the dialog posts nothing. */ + const [unknown, setUnknown] = useState<{ tokenId: string } | null>(null); + const [outcome, setOutcome] = useState(null); + const operationIdRef = useRef(undefined); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + + useEffect(() => { + if (confirming) redeemRef.current?.focus(); + }, [confirming]); + + const handleCancel = useCallback((event: React.SyntheticEvent) => { + event.preventDefault(); + onClose(); + }, [onClose]); + + const coupons = couponsOf(entry); + const next = coupons[0]; + + const startConfirm = () => { + if (unknown) return; + const id = newOperationId(); + if (!id) { + setOutcome({ tone: "warn", key: "grokCoupon.noOperationId" }); + return; + } + operationIdRef.current = id; + setOutcome(null); + setConfirming(true); + }; + + const redeem = async () => { + if (redeeming || unknown) return; + const operationId = operationIdRef.current; + if (!next?.tokenId) { + setOutcome({ tone: "warn", key: "grokCoupon.noneAvailable" }); + return; + } + if (!operationId) { + setOutcome({ tone: "warn", key: "grokCoupon.noOperationId" }); + return; + } + setRedeeming(true); + const result = await controller.redeem(accountId, { tokenId: next.tokenId, operationId }); + setRedeeming(false); + if (result.ok) { + operationIdRef.current = undefined; + setConfirming(false); + setOutcome({ tone: "ok", key: result.replayed ? "grokCoupon.redeemReplayed" : "grokCoupon.redeemSuccess" }); + return; + } + if (result.code === "aborted") { + // Outcome unknown: hold the id, stop posting, and let the user re-read. + setUnknown({ tokenId: next.tokenId }); + setOutcome(null); + void controller.refresh(accountId); + return; + } + if (result.code === "operation_id_owned_by_another_account") operationIdRef.current = undefined; + setOutcome({ tone: "warn", key: FAILURE_KEYS[result.code] ?? "grokCoupon.redeemFailed" }); + }; + + const recheck = async () => { + if (!unknown || checking) return; + setChecking(true); + await controller.refresh(accountId); + setChecking(false); + }; + + const unresolvedToken = unknown + ? couponsOf(entry).some(coupon => coupon.tokenId === unknown.tokenId) + : false; + const remaining = String(coupons.length); + + const message = (result: Outcome) => ( +

    + {t(result.key, { count: remaining })} +

    + ); + + return ( + + + + + + ) : !confirming ? ( + <> +

    {t("grokCoupon.title")}

    +
    {accountLabel}
    +
    + {entry === undefined || entry.status === "loading" ? ( +

    {t("common.loading")}

    + ) : entry.status === "error" ? ( + <> +

    + {t(entry.reason === "auth" ? "grokCoupon.loadFailedAuth" : "grokCoupon.loadFailed")} +

    + + + ) : coupons.length > 0 ? ( + <> +

    {t("grokCoupon.available", { count: remaining })}

    +
    + {coupons.map((coupon, index) => ( + + ))} +
    + +

    {t("grokCoupon.fifoNote")}

    + + ) : ( + <> +

    {t("grokCoupon.none")}

    +

    {t("grokCoupon.desc")}

    + + )} + {outcome && message(outcome)} +
    + + ) : ( + <> +
    +
    +

    {t("grokCoupon.confirmTitle")}

    +

    {t("grokCoupon.confirmDesc", { count: remaining })}

    + {next?.validityEnd && ( +

    + {t("grokCoupon.confirmWhich", { date: formatCreditDate(next.validityEnd, locale) })} +

    + )} +

    {t("grokCoupon.irreversible")}

    + {outcome && message(outcome)} +
    +
    + + +
    + + )} + +
    + ); +} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index fecf40ac2b..6ad65475f4 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -3,7 +3,7 @@ * embedding for the workspace Settings tab (WP091). Consumes WP040+WP060 * handlers via props-down; no internal auth machinery. */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import { IconLock, IconRefresh, IconTrash } from "../../icons"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; @@ -21,7 +21,9 @@ import AnthropicAccountPoolSettings from "./AnthropicAccountPoolSettings"; import { LoginHint as LoginHintView } from "../login-url-block"; import { OpenBrowserPrefToggle } from "../open-browser-pref-toggle"; import ProviderAccountQuota from "./ProviderAccountQuota"; +import { GrokCouponBadge, GrokResetCouponModal } from "./GrokResetCoupons"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; +import { useGrokResetCoupons } from "../../hooks/useGrokResetCoupons"; import { Switch } from "../../ui"; import type { AccountLoadState, @@ -37,6 +39,15 @@ const COCKPIT_IMPORT_MAX_BYTES = 256 * 1024; const EMPTY_OAUTH_ACCOUNTS: OAuthAccountRow[] = []; const EMPTY_API_KEYS: ApiKeyRow[] = []; +/** + * One predicate for "this row cannot spend a coupon right now". The read set and + * the badge must agree: a row fetched here and hidden there is a billing RPC + * spent on a 401. + */ +function accountShowsReauth(account: OAuthAccountRow): boolean { + return Boolean(account.needsReauth) || oauthHealthShowsReauth(account.health?.status); +} + function XaiChatOptInControl({ initialState, onUpdateProvider, @@ -207,6 +218,22 @@ export default function ProviderAuthPanel({ }, [connectionIdentity]); const onRefreshQuota = authHandlers?.onRefreshQuota; + const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); + const isOauth = surface === "oauth-accounts"; + const isKeyAuth = surface === "api-keys"; + // Grok reset coupons live behind a billing RPC rather than the quota payload, + // so the xAI rows read them once per roster instead of riding the quota probe. + // The gate names the OAuth surface here rather than relying on the roster + // loader three files away to leave `accounts` empty for key-auth xAI. + const grokCouponsEnabled = isOauth && item.name === "xai" && accounts.length > 0; + const grokAccountIds = useMemo( + () => (grokCouponsEnabled + ? accounts.filter(account => !accountShowsReauth(account)).map(account => account.id) + : []), + [grokCouponsEnabled, accounts], + ); + const grokCoupons = useGrokResetCoupons({ apiBase, accountIds: grokAccountIds, enabled: grokCouponsEnabled }); + const [couponAccount, setCouponAccount] = useState(null); const refreshQuota = async () => { if (!onRefreshQuota || refreshingQuota) return; const generation = ++quotaRefreshGeneration.current; @@ -222,10 +249,6 @@ export default function ProviderAuthPanel({ } }; - const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); - const isOauth = surface === "oauth-accounts"; - const isKeyAuth = surface === "api-keys"; - if (surface === "codex-accounts") { return (
    @@ -495,7 +518,7 @@ export default function ProviderAuthPanel({ const label = oauthAccountDisplayLabel(accounts, account, t); const switching = switchingAccountId === account.id; const healthStatus = account.health?.status; - const showReauth = Boolean(account.needsReauth) || oauthHealthShowsReauth(healthStatus); + const showReauth = accountShowsReauth(account); const inCooldown = oauthHealthIsCooldown(healthStatus); const maskedId = displayAccountId(account.id); const healthLabel = formatOAuthHealthLabel(t, account.health); @@ -536,6 +559,13 @@ export default function ProviderAuthPanel({ {t("pws.reauthenticate")} )} + {grokCouponsEnabled && !showReauth && ( + setCouponAccount(account)} + /> + )} + )} + + ) : loggedIn ? ( + <> + {onManage && ( + + )} + {onLogin && ( + + )} + {busy && onCancelLogin && ( + + )} + {onLogout && !busy && ( + + )} + + ) : busy ? ( + onCancelLogin && + ) : ( + onLogin && + )} + + + {showHint && loginHint && ( + paste.onSubmit(row.id), + }, + } + : {})} + /> + )} + + ); +} diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index d3122316db..b0e7f50992 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -4,29 +4,25 @@ * login rows on the Accounts tab. Presentational: presets/usage arrive via props; * view state (tab, query) lives here; selection lifts up. */ -import { useMemo, useState } from "react"; +import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import { bucketPresets, pinSponsors, - filterPresets, noteNeedsReveal, + matchesCatalogQuery, + sortCatalogMatches, + filterAccountRows, + dropPresetsCoveredByAccounts, type CatalogPreset, type CatalogTier, } from "./provider-presets"; -import { shouldShowLoginHint, type CatalogLoginHint } from "./login-hint-visibility"; -import { LoginHint } from "../login-url-block"; +import { type CatalogLoginHint } from "./login-hint-visibility"; +import CatalogAccountRow from "./CatalogAccountRow"; +import type { AccountLoginRow, AccountLoginStatus } from "./account-row-types"; import { ProviderIcon } from "../provider-workspace/ProviderRail"; -export type AccountLoginStatus = { loggedIn: boolean; email?: string; error?: string; needsReauth?: boolean }; -export type AccountLoginRow = { - id: string; - label: string; - kind: "oauth" | "key" | "codex"; - statusLabel?: string; - /** Optional deep-link for codex/account-pool management. */ - href?: string; -}; +export type { AccountLoginRow, AccountLoginStatus }; export type { CatalogTier }; @@ -49,6 +45,8 @@ export default function ProviderCatalog({ usageRank = EMPTY_USAGE_RANK, presetsLoading = false, initialTier = "free", + query, + onQueryChange, onSelectPreset, onSelectCustom, onShowNote, @@ -66,6 +64,14 @@ export default function ProviderCatalog({ usageRank?: Record; presetsLoading?: boolean; initialTier?: CatalogTier; + /** + * The unified search text, owned by the modal. It lives up there because the + * add-provider modal's Escape handler is on `window` and registers before this + * component's would: Escape has to clear a non-empty query instead of closing the + * dialog, and a child listener never gets the chance. + */ + query: string; + onQueryChange: (value: string) => void; onSelectPreset: (preset: CatalogPreset) => void; onSelectCustom: () => void; /** Open the full-note popup for a row whose note is clamped. Owned by the modal. */ @@ -93,7 +99,19 @@ export default function ProviderCatalog({ }) { const t = useT(); const [tier, setTier] = useState(initialTier); - const [query, setQuery] = useState(""); + const rowsId = useId(); + const groupId = (candidate: CatalogTier) => `${rowsId}-${candidate}`; + const rowsRef = useRef(null); + const searching = query.trim().length > 0; + + /** + * Entering or leaving search mode, and switching tabs, replaces the dataset entirely. + * Restoring an old scroll offset onto a different list lands somewhere meaningless, so + * the list goes back to the top instead. + */ + useEffect(() => { + if (rowsRef.current) rowsRef.current.scrollTop = 0; + }, [tier, searching]); const catalog = useMemo(() => presets.filter(p => p.id !== "custom"), [presets]); @@ -113,7 +131,96 @@ export default function ProviderCatalog({ const buckets = useMemo(() => bucketPresets(pinSponsors(ranked)), [ranked]); const tierList = buckets[tier]; - const rows = useMemo(() => filterPresets(tierList, query), [tierList, query]); + + /** + * Search mode replaces browse mode rather than filtering inside it. While a query is + * live the selected tab is frozen and every group is rendered, because a jump from + * Free to Accounts would not merely change which rows are listed - it changes the kind + * of row, from a preset-select button to a login row with Log in and Add account + * buttons. Clearing the query returns to the tab the user actually chose. + */ + const accountMatches = useMemo( + () => (searching ? filterAccountRows(accountRows, query, busyProvider) : accountRows), + [accountRows, query, searching, busyProvider], + ); + + const presetGroups = useMemo(() => { + const presetTabs = TIER_TABS.filter(candidate => candidate !== "accounts"); + if (!searching) { + return tier === "accounts" ? [] : [{ tier, rows: tierList }]; + } + return presetTabs.map(candidate => ({ + tier: candidate, + rows: sortCatalogMatches( + dropPresetsCoveredByAccounts( + buckets[candidate].filter(p => matchesCatalogQuery(p, query)), + accountMatches, + ), + query, + ), + })); + }, [searching, tier, tierList, buckets, query, accountMatches]); + + const counts = useMemo(() => { + const byTier = Object.fromEntries(presetGroups.map(group => [group.tier, group.rows.length])) as Record; + return { ...byTier, accounts: accountMatches.length } as Record; + }, [presetGroups, accountMatches]); + + const totalMatches = TIER_TABS.reduce((sum, candidate) => sum + (counts[candidate] ?? 0), 0); + const matchedTiers = TIER_TABS.filter(candidate => (counts[candidate] ?? 0) > 0); + + /** + * What is actually on screen right now. In browse mode that is one tab, and it is NOT + * `totalMatches`: the accounts bucket is unfiltered while browsing, and an OpenAI login + * row is almost always present, so keying the loading and empty states off the total + * left a still-loading Free tab rendering a blank pane instead of saying it was loading. + */ + const visibleCount = searching + ? totalMatches + : tier === "accounts" ? accountMatches.length : (presetGroups[0]?.rows.length ?? 0); + + /** + * A chip scrolls its group into view; it does not change `tier`. Focus moves to the + * heading so a keyboard user lands where they aimed - unless a login is in flight, + * because that row owns the paste field the user may be typing into. + */ + const jumpToGroup = (candidate: CatalogTier) => { + const container = rowsRef.current; + // Looked up by data attribute rather than by id: the id comes from `useId`, which + // emits colons, so selecting on it needs `CSS.escape` — and `CSS` does not exist in + // the happy-dom environment the GUI tests run in, so a chip click would throw there + // rather than merely be untested. The tier values are plain lowercase words. + const heading = container?.querySelector(`[data-catalog-group="${candidate}"]`); + if (!container || !heading) return; + // Scroll the list itself rather than calling scrollIntoView: `.modal-card` is also a + // scroll container, so delegating to the browser can drag the search field out of + // view while jumping between groups inside a 360px list. + container.scrollTop = heading.offsetTop - container.offsetTop; + // `preventScroll` for the same reason the scroll is manual: the default would let the + // focus move drag the translucent modal card that the list sits inside. + if (!busyProvider) heading.focus({ preventScroll: true }); + }; + + /** ArrowDown out of the input lands on the first result, never on a chip. */ + const onSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "ArrowDown") return; + const first = rowsRef.current?.querySelector("button, a[href]"); + if (!first) return; + e.preventDefault(); + first.focus(); + }; + + const groupHeading = (candidate: CatalogTier, count: number) => ( +

    + {t(TIER_TAB_LABEL[candidate])} + {count} +

    + ); const badges = (p: CatalogPreset) => { const auth = p.codexAccountMode === "direct" ? {t("modal.badge.direct")} @@ -136,38 +243,99 @@ export default function ProviderCatalog({ return (
    -
    - {TIER_TABS.map(candidate => ( - - ))} -
    + {/* Search first, then the filters it overrides. It reaches every tab, so putting it + under one tab's header would say the opposite of what it does. */} + onQueryChange(e.target.value)} + onKeyDown={onSearchKeyDown} + placeholder={t("modal.search")} + aria-label={t("modal.search")} + /> - {tier === "accounts" && ( + {searching ? ( + // Not a tablist any more: the panel below is showing every group, so a `tab` with + // `aria-selected` would announce "Free, selected" over a Paid row. These are jump + // chips with counts, and a chip with no matches is disabled rather than hidden so + // the strip does not reflow under the pointer on every keystroke. +
    + {TIER_TABS.map(candidate => ( + + ))} +
    + ) : ( +
    + {TIER_TABS.map(candidate => ( + + ))} +
    + )} + + {!searching && tier === "accounts" && (
    {t("modal.accountsHint")}
    )} - setQuery(e.target.value)} - placeholder={t("modal.search")} - /> +
    + {searching + ? (totalMatches === 0 + ? t("modal.noMatch") + : t("modal.searchResults", { + count: totalMatches, + tiers: matchedTiers.map(candidate => t(TIER_TAB_LABEL[candidate])).join(", "), + })) + : ""} +
    -
    - {presetsLoading && rows.length === 0 && ( +
    + {presetsLoading && visibleCount === 0 && (
    {t("modal.catalogLoading")}
    )} - {tier !== "accounts" && rows.map(p => ( + {(searching || tier === "accounts") && accountMatches.length > 0 && ( + + {searching && groupHeading("accounts", accountMatches.length)} + {accountMatches.map(row => ( + + ))} + + )} + {presetGroups.map(group => group.rows.length === 0 ? null : ( + + {searching && groupHeading(group.tier, group.rows.length)} + {group.rows.map(p => ( // The reveal control is a SIBLING of the row button, never a child of it: the row // is already a
    ))} - {tier !== "accounts" && !presetsLoading && rows.length === 0 && ( -
    {t("modal.noMatch")}
    - )} - - {tier === "accounts" && accountRows.map(row => { - const status = accountStatus[row.id]; - const busy = busyProvider === row.id; - const loggedIn = !!status?.loggedIn; - const statusText = loggedIn - ? (status?.email ?? row.statusLabel ?? t("modal.accountLoggedIn")) - : (status?.error ?? row.statusLabel ?? t("modal.accountLoggedOut")); - // A first-time add is the one moment the operator has no other way in: - // the provider has no workspace panel yet, so without this the - // authorization URL is computed and never drawn. - const showHint = shouldShowLoginHint(row, busyProvider, loginHint); - return ( -
    -
    - {/* Account rows are providers too. A logo beside Cursor and a bare - tile beside Kiro reads as a bug, not as a distinction. */} - -
    -
    {row.label}
    -
    {statusText}
    -
    -
    - {row.kind === "key" ? null : row.kind === "codex" ? ( - <> - {loggedIn && ( - {t("modal.accountManage")} - )} - {onLogin && ( - - )} - - ) : loggedIn ? ( - <> - {onManage && ( - - )} - {onLogin && ( - - )} - {busy && onCancelLogin && ( - - )} - {onLogout && !busy && ( - - )} - - ) : busy ? ( - onCancelLogin && - ) : ( - onLogin && - )} -
    -
    - {showHint && loginHint && ( - paste.onSubmit(row.id), - }, - } - : {})} - /> - )} -
    - ); - })} - {tier === "accounts" && accountRows.length === 0 && !presetsLoading && ( + + ))} + {!presetsLoading && visibleCount === 0 && (
    {t("modal.noMatch")}
    )}
    - {tier !== "accounts" && ( + {/* Browse copy. "Not listed?" is the escape hatch at the end of a list you read, + not a search result, so it stays out of the way while a query is live. */} + {!searching && tier !== "accounts" && ( )}
    diff --git a/gui/src/components/provider-catalog/account-row-types.ts b/gui/src/components/provider-catalog/account-row-types.ts new file mode 100644 index 0000000000..e1a63d41ff --- /dev/null +++ b/gui/src/components/provider-catalog/account-row-types.ts @@ -0,0 +1,15 @@ +/** + * Shapes shared by the catalog's Accounts rows. They live here rather than in + * ProviderCatalog so CatalogAccountRow can import them without a cycle back through + * the component that renders it. + */ +export type AccountLoginStatus = { loggedIn: boolean; email?: string; error?: string; needsReauth?: boolean }; + +export type AccountLoginRow = { + id: string; + label: string; + kind: "oauth" | "key" | "codex"; + statusLabel?: string; + /** Optional deep-link for codex/account-pool management. */ + href?: string; +}; diff --git a/gui/src/components/provider-catalog/provider-presets.ts b/gui/src/components/provider-catalog/provider-presets.ts index 1198c9b5da..15d9c4d56b 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -133,6 +133,90 @@ export function noteNeedsReveal(note: string | undefined): boolean { return (note?.trim().length ?? 0) > NOTE_CLAMP_CHARS; } +/** + * Queries that mean "a runtime on my own machine" without naming one. Resolved through + * `isLocalCatalogPreset` rather than a substring match, so `localhost` finds the Local + * group instead of matching every base URL that happens to contain the word. + */ +const LOCAL_QUERY_ALIASES = new Set(["local", "localhost", "ollama", "vllm", "lmstudio", "lm studio", "self-hosted", "selfhosted"]); + +/** + * Unified-search match for one preset. + * + * The haystack stays label + id, for the same reason `filterPresets` documents: a + * substring match on the adapter would return Ollama, vLLM, LM Studio, Groq, Cerebras + * and PackyCode for the query `openai`, and matching base URLs would return every local + * row for `localhost`. It widens in exactly two controlled ways instead — an *equality* + * match on the adapter id, so `cursor` finds Cursor while `openai` still does not match + * `openai-chat`, and the local aliases above. + */ +export function matchesCatalogQuery(preset: CatalogPreset, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + if (preset.label.toLowerCase().includes(q)) return true; + if (preset.id.toLowerCase().includes(q)) return true; + if (preset.adapter.toLowerCase() === q) return true; + return LOCAL_QUERY_ALIASES.has(q) && isLocalCatalogPreset(preset); +} + +/** + * Order matched rows WITHIN one group: exact id or label first, then a label/id prefix, + * then everything else in the order the caller already established — which carries the + * sponsor pin, then usage rank, then label. Deliberately never applied across groups: a + * paid sponsor sorted above free NVIDIA on the query `nim` reads as an ad slot, and the + * sponsor already has a badge and a pin inside its own group. + */ +export function sortCatalogMatches(presets: CatalogPreset[], query: string): CatalogPreset[] { + const q = query.trim().toLowerCase(); + if (!q) return presets; + const rank = (p: CatalogPreset): number => { + const label = p.label.toLowerCase(); + const id = p.id.toLowerCase(); + if (id === q || label === q) return 0; + if (label.startsWith(q) || id.startsWith(q)) return 1; + return 2; + }; + return presets + .map((preset, index) => ({ preset, index })) + .sort((a, b) => rank(a.preset) - rank(b.preset) || a.index - b.index) + .map(entry => entry.preset); +} + +/** + * Account-tab login rows are a different shape from presets and are built elsewhere, so + * they get their own label/id filter rather than a widened `filterPresets`. + * + * `pinnedId` is the provider with a login in flight. It survives a non-matching query on + * purpose: the row owns the authorization URL and the paste field, and unmounting it + * mid-login throws away what the user is in the middle of doing. + */ +export function filterAccountRows( + rows: readonly T[], + query: string, + pinnedId?: string | null, +): T[] { + const q = query.trim().toLowerCase(); + if (!q) return [...rows]; + return rows.filter(row => + row.id === pinnedId + || row.label.toLowerCase().includes(q) + || row.id.toLowerCase().includes(q)); +} + +/** + * Drop presets that a matched login row already represents. A login row and a preset can + * share an id (`openai`); the login row is the one that can actually be acted on, so it + * wins rather than the same provider appearing twice under two different tiers. + */ +export function dropPresetsCoveredByAccounts( + presets: CatalogPreset[], + accountRows: readonly { id: string }[], +): CatalogPreset[] { + if (accountRows.length === 0) return presets; + const covered = new Set(accountRows.map(row => row.id)); + return presets.filter(preset => !covered.has(preset.id)); +} + const SPONSOR_RANK: Record, number> = { main: 0, standard: 1 }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d5280552e9..7434eddfa7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1902,6 +1902,7 @@ export const de: Record = { "modal.tab.local": "Lokal", "modal.tab.paid": "Bezahlt", "modal.noteMore": "Vollständige Beschreibung anzeigen", + "modal.searchResults": "{count} Ergebnisse in {tiers}", "modal.accountsHint": "Hier ChatGPT/Codex, OAuth-Provider und API-Key-Konten anmelden. OpenAI ist eingebaut — anmelden statt erneut hinzufügen.", "modal.accountsCodexAuthLink": "Codex Auth", "modal.notListed": "Provider nicht dabei? Eigenen hinzufügen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 032d5aff46..c0f478b4a2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1158,6 +1158,7 @@ export const en = { "modal.tab.local": "Local", "modal.tab.paid": "Paid", "modal.noteMore": "Show full description", + "modal.searchResults": "{count} results across {tiers}", "modal.accountsHint": "Sign in to ChatGPT/Codex, OAuth providers, and API-key accounts here. OpenAI is built in — log in rather than adding it again.", "modal.accountsCodexAuthLink": "Codex Auth", "modal.notListed": "Provider not listed? Add a custom one", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 322d496d6e..a749064352 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1131,6 +1131,7 @@ export const fr: Record = { "modal.tab.local": "Local", "modal.tab.paid": "Payant", "modal.noteMore": "Afficher la description complète", + "modal.searchResults": "{count} résultats dans {tiers}", "modal.accountsHint": "Connectez-vous ici à ChatGPT/Codex, aux fournisseurs OAuth et aux comptes avec clé API. OpenAI est intégré : connectez-vous au lieu de l’ajouter de nouveau.", "modal.accountsCodexAuthLink": "Codex Auth", "modal.notListed": "Fournisseur absent de la liste ? Ajoutez-en un personnalisé", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index f2ab45b8e9..e444aba8a4 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1071,6 +1071,7 @@ export const ja: Record = { "modal.tab.local": "ローカル", "modal.tab.paid": "有料", "modal.noteMore": "説明をすべて表示", + "modal.searchResults": "{tiers} で {count} 件", "modal.accountsHint": "ChatGPT/Codex、OAuth プロバイダー、API キーアカウントにここからサインインします。OpenAI は組み込み済み — 再度追加せずログインしてください。", "modal.accountsCodexAuthLink": "Codex 認証", "modal.notListed": "プロバイダーが載っていませんか? カスタムを追加", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a79196b3f0..edee1deedd 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1941,6 +1941,7 @@ export const ko: Record = { "modal.tab.local": "로컬", "modal.tab.paid": "유료", "modal.noteMore": "설명 전체 보기", + "modal.searchResults": "{tiers}에서 {count}개", "modal.accountsHint": "여기서 ChatGPT/Codex, OAuth, API 키 계정에 로그인하세요. OpenAI는 기본 제공 — 다시 추가하지 말고 로그인하세요.", "modal.accountsCodexAuthLink": "Codex 인증", "modal.notListed": "찾는 프로바이더가 없나요? 직접 추가", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 394e312abf..f7a6ea0dbf 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1126,6 +1126,7 @@ export const ru: Record = { "modal.tab.local": "Локальные", "modal.tab.paid": "Платные", "modal.noteMore": "Показать полное описание", + "modal.searchResults": "{count} результатов в {tiers}", "modal.accountsHint": "Здесь можно войти в аккаунты ChatGPT/Codex и OAuth-провайдеров, а также в аккаунты с API-ключами. Провайдер OpenAI уже встроен — просто войдите, а не добавляйте его заново.", "modal.accountsCodexAuthLink": "Аутентификация Codex", "modal.notListed": "Нет нужного провайдера? Добавьте свой", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c6438767ea..0e642ca9ed 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1145,6 +1145,7 @@ export const tr: Record = { "modal.tab.local": "Yerel", "modal.tab.paid": "Ücretli", "modal.noteMore": "Açıklamanın tamamını göster", + "modal.searchResults": "{tiers} içinde {count} sonuç", "modal.accountsHint": "ChatGPT/Codex ve OAuth hesaplarına buradan giriş yapın.", "modal.accountsCodexAuthLink": "Codex Kimlik Doğrulaması", "modal.notListed": "Sağlayıcı listede yok mu? Özel sağlayıcı ekleyin", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 59e4ccfb47..17c1bd8ab5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -924,6 +924,7 @@ export const zhTW: Record = { "modal.tab.local": "本地", "modal.tab.paid": "付費", "modal.noteMore": "查看完整說明", + "modal.searchResults": "在 {tiers} 中找到 {count} 個", "modal.accountsHint": "在此登入 ChatGPT/Codex、OAuth 與 API 金鑰帳號。OpenAI 為內建供應商 — 請登入,無需再次新增。", "modal.accountsCodexAuthLink": "Codex 認證", "modal.notListed": "沒有你要的供應商?新增自訂", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e4ecec59e8..61a2b3401b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1922,6 +1922,7 @@ export const zh: Record = { "modal.tab.local": "本地", "modal.tab.paid": "付费", "modal.noteMore": "查看完整说明", + "modal.searchResults": "在 {tiers} 中找到 {count} 个", "modal.accountsHint": "在此登录 ChatGPT/Codex、OAuth 与 API 密钥账户。OpenAI 为内置提供商 — 请登录,无需再次添加。", "modal.accountsCodexAuthLink": "Codex 认证", "modal.notListed": "没有你要的提供商?添加自定义", diff --git a/gui/src/styles/provider-catalog.css b/gui/src/styles/provider-catalog.css index 11df684e98..c764126a4b 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -28,6 +28,85 @@ border-bottom-color: var(--accent); } +/* Search mode: the strip stops being a tablist and becomes jump chips with counts, so + it loses the selected-underline vocabulary and gains a count badge. A chip with no + matches is disabled rather than hidden, so the strip does not reflow under the + pointer on every keystroke. */ +.provider-catalog-tabs--chips { + border-bottom: none; + flex-wrap: wrap; +} + +.provider-catalog-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 999px; +} + +.provider-catalog-chip:not(:disabled):hover { + color: var(--text); + border-color: var(--accent-ring); +} + +.provider-catalog-chip:disabled { + opacity: 0.45; + cursor: default; +} + +.provider-catalog-chip-count { + font-variant-numeric: tabular-nums; + font-size: var(--text-label); + color: var(--muted); +} + +/* Group heading inside the result list: a small caps label with a hairline running out + to the right, the way a dense list separates sections without adding another slab of + chrome. It scrolls with the content rather than sticking — `.modal-card` is a + translucent glass panel, so any opaque sticky bar shows a seam against it, and with + four short groups in a 360px list the chips above are the index anyway. */ +.provider-catalog-group-head { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 12px 2px 2px; + color: var(--muted); + font-size: 11px; + font-weight: var(--weight-semibold); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.provider-catalog-group-head:first-child { + padding-top: 2px; +} + +.provider-catalog-group-head::after { + content: ""; + flex: 1 1 auto; + height: 1px; + background: var(--border); +} + +.provider-catalog-group-head:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: 3px; + border-radius: var(--radius-xs); +} + +/* The count is data, not a label: tabular figures, no small caps, and it sits before + the rule so the eye reads "FREE 1 ————" as one unit. */ +.provider-catalog-group-count { + font-variant-numeric: tabular-nums; + font-weight: var(--weight-normal, 400); + letter-spacing: 0; + text-transform: none; + opacity: 0.75; +} + .provider-catalog-accounts-hint { padding: 2px 2px 0; } diff --git a/gui/tests/provider-catalog-search.test.tsx b/gui/tests/provider-catalog-search.test.tsx new file mode 100644 index 0000000000..c4e067e3bc --- /dev/null +++ b/gui/tests/provider-catalog-search.test.tsx @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import AddProviderModal from "../src/components/AddProviderModal"; + +/** + * Unified search replaces browse mode instead of filtering inside one tab, and the tab + * strip becomes jump chips rather than moving the selection. These pin the three things + * that make that safe rather than merely different: the selected tab survives a search + * that matches nothing in it, a strip click does not throw the query away, and a login + * already in flight is never unmounted by a query that does not happen to match it. + */ + +const PRESETS = [ + { id: "cerebras", label: "Cerebras", adapter: "openai-completions", baseUrl: "https://api.cerebras.ai/v1", auth: "key" }, + { id: "nvidia", label: "NVIDIA NIM", adapter: "openai-chat", baseUrl: "https://integrate.api.nvidia.com/v1", auth: "key", freeTier: true }, +]; + +const ACCOUNT_ROWS = [ + { id: "cursor", label: "Cursor", kind: "oauth" as const }, + { id: "anthropic", label: "Anthropic (Claude)", kind: "oauth" as const }, +]; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previous; + originalFetch = globalThis.fetch; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/provider-presets") return Response.json({ providers: PRESETS }); + if (url.pathname === "/api/oauth/providers") return Response.json({ providers: [] }); + if (url.pathname === "/api/usage") return Response.json({ providers: [] }); + return Response.json({}); + }, + }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +type ModalExtras = Partial[0]>; + +async function mount(extras: ModalExtras = {}) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + {}} onAdded={() => {}} {...extras} /> + , + ); + }); + await act(async () => { await new Promise(r => setTimeout(r, 60)); }); +} + +function search(): HTMLInputElement { + return win.document.querySelector(".provider-catalog-search") as unknown as HTMLInputElement; +} + +async function type(value: string) { + const input = search(); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new win.Event("input", { bubbles: true }) as never); + }); +} + +function chips(): HTMLButtonElement[] { + return [...win.document.querySelectorAll(".provider-catalog-chip")] as unknown as HTMLButtonElement[]; +} + +function selectedTabs(): string[] { + return [...win.document.querySelectorAll('[role="tab"][aria-selected="true"]')].map(el => el.textContent ?? ""); +} + +test("a query that matches nothing on the selected tab does not move the tab", async () => { + await mount(); + expect(selectedTabs()).toEqual(["Paid"]); + + // NVIDIA is a Free row; Paid has no hit at all. + await type("nvidia"); + + // Search mode: the strip is chips, so nothing is announced as a selected tab, and the + // Free group is on screen without the Paid tab having been stolen. + expect(selectedTabs()).toEqual([]); + expect(chips().length).toBe(4); + expect(win.document.querySelector(".provider-catalog-rows")?.textContent).toContain("NVIDIA NIM"); + + // Clearing restores the tab the user actually chose. + await type(""); + expect(selectedTabs()).toEqual(["Paid"]); +}); + +test("clicking the strip during a search does not throw the query away", async () => { + await mount(); + await type("nvidia"); + const free = chips().find(chip => (chip.textContent ?? "").startsWith("Free")); + expect(free?.disabled).toBe(false); + await act(async () => { free?.click(); }); + expect(search().value).toBe("nvidia"); +}); + +test("a login in flight survives a query that does not match its row", async () => { + await mount({ + accountRows: ACCOUNT_ROWS, + accountBusy: "cursor", + accountLoginHint: { provider: "cursor", url: "https://example.com/authorize" }, + }); + await type("nvidia"); + const rows = win.document.querySelector(".provider-catalog-rows")?.textContent ?? ""; + // Cursor does not match "nvidia". It stays because it owns the authorization URL and + // the paste field, and unmounting it mid-login throws away the login in progress. + expect(rows).toContain("Cursor"); + expect(rows).not.toContain("Anthropic"); +}); diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index c6b35fbad8..95688f5c9f 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -301,7 +301,7 @@ single forms, and the shell pattern is the part worth keeping stable: | Storage | Rail plus cleanup and trash detail (`gui/src/components/storage-workspace/`). | | Subagents | Featured-roster selection workspace (`gui/src/components/subagents-workspace/`). | | Combos | Rail, detail panel, and an add flow (`gui/src/components/ComboWorkspace.tsx`). | -| Add provider | Catalog browser plus form and OAuth panes (`gui/src/components/provider-catalog/`, `gui/src/components/AddProviderModal.tsx`). | +| Add provider | Catalog browser plus form and OAuth panes (`gui/src/components/provider-catalog/`, `gui/src/components/AddProviderModal.tsx`). The catalog browses four tabs — Accounts, Free, Local, Paid — where Local is a catalog-only bucket peeled out of `bucketPresets` after `presetTier` has classified; the workspace `providerTier` stays three-way, so the rail, the free-paid sort and the Free count still treat a local runtime as free. Search sits above the tabs and reaches every tab at once: while a query is live the list renders all four groups with headings and the strip becomes jump chips with counts rather than a tablist, because moving the selected tab would change the row kind under the user (a preset-select button becomes a login row). Long provider notes clamp to two lines and open in full in a stacked native `` owned by `AddProviderModal`, which also owns the search text so its `window` Escape handler can unwind popup, then query, then dialog. | | Codex accounts | Account pool cards, add-account flow, switch and reset modals (`gui/src/components/CodexAccountPool.tsx`, `gui/src/components/AddCodexAccountModal.tsx`), plus the generic account-targeting picker opt-in on `gui/src/pages/codex-set-multiauth.tsx`. Add/delete/login completion is projected to one boolean before presentation; pending catalog work is a warning, not a failed account mutation. | | Dashboard overview | Overview, Providers, and Models tabs at the page level (`gui/src/pages/Dashboard.tsx`), the 30-day token and coverage stats in the overview head (`gui/src/pages/dashboard-overview-head.tsx`), and the effort-cap, injection, maintenance, sidecar, and memory panels below it (`gui/src/pages/dashboard-overview-panels.tsx`). | diff --git a/tests/gui/provider-workspace-data.test.ts b/tests/gui/provider-workspace-data.test.ts index 1cc373dd8b..554a319e37 100644 --- a/tests/gui/provider-workspace-data.test.ts +++ b/tests/gui/provider-workspace-data.test.ts @@ -36,6 +36,10 @@ import { presetTier, noteNeedsReveal, NOTE_CLAMP_CHARS, + matchesCatalogQuery, + sortCatalogMatches, + filterAccountRows, + dropPresetsCoveredByAccounts, type CatalogPreset, } from "../../gui/src/components/provider-catalog/provider-presets"; import { isLocalProvider, providerKind } from "../../gui/src/provider-workspace/kind"; @@ -583,6 +587,61 @@ describe("add-provider catalog presets (WP050a)", () => { expect(noteNeedsReveal(`${"x".repeat(NOTE_CLAMP_CHARS)}${" ".repeat(40)}`)).toBe(false); }); + test("unified search widens by adapter EQUALITY, never by adapter prefix or base URL", () => { + const ollama = preset({ id: "ollama", label: "Ollama (local)", auth: "local", baseUrl: "http://localhost:11434/v1" }); + const cursor = preset({ id: "cursor", label: "Cursor", adapter: "cursor", baseUrl: "https://api.cursor.com/v1" }); + + // The whole reason the haystack is not the adapter: openai-chat is the adapter of + // Ollama, vLLM, LM Studio, Groq, Cerebras and PackyCode, so a prefix match on + // "openai" would return half the catalog. + expect(matchesCatalogQuery(ollama, "openai")).toBe(false); + expect(matchesCatalogQuery(ollama, "openai-chat")).toBe(true); + expect(matchesCatalogQuery(cursor, "cursor")).toBe(true); + + // Base URLs stay out of the haystack: otherwise "api" returns most of the Paid tab. + expect(matchesCatalogQuery(cursor, "api.cursor.com")).toBe(false); + + // Aliases reach the Local group through the classifier, not through a substring. + expect(matchesCatalogQuery(ollama, "localhost")).toBe(true); + expect(matchesCatalogQuery(ollama, "self-hosted")).toBe(true); + expect(matchesCatalogQuery(cursor, "localhost")).toBe(false); + + // Label and id remain the ordinary path, case-insensitively. + expect(matchesCatalogQuery(cursor, "CURS")).toBe(true); + expect(matchesCatalogQuery(cursor, "")).toBe(true); + }); + + test("search ranking is exact, then prefix, then the order the caller already chose", () => { + // Incoming order carries the sponsor pin, then usage rank, then label — this must + // only reorder for exact and prefix hits, never re-rank the tail. + const rows = [ + preset({ id: "groq-cloud", label: "Groq Cloud" }), + preset({ id: "xyz", label: "Not a groq thing" }), + preset({ id: "groq", label: "Groq" }), + ]; + expect(sortCatalogMatches(rows, "groq").map(p => p.id)).toEqual(["groq", "groq-cloud", "xyz"]); + // An empty query is browse mode: the caller's order is returned untouched. + expect(sortCatalogMatches(rows, "").map(p => p.id)).toEqual(["groq-cloud", "xyz", "groq"]); + }); + + test("a login in flight survives a query that does not match it", () => { + const rows = [ + { id: "cursor", label: "Cursor" }, + { id: "anthropic", label: "Anthropic (Claude)" }, + ]; + expect(filterAccountRows(rows, "claude").map(r => r.id)).toEqual(["anthropic"]); + // The busy row owns the authorization URL and the paste field; unmounting it + // mid-login throws away what the user is in the middle of doing. + expect(filterAccountRows(rows, "claude", "cursor").map(r => r.id)).toEqual(["cursor", "anthropic"]); + expect(filterAccountRows(rows, "").map(r => r.id)).toEqual(["cursor", "anthropic"]); + }); + + test("a provider that already has a login row is not also listed as a preset", () => { + const presets = [preset({ id: "openai", label: "OpenAI" }), preset({ id: "groq", label: "Groq" })]; + expect(dropPresetsCoveredByAccounts(presets, [{ id: "openai" }]).map(p => p.id)).toEqual(["groq"]); + expect(dropPresetsCoveredByAccounts(presets, []).map(p => p.id)).toEqual(["openai", "groq"]); + }); + }); describe("provider kind classification (WP080a)", () => { From e3fdf8fd26266f828c6c513a61e2d5e2d58f8bdb Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 11:14:14 +0900 Subject: [PATCH 147/231] feat(gui): clamp long provider notes to two lines and open the full text in a popup One catalog note is a paragraph. The opencode-free note runs ~1100 characters and meta-muse is longer still, so at the modal width a single row rendered 15-20 lines inside a 360px scroller and became the only row anyone could see. Notes now clamp to two lines and rows with more to show grow a reveal strip along their bottom edge. The reveal is a SIBLING of the row button inside a new .provider-catalog-row-wrap, never a child: .list-row is itself a + // The reveal control is a SIBLING of the row button, never a child of it: the row + // is already a + {onShowNote && noteNeedsReveal(p.note) && ( + + )} +
    ))} {tier !== "accounts" && !presetsLoading && rows.length === 0 && (
    {t("modal.noMatch")}
    diff --git a/gui/src/components/provider-catalog/ProviderNoteModal.tsx b/gui/src/components/provider-catalog/ProviderNoteModal.tsx new file mode 100644 index 0000000000..1f0338f609 --- /dev/null +++ b/gui/src/components/provider-catalog/ProviderNoteModal.tsx @@ -0,0 +1,79 @@ +/** + * Full-text popup for a catalog row's provider note. + * + * The catalog clamps a note to two lines, because a few of them are paragraphs: the + * `opencode-free` note is ~1100 characters and `meta-muse` is longer still, and at the + * modal width either one fills the entire 360px scroll viewport, so the row it belongs + * to becomes the only row a user can see. + * + * Native `` + `showModal()`, deliberately the same shape as + * `OAuthTosWarningModal`: it gives focus trapping and a backdrop for free, and — the + * part a hand-rolled overlay does not get — it restores focus to the control that + * opened it when it closes. It is rendered as a sibling of the add-provider overlay + * rather than inside it, so there is no dialog nested in a dialog's DOM. + */ +import { useCallback, useEffect, useId, useRef } from "react"; +import { useT } from "../../i18n/shared"; +import { IconX } from "../../icons"; +import { ProviderIcon } from "../provider-workspace/ProviderRail"; + +export default function ProviderNoteModal({ + providerId, + label, + adapter, + note, + onClose, +}: { + providerId: string; + label: string; + adapter: string; + note: string; + onClose: () => void; +}) { + const t = useT(); + const titleId = useId(); + const bodyId = useId(); + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + + // Native fires "cancel" on Escape — forward it so this popup closes first + // and the add-provider modal underneath stays open. + const handleCancel = useCallback((e: React.SyntheticEvent) => { + e.preventDefault(); + onClose(); + }, [onClose]); + + return ( + + +
    +
    + {adapter} +

    {note}

    +
    +
    + +
    + + + ); +} diff --git a/gui/src/components/provider-catalog/provider-presets.ts b/gui/src/components/provider-catalog/provider-presets.ts index 93119b85fa..1198c9b5da 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -119,6 +119,20 @@ export function filterPresets(presets: CatalogPreset[], query: string): CatalogP return presets.filter(p => p.label.toLowerCase().includes(q) || p.id.toLowerCase().includes(q)); } +/** + * Longest note that still fits the two-line clamp on a catalog row at the modal width. + * Deliberately a character count rather than a layout read: `scrollHeight > clientHeight` + * needs a ref on every row plus a resize observer, and it makes the decision impossible + * to test without a DOM. Erring slightly long only costs a reveal control on a row that + * did not strictly need one. + */ +export const NOTE_CLAMP_CHARS = 90; + +/** True when a note is long enough that the clamp hides part of it. */ +export function noteNeedsReveal(note: string | undefined): boolean { + return (note?.trim().length ?? 0) > NOTE_CLAMP_CHARS; +} + const SPONSOR_RANK: Record, number> = { main: 0, standard: 1 }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8153debace..d5280552e9 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1901,6 +1901,7 @@ export const de: Record = { "modal.tab.free": "Kostenlos", "modal.tab.local": "Lokal", "modal.tab.paid": "Bezahlt", + "modal.noteMore": "Vollständige Beschreibung anzeigen", "modal.accountsHint": "Hier ChatGPT/Codex, OAuth-Provider und API-Key-Konten anmelden. OpenAI ist eingebaut — anmelden statt erneut hinzufügen.", "modal.accountsCodexAuthLink": "Codex Auth", "modal.notListed": "Provider nicht dabei? Eigenen hinzufügen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c5abf0e2cb..032d5aff46 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1157,6 +1157,7 @@ export const en = { "modal.tab.free": "Free", "modal.tab.local": "Local", "modal.tab.paid": "Paid", + "modal.noteMore": "Show full description", "modal.accountsHint": "Sign in to ChatGPT/Codex, OAuth providers, and API-key accounts here. OpenAI is built in — log in rather than adding it again.", "modal.accountsCodexAuthLink": "Codex Auth", "modal.notListed": "Provider not listed? Add a custom one", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 09ff78305a..322d496d6e 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1130,6 +1130,7 @@ export const fr: Record = { "modal.tab.free": "Gratuit", "modal.tab.local": "Local", "modal.tab.paid": "Payant", + "modal.noteMore": "Afficher la description complète", "modal.accountsHint": "Connectez-vous ici à ChatGPT/Codex, aux fournisseurs OAuth et aux comptes avec clé API. OpenAI est intégré : connectez-vous au lieu de l’ajouter de nouveau.", "modal.accountsCodexAuthLink": "Codex Auth", "modal.notListed": "Fournisseur absent de la liste ? Ajoutez-en un personnalisé", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 5cb65d9f07..f2ab45b8e9 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1070,6 +1070,7 @@ export const ja: Record = { "modal.tab.free": "無料", "modal.tab.local": "ローカル", "modal.tab.paid": "有料", + "modal.noteMore": "説明をすべて表示", "modal.accountsHint": "ChatGPT/Codex、OAuth プロバイダー、API キーアカウントにここからサインインします。OpenAI は組み込み済み — 再度追加せずログインしてください。", "modal.accountsCodexAuthLink": "Codex 認証", "modal.notListed": "プロバイダーが載っていませんか? カスタムを追加", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f7e8c259a0..a79196b3f0 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1940,6 +1940,7 @@ export const ko: Record = { "modal.tab.free": "무료", "modal.tab.local": "로컬", "modal.tab.paid": "유료", + "modal.noteMore": "설명 전체 보기", "modal.accountsHint": "여기서 ChatGPT/Codex, OAuth, API 키 계정에 로그인하세요. OpenAI는 기본 제공 — 다시 추가하지 말고 로그인하세요.", "modal.accountsCodexAuthLink": "Codex 인증", "modal.notListed": "찾는 프로바이더가 없나요? 직접 추가", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2a6b3dc075..394e312abf 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1125,6 +1125,7 @@ export const ru: Record = { "modal.tab.free": "Бесплатные", "modal.tab.local": "Локальные", "modal.tab.paid": "Платные", + "modal.noteMore": "Показать полное описание", "modal.accountsHint": "Здесь можно войти в аккаунты ChatGPT/Codex и OAuth-провайдеров, а также в аккаунты с API-ключами. Провайдер OpenAI уже встроен — просто войдите, а не добавляйте его заново.", "modal.accountsCodexAuthLink": "Аутентификация Codex", "modal.notListed": "Нет нужного провайдера? Добавьте свой", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c1a7d958f6..c6438767ea 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1144,6 +1144,7 @@ export const tr: Record = { "modal.tab.free": "Ücretsiz", "modal.tab.local": "Yerel", "modal.tab.paid": "Ücretli", + "modal.noteMore": "Açıklamanın tamamını göster", "modal.accountsHint": "ChatGPT/Codex ve OAuth hesaplarına buradan giriş yapın.", "modal.accountsCodexAuthLink": "Codex Kimlik Doğrulaması", "modal.notListed": "Sağlayıcı listede yok mu? Özel sağlayıcı ekleyin", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 7801fdf277..59e4ccfb47 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -923,6 +923,7 @@ export const zhTW: Record = { "modal.tab.free": "免費", "modal.tab.local": "本地", "modal.tab.paid": "付費", + "modal.noteMore": "查看完整說明", "modal.accountsHint": "在此登入 ChatGPT/Codex、OAuth 與 API 金鑰帳號。OpenAI 為內建供應商 — 請登入,無需再次新增。", "modal.accountsCodexAuthLink": "Codex 認證", "modal.notListed": "沒有你要的供應商?新增自訂", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cccbca8dcd..e4ecec59e8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1921,6 +1921,7 @@ export const zh: Record = { "modal.tab.free": "免费", "modal.tab.local": "本地", "modal.tab.paid": "付费", + "modal.noteMore": "查看完整说明", "modal.accountsHint": "在此登录 ChatGPT/Codex、OAuth 与 API 密钥账户。OpenAI 为内置提供商 — 请登录,无需再次添加。", "modal.accountsCodexAuthLink": "Codex 认证", "modal.notListed": "没有你要的提供商?添加自定义", diff --git a/gui/src/styles/provider-catalog.css b/gui/src/styles/provider-catalog.css index 6d87c59c5b..11df684e98 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -55,6 +55,107 @@ padding: 8px; } +/* A preset row and its note-reveal control. The control has to be a SIBLING of the row + button rather than a child, because the row is itself a

    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 S)Nz4tu(Z@3tJW*qzWjguL3%I% literal 0 HcmV?d00001 From f5677142dc327e937e423b838ec931198213b6a6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 04:19:47 +0900 Subject: [PATCH 116/231] test(cli): cover the unified pool route in the headless parity sweep CI shard 1/4 caught /api/pool/settings with no CLI resource row. The verbs exist and declare the route in src/cli/capabilities.ts; the sweep table simply had not been extended when the route landed. --- tests/cli/cli-headless-parity.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 5594d44a59..0206c8e54a 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -250,6 +250,11 @@ describe("headless GUI parity CLI", () => { // skipping the endpoint. ["/api/github/star", "(none — GUI-only)"], ["/api/oauth", "ocx account"], + // The unified pool-settings route (#695 wp5c). One path answers for every pool + // kind, and `ocx account strategy` / `ocx account sticky` / `ocx account auto-switch` + // are what drive it headlessly — they declare it in src/cli/capabilities.ts rather + // than the retired per-namespace paths. + ["/api/pool/settings", "ocx account strategy/sticky/auto-switch"], ["/api/accounts/events", "(none — dashboard invalidation; ocx account reads current selection)"], ["/api/providers/keys", "ocx account"], ["/api/providers", "ocx provider"], From d2ca1abca19ae6c25d5219ef9b7435e94c5af76b Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 04:36:24 +0900 Subject: [PATCH 117/231] fix(providers): rebuild the route when the pre-dispatch key pick fires selectProactiveApiKey answers with the PERSISTED provider row, which carries none of the registry backfills routedProviderConfig merges in at request time. All four first-send call sites assigned it to a live route wholesale, so the one backfill that matters most was silently dropped: a stored key reference is resolved in routedProviderConfig and nowhere in the adapter, which means the upstream received the literal reference as its bearer token. The 429 path already solved this. rotateProviderTransportOn429 rebuilds from the committed row through applyRotatedTransport, which reapplies registry metadata and retains only explicit runtime transport state. selectProactiveApiKeyTransport is the pre-dispatch twin, and the four call sites now use it. Found by Codex review on #4292. The review named adapter and baseUrl as the loss; those are schema-required on a stored row, so the resolved credential is the demonstrable failure and the new /v1/responses regression test pins that instead. --- src/providers/key-failover.ts | 29 +++++++++++ src/server/chat-native.ts | 6 ++- src/server/images.ts | 7 ++- src/server/responses/compact.ts | 6 ++- src/server/responses/core.ts | 15 ++++-- tests/server/server-key-failover-e2e.test.ts | 55 ++++++++++++++++++++ 6 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/providers/key-failover.ts b/src/providers/key-failover.ts index 5427beb0b4..8caa7472e4 100644 --- a/src/providers/key-failover.ts +++ b/src/providers/key-failover.ts @@ -178,6 +178,14 @@ function rankKeysByHeadroom( * * Returning null is the common path, so the persisted-selection transaction is not on * the per-request hot path. + * + * Like `rotateKeyAfterFailure`, the returned object is a snapshot of the PERSISTED config + * and carries none of the registry backfills `routedProviderConfig` merges in at request + * time. A request path must not assign it to an active route wholesale -- for a built-in + * provider stored in its valid minimal form that would drop the adapter id, the base URL and + * the static headers, so `resolveAdapter()` throws `Unknown adapter: undefined` and a + * hand-built URL dereferences a missing `baseUrl`. Use + * `selectProactiveApiKeyTransport`, the pre-dispatch twin of `rotateProviderTransportOn429`. */ export function selectProactiveApiKey( config: OcxConfig, @@ -236,6 +244,27 @@ export function selectProactiveApiKey( return structuredClone(committed); } +/** + * Pre-dispatch twin of `rotateProviderTransportOn429`: pick a warm key, then rebuild the + * active route from the committed row through the same seam the 429 path uses, so the + * registry backfills survive and only explicit runtime transport state (`fetch` and a + * generated OpenCode session header) is carried over from the route being replaced. + * + * Every request path that assigns the result to a live route must call THIS, not + * `selectProactiveApiKey`, which answers with a persisted snapshot. + */ +export function selectProactiveApiKeyTransport( + config: OcxConfig, + providerName: string, + routedProvider: OcxProviderTransport, + promptCacheKey?: string, + now = Date.now(), +): OcxProviderTransport | null { + const committed = selectProactiveApiKey(config, providerName, now); + if (!committed) return null; + return applyRotatedTransport(providerName, routedProvider, committed, promptCacheKey); +} + /** * Normalize a provider's `retryOn429` policy, or return null when the knob is absent, * explicitly disabled, or the provider is not key-auth (OAuth/forward credentials must not be diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index a4bb684252..7f3c97c10e 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -33,7 +33,7 @@ import { } from "../lib/translator-budget"; import { hasKeyPoolFailover, - selectProactiveApiKey, + selectProactiveApiKeyTransport, rateLimitRetryDelayMs, rateLimitRetryPolicyFor, rotateProviderTransportOn429, @@ -240,7 +240,9 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio // through the Responses core -- so the pre-dispatch key preference is applied again here // rather than inherited. Assigned before the adapter binds below, for the same reason it is // assigned before the transport pin in core.ts. - const proactiveKeyProvider = selectProactiveApiKey(config, route.providerName); + // Transport variant: the bare picker answers with the persisted row, which for a built-in + // provider carries no adapter id or base URL until routedProviderConfig backfills it. + const proactiveKeyProvider = selectProactiveApiKeyTransport(config, route.providerName, route.provider); if (proactiveKeyProvider) route.provider = proactiveKeyProvider; let activeProvider: OcxProviderConfig = route.provider; let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider); diff --git a/src/server/images.ts b/src/server/images.ts index 0378aba753..3b26f3e619 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -28,7 +28,7 @@ import { readBoundedResponseBytes, type BoundedBytesResult } from "../lib/bounde import { sidecarEnter } from "../lib/sidecar-tracker"; import type { OcxConfig } from "../types"; import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar"; -import { selectProactiveApiKey } from "../providers/key-failover"; +import { selectProactiveApiKeyTransport } from "../providers/key-failover"; import { resolveProviderApiKey } from "../providers/key-store"; import { getProviderRegistryEntry } from "../providers/registry"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; @@ -709,7 +709,10 @@ export async function handleImages( // that never used the key. And the header is rebuilt from the returned clone rather than // from candidates.keyed.apiKey, which is a snapshot resolved earlier: reusing it would // send the OLD key while the picker had already persisted the new one. - const warmKeyProvider = selectProactiveApiKey(config, providerName); + // Transport variant: this branch reads `provider.baseUrl` and `provider.headers` to build + // the URL and the request, and the persisted row carries neither for a built-in provider + // stored in its minimal form. + const warmKeyProvider = selectProactiveApiKeyTransport(config, providerName, candidates.keyed.provider); const provider = warmKeyProvider ?? candidates.keyed.provider; const apiKey = warmKeyProvider?.apiKey ? (resolveProviderApiKey(warmKeyProvider.apiKey) ?? candidates.keyed.apiKey) diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 6a0e39b0ce..21958143db 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -111,7 +111,7 @@ import { UnsupportedContentEncodingError, } from "../request-decompress"; import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve"; -import { hasKeyPoolFailover, rotateProviderTransportOn429, selectProactiveApiKey } from "../../providers/key-failover"; +import { hasKeyPoolFailover, rotateProviderTransportOn429, selectProactiveApiKeyTransport } from "../../providers/key-failover"; import { shouldAttemptImageTierRetry } from "../image-retry"; import { resolveProviderTransport } from "../../providers/xai-transport"; import type { WsData } from "../ws-bridge"; @@ -746,7 +746,9 @@ export async function handleResponsesCompact( // Native compact never enters handleResponses, so it needs its own pre-dispatch key // pick. Kept inside this branch on purpose: the overlay above owns the forward and // codexAccountMode cases, and the picker returns null for them anyway. - const warmKeyProvider = selectProactiveApiKey(config, route.providerName); + // Transport variant, for the same reason as the Responses core: the persisted row has no + // registry backfills, and compactProvider is read for its transport fields below. + const warmKeyProvider = selectProactiveApiKeyTransport(config, route.providerName, compactProvider); if (warmKeyProvider?.apiKey) compactProvider = warmKeyProvider; headers.set("authorization", `Bearer ${resolveProviderApiKey(compactProvider.apiKey)}`); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 28ebc95fe8..1f78570eae 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -267,7 +267,7 @@ import type { AdapterRequest, ProviderAdapter } from "../../adapters/base"; import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../../providers/api-key-selection"; import { hasKeyPoolFailover, - selectProactiveApiKey, + selectProactiveApiKeyTransport, rateLimitRetryDelayMs, rateLimitRetryPolicyFor, rotateProviderTransportOn429, @@ -4453,11 +4453,20 @@ async function handleResponsesInner( // runtime could already predict. The picker refuses to override a healthy committed key and // returns null without a configured strategy, so an ordinary install evaluates one predicate. // - // It RETURNS a clone rather than mutating the route, and the assignment has to land here -- + // It RETURNS a rebuilt route rather than mutating one, and the assignment has to land here -- // ahead of the transport pin below, the adapterProvider copy that follows it, and the request // the HTTP path bakes later. The image bridge and web search read route.provider directly and // have no stale-selection re-read to save them, so ordering is the whole correctness argument. - const proactiveKeyProvider = selectProactiveApiKey(config, route.providerName); + // + // The Transport variant, not the bare picker: the picker answers with the PERSISTED row, and + // a built-in provider stored in its valid minimal form would lose the adapter id, base URL + // and static headers registry backfill supplies, throwing `Unknown adapter: undefined`. + const proactiveKeyProvider = selectProactiveApiKeyTransport( + config, + route.providerName, + route.provider, + parsed.options.promptCacheKey, + ); if (proactiveKeyProvider) route.provider = proactiveKeyProvider; route.provider = resolveProviderTransport( route.providerName, diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index 267c687ac8..f5d2d1dfee 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -729,3 +729,58 @@ describe("server 429 key failover (end-to-end)", () => { await server.stop(true); } }); + + /** + * The two cases above pin the behaviour but not the PATH: an `openai-chat` provider sends + * /v1/chat/completions through `handleNativeChatCompletions`, so the independently changed + * pick in `responses/core.ts` never runs. This one goes through /v1/responses. + * + * It also pins what a naive pick gets wrong. The picker answers with the PERSISTED row, + * which carries none of the backfills `routedProviderConfig` merges in at request time -- + * and one of those is the API key itself: a stored `\${VAR}` reference is resolved there and + * nowhere in the adapter. Assigning the picked row to `route.provider` wholesale therefore + * sends the literal reference as the bearer token. `adapter` and `baseUrl` are not the + * demonstrable half of this, because the config schema requires both on a stored row; the + * resolved credential is. + * + * Red control: swap `selectProactiveApiKeyTransport` back for `selectProactiveApiKey` in + * core.ts and the upstream sees `Bearer \${OCX_KEYFAIL_WARM}` verbatim. + */ + test("the Responses core pick keeps the route's resolved credential", async () => { + const seen: string[] = []; + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + seen.push(req.headers.get("authorization") ?? ""); + return Response.json({ id: "chatcmpl-warm", object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "warm" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + } }); + process.env.OCX_KEYFAIL_COOLED = "resolved-cooled"; + process.env.OCX_KEYFAIL_WARM = "resolved-warm"; + saveConfig({ port: 0, hostname: "127.0.0.1", defaultProvider: "env-pooled", providers: { "env-pooled": { + adapter: "openai-chat", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, + authMode: "key", apiKey: "\${OCX_KEYFAIL_COOLED}", apiKeyPoolStrategy: "round-robin", + apiKeyPool: [ + { id: "cooled", key: "\${OCX_KEYFAIL_COOLED}" }, + { id: "warm", key: "\${OCX_KEYFAIL_WARM}" }, + ], + } } } as OcxConfig); + const live = loadConfig(); + rotateKeyOn429(live, "env-pooled", null, Date.now(), "\${OCX_KEYFAIL_COOLED}"); + const restored = loadConfig(); + restored.providers["env-pooled"]!.apiKey = "\${OCX_KEYFAIL_COOLED}"; + saveConfig(restored); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "env-pooled/test", input: "hi", stream: false }), + }); + expect(response.status).toBe(200); + expect(seen).toEqual(["Bearer resolved-warm"]); + } finally { + await server.stop(true); + delete process.env.OCX_KEYFAIL_COOLED; + delete process.env.OCX_KEYFAIL_WARM; + } + }); From 86b3613a2b6dbd8771fdfff8ff0aeaad5a1b82c0 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 04:39:47 +0900 Subject: [PATCH 118/231] test(providers): pin the rebuild contract and the Responses core path Two gaps Codex review named on #4292. The e2e file only sent /v1/chat/completions, which handleNativeChatCompletions serves, so the core.ts call site was never executed; the new /v1/responses case covers it and fails with the cooled key when the pick is removed. The rebuild contract is pinned as a unit instead, because the Responses core self-heals a wholesale assignment through refreshDispatchAdapter and therefore cannot show the difference. The unit red control does: returning the picker snapshot hands back the literal key reference, which is what would have gone upstream as the bearer token. --- tests/adapters/key-failover.test.ts | 44 ++++++++++++++++++++ tests/server/server-key-failover-e2e.test.ts | 27 ++++++------ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/tests/adapters/key-failover.test.ts b/tests/adapters/key-failover.test.ts index 8a0b290cd3..3f997db138 100644 --- a/tests/adapters/key-failover.test.ts +++ b/tests/adapters/key-failover.test.ts @@ -21,6 +21,7 @@ import { import { forgetApiKeyRotationCursor, selectProactiveApiKey, + selectProactiveApiKeyTransport, } from "../../src/providers/key-failover"; import { resolveOpenCodeGoTransport } from "../../src/providers/opencode-go-transport"; import { deriveXaiConvId } from "../../src/providers/xai-transport"; @@ -495,6 +496,49 @@ describe("rotateKeyOn401", () => { expect(getKeyCooldownUntil("p", "k1", now)).toBeGreaterThan(now); }); + /** + * The picker answers with the PERSISTED row, so a request path that assigns it to a live + * route wholesale loses everything `routedProviderConfig` backfills at request time and + * every piece of explicit runtime transport state the route was carrying. The most + * load-bearing of those is the credential: a stored `\${VAR}` reference is resolved in + * `routedProviderConfig` and nowhere in the adapter, so the snapshot's `apiKey` is the + * reference itself. + * + * `selectProactiveApiKeyTransport` is the pre-dispatch twin of + * `rotateProviderTransportOn429` and goes through the same rebuild seam. Red control: + * return the snapshot from it and both the resolved credential and the retained `fetch` + * assertions below fail. + */ + test("the Transport twin rebuilds the route the picker only snapshots", () => { + process.env.OCX_KEYFAILOVER_WARM = "resolved-warm-key"; + try { + const config = makeConfig({ + apiKey: "key-alpha-000111222333", + apiKeyPool: [ + { id: "k1", key: "key-alpha-000111222333", addedAt: 1 }, + { id: "k2", key: "\${OCX_KEYFAILOVER_WARM}", addedAt: 2 }, + ], + apiKeyPoolStrategy: "round-robin", + }); + forgetApiKeyRotationCursor("p"); + rotateKeyOn429(config, "p", null, now); + setActiveProviderApiKey(config, "p", "k1"); + const sentinelFetch = (async () => new Response("")) as typeof fetch; + const routed = { ...routedProviderConfig("p", config.providers.p!), fetch: sentinelFetch }; + const transport = selectProactiveApiKeyTransport(config, "p", routed, undefined, now); + expect(transport).not.toBeNull(); + // The route gets the RESOLVED credential. + expect(transport?.apiKey).toBe("resolved-warm-key"); + // Explicit runtime transport state survives the rebuild, exactly as it does on 429. + expect(transport?.fetch).toBe(sentinelFetch); + // And the persisted row still holds the reference, which is what made the wholesale + // assignment wrong in the first place. + expect(loadConfig().providers.p!.apiKey).toBe("\${OCX_KEYFAILOVER_WARM}"); + } finally { + delete process.env.OCX_KEYFAILOVER_WARM; + } + }); + test("returns null when every key is cooling", () => { const config = makeConfig({ apiKey: "key-alpha-000111222333", diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index f5d2d1dfee..418ef993ca 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -732,21 +732,24 @@ describe("server 429 key failover (end-to-end)", () => { /** * The two cases above pin the behaviour but not the PATH: an `openai-chat` provider sends - * /v1/chat/completions through `handleNativeChatCompletions`, so the independently changed - * pick in `responses/core.ts` never runs. This one goes through /v1/responses. + * /v1/chat/completions through `handleNativeChatCompletions`, so the pick in + * `responses/core.ts` never runs in either of them. This one goes through /v1/responses, so + * the independently changed core call site is actually covered. * - * It also pins what a naive pick gets wrong. The picker answers with the PERSISTED row, - * which carries none of the backfills `routedProviderConfig` merges in at request time -- - * and one of those is the API key itself: a stored `\${VAR}` reference is resolved there and - * nowhere in the adapter. Assigning the picked row to `route.provider` wholesale therefore - * sends the literal reference as the bearer token. `adapter` and `baseUrl` are not the - * demonstrable half of this, because the config schema requires both on a stored row; the - * resolved credential is. + * The pool keys are stored as `\${VAR}` references on purpose. Reference resolution is one of + * the backfills `routedProviderConfig` applies and the adapter does not, so the upstream + * bearer proves the route the core path dispatched was a rebuilt one rather than the + * picker's persisted snapshot. * - * Red control: swap `selectProactiveApiKeyTransport` back for `selectProactiveApiKey` in - * core.ts and the upstream sees `Bearer \${OCX_KEYFAIL_WARM}` verbatim. + * Red control: remove the pick from core.ts and the upstream sees `Bearer resolved-cooled`, + * because the committed selection still points at the cooled key. + * + * What this case does NOT prove is the Transport-vs-snapshot distinction on this path: + * `refreshDispatchAdapter` re-derives the transport from config before dispatch, so the + * Responses core self-heals a wholesale assignment. That contract is pinned as a unit in + * tests/adapters/key-failover.test.ts, where it has a red control that actually fails. */ - test("the Responses core pick keeps the route's resolved credential", async () => { + test("the Responses core pick reaches the warm key through /v1/responses", async () => { const seen: string[] = []; upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { seen.push(req.headers.get("authorization") ?? ""); From 2559502ea6fec0950b156fe06c884f8eb3785f6c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 04:46:23 +0900 Subject: [PATCH 119/231] fix(providers): expire a cached key quota and reset cursors on a batch edit Two P2 findings from Codex review on #4292, both confirmed against the code. cachedApiKeyQuota rejected unavailable rows but not expired ones, so a successful measurement could outlive ACCOUNT_QUOTA_TTL_MS and keep ranking above a key with no evidence until an unrelated write happened to sweep it. It now applies readEntry freshness predicate inverted, and still never probes. The batch provider PUT cleared every key cooldown but no rotation cursor, so round-robin resumed after the pre-edit position instead of the roster head the operator had just saved. forgetApiKeyRotationCursor takes an optional name now, mirroring clearKeyCooldowns, and the PUT calls it with none. Both red controls fail without the fix: the TTL case returns the roomier stale key, and the cursor case returns sk-alpha-three instead of the first eligible key. --- src/providers/key-failover.ts | 14 ++++- src/providers/quota-key-accounts.ts | 9 ++++ src/server/management/provider-routes.ts | 4 ++ tests/adapters/key-failover.test.ts | 26 ++++++++++ .../provider-config-batch-management.test.ts | 52 +++++++++++++++++++ 5 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/providers/key-failover.ts b/src/providers/key-failover.ts index 8caa7472e4..d5ccd759e3 100644 --- a/src/providers/key-failover.ts +++ b/src/providers/key-failover.ts @@ -112,8 +112,18 @@ export function hasKeyPoolFailover(provider: OcxProviderConfig): boolean { */ const keyRotationCursor = new Map(); -/** Forget a provider's cursor so an operator's manual key selection is not second-guessed. */ -export function forgetApiKeyRotationCursor(providerName: string): void { +/** + * Forget a provider's cursor so an operator's manual key selection is not second-guessed. + * + * Optional name, mirroring `clearKeyCooldowns`, because the batch provider PUT rewrites the + * entire roster: a cursor that survives a reorder still names a real id, so round-robin + * resumes after the pre-edit position and can skip the first eligible key in the new pool. + */ +export function forgetApiKeyRotationCursor(providerName?: string): void { + if (!providerName) { + keyRotationCursor.clear(); + return; + } keyRotationCursor.delete(providerName); } diff --git a/src/providers/quota-key-accounts.ts b/src/providers/quota-key-accounts.ts index f52cfa54e3..21d4124201 100644 --- a/src/providers/quota-key-accounts.ts +++ b/src/providers/quota-key-accounts.ts @@ -39,6 +39,12 @@ export function clearProviderApiKeyQuotaCache(): void { * keeps a last-good quota attached for up to LAST_GOOD_MS after a probe starts failing, so * returning `entry.quota` on any hit would rank on a number up to half an hour stale -- and * rank it ABOVE a key with no row at all. Last-good is a display value, not a selection input. + * + * A SUCCESSFUL row expires too, on exactly `readEntry`'s freshness predicate. Checking only + * `unavailable` was not enough: nothing on the selection path probes or sweeps, so once a + * dashboard or CLI read had populated the cache, a row could outlive ACCOUNT_QUOTA_TTL_MS and + * keep a "roomy" ten-minute-old measurement ranked above a key with no evidence at all -- + * until some unrelated write happened to sweep it. Expired is no evidence, same as absent. */ export function cachedApiKeyQuota( name: string, @@ -53,6 +59,9 @@ export function cachedApiKeyQuota( if (!resolved) return null; const entry = cache.get(identity(name, provider, keyId, resolved)); if (!entry || entry.unavailable || !entry.quota) return null; + const now = Date.now(); + if (now - entry.ts >= ACCOUNT_QUOTA_TTL_MS) return null; + if (now - entry.quota.updatedAt >= LAST_GOOD_MS) return null; return entry.quota; } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 448771224b..c500d7c611 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -932,6 +932,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { expect(selectProactiveApiKey(config, "p", now)?.apiKey).toBe("key-beta-444555666777"); }); + /** + * A SUCCESSFUL row expires too. Nothing on the selection path probes or sweeps, so once a + * dashboard or CLI read populated the cache, a ten-minute-old measurement could keep + * outranking a key with no evidence until some unrelated write swept it. + * + * gamma is the roomier key, so a cache that still counts as evidence picks gamma. Expired, + * neither row is evidence and the roster order decides -- beta, the same answer the + * nothing-measured case above gets. Red control: drop the two freshness checks in + * `cachedApiKeyQuota` and this returns gamma. + */ + test("a successful row past its TTL is not evidence either", () => { + const config = cooledFirstKey("quota"); + seedQuota(config, "k2", "key-beta-444555666777", 90); + seedQuota(config, "k3", "key-gamma-888999000111", 10); + const realNow = Date.now; + // Only the CACHE clock moves. The cooldown clock is the `now` the selector is handed, and + // the two are independent on purpose -- otherwise this would also un-cool k1. + Date.now = () => realNow() + ACCOUNT_QUOTA_TTL_MS + 1; + try { + expect(selectProactiveApiKey(config, "p", now)?.apiKey).toBe("key-beta-444555666777"); + } finally { + Date.now = realNow; + } + }); + }); }); diff --git a/tests/providers/provider-config-batch-management.test.ts b/tests/providers/provider-config-batch-management.test.ts index c0646c98f9..293f93a69b 100644 --- a/tests/providers/provider-config-batch-management.test.ts +++ b/tests/providers/provider-config-batch-management.test.ts @@ -9,6 +9,8 @@ import { safeConfigDTO } from "../../src/server/auth-cors"; import { handleManagementAPI } from "../../src/server/management-api"; import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { clearKeyCooldowns, forgetApiKeyRotationCursor, rotateKeyOn429, selectProactiveApiKey } from "../../src/providers/key-failover"; +import { setActiveProviderApiKey } from "../../src/providers/api-keys"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { ManagementRequest as Request } from "../helpers/management-auth"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -360,4 +362,54 @@ describe("atomic provider editor batch", () => { error: "Full config PUT is disabled. Use /api/providers POST for provider changes.", }); }); + + /** + * The batch PUT rewrites the whole roster, which is why it already clears every key cooldown + * without naming a provider. The rotation cursor is the other half of that state and was + * being left behind, so round-robin resumed after the pre-edit position instead of at the + * head of the roster the operator had just saved. + * + * Red control: drop `forgetApiKeyRotationCursor()` from the PUT success path and the pick + * below returns `sk-alpha-three`, continuing after the stale cursor instead of taking the + * first eligible key. + */ + test("a batch PUT forgets the rotation cursor along with the cooldowns", async () => { + const liveConfig = seededConfig(); + liveConfig.providers.alpha!.apiKeyPoolStrategy = "round-robin"; + liveConfig.providers.alpha!.apiKeyPool = [ + { id: "one", key: "sk-alpha-one" }, + { id: "two", key: "sk-alpha-two" }, + { id: "three", key: "sk-alpha-three" }, + ]; + liveConfig.providers.alpha!.apiKey = "sk-alpha-one"; + saveConfig(liveConfig); + clearKeyCooldowns(); + forgetApiKeyRotationCursor(); + + // Establish a cursor the honest way: cool the committed key, point the stored selection + // back at it -- which is the state a restart or a config reload leaves -- and let the pool + // advance. Cooling alone is not enough, because rotateKeyOn429 already commits the next + // key and the picker refuses to second-guess a healthy committed one. + const t0 = Date.now(); + rotateKeyOn429(loadConfig(), "alpha", null, t0, "sk-alpha-one"); + setActiveProviderApiKey(loadConfig(), "alpha", "one"); + const first = selectProactiveApiKey(loadConfig(), "alpha", t0); + expect(first?.apiKey).toBe("sk-alpha-two"); + + const baseline = editorBaseline(loadConfig()); + // apiKeyPoolStrategy is a public editor field, so it has to appear in the baseline or the + // deep-equal staleness check rejects the PUT. + baseline.providers.alpha!.apiKeyPoolStrategy = "round-robin"; + const next = structuredClone(baseline); + next.providers.alpha!.defaultModel = "alpha-new"; + const response = await putBatch(loadConfig(), { baseline, next }); + expect(response?.status).toBe(200); + + // The PUT cleared the cooldowns, so key one is eligible again. Cool only the committed key + // and point the selection back at it, the same way as above. + rotateKeyOn429(loadConfig(), "alpha", null, t0, "sk-alpha-two"); + setActiveProviderApiKey(loadConfig(), "alpha", "two"); + const second = selectProactiveApiKey(loadConfig(), "alpha", t0); + expect(second?.apiKey).toBe("sk-alpha-one"); + }); }); From f593498e4c2499259216306adc9cda0caa56e526 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 04:46:23 +0900 Subject: [PATCH 120/231] docs: document cache affinity and the pre-dispatch key pick pool.cacheAffinity had no docs-site row, and the configuration reference still claimed without qualification that a bound task may move once autoSwitchThreshold is crossed. Both are corrected in English and in the locales carrying those rows. structure/ describes the first-attempt pick as well: where it lands on the Responses path and why it must precede the transport pin, that native chat, native compact and the keyed images relay each repeat it, and that request paths take the Transport variant rather than the persisted snapshot. Both gaps were raised by Codex review on #4292. --- .../fr/reference/configuration/providers.md | 11 ++++--- .../ja/reference/configuration/providers.md | 10 +++--- .../ko/reference/configuration/providers.md | 14 +++++---- .../docs/reference/configuration/providers.md | 14 +++++---- .../ru/reference/configuration/providers.md | 12 ++++--- .../tr/reference/configuration/providers.md | 17 +++++----- .../reference/configuration/providers.md | 12 ++++--- .../reference/configuration/providers.md | 9 +++--- structure/data-planes/images.md | 3 ++ structure/data-planes/inbound-compat.md | 6 +++- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 31 +++++++++++++++++++ 12 files changed, 97 insertions(+), 44 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index b8da2537bb..492548c30f 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -38,8 +38,9 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `activeCodexAccountId?` | `string` | — | Compte de pool sélectionné manuellement pour la prochaine demande. La sélection efface l'affinité des threads ; les demandes en cours conservent les informations d’identification capturées. | | `codexAccountPriorities?` | `Record` | — | Ordre de sélection par compte pour le pool Codex : identifiant de compte → entier de `-100` à `100`, **les valeurs élevées sont prioritaires**, une valeur absente équivaut à `0`. Cette limite porte sur le classement, et non sur l'admissibilité : la sélection retient, parmi les comptes déjà admissibles, le niveau prioritaire le plus élevé qui dispose encore d'une marge de quota, puis `accountPoolStrategy` choisit un compte dans ce niveau. Un niveau est ignoré uniquement lorsque chacun de ses membres dépasse `autoSwitchThreshold`, est en temporisation, est temporairement évité, est suspendu ou doit être réauthentifié ; un quota inconnu ne suffit jamais à considérer un niveau comme épuisé. L'ordre ne rend jamais admissible un compte qui ne l'est pas et ne réaffecte jamais une tâche déjà liée à un compte. Le compte principal `__main__` participe selon les mêmes règles ; la connexion Codex Desktop peut ainsi être configurée pour être utilisée en dernier. Sans entrée, le pool se comporte exactement comme auparavant. Un mappage mal formé est ignoré avec un avertissement dans la console : l'ordre est désactivé et la configuration n'est pas réparée. Ce champ est géré par `ocx account priority` et la page Codex Auth. | | `activeCodexAccountPinned?` | `string` | — | Identifiant du compte du dernier opérateur sélectionné manuellement. Lorsqu'il est défini, un niveau `codexAccountPriorities` supérieur ne peut pas le préempter jusqu'à ce que la broche soit libérée par drainage, exclusion, suppression ou un failover/promotion explicite. Un mouvement circulaire ordinaire à l’intérieur du niveau plafonné ne le libère pas. L'écriture d'une entrée `codexAccountPriorities` libère également le pin, donc un pin créé avant qu'un ordre n'existe ne peut pas surpasser un ensemble par la suite. `GET /api/codex-auth/active` indique à la fois si le compte effectif est épinglé (`pinned`) et le compte portant le plafond (`pinnedAccountId`). | -| `autoSwitchThreshold?` | `number` | `80` | Seuil d'utilisation pour la commutation proactive. `quota` peut réévaluer les tâches liées et non liées lors de leur prochaine requête ; `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée ou relier de manière proactive une tâche liée à un compte admissible moins utilisé. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. | +| `autoSwitchThreshold?` | `number` | `80` | Seuil d'utilisation pour la commutation proactive. `quota` peut réévaluer les requêtes non liées lors de leur prochaine requête et, par défaut, réévalue aussi les tâches liées une fois ce seuil franchi. Avec `pool.cacheAffinity` activé, une tâche liée conserve son compte au-delà du seuil jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir. `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. | +| `pool.cacheAffinity?` | `boolean` | `false` | Ordre d'affinité de cache optionnel pour les threads Codex liés, indépendant de `pool.kernel`. Désactivé par défaut ; une valeur mal formée est lue comme désactivée. Une fois activé, une liaison active prime sur la marge de quota : `quota` ne déplace pas le thread simplement parce que l'utilisation a franchi `autoSwitchThreshold`. Le thread quitte encore le compte s'il ne peut plus servir — suspendu, inutilisable, ou réellement épuisé (utilisation connue à 100 %) — l'affinité est donc un réordonnancement, pas un verrouillage. | | `accountPoolStickyLimit?` | `number` | `1` | Nombre d'affectations de tâches nouvelles ou non liées conservées sur une même sélection tournante avant de passer à la suivante ; le compteur avance lorsqu'une tâche est liée, et non après une réponse réussie en amont. Plage : 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Nombre d'échecs transitoires consécutifs avant le basculement des futures nouvelles sessions. Réglez `0` pour désactiver ce mécanisme. Pour les requêtes Responses ordinaires et les envois compacts natifs, les échecs avérés d'accessibilité DNS/TCP avant connexion sont suivis au niveau du couple fournisseur-hôte : ils n'affectent jamais l'état ni la temporisation du compte, l'affinité de tâche ou de session, la sélection du compte actif ou le routage du pool, et ne sont jamais comptabilisés dans ce seuil. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Seuil facultatif du coupe-circuit pour les échecs DNS/TCP avérés avant connexion sur les requêtes Responses OpenAI natives en mode transfert et les envois compacts. `0` le désactive ; `1`–`20` ouvre, après ce nombre de requêtes logiques arrivées à leur terme, une temporisation de 30 secondes propre à l'origine du fournisseur. Tant que le circuit est ouvert, les requêtes reçoivent `503` avec `Retry-After` avant la sélection du compte ou l'envoi en amont ; après la temporisation, une requête est admise en état semi-ouvert. Les délais d'attente et les réponses HTTP ne sont jamais comptabilisés, et toute réponse HTTP ferme le circuit. Ce mécanisme s'applique uniquement au routage du pool Codex sans compte épinglé ; il reste inactif pour `codexAccountMode: "direct"` et les sélecteurs qualifiés par compte. | @@ -182,8 +183,8 @@ Deux accommodements fake-IP DNS existent pour les utilisateurs de Clash / Surge Utilisez **Codex Auth** dans le tableau de bord pour ajouter des comptes au groupe et actualiser les quotas. `config.json` stocke les métadonnées non secrètes ; les jetons d'accès et d'actualisation utilisent le magasin d'identifiants renforcé. Le routage du pool distingue l'affectation des requêtes nouvelles ou non liées, la commutation proactive fondée sur l'utilisation et la récupération après incident. Une tâche liée -conserve normalement son affinité, mais `quota` peut la relier lors de sa requête suivante une fois le seuil d'utilisation -franchi ; la suspension, la temporisation, la réauthentification et la gestion des échecs peuvent, indépendamment, effacer ou déplacer son routage. +conserve normalement son affinité. Par défaut, `quota` peut la relier lors de sa requête suivante une fois le seuil d'utilisation +franchi ; avec `pool.cacheAffinity` activé, cette réaffectation attend que le compte lié soit épuisé ou ne puisse plus servir. La suspension, la temporisation, la réauthentification et la gestion des échecs peuvent, indépendamment, effacer ou déplacer son routage. Une requête non liée ne possède aucune liaison active à un compte ; il peut s'agir d'une tâche existante visible après le redémarrage du proxy ou la réinitialisation de l'affinité. Un 429 ou un 402 reçu avant le début de la diffusion déclenche une nouvelle tentative unique sur un autre compte admissible au sein de la même requête, même lorsque la commutation proactive fondée sur l'utilisation est désactivée. Les changements de @@ -203,7 +204,7 @@ et suspend uniquement ceux dont l'utilisation vient d'être confirmée à 100 % | Stratégie | Comportement | | --- | --- | -| `quota` (par défaut) | S'il n'existe aucun compte actif, choisir le compte admissible le moins utilisé selon les fenêtres de 5 heures, d'une semaine et de 30 jours. Sinon, conserver un compte actif admissible sous `autoSwitchThreshold` ; une fois le seuil franchi, une requête non liée ou la requête suivante d'une tâche liée peut être déplacée vers un compte admissible moins utilisé. `0` désactive cette réévaluation fondée sur l'utilisation, mais pas la récupération après incident. | +| `quota` (par défaut) | S'il n'existe aucun compte actif, choisir le compte admissible le moins utilisé selon les fenêtres de 5 heures, d'une semaine et de 30 jours. Sinon, conserver un compte actif admissible sous `autoSwitchThreshold` ; une fois le seuil franchi, une requête non liée peut être déplacée vers un compte admissible moins utilisé, et la requête suivante d'une tâche liée aussi sauf si `pool.cacheAffinity` est activé. Avec ce drapeau, l'affinité de cache prime sur la marge de quota et la tâche liée reste jusqu'à ce que le compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir (suspendu, inutilisable). `0` désactive cette réévaluation fondée sur l'utilisation, mais pas la récupération après incident. | | `round-robin` | Répartit uniformément les requêtes non liées entre les comptes admissibles. `autoSwitchThreshold` ne modifie pas la sélection circulaire normale. `accountPoolStickyLimit` (1–100) compte les affectations effectuées avec une même sélection, et non les réponses réussies en amont. | | `fill-first` | Attribue les requêtes non liées au compte actif jusqu'à sa temporisation, sa réauthentification ou le seuil d'évacuation configuré ; une utilisation inconnue n'impose pas de changement. Les tâches liées et saines conservent leur affinité. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 002cedbec0..118ea830b9 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -36,8 +36,9 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `codexAccountPickerEnabled?` | `boolean` | map が空なら off | 有効な `codexAccountNamespaces` mapping から account-qualified Codex picker row を生成するかを制御します。`true` は mapping された行の表示を許可します。空でない map で省略した場合は後方互換性のため有効として扱われ、map が空なら off です。`false` は mapping を削除せず、明示的な `/` routing も無効にせずに、生成行を非表示にして picker の bare native 行を復元します。 | | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | | `codexAccountPriorities?` | `Record` | — | Codex pool のアカウント別選択順。アカウント ID → `-100` から `100` の整数で、**大きいほど先に使われ**、未設定は `0` です。これは eligibility ではなく順序の境界です。選択は適格なアカウントを、まだ quota に余裕がある最上位 tier に絞り込み、その tier の中を `accountPoolStrategy` が選びます。tier が飛ばされるのは、そのメンバー全員が `autoSwitchThreshold` 超過、cooldown 中、soft-avoid、一時停止、または再認証待ちのときだけで、usage 不明が tier を drain させることはありません。順序付けが不適格なアカウントを選択可能にすることはなく、すでにアカウントが結び付いた thread を再 bind することもありません。メインの `__main__` も同じ条件で参加するため、Codex Desktop ログインを最後に使わせられます。エントリが 1 つもなければ挙動は従来どおりです。map が不正な場合は警告を出して順序付けを無効にします(config の修復処理は走りません)。`ocx account priority` と Codex Auth ページで管理します。 | -| `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は紐付け済み/未紐付けタスクの次のリクエストを再評価でき、`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は、未紐付けリクエストまたは紐付け済みタスクの次のリクエストを usage の低い適格アカウントへ移せます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | +| `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は未紐付けタスクの次のリクエストを再評価でき、既定では使用量がこのしきい値を超えると紐付け済みタスクも再評価します。`pool.cacheAffinity` がオンなら、紐付け済みタスクはアカウントが使い切られるか処理できなくなるまでしきい値超過後も同じアカウントを維持します。`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | +| `pool.cacheAffinity?` | `boolean` | `false` | 紐付け済み Codex スレッド向けのオプトイン cache-affinity 順序。`pool.kernel` とは独立で、既定はオフです。不正な値はオフとして読みます。オンにすると live な紐付けが quota 余裕より優先されます。`quota` は使用量が `autoSwitchThreshold` を超えたという理由だけではスレッドを移しません。一時停止、使用不可、または実際に使い切られたアカウント(既知 usage 100%)では離れるので、affinity は固定ではなく並べ替えです。 | | `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` |今後の新しいセッションがフェイルオーバーする前に一時的なエラーが連続して発生する。 `0` を無効に設定します。通常のResponses送信とネイティブcompact送信では、実証済みの接続前DNS/TCP到達不能障害はprovider-host単位で記録され、アカウントの健全性、アカウントのクールダウン、スレッド/セッションの親和性、アクティブアカウントの選択、Poolルーティングには影響せず、この閾値にもカウントされません。 | | `upstreamHostCircuitThreshold?` | `number` | `0` | ネイティブOpenAI forwardのResponses送信とcompact送信で、実証済みの接続前DNS/TCP障害に適用するオプトインのサーキットしきい値です。`0`で無効、`1`〜`20`ではその回数の終端論理リクエストが失敗するとprovider-originを30秒間遮断します。遮断中はアカウント選択やupstream送信の前に`Retry-After`付き`503`を返し、時間経過後はhalf-openリクエストを1件だけ許可します。タイムアウトとHTTP応答は数えず、HTTP応答が1件でもあれば回路を閉じます。 Codex Pool ルーティングでアカウントが固定されていない場合にのみ適用され、`codexAccountMode: "direct"` とアカウント修飾セレクターでは動作しません。 | @@ -162,7 +163,8 @@ Clash / Surge / Mihomo 利用者向けの fake-IP DNS 例外は 2 種類あり pool アカウントの追加と quota 更新はダッシュボードの **Codex Auth** ページで処理してください。設定には secret で ないアカウント metadata だけを保存し、access/refresh token は強化された Codex アカウント credential store に別途 保管します。Pool routing は新規/未紐付け割り当て、使用量ベースのプロアクティブ切り替え、障害回復に分かれます。 -紐付け済みタスクは通常 affinity を維持しますが、`quota` はしきい値超過後の次のリクエストで再紐付けでき、 +紐付け済みタスクは通常 affinity を維持します。既定では `quota` はしきい値超過後の次のリクエストで再紐付けでき、 +`pool.cacheAffinity` がオンなら、紐付け先アカウントが使い切られるか処理できなくなるまでその再紐付けを延期します。 pause、cooldown、再認証、障害処理も独立して routing を消去または変更できます。未紐付けリクエストには プロキシ再起動や affinity リセット後の既存タスクも含まれます。出力前の **429/402** は使用量ベースの 切り替えがオフでも同じリクエストで適格な代替アカウントへ 1 回再試行できます。アカウント変更後も会話 @@ -176,7 +178,7 @@ pause、cooldown、再認証、障害処理も独立して routing を消去ま 別の適格な Pool アカウントへリクエストを切り替えることがあります。これらの障害回復は `autoSwitchThreshold: 0` でも有効であり、`0` が無効にするのは使用量に基づく予防的な切り替えだけです。 -**割り当てとプロアクティブ切り替え戦略:** `quota`(既定)はアクティブアカウントがない場合に最小 usage の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。`autoSwitchThreshold` 超過後は紐付け済みタスクの次のリクエストも再紐付けできます。`round-robin` は +**割り当てとプロアクティブ切り替え戦略:** `quota`(既定)はアクティブアカウントがない場合に最小 usage の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。`autoSwitchThreshold` 超過後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも再紐付けできます。オンなら cache affinity が quota 余裕より優先され、紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は 未紐付けリクエストを均等分散し、しきい値は通常の rotation を変えません。`accountPoolStickyLimit` (既定 `1`、1–100)は成功応答ではなく割り当て/紐付け数を数えます。`fill-first` は未紐付けリクエストを cooldown、再認証、または drain threshold までアクティブアカウントへ割り当て、正常な紐付け済みタスクは diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 7cfe363870..dfda7297e8 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -36,8 +36,9 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `codexAccountPickerEnabled?` | `boolean` | map이 비어 있으면 꺼짐 | 유효한 `codexAccountNamespaces` 매핑에서 account-qualified Codex 선택기 행을 생성할지 제어합니다. `true`는 매핑된 행의 표시를 허용합니다. 비어 있지 않은 map에서 생략하면 이전 버전과의 호환성을 위해 활성화된 것으로 취급되며, map이 비어 있으면 꺼집니다. `false`는 매핑을 삭제하거나 명시적 `/` 라우팅을 비활성화하지 않은 채 생성 행을 숨기고 선택기에 bare native 행을 복원합니다. | | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | | `codexAccountPriorities?` | `Record` | — | Codex pool의 계정별 선택 순서. 계정 ID → `-100`부터 `100`까지의 정수이며 **값이 클수록 먼저** 쓰이고, 항목이 없으면 `0`입니다. 이는 eligibility 경계가 아니라 순서 경계입니다. 선택은 이미 적격한 계정들을 quota 여유가 남은 최상위 tier로 좁히고, 그 tier 안에서 `accountPoolStrategy`가 계정을 고릅니다. tier를 건너뛰는 경우는 그 구성원 전부가 `autoSwitchThreshold` 초과, cooldown, soft-avoid, 일시 중지 또는 재인증 대기일 때뿐이며, usage를 알 수 없다고 해서 tier가 소진되지는 않습니다. 순서는 부적격 계정을 선택 가능하게 만들지 않고, 이미 계정에 묶인 thread를 다시 bind하지도 않습니다. 메인 `__main__` 계정도 동일한 조건으로 참여하므로 Codex Desktop 로그인을 마지막에 쓰도록 둘 수 있습니다. 항목이 하나도 없으면 동작은 이전과 같습니다. map이 잘못된 경우 경고를 출력하고 순서 지정을 끕니다(config 복구는 하지 않습니다). `ocx account priority`와 Codex Auth 페이지에서 관리합니다. | -| `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩된 작업과 바인딩 없는 작업의 다음 요청을 모두 재평가할 수 있고, `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청이나 바인딩된 작업의 다음 요청을 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | +| `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩 없는 작업의 다음 요청을 재평가할 수 있고, 기본값에서는 사용량이 이 임계값을 넘으면 바인딩된 작업도 재평가합니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때까지 임계값을 넘어도 계정을 유지합니다. `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | +| `pool.cacheAffinity?` | `boolean` | `false` | 바인딩된 Codex 스레드의 선택적 cache-affinity 순서입니다. `pool.kernel`과는 별개이며 기본값은 꺼짐입니다. 잘못된 값은 꺼진 것으로 읽습니다. 켜면 live 바인딩이 quota 여유보다 우선합니다. `quota`는 사용량이 `autoSwitchThreshold`를 넘었다는 이유만으로 스레드를 옮기지 않습니다. 해당 계정이 일시 중지되었거나 사용할 수 없거나 실제로 소진된 경우(알려진 usage 100%)에는 여전히 떠나므로, affinity는 고정이 아니라 재정렬입니다. | | `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 연속된 일시적 실패가 이 횟수에 도달하면 이후 새 세션은 failover됩니다. `0`으로 두면 비활성화됩니다. 일반 Responses와 네이티브 compact 전송에서 입증된 연결 전 DNS/TCP 도달 불가 실패는 provider-host 범위로 기록되며 계정 상태, 계정 쿨다운, 스레드/세션 선호도, 활성 계정 선택 또는 Pool 라우팅에 영향을 주지 않고 이 임계값에도 집계되지 않습니다. | | `upstreamHostCircuitThreshold?` | `number` | `0` | 네이티브 OpenAI forward Responses와 compact 전송에서 입증된 연결 전 DNS/TCP 실패에 적용하는 선택적 회로 차단 임계값입니다. `0`은 비활성화하며, `1`~`20`은 이 횟수만큼 최종 논리 요청이 실패하면 provider-origin을 30초 동안 차단합니다. 차단 중에는 계정 선택이나 업스트림 전송 전에 `Retry-After`가 포함된 `503`을 반환하고, 시간이 지나면 반개방 요청 하나만 허용합니다. 타임아웃과 HTTP 응답은 집계하지 않으며, HTTP 응답이 하나라도 오면 회로를 닫습니다. Codex Pool 라우팅에서 계정이 고정되지 않은 경우에만 적용되며, `codexAccountMode: "direct"` 및 계정 한정 선택자에서는 동작하지 않습니다. | @@ -162,9 +163,10 @@ Clash / Surge / Mihomo 사용자를 위한 fake-IP DNS 예외는 두 가지이 pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지에서 처리하세요. 설정에는 secret이 아닌 계정 metadata만 저장하고, access/refresh token은 강화된 Codex 계정 credential store에 따로 보관합니다. Pool 라우팅은 새 작업/바인딩 없는 작업 배정, 사용량 기반 선제 전환, 실패 복구로 -구분됩니다. 바인딩된 작업은 보통 affinity를 유지하지만 `quota`는 사용량 임계값을 넘은 뒤 다음 -요청에서 재바인딩할 수 있고, 일시 중지, cooldown, 재인증, 실패 처리도 독립적으로 라우팅을 -지우거나 바꿀 수 있습니다. 바인딩 없는 요청은 live 계정 바인딩이 없는 요청이며, 프록시 재시작이나 +구분됩니다. 바인딩된 작업은 보통 affinity를 유지합니다. 기본값에서 `quota`는 사용량 임계값을 넘은 뒤 +다음 요청에서 재바인딩할 수 있고, `pool.cacheAffinity`가 켜져 있으면 바인딩된 계정이 소진되었거나 +더 이상 처리할 수 없을 때까지 그 재바인딩을 미룹니다. 일시 중지, cooldown, 재인증, 실패 처리도 +독립적으로 라우팅을 지우거나 바꿀 수 있습니다. 바인딩 없는 요청은 live 계정 바인딩이 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 전 **429/402**는 사용량 기반 선제 전환이 꺼져 있어도 같은 요청에서 적격 대체 계정으로 한 번 재시도할 수 있습니다. 계정이 바뀌어도 대화 문맥은 보존·재생되지만 계정 간 프로바이더 측 prompt cache 재사용은 보장되지 않아 다시 @@ -179,7 +181,7 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 `autoSwitchThreshold: 0`에서도 계속 작동하며, `0`은 사용량 기반 선제 전환만 비활성화합니다. **배정 및 선제 전환 전략:** `quota`(기본)는 활성 계정이 없을 때 최저 usage의 적격 계정을 선택하고, -적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청이나 바인딩된 작업의 다음 요청을 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. +적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. 플래그가 켜져 있으면 cache affinity가 quota 여유보다 우선하며, 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하며 임계값은 기본 순환에 영향을 주지 않습니다. `accountPoolStickyLimit`(기본 `1`, 1–100)은 성공 응답이 아니라 배정/바인딩 횟수를 셉니다. `fill-first`는 바인딩 없는 요청을 cooldown, 재인증 또는 drain threshold까지 활성 계정에 배정하고, diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d23ecbe88e..ae274c0b08 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -51,8 +51,9 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. | | `codexAccountPriorities?` | `Record` | — | Per-account selection order for the Codex pool: account id → integer from `-100` to `100`, **higher is used earlier**, absent means `0`. This is an ordering boundary, not an eligibility one: selection narrows the already-eligible accounts to the highest tier that still has quota headroom, and `accountPoolStrategy` then picks within that tier. A tier is skipped only when every member is over `autoSwitchThreshold`, cooling down, soft-avoided, paused, or needs reauthentication — unknown quota never drains a tier. Ordering never makes an ineligible account selectable and never re-binds a thread that already has an account. The main `__main__` account participates on equal terms, which is how the Codex Desktop login can be set to drain last. With no entries the pool behaves exactly as before. A malformed map is ignored with a console warning (ordering off, no config repair). Managed by `ocx account priority` and the Codex Auth page. | | `activeCodexAccountPinned?` | `string` | — | Account id the operator last selected by hand. While set, a higher `codexAccountPriorities` tier cannot preempt it until the pin is released by drain, exclusion, deletion, or an explicit failover/promotion away. Ordinary round-robin movement inside the capped tier does not release it. Writing any `codexAccountPriorities` entry also releases the pin, so a pin made before an order existed cannot outrank one set afterward. `GET /api/codex-auth/active` reports both whether the effective account is pinned (`pinned`) and the account carrying the ceiling (`pinnedAccountId`). | -| `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate both bound and unbound tasks on their next request; `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or proactively rebind a bound task to a lower-usage eligible account. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | +| `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate unbound tasks on their next request, and by default also re-evaluates bound tasks once usage crosses this threshold. With `pool.cacheAffinity` on, a bound task keeps its account past the threshold until that account is exhausted or otherwise cannot serve. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | +| `pool.cacheAffinity?` | `boolean` | `false` | Opt-in cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. Off by default; a malformed value reads as off. With it on, a live binding outranks quota headroom: `quota` does not move the thread merely because usage crossed `autoSwitchThreshold`. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — so affinity is a reordering, not a pin. | | `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. For regular Responses and native compact sends, proven pre-connection DNS/TCP reachability failures are tracked at the provider-host level: they never affect account health, account cooldowns, thread/session affinity, active-account selection, or Pool routing, and never count toward this threshold. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Opt-in circuit threshold for proven pre-connection DNS/TCP failures on native OpenAI forward Responses and compact sends. `0` disables it; `1`–`20` opens a 30-second provider-origin cooldown after that many terminal logical requests. While open, requests receive `503` with `Retry-After` before account selection or upstream send; after cooldown, one half-open request is admitted. Timeouts and HTTP responses never count, and any HTTP response closes the circuit. Applies only to Codex Pool routing with no pinned account; it is inert for `codexAccountMode: "direct"` and account-qualified selectors. | @@ -476,9 +477,10 @@ validation never applies the IPv6 accommodation. Use **Codex Auth** in the dashboard to add pool accounts and refresh quotas. `config.json` stores non-secret metadata; access and refresh tokens use the hardened credential store. Pool routing separates new/unbound assignment, usage-based proactive switching, and failure recovery. A bound task -normally keeps affinity, but `quota` may rebind it on its next request after the usage threshold is -crossed, while pause, cooldown, reauthentication, and failure handling can clear or move routing -independently. An unbound request has no live account binding; this can include an existing visible +normally keeps affinity. By default `quota` may rebind it on its next request after the usage +threshold is crossed; with `pool.cacheAffinity` on, that rebind waits until the bound account is +exhausted or otherwise cannot serve. Pause, cooldown, reauthentication, and failure handling can +clear or move routing independently. An unbound request has no live account binding; this can include an existing visible task after proxy restart or affinity reset. A pre-stream 429 or 402, or a 5xx response whose bounded body explicitly reports quota exhaustion, retries once on an eligible alternate account in the same request, even when usage-based proactive switching is off. The ordinary transient-5xx policy runs @@ -499,7 +501,7 @@ and pauses only accounts freshly confirmed at 100%; unknown or failed refreshes | Strategy | Behaviour | | --- | --- | -| `quota` (default) | If no active account exists, choose the lowest-usage eligible account across 5-hour, weekly, and 30-day windows. Otherwise retain an eligible active account below `autoSwitchThreshold`; after it crosses the threshold, an unbound request or a bound task's next request can move to a lower-usage eligible account. `0` disables this usage-driven re-evaluation, not failure recovery. | +| `quota` (default) | If no active account exists, choose the lowest-usage eligible account across 5-hour, weekly, and 30-day windows. Otherwise retain an eligible active account below `autoSwitchThreshold`; after it crosses the threshold, an unbound request can move to a lower-usage eligible account, and a bound task's next request can too unless `pool.cacheAffinity` is on. With that flag on, cache affinity outranks quota headroom and the bound task stays until the account is exhausted (known usage at 100%) or cannot serve (paused, unusable). `0` disables this usage-driven re-evaluation, not failure recovery. | | `round-robin` | Evenly assign unbound requests across eligible accounts. `autoSwitchThreshold` does not change normal round-robin selection. `accountPoolStickyLimit` (1–100) counts assignments on one pick, not successful upstream responses. | | `fill-first` | Assign unbound requests to the active account until cooldown, reauthentication, or the configured drain threshold; unknown usage does not force a switch. Healthy bound tasks keep affinity. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 5966758e29..6c76dd556b 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -37,8 +37,9 @@ ocx models provider openrouter on | `codexAccountPickerEnabled?` | `boolean` | выкл. при пустой map | Управляет созданием account-qualified строк picker'а Codex из подходящих сопоставлений `codexAccountNamespaces`. `true` разрешает показывать сопоставленные строки. Если поле не задано при непустой map, функция считается включённой для обратной совместимости; при пустой map она выключена. `false` скрывает созданные строки и возвращает bare native-строки в picker, не удаляя сопоставления и не отключая точную маршрутизацию `/`. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | | `codexAccountPriorities?` | `Record` | — | Порядок выбора для каждого аккаунта пула Codex: id аккаунта → целое число от `-100` до `100`, **больше — используется раньше**, отсутствие означает `0`. Это граница порядка, а не пригодности: выбор сужает уже подходящие аккаунты до самого высокого уровня, у которого ещё есть запас квоты, а внутри этого уровня аккаунт выбирает `accountPoolStrategy`. Уровень пропускается, только когда все его аккаунты превысили `autoSwitchThreshold`, находятся в cooldown, под soft-avoid, на паузе или требуют повторной аутентификации; неизвестный usage никогда не исчерпывает уровень. Порядок не делает выбираемым непригодный аккаунт и не перепривязывает поток, у которого аккаунт уже есть. Основной аккаунт `__main__` участвует на равных — именно так логин Codex Desktop можно оставить на самый конец. Без записей поведение остаётся прежним. Некорректная map игнорируется с предупреждением в консоли (порядок отключается, восстановление config не запускается). Управляется через `ocx account priority` и страницу Codex Auth. | -| `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий запрос как привязанной, так и непривязанной задачи; `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос или следующий запрос привязанной задачи на подходящий аккаунт с меньшим usage. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | +| `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий непривязанный запрос, а по умолчанию — и привязанную задачу, когда usage пересекает этот порог. При включённом `pool.cacheAffinity` привязанная задача сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос. `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | +| `pool.cacheAffinity?` | `boolean` | `false` | Опциональный порядок cache-affinity для привязанных потоков Codex, независимый от `pool.kernel`. По умолчанию выключен; некорректное значение читается как выключенное. Когда флаг включён, живая привязка важнее запаса квоты: `quota` не переносит поток только потому, что usage пересёк `autoSwitchThreshold`. Поток всё равно уходит, если аккаунт не может обслуживать запрос — на паузе, непригоден или реально исчерпан (известный usage 100%). Affinity меняет порядок, а не закрепляет учётные данные. | | `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Сколько подряд transient failure допустить, прежде чем новые сессии начнут делать failover. `0` отключает эту логику. Для обычных Responses-запросов и нативных compact-отправок доказанные ошибки доступности DNS/TCP до соединения учитываются на уровне пары «провайдер, хост» и не влияют на здоровье аккаунта, кулдауны аккаунта, привязку потока/сессии, выбор активного аккаунта или маршрутизацию пула, а также не учитываются в этом пороге. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Опциональный порог circuit breaker для доказанных DNS/TCP-сбоев до соединения в нативных OpenAI forward Responses- и compact-отправках. `0` отключает его; `1`–`20` открывает 30-секундный cooldown для provider-origin после такого числа завершившихся логических запросов. Пока circuit открыт, до выбора аккаунта и upstream-отправки возвращается `503` с `Retry-After`; после cooldown допускается один half-open запрос. Таймауты и HTTP-ответы не учитываются, а любой HTTP-ответ закрывает circuit. Применяется только к маршрутизации Codex Pool без закреплённого аккаунта; при `codexAccountMode: "direct"` и для селекторов с указанием аккаунта схема не активна. | @@ -191,8 +192,9 @@ redirect'ов для обычных provider-request'ов реализована Конфигурация хранит только несекретные метаданные аккаунтов; access- и refresh-токены хранятся в защищённом хранилище учётных данных аккаунтов Codex. Pool routing разделяет назначение новых/непривязанных задач, проактивное переключение по использованию и восстановление после сбоев. -Привязанная задача обычно сохраняет affinity, но `quota` может перепривязать её при следующем -запросе после превышения порога; pause, cooldown, повторная аутентификация и обработка сбоев также +Привязанная задача обычно сохраняет affinity. По умолчанию `quota` может перепривязать её при следующем +запросе после превышения порога; при включённом `pool.cacheAffinity` эта перепривязка ждёт, пока +привязанный аккаунт не будет исчерпан или не сможет обслуживать запрос. Pause, cooldown, повторная аутентификация и обработка сбоев также могут независимо очистить или изменить routing. Непривязанным может стать и существующая задача после перезапуска прокси или сброса affinity. Отказ **429/402** до вывода допускает одну попытку на подходящем альтернативном аккаунте даже при выключенном переключении по использованию. @@ -209,7 +211,7 @@ redirect'ов для обычных provider-request'ов реализована после чего запрос может перейти на другой подходящий аккаунт Pool. Эти переходы восстановления остаются активными при `autoSwitchThreshold: 0`; значение `0` отключает только проактивное переключение по использованию. -**Стратегии назначения и проактивного переключения:** `quota` выбирает подходящий аккаунт с наименьшим usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос или следующий запрос привязанной задачи на подходящий аккаунт с меньшим usage. `round-robin` равномерно распределяет непривязанные запросы, а порог не +**Стратегии назначения и проактивного переключения:** `quota` выбирает подходящий аккаунт с наименьшим usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, cache affinity важнее запаса квоты, и привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы, а порог не меняет обычную ротацию. `accountPoolStickyLimit` (по умолчанию `1`, 1–100) считает назначения/bind, а не успешные ответы. `fill-first` назначает непривязанные запросы активному аккаунту до cooldown, reauth или порога исчерпания; здоровые привязанные задачи сохраняют affinity. Эти стратегии не diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 7b0c14cfcc..9fce4f0ba9 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -38,8 +38,9 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `activeCodexAccountId?` | `string` | — | Sonraki istek için manuel olarak seçilen Havuz hesabı. Seçim iş parçacığı bağlılığını temizler; devam eden istekler yakalanan kimlik bilgilerini korur. | | `codexAccountPriorities?` | `Record` | — | Codex havuzu için hesap başına seçim sırası: hesap kimliği → `-100` ile `100` arası tam sayı, **daha yüksek olan daha önce kullanılır**, yoksa `0` anlamına gelir. Bu bir öncelik sırası sınırıdır, bir uygunluk sınırı değildir: seçim, zaten uygun olan hesapları hala kota payı bulunan en yüksek katmana daraltır ve `accountPoolStrategy` daha sonra bu katman içinde seçim yapar. Bir katman, yalnızca her üye `autoSwitchThreshold` üzerinde olduğunda, soğumada olduğunda, yumuşak kaçınıldığında, duraklatıldığında veya yeniden kimlik doğrulama gerektiğinde atlanır — bilinmeyen kota asla bir katmanı boşaltmaz. Sıralama asla uygun olmayan bir hesabı seçilebilir yapmaz ve zaten bir hesabı olan bir iş parçacığını asla yeniden bağlamaz. Ana `__main__` hesap eşit şartlarda katılır, bu sayede Codex Desktop girişi en son tükenecek şekilde ayarlanabilir. Hiçbir girdi olmadığında havuz tam olarak eskisi gibi davranır. Hatalı biçimlendirilmiş bir harita bir konsol uyarısıyla yok sayılır (sıralama kapalı, yapılandırma onarımı yok). `ocx account priority` ve Codex Auth sayfası tarafından yönetilir. | | `activeCodexAccountPinned?` | `string` | — | Operatörün en son elle seçtiği hesap kimliği. Ayarlandığı sürece, pin tükenme, hariç tutma, silme veya açık bir yük devretme/yükseltme ile serbest bırakılana kadar daha yüksek bir `codexAccountPriorities` katmanı onu öncelikleyemez. Sınırlı katman içindeki sıradan round-robin hareketi onu serbest bırakmaz. Herhangi bir `codexAccountPriorities` girdisi yazmak da pini serbest bırakır, böylece bir sıra var olmadan önce yapılan bir pin daha sonra ayarlanan bir pinin önüne geçemez. `GET /api/codex-auth/active`, hem geçerli hesabın sabitlenip sabitlenmediğini (`pinned`) hem de tavanı taşıyan hesabı (`pinnedAccountId`) bildirir. | -| `autoSwitchThreshold?` | `number` | `80` | Proaktif geçiş için kullanım eşiği. `quota`, bir sonraki isteklerinde hem bağlı hem de bağımsız görevleri yeniden değerlendirebilir; `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir veya bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. | +| `autoSwitchThreshold?` | `number` | `80` | Proaktif geçiş için kullanım eşiği. `quota`, bağımsız görevlerin bir sonraki isteğini yeniden değerlendirebilir ve varsayılan olarak kullanım bu eşiği geçince bağlı görevleri de yeniden değerlendirir. `pool.cacheAffinity` açıkken bağlı bir görev, hesap tükenene veya hizmet veremez hale gelene kadar eşiğin ötesinde hesabını korur. `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. | +| `pool.cacheAffinity?` | `boolean` | `false` | Bağlı Codex iş parçacıkları için isteğe bağlı önbellek bağlılığı sıralaması; `pool.kernel`'dan bağımsızdır. Varsayılan olarak kapalıdır; hatalı bir değer kapalı okunur. Açıkken canlı bağlama kota payından öndedir: `quota`, kullanımın `autoSwitchThreshold`'u geçmesi nedeniyle iş parçacığını taşımaz. Hesap duraklatılmış, kullanılamaz veya gerçekten tükenmişse (bilinen kullanım %100) iş parçacığı yine ayrılır; bağlılık bir sabitleme değil yeniden sıralamadır. | | `accountPoolStickyLimit?` | `number` | `1` | İlerlemeden önce bir round-robin seçiminde tutulan yeni/bağımsız görev atamaları; sayaç yukarı akış başarısından sonra değil, bir görev bağlandığında ilerler. Aralık 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Gelecekteki yeni oturumların yük devretmesinden önceki ardışık geçici arızalar. Devre dışı bırakmak için `0` ayarlayın. Düzenli Responses ve yerel sıkıştırma gönderimleri için kanıtlanmış bağlantı öncesi DNS/TCP erişilebilirlik arızaları sağlayıcı-ana bilgisayar düzeyinde izlenir: hesap sağlığını, hesap soğuma sürelerini, iş parçacığı/oturum bağlılığını, aktif hesap seçimini veya Havuz yönlendirmesini asla etkilemez ve bu eşiğe asla sayılmaz. | | `upstreamHostCircuitThreshold?` | `number` | `0` | Yerel OpenAI iletme Responses ve sıkıştırma gönderimlerinde kanıtlanmış bağlantı öncesi DNS/TCP arızaları için isteğe bağlı devre eşiği. `0` devre dışı bırakır; `1`–`20`, bu kadar terminal mantıksal istekten sonra 30 saniyelik bir sağlayıcı-kaynak soğuma süresi açar. Açıkken istekler, hesap seçiminden veya yukarı akış gönderiminden önce `Retry-After` ile `503` alır; soğuma süresinden sonra bir yarı açık isteğe izin verilir. Zaman aşımları ve HTTP yanıtları asla sayılmaz ve herhangi bir HTTP yanıtı devreyi kapatır. Yalnızca sabitlenmiş hesabı olmayan Codex Havuz yönlendirmesi için geçerlidir; `codexAccountMode: "direct"` ve hesap nitelikli seçiciler için etkisizdir. | @@ -196,10 +197,12 @@ Havuz hesapları eklemek ve kotaları yenilemek için kontrol panelinde **Codex Auth** kullanın. `config.json` gizli olmayan meta verileri saklar; erişim ve yenileme belirteçleri güçlendirilmiş kimlik bilgisi deposunu kullanır. Havuz yönlendirmesi yeni/bağımsız atamayı, kullanıma dayalı proaktif geçişi ve arıza -kurtarmayı ayırır. Bağlı bir görev normalde bağlılığı korur, ancak `quota`, -kullanım eşiği aşıldıktan sonraki bir sonraki isteğinde onu yeniden -bağlayabilir; duraklatma, soğuma, yeniden kimlik doğrulama ve arıza işleme ise -yönlendirmeyi bağımsız olarak temizleyebilir veya taşıyabilir. Bağımsız bir +kurtarmayı ayırır. Bağlı bir görev normalde bağlılığı korur. Varsayılan olarak +`quota`, kullanım eşiği aşıldıktan sonraki isteğinde onu yeniden bağlayabilir; +`pool.cacheAffinity` açıkken bu yeniden bağlama, bağlı hesap tükenene veya +hizmet veremez hale gelene kadar bekler. Duraklatma, soğuma, yeniden kimlik +doğrulama ve arıza işleme ise yönlendirmeyi bağımsız olarak temizleyebilir veya +taşıyabilir. Bağımsız bir isteğin canlı hesap bağlaması yoktur; bu, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra mevcut görünür bir görevi içerebilir. Akış öncesi bir 429 veya 402, kullanıma dayalı proaktif geçiş kapalı olsa bile aynı istekte @@ -227,7 +230,7 @@ kalır. | Strateji | Davranış | | --- | --- | -| `quota` (varsayılan) | Aktif bir hesap yoksa 5 saatlik, haftalık ve 30 günlük pencerelerde en düşük kullanımlı uygun hesabı seçin. Aksi takdirde `autoSwitchThreshold` altında uygun bir aktif hesabı tutun; eşiği aştıktan sonra bağımsız bir istek veya bağlı bir görevin bir sonraki isteği daha düşük kullanımlı uygun bir hesaba geçebilir. `0`, bu kullanım odaklı yeniden değerlendirmeyi devre dışı bırakır, arıza kurtarmayı devre dışı bırakmaz. | +| `quota` (varsayılan) | Aktif bir hesap yoksa 5 saatlik, haftalık ve 30 günlük pencerelerde en düşük kullanımlı uygun hesabı seçin. Aksi takdirde `autoSwitchThreshold` altında uygun bir aktif hesabı tutun; eşiği aştıktan sonra bağımsız bir istek daha düşük kullanımlı uygun bir hesaba geçebilir ve `pool.cacheAffinity` kapalıysa bağlı bir görevin bir sonraki isteği de geçebilir. Bayrak açıkken önbellek bağlılığı kota payından öndedir ve bağlı görev, hesap tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene (duraklatılmış, kullanılamaz) kadar kalır. `0`, bu kullanım odaklı yeniden değerlendirmeyi devre dışı bırakır, arıza kurtarmayı devre dışı bırakmaz. | | `round-robin` | Bağımsız istekleri uygun hesaplar arasında eşit olarak atayın. `autoSwitchThreshold` normal round-robin seçimini değiştirmez. `accountPoolStickyLimit` (1–100), başarılı yukarı akış yanıtlarını değil, bir seçimdeki atamaları sayar. | | `fill-first` | Bağımsız istekleri soğuma, yeniden kimlik doğrulama veya yapılandırılmış tükenme eşiğine kadar aktif hesaba atayın; bilinmeyen kullanım geçişe zorlamaz. Sağlıklı bağlı görevler bağlılığı korur. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 01f499e454..2e287e14e4 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -36,8 +36,9 @@ ocx models provider openrouter on | `codexAccountPickerEnabled?` | `boolean` | 映射为空时关闭 | 控制是否根据有效的 `codexAccountNamespaces` 映射生成账户限定的 Codex 选择器行。`true` 允许显示映射行。在非空映射中省略此字段时,为保持向后兼容会视为已启用;映射为空时则关闭。`false` 会隐藏生成行并恢复选择器中的裸原生行,但不会删除映射,也不会禁用精确的 `/` 路由。 | | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | | `codexAccountPriorities?` | `Record` | — | Codex pool 各账号的选择顺序:账号 ID → `-100` 到 `100` 的整数,**数值越大越先使用**,未设置即为 `0`。这是顺序边界而非资格边界:选择会把已经合格的账号收窄到仍有 quota 余量的最高 tier,再由 `accountPoolStrategy` 在该 tier 内挑选。只有当某个 tier 的所有成员都超过 `autoSwitchThreshold`、处于 cooldown、被 soft-avoid、已暂停或需要重新认证时,该 tier 才会被跳过;usage 未知不会让 tier 耗尽。顺序不会让不合格的账号变得可选,也不会重新绑定已经绑定账号的 thread。主账号 `__main__` 同样参与排序,因此可以让 Codex Desktop 登录账号最后才被用到。没有任何条目时,行为与以往完全一致。映射格式非法时会打印警告并关闭排序(不会触发 config 修复)。可通过 `ocx account priority` 和 Codex Auth 页面管理。 | -| `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估已绑定和未绑定任务;`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求或已绑定任务的下一次请求切换到 usage 更低的合格账号。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | +| `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估未绑定任务;默认在用量越过该阈值时也会重新评估已绑定任务。开启 `pool.cacheAffinity` 后,已绑定任务在越过阈值后仍会保留账号,直到该账号耗尽或无法继续服务。`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | +| `pool.cacheAffinity?` | `boolean` | `false` | 已绑定 Codex 线程的可选 cache-affinity 排序,独立于 `pool.kernel`。默认关闭;非法值视为关闭。开启后,live 绑定优先于 quota 余量:`quota` 不会仅因用量越过 `autoSwitchThreshold` 就移动线程。账号暂停、不可用或真正耗尽(已知 usage 为 100%)时仍会离开,因此 affinity 是重排而非钉死。 | | `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次瞬态故障后,后续新会话会切换到备用上游。设为 `0` 可禁用。对于常规 Responses 和原生 compact 发送,已证明的连接前 DNS/TCP 不可达故障按 provider-host 粒度记录,不影响账户健康、账户冷却、线程/会话亲和性、活动账户选择或 Pool 路由,也不会计入此阈值。 | | `upstreamHostCircuitThreshold?` | `number` | `0` | 原生 OpenAI forward Responses 与 compact 发送的可选断路器阈值,仅统计已证明的连接前 DNS/TCP 故障。`0` 表示禁用;`1`–`20` 表示在这么多个终止逻辑请求失败后,对 provider-origin 冷却 30 秒。断路期间会在账户选择和上游发送之前返回带 `Retry-After` 的 `503`;冷却结束后只允许一个半开请求。超时和 HTTP 响应不计数,任意 HTTP 响应都会关闭断路器。 仅适用于未固定账户的 Codex Pool 路由;在 `codexAccountMode: "direct"` 或使用账户限定选择器时不会启用。 | @@ -161,8 +162,9 @@ API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account metadata;access/refresh token 存放在加固的 Codex account credential store 中。Pool routing -分为新建/未绑定任务分配、基于用量的主动切换和故障恢复。已绑定任务通常保持 affinity,但 `quota` -可在超过阈值后的下一次请求中重新绑定;暂停、cooldown、重新认证和故障处理也能独立清除或改变 +分为新建/未绑定任务分配、基于用量的主动切换和故障恢复。已绑定任务通常保持 affinity。默认情况下 +`quota` 可在超过阈值后的下一次请求中重新绑定;开启 `pool.cacheAffinity` 后,该重新绑定会等到 +绑定账号耗尽或无法继续服务。暂停、cooldown、重新认证和故障处理也能独立清除或改变 routing。未绑定请求没有 live 账号绑定,也可能是代理重启或 affinity 重置后的已有任务。输出前的 **429/402** 即使在关闭基于用量的主动切换时,也可在同一请求中对合格替代账号重试一次。 账号变化后会保留并重放对话上下文,但账号间的 provider prompt cache 不保证复用,可能需要重新预热。 @@ -175,7 +177,7 @@ routing。未绑定请求没有 live 账号绑定,也可能是代理重启或 并可将请求切换到另一个符合条件的 Pool 账户。即使 `autoSwitchThreshold: 0`, 这些故障恢复流程仍然有效;`0` 只会禁用基于用量的主动切换。 -**分配与主动切换策略:** `quota`(默认)在没有活跃账号时选择 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求或已绑定任务的下一次请求切换到 usage 更低的合格账号。`round-robin` 均匀分配未绑定请求,用量 +**分配与主动切换策略:** `quota`(默认)在没有活跃账号时选择 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,cache affinity 优先于 quota 余量,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求,用量 阈值不会改变正常轮换。`accountPoolStickyLimit`(默认 `1`,1–100)统计分配/绑定,而不是成功响应。 `fill-first` 在 cooldown、重新认证或耗尽阈值前把未绑定请求分配给活跃账号;健康的已绑定任务保持 affinity。这些策略不能规避 provider enforcement。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index b47d92eb48..4303ca73aa 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -34,8 +34,9 @@ ocx models provider openrouter on | `pausedCodexAccountIds?` | `string[]` | `[]` | 被排除於池選擇直到恢復的帳號,包含暫停時的 main `__main__` 帳號。 | | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | | `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 | -| `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在其下一個請求時重新評估綁定與未綁定任務;`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求或主動重新綁定綁定任務到較低用量的合格帳號。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 | +| `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務,且預設在用量越過此閾值時也會重新評估綁定任務。開啟 `pool.cacheAffinity` 後,綁定任務在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 | +| `pool.cacheAffinity?` | `boolean` | `false` | 綁定 Codex 執行緒的選擇性 cache-affinity 排序,獨立於 `pool.kernel`。預設關閉;格式錯誤視為關閉。開啟後,即時綁定優先於配額餘裕:`quota` 不會只因用量越過 `autoSwitchThreshold` 就移動執行緒。帳號暫停、無法使用或真正耗盡(已知用量 100%)時仍會離開,因此親和性是重排而非釘死。 | | `accountPoolStickyLimit?` | `number` | `1` | 在前進一個 round-robin 選擇前保留的新/未綁定任務指派;計數器在任務綁定時前進,而非在上游成功後。範圍 1–100。 | | `upstreamFailoverThreshold?` | `number` | `3` | 未來新 session 容錯移轉前的連續暫時性失敗。設 `0` 停用。 | | `modelCacheTtlMs?` | `number` | `300000` | Per-供應商 `/models` 快取的新鮮度視窗。 | @@ -129,7 +130,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 ## Codex 帳號池 -在儀表板中使用 **Codex Auth** 新增池帳號並重新整理配額。`config.json` 儲存非秘密中繼資料;access 與 refresh token 使用強化的憑證存放。池路由將新/未綁定指派、基於用量的主動切換與失敗復原分開。綁定任務通常保留親和性,但 `quota` 可在其超過用量閾值後的下一個請求時重新綁定它,而暫停、冷卻、重新認證與失敗處理可獨立清除或移動路由。未綁定請求沒有即時帳號綁定;這可包含代理重啟或親和性重置後的既有可見任務。Pre-stream 的 429 或 402 在同一個請求中於一個合格的備用帳號上重試一次,即使基於用量的主動切換關閉。帳號變更保留並重播對話 context,但跨帳號的供應商端 prompt-cache 重用不保證,cache 可能需要重新暖機。 +在儀表板中使用 **Codex Auth** 新增池帳號並重新整理配額。`config.json` 儲存非秘密中繼資料;access 與 refresh token 使用強化的憑證存放。池路由將新/未綁定指派、基於用量的主動切換與失敗復原分開。綁定任務通常保留親和性。預設下 `quota` 可在超過用量閾值後的下一個請求時重新綁定它;開啟 `pool.cacheAffinity` 後,該重新綁定會等到綁定帳號耗盡或無法繼續服務。暫停、冷卻、重新認證與失敗處理可獨立清除或移動路由。未綁定請求沒有即時帳號綁定;這可包含代理重啟或親和性重置後的既有可見任務。Pre-stream 的 429 或 402 在同一個請求中於一個合格的備用帳號上重試一次,即使基於用量的主動切換關閉。帳號變更保留並重播對話 context,但跨帳號的供應商端 prompt-cache 重用不保證,cache 可能需要重新暖機。 在 **401/403** 時,App 登入清除該帳號的行程本地親和性並要求重新認證。 在 **429** 時,opencodex 遵循 `Retry-After`、啟動帳號冷卻、清除親和性,並可能將請求輪換到另一個合格的池帳號。這些失敗轉換在 `autoSwitchThreshold: 0` 時仍然活躍;該設定僅停用基於用量的主動切換。 @@ -138,7 +139,7 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 | 策略 | 行為 | | --- | --- | -| `quota`(預設) | 若無現用帳號,跨 5 小時、週與 30 天視窗選擇最低用量的合格帳號。否則將合格現用帳號保持在 `autoSwitchThreshold` 以下;在超過閾值後,未綁定請求或綁定任務的下一個請求可移至較低用量的合格帳號。`0` 停用此用量驅動的重新評估,而非失敗復原。 | +| `quota`(預設) | 若無現用帳號,跨 5 小時、週與 30 天視窗選擇最低用量的合格帳號。否則將合格現用帳號保持在 `autoSwitchThreshold` 以下;在超過閾值後,未綁定請求可移至較低用量的合格帳號,未開啟 `pool.cacheAffinity` 時綁定任務的下一個請求也可。開啟後,cache affinity 優先於配額餘裕,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`0` 停用此用量驅動的重新評估,而非失敗復原。 | | `round-robin` | 在合格帳號間均勻指派未綁定請求。`autoSwitchThreshold` 不變更一般 round-robin 選擇。`accountPoolStickyLimit`(1–100)計數一次選擇上的指派,而非成功的上游回應。 | | `fill-first` | 將未綁定請求指派到現用帳號直到冷卻、重新認證或設定的排空閾值;未知用量不強制切換。健康的綁定任務保留親和性。 | diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 4183571a2a..270e6c38de 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -25,6 +25,9 @@ On non-loopback binds, data-plane authentication and origin policy cover both Im explicit keyed Images provider accepts the proxy admission secret as either an OpenAI-style bearer or `x-opencodex-api-key` because the provider key replaces caller authorization before fetch. The ChatGPT forward path still requires the dedicated header so its upstream bearer remains distinct. +The keyed path never enters `handleResponses`, so `src/server/images.ts` repeats +`selectProactiveApiKeyTransport` inside the keyed branch and rebuilds Authorization from the +returned clone rather than the earlier snapshot. The API-key `openai-responses` path also adapts Codex's private standalone image tool to the public Responses tool surface. A complete `image_gen` namespace is lowered to safe diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 927a26aa0e..cf0eda206b 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -24,7 +24,11 @@ one bounded event. EOF with an unterminated event and an event above the transla upstream failures, never successful partial completions. Provider-controlled structured error messages are redacted before either JSON or SSE reaches the client. The native path uses the same request-attempt logging, reset retry, same-key 429 replay, key rotation, usage extraction, and -request-signal cancellation contracts as routed Responses transport. +request-signal cancellation contracts as routed Responses transport. Because +`src/server/chat-completions.ts` never enters Responses core, +`src/server/chat-native.ts` repeats the pre-dispatch `selectProactiveApiKeyTransport` +call before it binds the adapter; the pick remains inert unless a strategy is configured +and the committed key is cooling. See [`responses.md`](../transports/responses.md). ## Chat streaming client with a JSON upstream result diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index dc9af564d6..5acafbf63b 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -18,7 +18,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Hosted search relay | `src/server/search.ts` | Direct relay; distinct from the web-search sidecar loop below. | | Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. | | GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | -| API-key pools | `src/providers/api-key-selection.ts`, `src/providers/key-failover.ts` | A 429 rotates the active key and records a cooldown; `provider.apiKey` keeps mirroring the active entry so routing stays single-key. | +| API-key pools | `src/providers/api-key-selection.ts`, `src/providers/key-failover.ts` | A configured `apiKeyPoolStrategy` plus a cooling committed key rotates before the first send (`selectProactiveApiKeyTransport`); a 429 still rotates after the send and records a cooldown. `provider.apiKey` keeps mirroring the active entry so routing stays single-key. The pick is inert without a strategy or while the committed key is healthy. | | OAuth account failover | `src/oauth/generic-account-failover.ts`, `src/oauth/anthropic-routing.ts` | Reactive pre-output 429 recovery is presence-driven with 2+ eligible accounts. Pool and `oauthAccountFailover` flags govern proactive routing, not the reactive retry: a disabled Anthropic pool recovers through quota ordering rather than its dormant strategy, and a per-provider `enabled` beats the global default in either direction. | | Alibaba regions | `src/providers/alibaba-region-backup.ts`, `src/providers/alibaba-region-migration.ts`, `src/providers/alibaba-region-startup.ts` | Region migration backs up before rewriting and is idempotent across restarts. | | Discovery and quota | `src/providers/model-discovery.ts`, `src/providers/quota.ts` | Discovery rejects a response over 4 MiB or past 2,000 raw rows before caching it. | diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b667d10b53..90504f7ac5 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -102,6 +102,37 @@ state. `openai-apikey` uses its configured key and canonical API base URL. Missi within their route; neither route falls through to the other. See [`openai-tiers.md`](../providers/openai-tiers.md). +### Pre-dispatch API-key pool pick + +Key-auth routes with a configured `apiKeyPoolStrategy` and two or more pool entries pick a +warm key before the first send (`selectProactiveApiKeyTransport` in +`src/providers/key-failover.ts`). The pick is inert unless that strategy is set and the +committed key is already cooling or missing from the pool: a healthy committed key, including +a manual selection, is left alone and the common path returns null without a config write. +`forgetApiKeyRotationCursor` drops the process-local round-robin cursor when the operator +edits the pool, so a later pick cannot second-guess that choice. + +On the shared Responses path the assignment lands in `src/server/responses/core.ts` +immediately before `resolveProviderTransport`. `route.provider` is copied into +`adapterProvider` on the next lines, and later `providerFetch` consumers (the HTTP send, +the image bridge, web search) read that pinned object with no stale-selection re-read. A +pick after the pin would leave the first attempt on the cooled key. + +Native Chat Completions is a separate entry path: `src/server/chat-completions.ts` routes +eligible `openai-chat` requests to `src/server/chat-native.ts` and never through Responses +core, so that file repeats the same call before it binds the adapter. Native compact +(`src/server/responses/compact.ts`) and the keyed Images relay (`src/server/images.ts`) +do the same for the same reason. Request paths assign the Transport variant, not the bare +`selectProactiveApiKey` snapshot: the snapshot is the persisted row, so it carries none of the +backfills `routedProviderConfig` merges in at request time and none of the route's explicit +runtime transport state. The load-bearing one is the credential -- a stored `\${VAR}` or +keychain reference is resolved in `routedProviderConfig` and nowhere in the adapter, so a +wholesale assignment sends the literal reference as the bearer token. `adapter` and `baseUrl` +are not at risk on a stored row, because the config schema requires both. + +Reactive 429 rotation (`rotateProviderTransportOn429`) remains the recovery path after a +send has already earned a throttle. + ### Routed service-tier capability OpenAI-compatible service-tier support is resolved only after the final provider/model wire is From fd7bde96c6aab8fe3246a29b0557a93be8b2ee8a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 04:48:41 +0900 Subject: [PATCH 121/231] feat(grok): reset-coupon inspection + gated redemption (gRPC-Web, journaled idempotency) (#4306) * docs(devlog): grok reset-coupons roadmap unit (000-030, docs-first wp1) * feat(grok): gRPC-Web reset-coupon client with journaled idempotent redemption Read Grok consumer reset coupons via prod_mc_billing.ConsumerUiSvc/ GetRemainingResets and redeem via RedeemReset using the stored xAI OIDC token (Bearer + X-XAI-Token-Auth: xai-grok-cli, no cookies). Hand-rolled gRPC-Web envelope codec (0x00 data / 0x80 trailer frames), minimal protobuf codec for the verified wire contract, and a crash-safe operation ledger with UUIDv4 idempotent replay mirroring the Codex reset-credit pattern. Tests registered in the layout map. * feat(grok): management API + CLI for reset coupons with gated, journaled consume GET /api/grok/reset-coupons?accountId= inspects remaining reset tokens and validity windows; POST /api/grok/reset-coupons/consume redeems one with UUIDv4 operation-id idempotency: the operation is journaled before the upstream call, identical ids replay the durable settlement, foreign ids 409, exhausted ledger 503. Route registered in the management table (mutates: true) and dispatched lazily like the quota routes so nothing eager-loads the module. New ocx account grok-reset-coupons mirrors reset-credits: --consume requires --yes, --operation-id validated as UUIDv4 client-side; flag-shaped positionals are never eaten as the account id. * docs(grok): document reset-coupon inspection and redemption across locales Reference pages (cli/providers-accounts.md, management-api.md) gain the new ocx account grok-reset-coupons subcommand and the two management routes in English and all seven translated locales, and structure/providers/xai-grok.md records the gRPC-Web billing parity contract under its hardening section. * feat(cli): declare grok-reset-coupons capability and regenerate skill surface The capability/route parity ratchet requires every management route to be capability-covered, exempt, or ratcheted. The new grok reset-coupon routes are genuinely covered by the new ocx account grok-reset-coupons verb, so they are declared here (routes, flags, idempotency note) and the committed skill surface map is regenerated. --- .../260912_grok_reset_coupons/000_plan.md | 53 + .../001_survey_seams.md | 97 ++ .../260912_grok_reset_coupons/005_status.md | 21 + .../010_phase1_core_client.md | 1175 +++++++++++++++++ .../020_phase2_surfaces.md | 547 ++++++++ .../030_phase3_delivery.md | 211 +++ .../fr/reference/cli/providers-accounts.md | 23 +- .../docs/fr/reference/management-api.md | 2 + .../ja/reference/cli/providers-accounts.md | 23 +- .../docs/ja/reference/management-api.md | 2 + .../ko/reference/cli/providers-accounts.md | 23 +- .../docs/ko/reference/management-api.md | 2 + .../docs/reference/cli/providers-accounts.md | 23 +- .../content/docs/reference/management-api.md | 2 + .../ru/reference/cli/providers-accounts.md | 23 +- .../docs/ru/reference/management-api.md | 2 + .../tr/reference/cli/providers-accounts.md | 23 +- .../docs/tr/reference/management-api.md | 2 + .../zh-cn/reference/cli/providers-accounts.md | 23 +- .../docs/zh-cn/reference/management-api.md | 2 + .../zh-tw/reference/cli/providers-accounts.md | 23 +- .../docs/zh-tw/reference/management-api.md | 2 + scripts/test-layout/layout.json | 2 + .../ocx/references/01_management_surface.md | 26 +- src/cli/account-auth.ts | 42 + src/cli/account.ts | 3 +- src/cli/capabilities.ts | 21 + src/cli/registry.ts | 3 +- src/grok/grpc-web.ts | 120 ++ src/grok/reset-coupon-ledger.ts | 139 ++ src/grok/reset-coupons.ts | 278 ++++ src/server/management-api.ts | 7 + src/server/management/grok-coupon-routes.ts | 287 ++++ src/server/management/route-registry.ts | 2 + structure/providers/xai-grok.md | 7 + tests/fixtures/test-layout-expected.json | 2 + .../xai/grok-reset-coupon-cli.test.ts | 97 ++ .../providers/xai/grok-reset-coupons.test.ts | 275 ++++ 38 files changed, 3603 insertions(+), 12 deletions(-) create mode 100644 devlog/_plan/260912_grok_reset_coupons/000_plan.md create mode 100644 devlog/_plan/260912_grok_reset_coupons/001_survey_seams.md create mode 100644 devlog/_plan/260912_grok_reset_coupons/005_status.md create mode 100644 devlog/_plan/260912_grok_reset_coupons/010_phase1_core_client.md create mode 100644 devlog/_plan/260912_grok_reset_coupons/020_phase2_surfaces.md create mode 100644 devlog/_plan/260912_grok_reset_coupons/030_phase3_delivery.md create mode 100644 src/grok/grpc-web.ts create mode 100644 src/grok/reset-coupon-ledger.ts create mode 100644 src/grok/reset-coupons.ts create mode 100644 src/server/management/grok-coupon-routes.ts create mode 100644 tests/providers/xai/grok-reset-coupon-cli.test.ts create mode 100644 tests/providers/xai/grok-reset-coupons.test.ts diff --git a/devlog/_plan/260912_grok_reset_coupons/000_plan.md b/devlog/_plan/260912_grok_reset_coupons/000_plan.md new file mode 100644 index 0000000000..22ba5d65b9 --- /dev/null +++ b/devlog/_plan/260912_grok_reset_coupons/000_plan.md @@ -0,0 +1,53 @@ +# Grok reset coupons — roadmap (000) + +## Reader summary + +Grok's consumer billing now hands out "reset coupons" (Codex-style usage-reset +credits). This unit teaches opencodex to read them and, only on an explicit +operator action, redeem one — using the xAI OAuth tokens opencodex already +stores, with no browser session. Live probes this session proved the upstream +contract (see [001_survey_seams.md](./001_survey_seams.md)); the implementation +mirrors the existing Codex reset-credit architecture end to end so operators get +the same safety shape they already know. + +## Loop spec + +- **Archetype:** satisfy-spec (feature delivery against a verified upstream contract). +- **Trigger:** user request this session: "이슈 올리고 pr 하고 머지까지" (file the issue, open the PR, merge) for Grok reset-coupon read + gated redeem. +- **Goal:** ocx can list a Grok account's remaining reset coupons (count + validity window) and redeem one only through an explicit, idempotent, journaled operator action, surfaced via management API + CLI; delivered as a templated issue + PR to `dev`, merged with exact-head CI evidence. +- **Non-goals:** no auto-redeem in this unit (opt-in auto-redeem is a follow-up); no GUI surface; no changes to the Codex reset-credit path; no new dependency (hand-rolled gRPC-Web codec, no @bufbuild/protobuf runtime import). +- **Verifier:** `bun test tests/providers/xai/grok-reset-coupons.test.ts` (targets the new test file directly), `bun run typecheck` (package.json:11 "bun x tsc --noEmit"), `bun run test` (package.json:12 "bun scripts/test.ts" — full tree, reads all domains incl. our layout registrations), `bun run privacy:scan` (package.json "bun scripts/privacy-scan.ts" — scans the tree incl. new files). Live smoke (sanitized) re-proves the read path against the real endpoint. +- **Stop condition:** all criteria met (goalplan c1–c6) and the PR is merged with exact-head CI + issue closed; report DONE. Missing authority (push/merge refusal) reports BLOCKED. +- **Memory artifact:** this unit (devlog/_plan/260912_grok_reset_coupons/, moves to _fin at wp4 D); goalplan + ledger under .codexclaw/goalplans/implement-grok-reset-coupon-support-in-opencodex/; evidence under .codexclaw/evidence/. +- **Expected terminal outcomes:** DONE (all criteria + merged), BLOCKED (missing external authority or upstream contract change), BUDGET_EXHAUSTED (host bounds), NEEDS_HUMAN (upstream schema drift on RedeemReset success shape). +- **Escalation condition:** upstream rejects the documented RedeemReset request shape on a real redeem → stop, report, ask operator how to proceed (spending a coupon is operator-owned). Main reclaims a lane after two distinct agents fail its packet (DISPATCH-RETIRE-01); pushing a slice to a worker requires a P-phase amendment. + +## Resource bounds (HOTL) + +Tool scope: local git/gh, repo files in this worktree, spawned read/executor subagents (unlimited parallel dispatch explicitly authorized by the operator this session; model picker left empty = inherit), ocx 10100 + aside lanes. Write scope: this worktree; remote branch push, issue, PR, and merge were explicitly authorized in the same session. Token budget: unset by operator (host default). Wall clock: until DONE/BLOCKED within this session. + +## Dependency-ordered phase map + +| Phase | Work-phase | Doc | Outcome | +|---|---|---|---| +| wp1 | Docs-first roadmap cycle (this cycle) | 000–030 | Roadmap locked at D | +| wp2 | Core gRPC-Web client + xai account integration | [010_phase1_core_client.md](./010_phase1_core_client.md) | src/grok/grpc-web.ts + src/grok/reset-coupons.ts + src/grok/reset-coupon-ledger.ts + focused tests + layout registration | +| wp3 | Surfaces: management API + CLI with gated consume | [020_phase2_surfaces.md](./020_phase2_surfaces.md) | GET/POST routes + ocx account grok-reset-coupons with --consume --yes + operation-id idempotency | +| wp4 | Delivery: docs sync, issue, PR, exact-head CI, merge | [030_phase3_delivery.md](./030_phase3_delivery.md) | docs-site updated, templated issue + PR, merged into dev, issue closed | + +## Scope boundary + +IN: files named in 010/020/030 only. OUT: src/lab/*, src/router.ts, src/server/lifecycle.ts, src/server/responses/core.ts (lab boundary, tests/lab/core-lab-boundary.test.ts), Codex reset-credit modules, GUI. + +## Conditional-path activation (C-ACTIVATION-GROUNDING-01) + +| Planned conditional path | Activation scenario at C | +|---|---| +| grpc-status non-zero (e.g. 3 "Invalid token_id") | stubbed fetch returns trailer frame status 3; test asserts surfaced message | +| 401/expired token → one refresh + replay | stubbed fetch 401 then 200; test asserts refresh called once with stored refresh token | +| Consume without --yes | CLI test asserts refusal before any fetch | +| Same operationId replay | ledger test: second call with same id returns journaled settlement, fetch called once | + +## SoT sync (SOT-SYNC-01) + +docs-site reference pages (targets verified by the docsite lane: docs-site/src/content/docs/reference/cli/providers-accounts.md, docs-site/src/content/docs/reference/management-api.md) + structure/ ownership check at wp2 P re-verification; devlog unit promotes to _fin at wp4 D. diff --git a/devlog/_plan/260912_grok_reset_coupons/001_survey_seams.md b/devlog/_plan/260912_grok_reset_coupons/001_survey_seams.md new file mode 100644 index 0000000000..179a2563d5 --- /dev/null +++ b/devlog/_plan/260912_grok_reset_coupons/001_survey_seams.md @@ -0,0 +1,97 @@ +# Grok Reset Coupons Seam Survey + +This document records the architectural survey, upstream API evidence, codebase seams, and system constraints for supporting Grok reset coupons (read and redeem) within OpenCodex. + +## 1. Upstream API Evidence + +### Endpoints +- **Endpoint A (Read Remaining Resets):** `POST https://grok.com/prod_mc_billing.ConsumerUiSvc/GetRemainingResets` + - Request message: Empty protobuf payload (`0` bytes in data frame). + - Response message: Repeated reset token descriptors. + - Verification method: Live probe this session via gRPC-Web client against `grok.com`. +- **Endpoint B (Redeem Reset):** `POST https://grok.com/prod_mc_billing.ConsumerUiSvc/RedeemReset` + - Request protobuf schema: Field 1 (tag 1, wire type 2 = length-delimited string): `token_id`. + - Verification method: Live probe this session with a synthetic token identifier. Returned HTTP 200 with gRPC trailer `grpc-status: 3` and message `redeem_reset(), Invalid token_id`. Intentionally probing non-existent method names returned `grpc-status: 12` (UNIMPLEMENTED), verifying the method path and service definition. + +### Transport & Framing +- **Protocol:** gRPC-Web over HTTP/2 or HTTP/1.1 with binary protobuf serialization. +- **Headers:** + - `Content-Type: application/grpc-web+proto` + - `X-Grpc-Web: 1` + - `Accept: application/grpc-web+proto` +- **Wire Envelope (5-byte header prefix per frame):** + - Byte 0 (`flag`): `0x00` for data frames, `0x80` for trailers. + - Bytes 1-4 (`length`): 32-bit unsigned big-endian integer denoting frame payload byte count. +- **Response Structure:** + - One or more data frames (`flag: 0x00`) carrying serialized protobuf response bytes. + - Exactly one trailer frame (`flag: 0x80`) containing ASCII header/trailer lines (e.g., `grpc-status:0\r\ngrpc-message:\r\n`). +- **Edge Behavior:** + - Plain `application/json` POST requests to the RPC endpoint return HTTP 200 with an empty `application/grpc` body. The upstream endpoint strictly requires valid gRPC-Web 5-byte framing and protobuf wire format. + - Verification method: Live probe this session comparing JSON request vs framed binary request. + +### Authentication Headers +- **Verified Header Tuple:** + - `Authorization: Bearer ` + - `X-XAI-Token-Auth: xai-grok-cli` +- **Cookie Requirement:** None. No session cookies or browser credentials are required when the bearer token and token-auth header are present. +- **Verification method:** Live probe this session using refreshed xAI OAuth tokens without cookie headers. + +### Response Protobuf Field Mapping +Hand-decoded from live payload bytes returned by `GetRemainingResets`: +- **Top-Level Message (`GetRemainingResetsResponse`):** + - Field 10 (wire type 2, length-delimited): repeated `ConsumerResetToken` +- **Nested Message (`ConsumerResetToken`):** + - Field 10 (wire type 2, length-delimited string): `tokenId` + - Field 20 (wire type 2, length-delimited submessage): `validityStart` (`google.protobuf.Timestamp`) + - Subfield 1 (wire type 0, varint): `seconds` (Unix epoch seconds) + - Field 30 (wire type 2, length-delimited submessage): `validityEnd` (`google.protobuf.Timestamp`) + - Subfield 1 (wire type 0, varint): `seconds` (Unix epoch seconds) +- **Observed Live Sample:** Active test account returned 1 token with a 31-day validity span between `validityStart` and `validityEnd`. +- **Verification method:** Live probe this session followed by binary protobuf wire decoding of returned bytes. + +--- + +## 2. Repo Seam Survey + +### OAuth Refresh Chain & Account Storage +- `src/oauth/xai.ts:369` (`refreshXaiToken(refreshToken, signal)`): Refreshes xAI OIDC OAuth tokens against the authorization server with request abort signaling. +- `src/oauth/index.ts:248-251` (`xai` OAuth provider entry in provider registry): Binds `refresh: refreshXaiToken` into the central OAuth registry map. +- `src/oauth/index.ts:613` (`getValidAccessSnapshotForAccount(provider, accountId, opts)`): Resolves an active token snapshot, automatically performing refresh with store file locking when expired or expiring. +- `src/oauth/store.ts:864` (`listAccounts(provider)`): Enumerates stored accounts for provider `xai`, supporting account discovery and status checks. +- `src/oauth/store.ts:890` (`getAccountCredentialWithStatus`): Retrieves the credential record and token status for a specific account without breaking isolation. +- `src/oauth/store.ts:923` (`captureOAuthAccountSelection("xai")`): Records the chosen account selection state for persistent CLI and server context. + +### Header Constants & Transport Defaults +- `src/providers/xai-transport.ts:28-56` (`XAI_GROK_COMPATIBILITY`): Defines xAI and Grok compatibility header constants, specifically `tokenAuth` header key `x-xai-token-auth` and value `xai-grok-cli`. + +### Grok Domain Logic +- `src/grok/*.ts`: Core domain modules containing Grok-specific client definitions, error mapping, and billing/quota abstractions. + +### Test Layout Registration +- `tests/providers/xai/grok-*.test.ts`: Unit and integration test suites for Grok-specific functionality. +- `scripts/test-layout/layout.json:694-704`: Explicit layout mapping registering Grok test files to their runner tiers. +- `tests/fixtures/test-layout-expected.json`: Snapshot expectation fixture for repository test layout verification that must match `layout.json`. + +### Management Route Table & Lazy Dispatch +- `src/server/management/route-registry.ts:94`: Codex reset-credits GET endpoint registration (`/api/codex-auth/reset-credits`). +- `src/server/management/route-registry.ts:102`: Codex reset-credits consume POST endpoint registration (`/api/codex-auth/reset-credits/consume`). +- `src/server/management/route-registry.ts:127-140`: Existing `/api/grok` management route definitions. +- `src/server/management-api.ts:140-144` (`handleQuotaResetRoutesOnDemand`): Lazy dynamic import pattern — namespace guard at 141, dynamic `import()` at 142, dispatch-chain entry at 243 — loading quota/reset route handlers only when matching endpoints are invoked. +- `src/server/management-api.ts:383`: The `/api/codex-auth/` prefix dispatch. + +### Codex Reset-Credit Mirror Pattern +- `src/codex/reset-credit-operation-ledger.ts:1191` (`openManualResetCreditOperation` definition): Journaled reset credit operation handler with atomicity, recovery records, and read/consume execution. `src/codex/auth-api.ts:2605-2647` is the consume-route call site. +- `src/codex/reset-credit-recovery.ts:40` (`isCodexResetCreditOperationId`): Operation ID syntax and format validation guard. +- `src/cli/account-auth.ts:275-302` (`resetCredits()`): CLI execution handler enforcing that `--consume` mandates explicit `--yes` confirmation and validates `--operation-id` via the recovery guard. +- `src/cli/account.ts:62,358-360`: Account command parser registering the reset-credits subcommand and argument options. +- `src/cli/registry.ts:224,236`: CLI router and dispatcher table wiring the reset-credits handler. + +--- + +## 3. Constraints & Risks + +- **Lab Boundary Invariant:** Core router and server lifecycle modules (`src/router.ts`, `src/server/lifecycle.ts`, and `src/server/responses/core.ts`) must never import from `src/lab`. Any new reset coupon abstraction must remain in production domain modules (`src/grok/`, `src/oauth/`, `src/server/management/`) without leaking experimental lab dependencies. +- **Privacy & Token Leak Prevention:** Authorization tokens, refresh tokens, and raw Bearer headers must never be written to logs, serialized to persistent console output, or returned in unmasked debug messages. +- **Bun-Native Runtime Invariants:** The codebase runs on the Bun runtime. Implementations must use standard Web APIs (`fetch`, `Uint8Array`, `DataView`, `ReadableStream`) or Bun-native primitives; Node-only modules (such as `http2`, `tls`, `stream/promises` specifics) must not be introduced. +- **Branch and Contribution Policy:** All changes and pull requests must target the `dev` branch. +- **Transport Strictness:** Upstream `grok.com` rejects non-framed JSON payloads with empty responses. The gRPC-Web encoder/decoder must handle 5-byte frame prefixes, varint parsing, and trailer parsing robustly without external heavy runtime dependencies. diff --git a/devlog/_plan/260912_grok_reset_coupons/005_status.md b/devlog/_plan/260912_grok_reset_coupons/005_status.md new file mode 100644 index 0000000000..146b4785a5 --- /dev/null +++ b/devlog/_plan/260912_grok_reset_coupons/005_status.md @@ -0,0 +1,21 @@ +# Unit status — Grok reset coupons + +## wp1 (docs-only roadmap cycle) — in Check + +- Authored: 000_plan.md (loop-spec, phase map), 001_survey_seams.md (live-probe + research), 010_phase1_core_client.md, 020_phase2_surfaces.md, + 030_phase3_delivery.md (diff-level PRDs). +- Authoring: 3 parallel Aside doc lanes + main integration. +- Audit: spawned reviewer adversarial audit round 1 = GAPS(15) — folded + (API unification getGrokRemainingResets/redeemGrokResetCoupon + Codex-mirror + ledger kinds execute|replay|identity-mismatch|capacity; field fixes + accountId/accessToken; real verifier commands; citation corrections; locale + sync + structure anchor). Round 2 = sole blocker evidenced stale; + confirmation round = VERDICT: PASS (residual cosmetic nits non-blocking). +- Architect reflection (same Aside session): 4 gaps — 3 folded, 1 rebutted with + structure/providers/xai-grok.md:1,3 evidence. +- Check gates: unit consistency grep CLEAN; bun test + tests/test-layout.test.ts tests/test-layout-tooling.test.ts = 17 pass / 0 fail. +- Next: wp2 consumes 010 (core client), wp3 consumes 020 (surfaces), wp4 + consumes 030 (delivery). Implementation begins next cycle per + LOOP-DOCS-FIRST-01. diff --git a/devlog/_plan/260912_grok_reset_coupons/010_phase1_core_client.md b/devlog/_plan/260912_grok_reset_coupons/010_phase1_core_client.md new file mode 100644 index 0000000000..2f71a2535b --- /dev/null +++ b/devlog/_plan/260912_grok_reset_coupons/010_phase1_core_client.md @@ -0,0 +1,1175 @@ +# 010 Phase 1 Core Client: Grok Reset Coupons + +This document specifies the exact diff-level implementation PRD for Phase 1 of Grok Reset Coupons support in OpenCodex. + +--- + +## 1. Architectural Context and Decisions + +### 1.1 Upstream Verification Facts +- **Endpoint A (Read):** `POST https://grok.com/prod_mc_billing.ConsumerUiSvc/GetRemainingResets` with empty protobuf message payload (`0` bytes in gRPC-Web data frame). +- **Endpoint B (Redeem):** `POST https://grok.com/prod_mc_billing.ConsumerUiSvc/RedeemReset` with protobuf message field 1 = `token_id` (wire type 2, length-delimited string). Probing with a synthetic token identifier returns HTTP 200 with trailer `grpc-status: 3` and trailer message `redeem_reset(), Invalid token_id`. Probing invalid method names returns `grpc-status: 12` (UNIMPLEMENTED). +- **Transport Framing:** gRPC-Web binary framing. Request headers: + - `Content-Type: application/grpc-web+proto` + - `X-Grpc-Web: 1` + - 5-byte envelope prefix per frame: `flag` (1 byte, `0x00` = data, `0x80` = trailer) + `length` (4 bytes, unsigned big-endian 32-bit integer). + - Plain `application/json` POST requests return HTTP 200 with an empty `application/grpc` body. Binary gRPC-Web framing is strictly mandatory. +- **Authentication Headers:** + - `Authorization: Bearer ` + - `X-XAI-Token-Auth: xai-grok-cli` (key at `src/providers/xai-transport.ts:34`, value at `src/providers/xai-transport.ts:54`, from `XAI_GROK_COMPATIBILITY.headers.tokenAuth`). + - No browser cookies or session cookies required. +- **Protobuf Wire Schema:** + - `GetRemainingResetsResponse`: + - Field 10 (wire type 2): repeated `ConsumerResetToken`. + - Nested `ConsumerResetToken`: + - Field 10 (wire type 2): `tokenId` (string). + - Field 20 (wire type 2): `validityStart` (`Timestamp` submessage with field 1 varint `seconds`). + - Field 30 (wire type 2): `validityEnd` (`Timestamp` submessage with field 1 varint `seconds`). + - `RedeemResetRequest`: + - Field 1 (wire type 2): `tokenId` (string). + - `RedeemResetResponse`: + - Empty message or success descriptor framed by gRPC status code `0` in trailers. + +### 1.2 Architect Decisions +- **D1:** Core client modules reside in `src/grok/grpc-web.ts`, `src/grok/reset-coupons.ts`, and `src/grok/reset-coupon-ledger.ts`. +- **D2:** Zero external dependencies for protobuf or gRPC-Web. Minimal self-contained varint / length-delimited codec and 5-byte framing parser using standard Web API typed arrays (`Uint8Array`, `DataView`). +- **D3:** Management routes (`GET /api/grok/reset-coupons` and `POST /api/grok/reset-coupons/consume`) wire via lazy route dispatch mirroring Codex reset-credit patterns. +- **D4:** CLI subcommand `grok-reset-coupons` in `src/cli/account-auth.ts` requires `--yes` confirmation when `--consume` is passed, validating operation IDs. +- **D5:** Test suites in `tests/providers/xai/grok-reset-coupons.test.ts`, explicitly mapped to `providers/xai` tier in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +--- + +## 2. Repo Seams & Anchor Points + +1. `src/oauth/xai.ts:369`: `refreshXaiToken(refreshToken, signal)` — token refresher for expired xAI access tokens. +2. `src/oauth/index.ts:248-251`: Central provider registry entry binding `xai` token refresh callback. +3. `src/oauth/index.ts:613`: `getValidAccessSnapshotForAccount(provider, accountId, opts)` — returns fresh access token, auto-refreshing under lock when expired. +4. `src/oauth/store.ts:864`: `listAccounts(provider)` — lists stored accounts for provider `xai`. +5. `src/oauth/store.ts:890`: `getAccountCredentialWithStatus` — retrieves account credential and validity status. +6. `src/oauth/store.ts:923`: `captureOAuthAccountSelection("xai")` — active account selection context. +7. `src/providers/xai-transport.ts:28-56`: `XAI_GROK_COMPATIBILITY` header definitions (`tokenAuth: "x-xai-token-auth"`, value `"xai-grok-cli"`). +8. `src/grok/*.ts`: Grok domain modules (`catalog.ts`, `effort.ts`, `inject.ts`, `status.ts`, `sync.ts`). +9. `scripts/test-layout/layout.json:694-704`: Test layout map registering `grok-*.test.ts` suites under `providers/xai`. +10. `tests/fixtures/test-layout-expected.json:528-538`: Snapshot fixture for test layout verification. +11. `src/server/management/route-registry.ts:94,102,127-140`: Route table definitions for reset credits and Grok APIs. +12. `src/server/management-api.ts:140-144`: Lazy dispatch pattern `handleQuotaResetRoutesOnDemand` (namespace guard at 141, dynamic `import()` at 142, dispatch-chain entry at 243); `:383` is the `/api/codex-auth/` prefix dispatch. +13. `openManualResetCreditOperation` is defined at `src/codex/reset-credit-operation-ledger.ts:1191`; `src/codex/auth-api.ts:2605-2647` is the consume-route call site for the journaled read and consume handlers. +14. `src/codex/reset-credit-auto-redeem.ts:71-105`: Crash-safe disk journal pattern using `atomicWriteFile`. +15. `src/codex/reset-credit-recovery.ts:40`: UUID operation ID validation regex and type guard. +16. `src/cli/account-auth.ts:275-302`: CLI reset-credits command execution pattern. +17. `src/cli/account.ts:62,358-360`: Account command line options parser. +18. `src/cli/registry.ts:224,236`: CLI route registry. + +--- + +## 3. Protobuf Wire Encoding and Decoding Specification + +### 3.1 Field Table + +| Message | Field Number | Field Name | Wire Type | Wire Type ID | Representation | +|:---|:---:|:---|:---|:---:|:---| +| `RedeemResetRequest` | 1 | `tokenId` | Length-delimited | 2 | UTF-8 encoded string | +| `GetRemainingResetsResponse` | 10 | `tokens` | Length-delimited | 2 | Repeated `ConsumerResetToken` submessage | +| `ConsumerResetToken` | 10 | `tokenId` | Length-delimited | 2 | UTF-8 encoded string | +| `ConsumerResetToken` | 20 | `validityStart` | Length-delimited | 2 | `google.protobuf.Timestamp` submessage | +| `ConsumerResetToken` | 30 | `validityEnd` | Length-delimited | 2 | `google.protobuf.Timestamp` submessage | +| `Timestamp` | 1 | `seconds` | Varint | 0 | 64-bit varint (Unix epoch seconds) | +| `Timestamp` | 2 | `nanos` | Varint | 0 | 32-bit varint (fractional nanoseconds, optional) | + +### 3.2 Wire Tag Calculation +Tag = `(field_number << 3) | wire_type`: +- `RedeemResetRequest.tokenId` (Field 1, Wire Type 2): `(1 << 3) | 2 = 10` (`0x0a`). +- `GetRemainingResetsResponse.tokens` (Field 10, Wire Type 2): `(10 << 3) | 2 = 82` (`0x52`). +- `ConsumerResetToken.tokenId` (Field 10, Wire Type 2): `(10 << 3) | 2 = 82` (`0x52`). +- `ConsumerResetToken.validityStart` (Field 20, Wire Type 2): `(20 << 3) | 2 = 162` (`0xa2, 0x01`). +- `ConsumerResetToken.validityEnd` (Field 30, Wire Type 2): `(30 << 3) | 2 = 242` (`0xf2, 0x01`). +- `Timestamp.seconds` (Field 1, Wire Type 0): `(1 << 3) | 0 = 8` (`0x08`). + +--- + +## 4. File-by-File Implementation Plan + +### 4.1 File 1: `src/grok/grpc-web.ts` (NEW) + +#### Exact Exported Signatures +```typescript +export interface GrpcWebTrailer { + status: number; + statusMessage?: string; + metadata: Record; +} + +export interface DecodedGrpcWebResponse { + messages: Uint8Array[]; + status: number; + statusMessage?: string; + trailers?: GrpcWebTrailer; +} + +export class GrpcWebError extends Error { + readonly status: number; + readonly statusMessage: string; + constructor(status: number, statusMessage: string); +} + +export function encodeGrpcWebEnvelope(message: Uint8Array): Uint8Array; +export function decodeGrpcWebResponse(bytes: Uint8Array): DecodedGrpcWebResponse; +export function parseGrpcWebTrailers(bytes: Uint8Array): GrpcWebTrailer; +``` + +#### Before / After Code +**Before:** File does not exist. + +**After:** +```typescript +/** + * Minimal, zero-dependency gRPC-Web binary framing encoder and decoder. + * Supports 5-byte header prefix: 0x00 data frames, 0x80 trailer frames. + */ + +export interface GrpcWebTrailer { + status: number; + statusMessage?: string; + metadata: Record; +} + +export interface DecodedGrpcWebResponse { + messages: Uint8Array[]; + status: number; + statusMessage?: string; + trailers?: GrpcWebTrailer; +} + +export class GrpcWebError extends Error { + readonly status: number; + readonly statusMessage: string; + + constructor(status: number, statusMessage: string) { + super(`gRPC-Web call failed with status ${status}: ${statusMessage}`); + this.name = "GrpcWebError"; + this.status = status; + this.statusMessage = statusMessage; + } +} + +const FRAME_DATA = 0x00; +const FRAME_TRAILER = 0x80; +const HEADER_SIZE = 5; + +/** + * Encodes a protobuf payload into a single gRPC-Web binary data frame (flag 0x00). + */ +export function encodeGrpcWebEnvelope(message: Uint8Array): Uint8Array { + const envelope = new Uint8Array(HEADER_SIZE + message.length); + envelope[0] = FRAME_DATA; + const view = new DataView(envelope.buffer, envelope.byteOffset, envelope.byteLength); + view.setUint32(1, message.length, false); // Big-endian u32 + envelope.set(message, HEADER_SIZE); + return envelope; +} + +/** + * Parses ASCII key-value lines from a gRPC-Web trailer frame payload. + */ +export function parseGrpcWebTrailers(bytes: Uint8Array): GrpcWebTrailer { + const text = new TextDecoder("utf-8").decode(bytes); + const lines = text.split(/\r?\n/); + const metadata: Record = {}; + let status = 0; + let statusMessage: string | undefined; + + for (const line of lines) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + const key = line.slice(0, colonIdx).trim().toLowerCase(); + const value = line.slice(colonIdx + 1).trim(); + if (!key) continue; + metadata[key] = value; + if (key === "grpc-status") { + const parsed = parseInt(value, 10); + if (!Number.isNaN(parsed)) { + status = parsed; + } + } else if (key === "grpc-message") { + try { + statusMessage = decodeURIComponent(value); + } catch { + statusMessage = value; + } + } + } + + return { status, statusMessage, metadata }; +} + +/** + * Decodes a contiguous gRPC-Web binary stream into data messages and trailing metadata. + */ +export function decodeGrpcWebResponse(bytes: Uint8Array): DecodedGrpcWebResponse { + const messages: Uint8Array[] = []; + let offset = 0; + let trailer: GrpcWebTrailer | undefined; + + while (offset + HEADER_SIZE <= bytes.length) { + const flag = bytes[offset]; + const view = new DataView(bytes.buffer, bytes.byteOffset + offset, HEADER_SIZE); + const length = view.getUint32(1, false); + const frameStart = offset + HEADER_SIZE; + const frameEnd = frameStart + length; + + if (frameEnd > bytes.length) { + throw new Error(`Incomplete gRPC-Web frame at offset ${offset}: expected ${length} bytes, got ${bytes.length - frameStart}`); + } + + const payload = bytes.subarray(frameStart, frameEnd); + + if (flag === FRAME_DATA) { + messages.push(payload); + } else if (flag === FRAME_TRAILER) { + trailer = parseGrpcWebTrailers(payload); + } + + offset = frameEnd; + } + + const finalStatus = trailer ? trailer.status : 0; + const finalMessage = trailer?.statusMessage; + + return { + messages, + status: finalStatus, + statusMessage: finalMessage, + trailers: trailer, + }; +} +``` + +#### Acceptance Criteria & Verifier +- **Acceptance Criteria:** + 1. `encodeGrpcWebEnvelope(bytes)` writes `0x00` at index 0, length in big-endian u32 at indices 1-4, and copies input payload starting at index 5. + 2. `decodeGrpcWebResponse(bytes)` parses multiple 0x00 frames and extracts 0x80 trailer frame with parsed `grpc-status` and `grpc-message`. + 3. Throws descriptive error on truncated payload frames. +- **Verifier Command:** + ```bash + bun test tests/providers/xai/grok-reset-coupons.test.ts + ``` + +--- + +### 4.2 File 2: `src/grok/reset-coupons.ts` (NEW) + +#### Exact Exported Signatures +```typescript +export const GROK_CONSUMER_UI_BASE_URL = "https://grok.com"; +export const GROK_GET_REMAINING_RESETS_ENDPOINT = + "https://grok.com/prod_mc_billing.ConsumerUiSvc/GetRemainingResets"; +export const GROK_REDEEM_RESET_ENDPOINT = + "https://grok.com/prod_mc_billing.ConsumerUiSvc/RedeemReset"; + +export interface GrokResetCoupon { + tokenId: string; + validityStart: string; + validityEnd: string; +} + +export interface GetRemainingResetsOptions { + accessToken: string; + fetchFn?: typeof globalThis.fetch; + signal?: AbortSignal; + endpoint?: string; +} + +export interface RedeemResetOptions { + accessToken: string; + tokenId: string; + fetchFn?: typeof globalThis.fetch; + signal?: AbortSignal; + endpoint?: string; +} + +export interface RedeemResetResult { + success: boolean; + status: number; + statusMessage?: string; +} + +export function encodeVarint(value: number | bigint): Uint8Array; +export function decodeVarint(bytes: Uint8Array, offset: number): { value: number; bytesRead: number }; +export function encodeRedeemResetRequest(tokenId: string): Uint8Array; +export function decodeGetRemainingResetsResponse(payload: Uint8Array): GrokResetCoupon[]; +export function getGrokRemainingResets(options: GetRemainingResetsOptions): Promise<{ tokens: GrokResetCoupon[] }>; +export function redeemGrokResetCoupon(options: RedeemResetOptions): Promise; +``` + +#### Before / After Code +**Before:** File does not exist. + +**After:** +```typescript +import { XAI_GROK_COMPATIBILITY } from "../providers/xai-transport"; +import { + decodeGrpcWebResponse, + encodeGrpcWebEnvelope, + GrpcWebError, +} from "./grpc-web"; + +export const GROK_CONSUMER_UI_BASE_URL = "https://grok.com"; +export const GROK_GET_REMAINING_RESETS_ENDPOINT = + "https://grok.com/prod_mc_billing.ConsumerUiSvc/GetRemainingResets"; +export const GROK_REDEEM_RESET_ENDPOINT = + "https://grok.com/prod_mc_billing.ConsumerUiSvc/RedeemReset"; + +export interface GrokResetCoupon { + tokenId: string; + validityStart: string; + validityEnd: string; +} + +export interface GetRemainingResetsOptions { + accessToken: string; + fetchFn?: typeof globalThis.fetch; + signal?: AbortSignal; + endpoint?: string; +} + +export interface RedeemResetOptions { + accessToken: string; + tokenId: string; + fetchFn?: typeof globalThis.fetch; + signal?: AbortSignal; + endpoint?: string; +} + +export interface RedeemResetResult { + success: boolean; + status: number; + statusMessage?: string; +} + +/** + * Encodes a 32/64-bit non-negative integer into protobuf varint wire bytes. + */ +export function encodeVarint(value: number | bigint): Uint8Array { + const bytes: number[] = []; + let val = BigInt(value); + while (val >= 0x80n) { + bytes.push(Number((val & 0x7fn) | 0x80n)); + val >>= 7n; + } + bytes.push(Number(val & 0x7fn)); + return new Uint8Array(bytes); +} + +/** + * Decodes a protobuf varint from bytes at offset. + */ +export function decodeVarint(bytes: Uint8Array, offset: number): { value: number; bytesRead: number } { + let result = 0; + let shift = 0; + let count = 0; + + while (offset + count < bytes.length) { + const b = bytes[offset + count]; + count++; + result |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + if (shift > 35) { + // For timestamps seconds, JS safe integers suffice. + break; + } + } + + return { value: result, bytesRead: count }; +} + +/** + * Encodes RedeemResetRequest protobuf: field 1 (string token_id). + */ +export function encodeRedeemResetRequest(tokenId: string): Uint8Array { + const tokenBytes = new TextEncoder().encode(tokenId); + const tag = (1 << 3) | 2; // Field 1, Wire Type 2 + const tagBytes = encodeVarint(tag); + const lenBytes = encodeVarint(tokenBytes.length); + + const out = new Uint8Array(tagBytes.length + lenBytes.length + tokenBytes.length); + out.set(tagBytes, 0); + out.set(lenBytes, tagBytes.length); + out.set(tokenBytes, tagBytes.length + lenBytes.length); + return out; +} + +/** + * Decodes a Timestamp submessage (field 1: int64 seconds). + */ +function decodeTimestamp(bytes: Uint8Array): number { + let offset = 0; + let seconds = 0; + + while (offset < bytes.length) { + const { value: tag, bytesRead: tagLen } = decodeVarint(bytes, offset); + offset += tagLen; + const fieldNum = tag >> 3; + const wireType = tag & 0x7; + + if (wireType === 0) { + const { value, bytesRead } = decodeVarint(bytes, offset); + offset += bytesRead; + if (fieldNum === 1) seconds = value; + } else if (wireType === 2) { + const { value: len, bytesRead } = decodeVarint(bytes, offset); + offset += bytesRead + len; + } else { + break; + } + } + + return seconds; +} + +/** + * Decodes a ConsumerResetToken submessage. + */ +function decodeConsumerResetToken(bytes: Uint8Array): GrokResetCoupon | null { + let offset = 0; + let tokenId = ""; + let startSec = 0; + let endSec = 0; + + while (offset < bytes.length) { + const { value: tag, bytesRead: tagLen } = decodeVarint(bytes, offset); + offset += tagLen; + const fieldNum = tag >> 3; + const wireType = tag & 0x7; + + if (wireType === 2) { + const { value: len, bytesRead: lenRead } = decodeVarint(bytes, offset); + offset += lenRead; + const sub = bytes.subarray(offset, offset + len); + offset += len; + + if (fieldNum === 10) { + tokenId = new TextDecoder("utf-8").decode(sub); + } else if (fieldNum === 20) { + startSec = decodeTimestamp(sub); + } else if (fieldNum === 30) { + endSec = decodeTimestamp(sub); + } + } else if (wireType === 0) { + const { bytesRead } = decodeVarint(bytes, offset); + offset += bytesRead; + } else { + break; + } + } + + if (!tokenId) return null; + + return { + tokenId, + validityStart: startSec > 0 ? new Date(startSec * 1000).toISOString() : "", + validityEnd: endSec > 0 ? new Date(endSec * 1000).toISOString() : "", + }; +} + +/** + * Decodes GetRemainingResetsResponse protobuf message: field 10 (repeated ConsumerResetToken). + */ +export function decodeGetRemainingResetsResponse(payload: Uint8Array): GrokResetCoupon[] { + const tokens: GrokResetCoupon[] = []; + let offset = 0; + + while (offset < payload.length) { + const { value: tag, bytesRead: tagLen } = decodeVarint(payload, offset); + offset += tagLen; + const fieldNum = tag >> 3; + const wireType = tag & 0x7; + + if (wireType === 2) { + const { value: len, bytesRead: lenRead } = decodeVarint(payload, offset); + offset += lenRead; + const sub = payload.subarray(offset, offset + len); + offset += len; + + if (fieldNum === 10) { + const token = decodeConsumerResetToken(sub); + if (token) tokens.push(token); + } + } else if (wireType === 0) { + const { bytesRead } = decodeVarint(payload, offset); + offset += bytesRead; + } else { + break; + } + } + + return tokens; +} + +function buildGrokHeaders(accessToken: string): Record { + return { + "Content-Type": "application/grpc-web+proto", + "X-Grpc-Web": "1", + "Accept": "application/grpc-web+proto", + "Authorization": `Bearer ${accessToken}`, + [XAI_GROK_COMPATIBILITY.headers.tokenAuth]: "xai-grok-cli", + }; +} + +/** + * Reads available Grok reset tokens for the authenticated xAI account. + */ +export async function getGrokRemainingResets(options: GetRemainingResetsOptions): Promise<{ tokens: GrokResetCoupon[] }> { + const fetchImpl = options.fetchFn ?? globalThis.fetch; + const endpoint = options.endpoint ?? GROK_GET_REMAINING_RESETS_ENDPOINT; + const emptyBody = encodeGrpcWebEnvelope(new Uint8Array(0)); + + const res = await fetchImpl(endpoint, { + method: "POST", + headers: buildGrokHeaders(options.accessToken), + body: emptyBody, + signal: options.signal, + }); + + if (!res.ok) { + throw new Error(`GetRemainingResets HTTP error ${res.status}: ${res.statusText}`); + } + + const rawBytes = new Uint8Array(await res.arrayBuffer()); + const decoded = decodeGrpcWebResponse(rawBytes); + + if (decoded.status !== 0) { + throw new GrpcWebError(decoded.status, decoded.statusMessage ?? "Unknown gRPC error"); + } + + if (decoded.messages.length === 0) { + return { tokens: [] }; + } + + return { tokens: decodeGetRemainingResetsResponse(decoded.messages[0]) }; +} + +/** + * Redeems a specific Grok reset token by tokenId. + */ +export async function redeemGrokResetCoupon(options: RedeemResetOptions): Promise { + const fetchImpl = options.fetchFn ?? globalThis.fetch; + const endpoint = options.endpoint ?? GROK_REDEEM_RESET_ENDPOINT; + const protoMessage = encodeRedeemResetRequest(options.tokenId); + const envelope = encodeGrpcWebEnvelope(protoMessage); + + const res = await fetchImpl(endpoint, { + method: "POST", + headers: buildGrokHeaders(options.accessToken), + body: envelope, + signal: options.signal, + }); + + if (!res.ok) { + throw new Error(`RedeemReset HTTP error ${res.status}: ${res.statusText}`); + } + + const rawBytes = new Uint8Array(await res.arrayBuffer()); + const decoded = decodeGrpcWebResponse(rawBytes); + + if (decoded.status !== 0) { + throw new GrpcWebError(decoded.status, decoded.statusMessage ?? "Unknown gRPC error"); + } + + return { + success: true, + status: decoded.status, + statusMessage: decoded.statusMessage, + }; +} +``` + +#### Acceptance Criteria & Verifier +- **Acceptance Criteria:** + 1. `getGrokRemainingResets` issues POST with `Content-Type: application/grpc-web+proto`, `X-Grpc-Web: 1`, `Authorization: Bearer `, and `x-xai-token-auth: xai-grok-cli`. + 2. Protobuf decoder correctly parses field 10 repeated `GrokResetCoupon` tokens with `tokenId` and ISO-string `validityStart`/`validityEnd` (epoch seconds are kept internally as `validityStartSeconds`/`validityEndSeconds` only during decode). + 3. `redeemGrokResetCoupon` encodes field 1 string `token_id` in a 5-byte envelope and surfaces `GrpcWebError` on non-zero gRPC statuses (e.g. status 3 invalid token). +- **Verifier Command:** + ```bash + bun test tests/providers/xai/grok-reset-coupons.test.ts + ``` + +--- + +### 4.3 File 3: `src/grok/reset-coupon-ledger.ts` (NEW) + +#### Exact Exported Signatures +```typescript +export type GrokResetCouponOperationKind = "execute" | "replay" | "identity-mismatch" | "capacity"; + +export interface GrokResetCouponOperationIdentity { + accountId: string; + tokenId?: string; + operationId: string; +} + +export interface GrokResetCouponOperationRecord { + kind: GrokResetCouponOperationKind; + operationId: string; + accountId?: string; + tokenId?: string; + code?: string; + settledAt?: number; +} + +export function grokCouponJournalPath(customDir?: string): string; +export function openGrokResetCouponOperation(identity: GrokResetCouponOperationIdentity, now?: number, journalPath?: string): GrokResetCouponOperationRecord; +export function recordGrokResetCouponSettlement(settlement: { operationId: string; tokenId?: string; code: string; status: "success" | "failed" }, now?: number, journalPath?: string): void; +``` + +#### Before / After Code +**Before:** File does not exist. + +**After:** +```typescript +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; + +export type GrokResetCouponOperationKind = "execute" | "replay" | "identity-mismatch" | "capacity"; + +export interface GrokResetCouponOperationIdentity { + accountId: string; + tokenId?: string; + operationId: string; +} + +export interface GrokResetCouponOperationRecord { + kind: GrokResetCouponOperationKind; + operationId: string; + accountId?: string; + tokenId?: string; + code?: string; + settledAt?: number; +} + +interface GrokResetCouponOperationState { + accountId: string; + tokenId?: string; + status: "open" | "settled" | "failed"; + code?: string; + createdAt: number; + updatedAt: number; +} + +interface GrokResetCouponLedger { + version: 1; + operations: Record; +} + +export function grokCouponJournalPath(customDir?: string): string { + const dir = customDir ?? getConfigDir(); + return join(dir, "grok-reset-coupon-ledger.json"); +} + +function readGrokCouponLedger(filePath: string): GrokResetCouponLedger { + if (!existsSync(filePath)) { + return { version: 1, operations: {} }; + } + try { + const raw = readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as GrokResetCouponLedger; + return parsed && parsed.version === 1 && parsed.operations && typeof parsed.operations === "object" + ? parsed + : { version: 1, operations: {} }; + } catch { + return { version: 1, operations: {} }; + } +} + +function writeGrokCouponLedger(filePath: string, ledger: GrokResetCouponLedger, now = Date.now()): void { + // Prune settled/failed operations older than 30 days to avoid unbounded growth + const retentionCutoff = now - 30 * 24 * 60 * 60_000; + ledger.operations = Object.fromEntries( + Object.entries(ledger.operations).filter( + ([, op]) => op.status === "open" || op.updatedAt > retentionCutoff, + ), + ); + atomicWriteFile(filePath, JSON.stringify(ledger, null, 2)); +} + +const MAX_GROK_RESET_COUPON_OPERATION_IDS = 256; + +export function openGrokResetCouponOperation( + identity: GrokResetCouponOperationIdentity, + now = Date.now(), + journalPath?: string, +): GrokResetCouponOperationRecord { + const filePath = journalPath ?? grokCouponJournalPath(); + const ledger = readGrokCouponLedger(filePath); + + if (Object.keys(ledger.operations).length >= MAX_GROK_RESET_COUPON_OPERATION_IDS) { + return { kind: "capacity", operationId: identity.operationId }; + } + + const existing = ledger.operations[identity.operationId]; + if (existing) { + if (existing.accountId !== identity.accountId) { + return { kind: "identity-mismatch", operationId: identity.operationId }; + } + if (existing.status !== "open") { + // Durably settled already: replay the recorded outcome instead of + // trusting upstream idempotency for an irreversible spend. + return { + kind: "replay", + operationId: identity.operationId, + accountId: existing.accountId, + tokenId: existing.tokenId, + code: existing.code, + settledAt: existing.updatedAt, + }; + } + return { + kind: "execute", + operationId: identity.operationId, + accountId: existing.accountId, + tokenId: existing.tokenId, + }; + } + + ledger.operations[identity.operationId] = { + accountId: identity.accountId, + ...(identity.tokenId === undefined ? {} : { tokenId: identity.tokenId }), + status: "open", + createdAt: now, + updatedAt: now, + }; + writeGrokCouponLedger(filePath, ledger, now); + return { + kind: "execute", + operationId: identity.operationId, + accountId: identity.accountId, + tokenId: identity.tokenId, + }; +} + +export function recordGrokResetCouponSettlement( + settlement: { operationId: string; tokenId?: string; code: string; status: "success" | "failed" }, + now = Date.now(), + journalPath?: string, +): void { + const filePath = journalPath ?? grokCouponJournalPath(); + const ledger = readGrokCouponLedger(filePath); + const existing = ledger.operations[settlement.operationId]; + if (!existing) return; + + existing.status = settlement.status === "success" ? "settled" : "failed"; + existing.code = settlement.code; + if (settlement.tokenId !== undefined) existing.tokenId = settlement.tokenId; + existing.updatedAt = now; + + writeGrokCouponLedger(filePath, ledger, now); +} + +``` + +#### Acceptance Criteria & Verifier +- **Acceptance Criteria:** + 1. Ledger uses `atomicWriteFile` ensuring durability without partial-write corruption. + 2. `openGrokResetCouponOperation` returns `"execute"` for a new or still-open operation, `"replay"` with the recorded outcome for an already-settled operation, `"identity-mismatch"` when the `operationId` belongs to another account, and `"capacity"` when the ledger is full — the Codex-mirror result kinds of `openManualResetCreditOperation` (`src/codex/reset-credit-operation-ledger.ts:1191-1207`; call-site pattern at `src/codex/auth-api.ts:2616-2641`). + 3. `recordGrokResetCouponSettlement` durably records the final outcome so later opens replay it. +- **Verifier Command:** + ```bash + bun test tests/providers/xai/grok-reset-coupons.test.ts + ``` + +--- + +### 4.4 File 4: `scripts/test-layout/layout.json` (MODIFY) + +#### Exact Changes +Add `"grok-reset-coupons.test.ts": "providers/xai"` into the JSON map under the `providers/xai` section. + +#### Before / After Code +**Before (lines 694-706):** +```json + "grok-attribution.test.ts": "providers/xai", + "grok-config-inject.test.ts": "providers/xai", + "grok-effort-inject.test.ts": "providers/xai", + "grok-lifecycle.test.ts": "providers/xai", + "grok-management-api.test.ts": "providers/xai", + "grok-models-effort-list.test.ts": "providers/xai", + "grok-orphan-adoption.test.ts": "providers/xai", + "grok-selection.test.ts": "providers/xai", + "grok-status.test.ts": "providers/xai", + "grok-sync.test.ts": "providers/xai", + "grok-writer-boundary.test.ts": "providers/xai", + "gui-api-error.test.ts": "gui", +``` + +**After:** +```json + "grok-attribution.test.ts": "providers/xai", + "grok-config-inject.test.ts": "providers/xai", + "grok-effort-inject.test.ts": "providers/xai", + "grok-lifecycle.test.ts": "providers/xai", + "grok-management-api.test.ts": "providers/xai", + "grok-models-effort-list.test.ts": "providers/xai", + "grok-orphan-adoption.test.ts": "providers/xai", + "grok-reset-coupons.test.ts": "providers/xai", + "grok-selection.test.ts": "providers/xai", + "grok-status.test.ts": "providers/xai", + "grok-sync.test.ts": "providers/xai", + "grok-writer-boundary.test.ts": "providers/xai", + "gui-api-error.test.ts": "gui", +``` + +#### Acceptance Criteria & Verifier +- **Acceptance Criteria:** `layout.json` parses as valid JSON with alphabetical key ordering preserved. +- **Verifier Command:** + ```bash + bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts + ``` + +--- + +### 4.5 File 5: `tests/fixtures/test-layout-expected.json` (MODIFY) + +#### Exact Changes +Add `"grok-reset-coupons.test.ts": "providers/xai"` into the snapshot expectation fixture to keep it synchronized with `layout.json`. + +#### Before / After Code +**Before (lines 534-540):** +```json + "grok-orphan-adoption.test.ts": "providers/xai", + "grok-selection.test.ts": "providers/xai", + "grok-status.test.ts": "providers/xai", + "grok-sync.test.ts": "providers/xai", + "grok-writer-boundary.test.ts": "providers/xai", + "gui-api-error.test.ts": "gui", +``` + +**After:** +```json + "grok-orphan-adoption.test.ts": "providers/xai", + "grok-reset-coupons.test.ts": "providers/xai", + "grok-selection.test.ts": "providers/xai", + "grok-status.test.ts": "providers/xai", + "grok-sync.test.ts": "providers/xai", + "grok-writer-boundary.test.ts": "providers/xai", + "gui-api-error.test.ts": "gui", +``` + +#### Acceptance Criteria & Verifier +- **Acceptance Criteria:** Test layout verification passes cleanly with zero layout mismatch. +- **Verifier Command:** + ```bash + bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts + ``` + +--- + +### 4.6 File 6: `tests/providers/xai/grok-reset-coupons.test.ts` (NEW) + +#### Exact Test List +1. **gRPC-Web framing round-trip:** Encodes data payload and decodes response with trailers, verifying flag bytes `0x00` and `0x80`, u32 length prefix, and parsed status. +2. **Decode captured live-shape fixture:** Decodes response bytes mimicking live `GetRemainingResets` response (field 10 tokens, field 10 tokenId, field 20/30 timestamps) and asserts exact parsed `GrokResetCoupon` ISO strings. +3. **Auth header assertions:** Intercepts outgoing HTTP request and verifies presence of `Authorization: Bearer ` and `X-XAI-Token-Auth: xai-grok-cli` without cookies. +4. **gRPC-status error surfacing:** Asserts that upstream trailer `grpc-status: 3` and message `redeem_reset(), Invalid token_id` throws `GrpcWebError` with status code 3. +5. **Ledger idempotent replay:** Opens an operation in a temporary test ledger, verifies re-opening a settled operation returns kind `replay`, and records settlement via `recordGrokResetCouponSettlement`. +6. **Refresh-on-401 with stubbed fetch:** Simulates initial 401 response triggering OAuth token refresh and subsequent retry to completion. + +#### Before / After Code +**Before:** File does not exist. + +**After:** +```typescript +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + decodeGrpcWebResponse, + encodeGrpcWebEnvelope, + GrpcWebError, + parseGrpcWebTrailers, +} from "../../../src/grok/grpc-web"; +import { + getGrokRemainingResets, + decodeGetRemainingResetsResponse, + encodeRedeemResetRequest, + encodeVarint, + GROK_GET_REMAINING_RESETS_ENDPOINT, + GROK_REDEEM_RESET_ENDPOINT, + redeemGrokResetCoupon, +} from "../../../src/grok/reset-coupons"; +import { + grokCouponJournalPath, + openGrokResetCouponOperation, + recordGrokResetCouponSettlement, +} from "../../../src/grok/reset-coupon-ledger"; + +describe("grok reset coupons", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "grok-coupons-test-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("round-trips grpc-web data framing and parses trailers", () => { + const payload = new TextEncoder().encode("test-payload-bytes"); + const dataEnvelope = encodeGrpcWebEnvelope(payload); + + expect(dataEnvelope[0]).toBe(0x00); + const view = new DataView(dataEnvelope.buffer, dataEnvelope.byteOffset, 5); + expect(view.getUint32(1, false)).toBe(payload.length); + + const trailerPayload = new TextEncoder().encode("grpc-status:0\r\ngrpc-message:ok\r\n"); + const trailerEnvelope = new Uint8Array(5 + trailerPayload.length); + trailerEnvelope[0] = 0x80; + const trailerView = new DataView(trailerEnvelope.buffer, trailerEnvelope.byteOffset, 5); + trailerView.setUint32(1, trailerPayload.length, false); + trailerEnvelope.set(trailerPayload, 5); + + const combined = new Uint8Array(dataEnvelope.length + trailerEnvelope.length); + combined.set(dataEnvelope, 0); + combined.set(trailerEnvelope, dataEnvelope.length); + + const decoded = decodeGrpcWebResponse(combined); + expect(decoded.messages.length).toBe(1); + expect(new TextDecoder().decode(decoded.messages[0])).toBe("test-payload-bytes"); + expect(decoded.status).toBe(0); + expect(decoded.statusMessage).toBe("ok"); + }); + + it("decodes captured live-shape GetRemainingResetsResponse fixture", () => { + // Construct protobuf binary: + // Field 10 (tokens): + // Field 10 (tokenId): "token_live_abc123" + // Field 20 (validityStart): Field 1 (seconds): 1726110000 + // Field 30 (validityEnd): Field 1 (seconds): 1728788400 + const buildTimestamp = (sec: number) => { + const secTag = (1 << 3) | 0; // field 1, varint + const secBytes = encodeVarint(sec); + const out = new Uint8Array(1 + secBytes.length); + out[0] = secTag; + out.set(secBytes, 1); + return out; + }; + + const buildToken = (tokenId: string, startSec: number, endSec: number) => { + const idBytes = new TextEncoder().encode(tokenId); + const idTag = (10 << 3) | 2; + const idLen = encodeVarint(idBytes.length); + + const startBytes = buildTimestamp(startSec); + const startTag = (20 << 3) | 2; + const startLen = encodeVarint(startBytes.length); + + const endBytes = buildTimestamp(endSec); + const endTag = (30 << 3) | 2; + const endLen = encodeVarint(endBytes.length); + + const totalLen = + 1 + idLen.length + idBytes.length + + encodeVarint(startTag).length + startLen.length + startBytes.length + + encodeVarint(endTag).length + endLen.length + endBytes.length; + + const out = new Uint8Array(totalLen); + let offset = 0; + out[offset++] = idTag; + out.set(idLen, offset); + offset += idLen.length; + out.set(idBytes, offset); + offset += idBytes.length; + + const startTagBytes = encodeVarint(startTag); + out.set(startTagBytes, offset); + offset += startTagBytes.length; + out.set(startLen, offset); + offset += startLen.length; + out.set(startBytes, offset); + offset += startBytes.length; + + const endTagBytes = encodeVarint(endTag); + out.set(endTagBytes, offset); + offset += endTagBytes.length; + out.set(endLen, offset); + offset += endLen.length; + out.set(endBytes, offset); + offset += endBytes.length; + + return out; + }; + + const tokenSub = buildToken("token_live_abc123", 1726110000, 1728788400); + const topTag = (10 << 3) | 2; + const topLen = encodeVarint(tokenSub.length); + const responsePayload = new Uint8Array(1 + topLen.length + tokenSub.length); + responsePayload[0] = topTag; + responsePayload.set(topLen, 1); + responsePayload.set(tokenSub, 1 + topLen.length); + + const tokens = decodeGetRemainingResetsResponse(responsePayload); + expect(tokens.length).toBe(1); + expect(tokens[0].tokenId).toBe("token_live_abc123"); + expect(tokens[0].validityStart).toBe(new Date(1726110000 * 1000).toISOString()); + expect(tokens[0].validityEnd).toBe(new Date(1728788400 * 1000).toISOString()); + }); + + it("asserts auth headers and tokenAuth compatibility header on request", async () => { + let capturedHeaders: Headers | undefined; + let capturedBody: Uint8Array | undefined; + + const mockFetch: typeof globalThis.fetch = async (input, init) => { + capturedHeaders = new Headers(init?.headers); + if (init?.body instanceof Uint8Array) { + capturedBody = init.body; + } + const emptyTrailer = new TextEncoder().encode("grpc-status:0\r\ngrpc-message:\r\n"); + const envelope = new Uint8Array(5 + emptyTrailer.length); + envelope[0] = 0x80; + new DataView(envelope.buffer).setUint32(1, emptyTrailer.length, false); + envelope.set(emptyTrailer, 5); + + return new Response(envelope, { + status: 200, + headers: { "content-type": "application/grpc-web+proto" }, + }); + }; + + await getGrokRemainingResets({ + accessToken: "mock-access-token-12345", + fetchFn: mockFetch, + }); + + expect(capturedHeaders?.get("authorization")).toBe("Bearer mock-access-token-12345"); + expect(capturedHeaders?.get("x-xai-token-auth")).toBe("xai-grok-cli"); + expect(capturedHeaders?.get("x-grpc-web")).toBe("1"); + expect(capturedHeaders?.get("content-type")).toBe("application/grpc-web+proto"); + expect(capturedBody).toBeDefined(); + expect(capturedBody?.[0]).toBe(0x00); // gRPC-Web data frame prefix + }); + + it("surfaces grpc-status 3 error on invalid token redemption", async () => { + const mockFetch: typeof globalThis.fetch = async () => { + const trailer = new TextEncoder().encode("grpc-status:3\r\ngrpc-message:redeem_reset()%2C%20Invalid%20token_id\r\n"); + const envelope = new Uint8Array(5 + trailer.length); + envelope[0] = 0x80; + new DataView(envelope.buffer).setUint32(1, trailer.length, false); + envelope.set(trailer, 5); + + return new Response(envelope, { + status: 200, + headers: { "content-type": "application/grpc-web+proto" }, + }); + }; + + let thrown: unknown; + try { + await redeemGrokResetCoupon({ + accessToken: "test-token", + tokenId: "invalid_id_999", + fetchFn: mockFetch, + }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(GrpcWebError); + const grpcErr = thrown as GrpcWebError; + expect(grpcErr.status).toBe(3); + expect(grpcErr.statusMessage).toContain("Invalid token_id"); + }); + + it("handles crash-safe ledger open and idempotent replay", () => { + const ledgerPath = grokCouponJournalPath(tempDir); + + const first = openGrokResetCouponOperation({ + accountId: "acc-123", + tokenId: "tok-456", + operationId: "op-uuid-1", + }, undefined, ledgerPath); + expect(first.kind).toBe("execute"); + + recordGrokResetCouponSettlement({ + operationId: "op-uuid-1", + tokenId: "tok-456", + code: "redeemed", + status: "success", + }, undefined, ledgerPath); + + // Re-opening the same settled operationId replays the durable outcome + const replay = openGrokResetCouponOperation({ + accountId: "acc-123", + tokenId: "tok-456", + operationId: "op-uuid-1", + }, undefined, ledgerPath); + expect(replay.kind).toBe("replay"); + expect(replay.code).toBe("redeemed"); + expect(replay.settledAt).toBeDefined(); + }); + + it("refreshes token on 401 when integrated with refresh provider stub", async () => { + let callCount = 0; + let tokenUsed = ""; + + const mockFetch: typeof globalThis.fetch = async (input, init) => { + callCount++; + const headers = new Headers(init?.headers); + tokenUsed = headers.get("authorization") || ""; + + if (callCount === 1) { + return new Response("Unauthorized", { status: 401 }); + } + + const emptyTrailer = new TextEncoder().encode("grpc-status:0\r\n"); + const envelope = new Uint8Array(5 + emptyTrailer.length); + envelope[0] = 0x80; + new DataView(envelope.buffer).setUint32(1, emptyTrailer.length, false); + envelope.set(emptyTrailer, 5); + + return new Response(envelope, { + status: 200, + headers: { "content-type": "application/grpc-web+proto" }, + }); + }; + + // Retry harness mimicking getValidAccessSnapshotForAccount wrapper + let activeToken = "expired-token"; + const executeWithRetry = async () => { + try { + return await getGrokRemainingResets({ accessToken: activeToken, fetchFn: mockFetch }); + } catch (err: any) { + if (err.message.includes("401")) { + activeToken = "refreshed-fresh-token"; + return await getGrokRemainingResets({ accessToken: activeToken, fetchFn: mockFetch }); + } + throw err; + } + }; + + const res = await executeWithRetry(); + expect(res).toEqual({ tokens: [] }); + expect(callCount).toBe(2); + expect(tokenUsed).toBe("Bearer refreshed-fresh-token"); + }); +}); +``` + +#### Acceptance Criteria & Verifier +- **Acceptance Criteria:** All 6 test scenarios execute and pass without network connectivity or timeouts. +- **Verifier Command:** + ```bash + bun test tests/providers/xai/grok-reset-coupons.test.ts + ``` + +--- + +## 5. Verification Commands Summary + +| Action | Target | Command | +|:---|:---|:---| +| Test Unit Suite | `tests/providers/xai/grok-reset-coupons.test.ts` | `bun test tests/providers/xai/grok-reset-coupons.test.ts` | +| Test Layout Check | `scripts/test-layout/layout.json` & fixture | `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | +| Full Provider Suite | `tests/providers/xai/` | `bun test tests/providers/xai/` | diff --git a/devlog/_plan/260912_grok_reset_coupons/020_phase2_surfaces.md b/devlog/_plan/260912_grok_reset_coupons/020_phase2_surfaces.md new file mode 100644 index 0000000000..6216985df7 --- /dev/null +++ b/devlog/_plan/260912_grok_reset_coupons/020_phase2_surfaces.md @@ -0,0 +1,547 @@ +# PRD: Grok Reset Coupons — Phase 2 Management API & CLI Surfaces + +This diff-level PRD specifies Phase 2 of the Grok reset coupon support within OpenCodex. It covers the management API routes (`GET /api/grok/reset-coupons` and `POST /api/grok/reset-coupons/consume`), lazy dispatch mounting in `src/server/management-api.ts`, route table registration in `src/server/management/route-registry.ts`, CLI subcommands in `src/cli/account-auth.ts`, `src/cli/account.ts`, and `src/cli/registry.ts`, and the test suite registration in `tests/providers/xai/grok-reset-coupons.test.ts` across `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +--- + +## 010 Context & Architectural Decisions + +### Accepted Decisions Summary +- **D1 (Domain Implementation):** Client encapsulated in `src/grok/grpc-web.ts`, coupon inspection/redemption in `src/grok/reset-coupons.ts`, and durable operation journaling in `src/grok/reset-coupon-ledger.ts`. +- **D3 (Management Endpoints & Routing):** Endpoints mounted under `/api/grok/reset-coupons` (GET) and `/api/grok/reset-coupons/consume` (POST) in `src/server/management/grok-coupon-routes.ts`. Handled via on-demand lazy import `handleGrokCouponRoutesOnDemand` in `src/server/management-api.ts` to preserve startup latency and maintain the core-lab boundary invariant. +- **D4 (CLI Interface):** Subcommand `grok-reset-coupons` in `src/cli/account-auth.ts`, routed through `src/cli/account.ts` and registered in `src/cli/registry.ts`. Mirroring `resetCredits()`: `--consume` strictly mandates `--yes`; `--operation-id` validates against UUIDv4 via `isCodexResetCreditOperationId`; supports `--token-id` selection. +- **D5 (Testing & Layout Verification):** Test suite in `tests/providers/xai/grok-reset-coupons.test.ts` mapped to category `"providers/xai"` in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +### Verified Upstream & Codebase Seams +- **Bearer Token Resolution:** `src/oauth/index.ts:613` (`getValidAccessSnapshotForAccount("xai", accountId)`) with token refresh through `src/oauth/xai.ts:369` (`refreshXaiToken`). +- **Header Constants:** `src/providers/xai-transport.ts:28-56` (`tokenAuth` header `"x-xai-token-auth": "xai-grok-cli"`). +- **Operation Journaling & Deduplication:** UUIDv4 validation using `isCodexResetCreditOperationId` from `src/codex/reset-credit-recovery.ts:40`. Durable journaling in `src/grok/reset-coupon-ledger.ts` writes intent prior to upstream fetch and replays cached settlement when the same `operationId` is presented. + +--- + +## 020 File Modifications & Exact Diffs + +### 1. NEW File: `src/server/management/grok-coupon-routes.ts` + +```typescript +/** + * Management API handlers for Grok quota reset coupons. + * + * Exposes inspection and consumption of Grok billing reset coupons via gRPC-Web + * to Grok ConsumerUiSvc upstream endpoints. + * + * Inherits management authentication from requireManagementAuth in management-api.ts. + * Lazy-loaded by handleGrokCouponRoutesOnDemand to keep startup fast and honor the + * core-lab boundary contract. + */ + +import { jsonResponse } from "../auth-cors"; +import type { ManagementContext } from "./context"; +import { isCodexResetCreditOperationId } from "../../codex/reset-credit-recovery"; +import { getValidAccessSnapshotForAccount } from "../../oauth"; +import { listAccounts, captureOAuthAccountSelection } from "../../oauth/store"; +import { + getGrokRemainingResets, + redeemGrokResetCoupon, + type GrokResetCoupon, +} from "../../grok/reset-coupons"; +import { + openGrokResetCouponOperation, + recordGrokResetCouponSettlement, + type GrokResetCouponOperationRecord, +} from "../../grok/reset-coupon-ledger"; + +export interface GrokResetCouponsResponse { + accountId: string; + tokens: Array<{ + tokenId: string; + validityStart: string; + validityEnd: string; + }>; + remaining: number; +} + +export interface GrokConsumeCouponRequestBody { + accountId?: string; + tokenId?: string; + operationId?: string; +} + +function resolveTargetAccountId(requestedAccountId?: string): string { + if (requestedAccountId && requestedAccountId.trim() !== "") { + return requestedAccountId.trim(); + } + const selection = captureOAuthAccountSelection("xai"); + if (selection?.accountId) { + return selection.accountId; + } + const accounts = listAccounts("xai"); + if (accounts.length > 0) { + return accounts[0].id; + } + throw new Error("No xAI account found or active"); +} + +export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise { + const { url, req, config } = ctx; + const { pathname } = url; + + if (pathname === "/api/grok/reset-coupons") { + if (req.method !== "GET") { + return jsonResponse({ error: "Method not allowed" }, 405, req, config); + } + + const queryAccountId = url.searchParams.get("accountId") ?? undefined; + let accountId: string; + try { + accountId = resolveTargetAccountId(queryAccountId); + } catch (err) { + return jsonResponse( + { error: { code: "no_account", message: err instanceof Error ? err.message : String(err) } }, + 400, + req, + config, + ); + } + + let tokenSnapshot; + try { + tokenSnapshot = await getValidAccessSnapshotForAccount("xai", accountId, { requireUsableAccount: true }); + } catch (err) { + return jsonResponse( + { error: { code: "auth_failed", message: "Failed to resolve valid xAI credentials for account" } }, + 401, + req, + config, + ); + } + + try { + const remainingResult = await getGrokRemainingResets({ + accessToken: tokenSnapshot.accessToken, + }); + + const payload: GrokResetCouponsResponse = { + accountId, + tokens: remainingResult.tokens.map((t) => ({ + tokenId: t.tokenId, + validityStart: t.validityStart, + validityEnd: t.validityEnd, + })), + remaining: remainingResult.tokens.length, + }; + + return jsonResponse(payload, 200, req, config); + } catch (err) { + return jsonResponse( + { error: { code: "upstream_error", message: err instanceof Error ? err.message : String(err) } }, + 502, + req, + config, + ); + } + } + + if (pathname === "/api/grok/reset-coupons/consume") { + if (req.method !== "POST") { + return jsonResponse({ error: "Method not allowed" }, 405, req, config); + } + + let body: GrokConsumeCouponRequestBody; + try { + body = (await req.json()) as GrokConsumeCouponRequestBody; + } catch { + return jsonResponse({ error: { code: "invalid_json", message: "Invalid JSON body" } }, 400, req, config); + } + + const { accountId: rawAccountId, tokenId: requestedTokenId, operationId } = body; + + if (operationId !== undefined && !isCodexResetCreditOperationId(operationId)) { + return jsonResponse( + { error: { code: "invalid_operation_id", message: "operationId must be a valid UUIDv4" } }, + 400, + req, + config, + ); + } + + let accountId: string; + try { + accountId = resolveTargetAccountId(rawAccountId); + } catch (err) { + return jsonResponse( + { error: { code: "no_account", message: err instanceof Error ? err.message : String(err) } }, + 400, + req, + config, + ); + } + + let tokenSnapshot; + try { + tokenSnapshot = await getValidAccessSnapshotForAccount("xai", accountId, { requireUsableAccount: true }); + } catch (err) { + return jsonResponse( + { error: { code: "auth_failed", message: "Failed to resolve valid xAI credentials for account" } }, + 401, + req, + config, + ); + } + + // Journaling and Idempotency settlement check + const effectiveOpId = operationId ?? crypto.randomUUID(); + const opRecord = openGrokResetCouponOperation({ + accountId, + tokenId: requestedTokenId, + operationId: effectiveOpId, + }); + + if (opRecord.kind === "replay") { + return jsonResponse( + { + code: opRecord.code, + replayed: true, + tokenId: opRecord.tokenId, + settledAt: opRecord.settledAt, + }, + 200, + req, + config, + ); + } + + if (opRecord.kind === "identity-mismatch") { + return jsonResponse( + { + error: { + code: "operation_id_owned_by_another_account", + message: "Operation ID was previously registered with a different account or token", + }, + }, + 409, + req, + config, + ); + } + + if (opRecord.kind !== "execute") { + return jsonResponse( + { + error: { + code: opRecord.kind, + message: "Coupon ledger capacity or unavailable failure", + }, + }, + 503, + req, + config, + ); + } + + let resolvedTokenId = requestedTokenId; + if (!resolvedTokenId) { + try { + const remaining = await getGrokRemainingResets({ accessToken: tokenSnapshot.accessToken }); + if (!remaining.tokens || remaining.tokens.length === 0) { + recordGrokResetCouponSettlement({ + operationId: effectiveOpId, + code: "no_coupons_available", + status: "failed", + }); + return jsonResponse( + { error: { code: "no_coupons_available", message: "No reset coupons available to redeem" } }, + 400, + req, + config, + ); + } + resolvedTokenId = remaining.tokens[0].tokenId; + } catch (err) { + return jsonResponse( + { error: { code: "fetch_resets_failed", message: err instanceof Error ? err.message : String(err) } }, + 502, + req, + config, + ); + } + } + + try { + const redeemResult = await redeemGrokResetCoupon({ + accessToken: tokenSnapshot.accessToken, + tokenId: resolvedTokenId, + }); + + recordGrokResetCouponSettlement({ + operationId: effectiveOpId, + tokenId: resolvedTokenId, + code: "redeemed", + status: "success", + }); + + return jsonResponse( + { + success: true, + code: "redeemed", + replayed: false, + tokenId: resolvedTokenId, + accountId, + operationId: effectiveOpId, + }, + 200, + req, + config, + ); + } catch (err) { + recordGrokResetCouponSettlement({ + operationId: effectiveOpId, + tokenId: resolvedTokenId, + code: "redeem_failed", + status: "failed", + }); + return jsonResponse( + { error: { code: "redeem_failed", message: err instanceof Error ? err.message : String(err) } }, + 502, + req, + config, + ); + } + } + + return null; +} +``` + +--- + +### 2. MODIFY File: `src/server/management/route-registry.ts` + +**Location:** Insert between line 133 (`POST /api/grok/apply`) and line 134 (`PUT /api/claude-code`). +**Exact Diff:** + +```diff +--- a/src/server/management/route-registry.ts ++++ b/src/server/management/route-registry.ts +@@ -131,6 +131,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ + { method: "GET", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: false }, + { method: "POST", path: "/api/claude-desktop/apply", module: "server/management/agent-settings-routes", mutates: true }, + { method: "POST", path: "/api/grok/apply", module: "server/management/agent-settings-routes", mutates: true }, ++ { method: "GET", path: "/api/grok/reset-coupons", module: "server/management/grok-coupon-routes", mutates: false }, ++ { method: "POST", path: "/api/grok/reset-coupons/consume", module: "server/management/grok-coupon-routes", mutates: true }, + { method: "PUT", path: "/api/claude-code", module: "server/management/agent-settings-routes", mutates: true }, + { method: "PUT", path: "/api/claude-desktop", module: "server/management/agent-settings-routes", mutates: true }, + { method: "PUT", path: "/api/codex-auth/features/default-mode-request-user-input", module: "server/management/agent-settings-routes", mutates: true }, +``` + +--- + +### 3. MODIFY File: `src/server/management-api.ts` + +**Location:** Around line 144 (after `handleQuotaResetRoutesOnDemand`) and line 243 (quota handler dispatched at 243, in the route dispatch chain). +**Exact Diff:** + +```diff +--- a/src/server/management-api.ts ++++ b/src/server/management-api.ts +@@ -142,6 +142,12 @@ async function handleQuotaResetRoutesOnDemand(ctx: ManagementContext): Promise { ++ if (!pathInManagementNamespace(ctx.url.pathname, "/api/grok/reset-coupons", true)) return null; ++ const { handleGrokCouponRoutes } = await import("./management/grok-coupon-routes"); ++ return handleGrokCouponRoutes(ctx); ++} + + export async function handleManagementAPI( + req: Request, +@@ -242,4 +248,5 @@ export async function handleManagementAPI( + ?? (await handleRequestHistoryRoutes(ctx)) + ?? (await handleQuotaResetRoutesOnDemand(ctx)) ++ ?? (await handleGrokCouponRoutesOnDemand(ctx)) + ?? (await handleRoutingAnalyticsRoutes(ctx)) + ?? (await handleRoutingProfileRoutesOnDemand(ctx)) +``` + +--- + +### 4. MODIFY File: `src/cli/account-auth.ts` + +**Location:** Line 39 in `USAGE`, function `grokResetCoupons()` after line 302, and line 309 in `handleAccountAuthCommand()`. +**Exact Diff:** + +```diff +--- a/src/cli/account-auth.ts ++++ b/src/cli/account-auth.ts +@@ -38,6 +38,7 @@ const USAGE = `Usage: + ocx account code [--flow ] [--json] (reads the code from stdin) + ocx account cancel [--flow ] [--json] + ocx account reset-credits [--consume --yes [--operation-id ]] [--json] ++ ocx account grok-reset-coupons [] [--consume --yes [--token-id ] [--operation-id ]] [--json] + + --device runs the OpenAI device-code login instead of the browser callback: use + it when the proxy has no browser or nothing can reach localhost:1455, such as a +@@ -301,6 +302,37 @@ async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise + printData(result, wantsJson); + } + ++async function grokResetCoupons(argv: string[], deps: RuntimeApiDeps): Promise { ++ const args = [...argv]; ++ const rawId = args.shift()?.trim(); ++ const wantsJson = takeFlag(args, "--json"); ++ const consume = takeFlag(args, "--consume"); ++ const yes = takeFlag(args, "--yes"); ++ const tokenId = takeOption(args, "--token-id"); ++ const operationId = takeOption(args, "--operation-id"); ++ ++ if (consume && !yes) throw new CliUsageError("consuming a Grok reset coupon requires --yes", USAGE); ++ if (operationId !== undefined && !consume) { ++ throw new CliUsageError("--operation-id requires --consume", USAGE); ++ } ++ if (tokenId !== undefined && !consume) { ++ throw new CliUsageError("--token-id requires --consume", USAGE); ++ } ++ if (operationId !== undefined && !isCodexResetCreditOperationId(operationId)) { ++ throw new CliUsageError("--operation-id must be a UUIDv4", USAGE); ++ } ++ rejectArgs(args, USAGE); ++ ++ const accountId = rawId ? (rawId === "main" ? "__main__" : rawId) : undefined; ++ const result = consume ++ ? await runtimeRequest("/api/grok/reset-coupons/consume", { ++ method: "POST", ++ body: JSON.stringify({ accountId, tokenId, ...(operationId === undefined ? {} : { operationId }) }), ++ }, deps) ++ : await runtimeRequest(`/api/grok/reset-coupons${accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""}`, {}, deps); ++ printData(result, wantsJson); ++} ++ + export async function handleAccountAuthCommand(sub: string, argv: string[], deps: RuntimeApiDeps = {}): Promise { + let action: (() => Promise) | undefined; + if (sub === "login" || sub === "reauth") action = () => login(sub === "reauth" ? [...argv, "--reauth"] : argv, deps); + else if (sub === "code") action = () => code(argv, deps); + else if (sub === "cancel") action = () => cancel(argv, deps); + else if (sub === "reset-credits") action = () => resetCredits(argv, deps); ++ else if (sub === "grok-reset-coupons") action = () => grokResetCoupons(argv, deps); + if (!action) return null; + return runCliAction(action); + } +``` + +--- + +### 5. MODIFY File: `src/cli/account.ts` + +**Location:** Line 62 in `ACCOUNT_USAGE` and line 358 in subcommands list. +**Exact Diff:** + +```diff +--- a/src/cli/account.ts ++++ b/src/cli/account.ts +@@ -60,6 +60,7 @@ Usage: + ocx account code [--flow ] [--json] (reads the code from stdin) + ocx account cancel [--flow ] [--json] + ocx account reset-credits [--consume --yes] [--json] ++ ocx account grok-reset-coupons [] [--consume --yes] [--token-id ] [--json] + ocx account main ... + + List and switch provider accounts and API-key pools (masked output only). +@@ -355,7 +356,7 @@ export async function handleAccountCommand(argv: string[], deps: RuntimeApiDeps + const { cmdNativeMainAccount } = await import("./account-main"); + return await cmdNativeMainAccount(rest, deps); + } +- if (["login", "reauth", "code", "cancel", "reset-credits"].includes(sub ?? "")) { ++ if (["login", "reauth", "code", "cancel", "reset-credits", "grok-reset-coupons"].includes(sub ?? "")) { + const { handleAccountAuthCommand } = await import("./account-auth"); + return await handleAccountAuthCommand(sub!, rest, deps) ?? 1; + } +``` + +--- + +### 6. MODIFY File: `src/cli/registry.ts` + +**Location:** Line 224 (`usage`) and line 236 (`details`). +**Exact Diff:** + +```diff +--- a/src/cli/registry.ts ++++ b/src/cli/registry.ts +@@ -221,7 +221,7 @@ export const ROOT_COMMANDS: readonly CommandSpec[] = [ + }, + { + name: "account", +- usage: "ocx account ...", ++ usage: "ocx account ...", + summary: "List and switch provider accounts and API-key pools (GUI parity).", + details: [ + "list [provider] Codex account pool, OAuth accounts and API keys (identifiers shown masked as the API returns them).", +@@ -234,6 +234,7 @@ export const ROOT_COMMANDS: readonly CommandSpec[] = [ + "add-key [--label