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/devlog/_fin/260912_devin_cli_account_login/000_plan.md b/devlog/_fin/260912_devin_cli_account_login/000_plan.md new file mode 100644 index 0000000000..ea736f4942 --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/000_plan.md @@ -0,0 +1,88 @@ +# Devin CLI as an account provider + +**Unit:** 260912_devin_cli_account_login +**Class:** C3 (public provider contract + a documented invariant + GUI surface) +**Goal (host):** register devin-cli as an account provider so it appears in the +dashboard accounts tab beside devin, by giving it a login entry that drives the +installed Devin CLI's own auth flow, without opencodex holding a usable Devin +bearer token. + +## Why this unit exists + +The dashboard's add-provider dialog has three tabs. Two of them (Free, Paid) are +rendered from the preset catalog; the Accounts tab is not. In +`gui/src/components/provider-catalog/ProviderCatalog.tsx` the preset rows are +drawn only when `tier !== "accounts"`, and the Accounts tab instead renders +`accountRows`, which is built from providers that have a login flow. The +`buckets.accounts` bucket that `bucketPresets` computes is never rendered at +all. + +That is why `devin-cli` is reachable only under Free today: `authKind: "local"` +makes `isFreeProvider` true (`gui/src/provider-workspace/catalog.ts`), the same +branch that holds Ollama, vLLM and LM Studio. Reclassifying the tier alone would +remove it from Free and put it in a bucket nothing draws, so it would vanish +from the dialog entirely. The only way into the Accounts tab is to become a +provider with a login. + +## The constraint this unit has to move + +`src/providers/registry.ts` and `tests/providers/devin-cli-adapter.test.ts` +currently pin the opposite posture: + +> The installed CLI carries its own credentials from `devin auth login`, so this +> provider takes no key and the proxy never sees a token for this provider. + +That statement is about the **request path**, and it stays true: the adapter +spawns `devin acp` and the child authenticates itself. What changes is the +**dashboard path**, which gains a login entry whose job is to run the CLI's own +auth flow and read back who is signed in. The distinction the unit must keep +explicit, in code comments and in the tests, is: + +- the adapter still never reads, requests, or forwards a credential at request time; +- the OAuth entry stores an identity marker, never a usable Devin bearer token. + +If those two cannot both hold, the unit stops and reports rather than inventing a +token to satisfy the framework. + +## Constraints + +- No repository-wide local suite, typecheck, or build. Focused tests only; hosted + CI on the exact PR head is the gate. Push with `--no-verify`. +- The Devin CLI is **not installed** on the development machine and must not be + installed as part of this unit without a separate instruction. Every code path + that depends on the binary needs a documented degraded behaviour and a test + that exercises it through an injected spawn, the way + `tests/providers/devin-cli-adapter.test.ts` already drives the adapter. +- `src/lab/` must stay off the core path; nothing here touches `src/router.ts`, + `src/server/lifecycle.ts`, or `src/server/responses/core.ts`. +- Target branch is `dev`. + +## Work-phase map + +Dependency-ordered; each is one full PABCD cycle. + +| Phase | Doc | Outcome | +|---|---|---| +| wp1 | this unit | Roadmap locked, every later phase written to diff level | +| wp2 | `010_phase1_cli_login.md` | `src/oauth/devin-cli.ts`: signed-in detection, login that drives the CLI, identity-only credential | +| wp3 | `020_phase2_reclassify.md` | Registry + OAUTH_PROVIDERS registration, invariant text, tests that pinned `local` | +| wp4 | `030_phase3_surface_and_land.md` | Accounts-tab proof against the running service, docs/locale, PR, merge | + +## Open risks carried into wp2 + +1. **CLI absent.** Signed-in detection cannot be proven end to end on this + machine. wp2 must therefore make the binary lookup injectable and prove both + branches (found / not found) with the existing `resolveDevinCliBinary` + override seam, and wp4 must state plainly that the live signed-in path is + unproven here. +2. **No documented status subcommand.** If the CLI exposes no non-interactive way + to report the signed-in account, the login entry can only report "the CLI + reports it is signed in" without an identity. That is still enough for an + accounts row, but it changes the credential shape, so wp2 decides this against + the subagent finding recorded in `001_cli_auth_survey.md` and amends + `010_` before building. +3. **Refresh.** The OAuth framework expects a refresh path. `src/oauth/devin.ts` + throws `invalid_grant` because Cognition mints no refresh token; the CLI entry + has the same shape and should reuse that posture rather than extending an + expiry it cannot honour. + diff --git a/devlog/_fin/260912_devin_cli_account_login/001_cli_auth_survey.md b/devlog/_fin/260912_devin_cli_account_login/001_cli_auth_survey.md new file mode 100644 index 0000000000..b513a2aeb5 --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/001_cli_auth_survey.md @@ -0,0 +1,82 @@ +# 001 — What the surfaces actually require + +Findings from three parallel read-only investigations (subagents Carson, Gibbs, +Rawls), recorded here so each later phase starts from evidence rather than from +the transcript. + +## The Accounts tab is fed by OAUTH_PROVIDERS, not by the preset catalog + +`GET /api/oauth/providers` returns `listOAuthProviders()`, which is +`Object.keys(OAUTH_PROVIDERS)` minus `chatgpt` +(`src/server/management/oauth-account-routes.ts:137-140`, +`src/oauth/index.ts:335-371`). The GUI turns that list into the Accounts rows in +`gui/src/pages/providers-page-utils.ts:8-25`. A provider does **not** need to be +in `config.json` to appear. So membership in `OAUTH_PROVIDERS` is the whole +admission rule. + +## What an OAuth entry must provide + +`OAuthProviderDef` (`src/oauth/index.ts:184-196`) requires `login`, `refresh`, +`providerConfig`, `defaultModel`. `providerConfig` is not hand-written: `oauthConfig(id)` +calls `deriveOAuthProviderConfig`, which finds the registry row **only when +`authKind === "oauth"`** and throws otherwise (`src/providers/derive.ts:350-353`). +That is why the registry reclassification and the OAuth registration are one +atomic change, not two independent edits. + +`OAuthCredentials` requires `access: string`, `refresh: string`, `expires: number`; +`normalizeCredential` drops the whole credential if any of the three is missing or +mistyped (`src/oauth/store.ts:447-502`). + +## The durable-key precedent already exists + +`devin` faces the same "no refresh endpoint" problem and solves it without +inventing one: it stores the durable key as both `access` and `refresh`, sets +`expires: Number.MAX_SAFE_INTEGER`, declares `defaultRefreshPolicy: "disabled"`, +and its `refresh` throws `invalid_grant` so a forced refresh marks the account +`needsReauth` instead of pretending success (`src/oauth/devin.ts:50-72, 155-166`, +`src/oauth/index.ts:310-315`). `orcarouter-oauth` does the same. An empty +`refresh: ""` is explicitly the wrong shape — it makes `detectOAuthWarning` report +`stale_credentials` from the moment of login. + +This unit reuses that shape, with one difference that has to stay visible: for +`devin` the stored string is a real API key; for `devin-cli` it is a non-secret +presence marker, because there is no token for opencodex to hold. + +## The fail-closed check that makes this a migration + +`src/server/auth-cors.ts:731-737` rejects a saved provider row whose +`authMode === "local"` when its registry entry is not local: + +> `provider ${name} cannot use authMode "local" — its registry entry requires ${entry.authKind} auth` + +`derive.ts:217-231` seeds `authMode` from `authKind`, so every config saved while +`devin-cli` was local carries `authMode: "local"`. Flipping the registry to +`oauth` without a migration turns those configs into a startup rejection. This is +the single highest-risk item in the unit and `020` owns it. + +## Everything else `"local"` currently controls for this provider + +From `gui/src/provider-workspace/`: `catalog.ts:137-143` treats local as +configuration-ready; `catalog.ts:170-174` puts it in the Free tier; +`auth.ts:21-22` returns `null` so no auth surface is drawn; `kind.ts:12-21` +classifies it as kind `local` for the rail filter. Under `oauth` all four change +behaviour, which is the intent — an OAuth row gets an auth surface and a login +button — but `030` has to look at the rail, not only the modal. + +From `src/providers/`: `fastwire.ts:109` returns `"none"` for local, so no +Authorization header is attached. This matters: the `devin-cli` adapter never +travels the fetch path at all (`buildRequest` is a placeholder), so the header +policy is inert for it either way. `quota.ts:2899` and `key-failover.ts` skip +local rows; under `oauth` they take the OAuth branches, which is correct because +there is now an account to reason about. + +## What the Devin CLI itself stores + +The CLI keeps its own credential on disk as `credentials.toml`. opencodex never +reads it; the adapter only spawns `devin acp` and the child authenticates itself +(`src/adapters/devin-cli/adapter.ts:1-8`). The CLI is **not installed** on this +machine, so the exact path and any non-interactive status subcommand are +unconfirmed. `010` therefore treats both the path and the status probe as +injected dependencies with a proven not-found branch, and `030` states plainly +that the live signed-in path is unproven here. + diff --git a/devlog/_fin/260912_devin_cli_account_login/002_audit_resolution.md b/devlog/_fin/260912_devin_cli_account_login/002_audit_resolution.md new file mode 100644 index 0000000000..dc9e6f4ef5 --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/002_audit_resolution.md @@ -0,0 +1,101 @@ +# 002 — Audit resolution: the reclassification is the wrong mechanism + +Independent adversarial audit of `000`/`010`/`020`/`030` returned **VERDICT: FAIL** +with four blockers. Three are fixable in place. The first invalidates the central +decision, and the roadmap changes rather than arguing with it. + +## Blocker 1 (fatal to the original design) + +Flipping `authKind` to `oauth` couples the REQUEST path to a credential that +carries no meaning. `src/router.ts:317-318` forces `authMode` from the registry +for oauth entries, and `src/server/responses/core.ts:4323` then always calls +`getValidAccessTokenSnapshot`, which throws `OAuthLoginRequiredError` when no +account set exists (`src/oauth/index.ts:576-578`) and stamps +`apiKey: resolved.accessToken` at `:4401`. + +Today a configured `devin-cli` row answers with no opencodex credential at all, +because the child authenticates itself. Under the original plan every turn would +401 until someone clicked Login, and a dashboard logout would break inference +while the CLI stayed signed in. `020`'s boundary forbids touching +`responses/core.ts`, so the plan could not have special-cased its way out. + +The audit also killed a claim in `000`: reclassifying does NOT make the row vanish. +`providerTier` only puts the canonical OpenAI forward provider in `accounts` +(`gui/src/provider-workspace/catalog.ts:160-181`), so an oauth preset with a +non-loopback base URL lands in **Paid**, which is rendered. The original +motivation sentence was wrong about the failure mode while being right that the +Accounts tab is unreachable from the preset catalog. + +## The corrected mechanism + +Accounts-tab admission is `OAUTH_PROVIDERS` membership — `listOAuthProviders()` +is `Object.keys(OAUTH_PROVIDERS)` minus `chatgpt` +(`src/oauth/index.ts:369-371`, `src/server/management/oauth-account-routes.ts:139-140`). +Nothing in that path reads `authKind`. + +`authKind: "oauth"` was only needed because `oauthConfig(id)` derives +`providerConfig` through `deriveOAuthProviderConfig`, which filters on it +(`src/providers/derive.ts:350-353`). But `providerConfig` is an ordinary +`OcxProviderConfig` field — it can be built from the registry row directly. + +**So: register `devin-cli` in `OAUTH_PROVIDERS` and leave `authKind: "local"` +alone.** The Accounts row appears; the request path keeps seeing a local +provider, demands no token, and behaves exactly as it does today. The +`auth-cors` migration in `020` and its whole new migration module become +unnecessary, because no persisted `authMode` ever mismatches. + +This also resolves the honesty problem that made the original design +uncomfortable: opencodex no longer needs a marker to stand in for a bearer +token on the request path, because the request path never asks. The stored +credential exists only so the Accounts row has a state to show. + +The residual risk moves to `isOAuthProvider("devin-cli")` becoming true, which +switches on `ocx login` (`src/oauth/login-cli.ts:86-88`), changes `ocx account` +(`src/cli/account-api.ts:83-93`), and admits the row to generic 429 failover +(`src/oauth/generic-account-failover.ts:97-98`). wp2 must prove each of those +three is either intended or inert for a stdio adapter, and `openUrl("")` in the +CLI login path must not be reached. + +## Blocker 2 — preset duplication + +`dashboardPreset: true` keeps the row in `deriveProviderPresets` +(`src/providers/derive.ts:365`), so it would show on a preset tab as well as +Accounts. Set `dashboardPreset: false`, matching `devin` and `cursor`, and update +the assertion at `tests/providers/devin-cli-adapter.test.ts:33` that currently +pins it true. With `authKind` staying local the preset tab would otherwise be +Free, not Paid, but the duplication is the same defect either way. + +## Blocker 3 — login cannot inherit stdio + +`010` said to run `devin auth login` with inherited stdio. Dashboard login is +`POST /api/oauth/login` inside the proxy, typically a launchd process with no +TTY. Use kiro's working shape instead: piped spawn with `stdin: "ignore"` +(`src/oauth/kiro.ts:151-156`), surface the CLI's own output through +`ctrl.onProgress`, and treat a login that cannot complete without a terminal as +a reported failure rather than a hang. If the CLI turns out to require a TTY, the +honest end state is an Accounts row that reports signed-in status and tells the +operator to run `devin auth login` in their own terminal — wp2 decides this +against the real binary and records which it was. + +## Blocker 4 — wrong label file + +Accounts rows use `oauthLabel` → `OAUTH_LABELS[id] ?? id` +(`gui/src/pages/providers-shared.ts:49-59`), not `formatProviderDisplayName`. +Without an `OAUTH_LABELS` entry the row reads `devin-cli`. `030`'s write set +moves from `gui/src/provider-icons.ts` to `gui/src/pages/providers-shared.ts`. + +## Structure obligation the plan missed + +`structure/AGENTS.md:49` binds changes in `src/oauth/` and `src/providers/` to +`runtime.md`, `subagents.md`, `transports/inventory.md`, and +`providers/xai-grok.md`, not only `adapters/registry.md`. wp4 checks each for a +sentence this change falsifies. + +## Effect on the work-phase map + +wp2 and wp3 swap emphasis: wp2 still builds `src/oauth/devin-cli.ts` (now with a +piped spawn and no marker-as-bearer concern), wp3 becomes registration plus the +`dashboardPreset` flip and the three `isOAuthProvider` consequences, with the +`authKind` flip and its migration DELETED. wp4 is unchanged apart from the label +file and the structure docs. + diff --git a/devlog/_fin/260912_devin_cli_account_login/003_blocker1_resolution.md b/devlog/_fin/260912_devin_cli_account_login/003_blocker1_resolution.md new file mode 100644 index 0000000000..e91d181c5c --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/003_blocker1_resolution.md @@ -0,0 +1,81 @@ +# 003 — Blocker 1 resolved: import-first login, oauth classification kept + +`002` proposed dodging the request-path coupling by leaving `authKind: "local"` +and registering in `OAUTH_PROVIDERS` anyway. The operator rejected the premise: +devin-cli is not a local runtime. It is a CLI that requires a vendor account — +demonstrated by installing it and signing in, after which +`devin auth status` reports `Logged in (via Devin)` with its credential at +`~/.local/share/devin/credentials.toml`. Ollama, vLLM and LM Studio have no +account at all; grouping devin-cli with them was a taxonomy error. + +So the classification is `oauth`, and blocker 1 has to be solved rather than +avoided. + +## The resolution + +Blocker 1 said: with `authKind: "oauth"`, `src/router.ts:317-318` forces +`authMode`, `src/server/responses/core.ts:4323` calls +`getValidAccessTokenSnapshot`, and that throws `OAuthLoginRequiredError` when no +account set exists. + +That is only a defect while no credential is stored. Once the login entry has +run, a credential exists, the snapshot resolves, `apiKey` is stamped onto a +provider config the adapter never reads, and the turn proceeds exactly as it does +today. Requiring one sign-in before an account provider answers is not a +regression — it is what an account provider means, and it is what the operator +asked for. + +**No change to `core.ts` or `router.ts` is needed.** The boundary in `000` holds. + +> **Superseded in part by `020`.** Audit round 2 disproved the startup-import half +> of this section: `projectStartupConfigRepairs` is a synchronous projector +> persisted through `mutatePersistedConfig`, which writes `config.json` only, +> while `getValidAccessTokenSnapshot` reads the auth store. A boot pass there +> cannot mint a credential, so existing installs DO need one sign-in after +> upgrade. `020` carries the corrected, honest version. The login-time +> import-first design below stands unchanged; only the boot-import claim is dead. + +## What does have to be built: import-first, so nobody is broken mid-flight + +A user who has `devin-cli` configured today and is signed into the CLI must not +wake up to 401s. Kiro already solves this shape (`src/oauth/kiro.ts:335-429`): +login imports an existing CLI session rather than starting a browser flow. + +wp2 therefore builds `loginDevinCli` import-first: + +1. `devin auth status` — confirmed present and non-interactive on 3000.10.21, + printing `Logged in (via Devin).` plus the credential path. This is the probe; + the subcommand is no longer a guess (`001` recorded it as unconfirmed). +2. already signed in -> return the marker credential immediately, no browser. +3. signed out -> run `devin auth login` with kiro's piped-spawn shape + (blocker 3), surfacing the CLI's own `Visit ... paste the code` prompt + through `ctrl.onAuth`/`onManualCodeInput` — that flow is confirmed: the CLI + prints a PKCE URL and accepts a pasted one-time code, which is exactly the + shape `onManualCodeInput` exists for. + +And wp3 adds a startup import for existing installs: when `devin-cli` is +configured, has no stored credential, and `devin auth status` says signed in, +store the marker so the first turn after upgrade succeeds without a click. Same +repair pass as the other two migrations +(`src/providers/model-rename-startup.ts`). + +## Blocker 2, 3, 4 — unchanged from `002` + +`dashboardPreset: false`; piped spawn; `OAUTH_LABELS` in +`gui/src/pages/providers-shared.ts` is the Accounts label, not +`provider-icons.ts`. The `로컬` badge disappears on its own once `auth` stops +being `"local"` (`ProviderCatalog.tsx` badge ladder), which is the mark the +operator asked to have removed. + +## Corrections to earlier docs from the live install + +- credential path is `$XDG_DATA_HOME/devin/credentials.toml` + (`~/.local/share/devin/...`), not `~/.config`. `010`'s path resolver changes. +- `devin auth status` exists and is non-interactive. `010`'s "unconfirmed + subcommand" hedge is replaced by a real probe, and its test case 4 becomes a + regression guard rather than a guess. +- A separate live defect was found and already landed on `dev` outside this unit: + the adapter passed `DEVIN_PERMISSION_MODE=ask`, which the CLI rejects with exit + 2, so every default-configuration turn failed (PR #4332, `e7f7487b3d`). This + unit assumes that fix is present. + diff --git a/devlog/_fin/260912_devin_cli_account_login/004_live_cli_probe.md b/devlog/_fin/260912_devin_cli_account_login/004_live_cli_probe.md new file mode 100644 index 0000000000..7daa74d82d --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/004_live_cli_probe.md @@ -0,0 +1,63 @@ +# 004 — Live probe of the installed CLI + +Devin CLI 3000.10.21 installed to `~/.local/bin/devin` and signed in, so the +guesses `001` recorded as unconfirmed are now measured. Everything below is +observed output, not documentation. + +## `devin auth status` + +Exists, non-interactive, exit 0 when signed in: + +``` +$ devin auth status +Logged in (via Devin). + +Credentials: + File: /Users/jun/.local/share/devin/credentials.toml + API server: https://server.codeium.com + Devin webapp: https://app.devin.ai + Devin API: https://api.devin.ai +$ echo $? +0 +``` + +Signed out, before login, it printed `Not logged in.` with the same credentials +path and a hint to run `devin auth login`. + +**No `--json`.** `devin auth status --json` fails with +`error: unexpected argument '--json' found`. `010`'s probe must therefore parse +exit status plus the literal prefix `Logged in`, not a JSON field. Parsing prose +is fragile, so the probe treats exit 0 as authoritative and the prose only as a +tiebreaker, and the `010` test set gains a case for a future wording change. + +## No identity to report + +`credentials.toml` holds four keys and none of them is an account identity: + +``` +windsurf_api_key = +api_server_url = +devin_webapp_host = +devin_api_url = +``` + +Neither does `auth status`. So the credential this unit stores carries **no** +`email` and **no** `accountId`. Two consequences `020` must handle: + +1. The Accounts row shows a signed-in state without an address. That is honest and + matches what the CLI itself can say. +2. `saveCredential` upserts by `accountId ?? email`, so an identity-less + credential replaces the active slot rather than adding one + (`src/oauth/store.ts:735-818`). Multi-account is therefore out of scope for + this provider, and `020` should say so rather than leave a half-working + "Add account" button implying otherwise. + +## The file confirms the boundary this unit promised to keep + +`windsurf_api_key` is a real credential sitting in the CLI's own file. opencodex +must not read it — doing so would turn "the CLI owns its credential" into a lie +and would give the proxy a Cognition key it has no reason to hold. The probe +checks **presence and exit status only**, never contents. `010`'s +`readDevinCliSignedInState` is written that way and its test asserts no file +bytes are read. + diff --git a/devlog/_fin/260912_devin_cli_account_login/005_cli_key_is_a_real_credential.md b/devlog/_fin/260912_devin_cli_account_login/005_cli_key_is_a_real_credential.md new file mode 100644 index 0000000000..79e744b54a --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/005_cli_key_is_a_real_credential.md @@ -0,0 +1,83 @@ +# 005 — The CLI credential is an ordinary Cognition key + +This invalidates the mechanism the whole unit was built on, so it is recorded +before anything else changes. + +## What was measured + +`~/.local/share/devin/credentials.toml` holds `windsurf_api_key` and +`api_server_url`. Feeding those two straight into the EXISTING cloud-direct +client: + +``` +host: https://server.codeium.com +key shape: devin-session-token$ey...(189 chars) +user_jwt minted: true +catalog HTTP: 200 +models in catalog: 229 enabled: 229 +``` + +and then a real turn through `streamChatEvents`: + +``` +REPLY: "CLIKEY-OK" +``` + +The CLI's key is a `devin-session-token$` — byte-for-byte the same shape +`src/oauth/devin/types.ts` already documents for the Cognition era, and the same +shape `ocx login devin` obtains through RegisterUser. It mints a `user_jwt`, it +opens the full 229-model catalog, and it streams chat over +`exa.api_server_pb.ApiServerService/GetChatMessage`. + +## Why that ends the argument the unit was having + +Every blocker the three audit rounds produced was downstream of one decision: that +`devin-cli` has no credential opencodex may hold, so an account row would have to +be faked with a marker. + +That premise is false. There is a real credential, in a file the CLI writes, in +the format the proxy already parses. + +With a real token: + +- the marker disappears, and with it the "anything that treats this as a bearer is + a bug" caveat the first audit round correctly called already-false; +- `authKind: "oauth"` is honest rather than a classification trick — the request + path resolves a genuine key and uses it; +- blocker 1 evaporates: `getValidAccessTokenSnapshot` returns a working token, so + there is no 401-until-you-click and no upgrade story to apologise for; +- blocker 3 evaporates: login is a file read, not an interactive paste, so the + `stdin` design that three rounds could not get right is not needed at all. + `spawnInteractive` and `DevinCliLoginChild` are deleted. + +This is exactly kiro's import-first shape, and now with the same substance: +kiro imports a real token from an installed CLI's own store, and so does this. + +## The direction change + +**Before:** `devin-cli` drives `devin acp` over stdio; opencodex holds nothing; +an account row needs a marker. + +**After:** `devin-cli` imports the CLI's key and routes through the cloud-direct +Connect-RPC transport the `devin` adapter already owns. + +LOOP-CONTINUITY-01 requires a reason for changing direction. The reason is +measured, above: the ACP route was chosen when the credential was believed +unreachable, and it is not. + +What is genuinely given up: ACP runs Devin's own agent loop in the child, with its +own tools and permissions. The cloud route is plain inference. For a proxy whose +job is to expose a model to Codex and Claude Code, plain inference is the correct +surface — the local agent loop was never the thing being exposed, and it is what +produced the `--permission-mode` defect already fixed on `dev` (PR #4332). + +## What the unit becomes + +wp2 shrinks to a credential importer. wp3 keeps the `authKind` flip and +`dashboardPreset: false`, and additionally repoints the adapter. wp4 is unchanged +apart from describing the new transport. + +The phase documents are rewritten in `011`, `021`, `031`; `010`/`020`/`030` stay +in place as the superseded record of the ACP design, because the audit trail that +killed it is worth keeping. + diff --git a/devlog/_fin/260912_devin_cli_account_login/006_prior_art.md b/devlog/_fin/260912_devin_cli_account_login/006_prior_art.md new file mode 100644 index 0000000000..a85cd46437 --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/006_prior_art.md @@ -0,0 +1,84 @@ +# 006 — Prior art: how everyone else uses this credential + +Survey of public implementations, to check that `005`'s direction is the one the +ecosystem converged on rather than a local guess. + +## There is no OpenAI-shaped Devin API to point at + +No repo, and no Cognition page, exposes `POST /v1/chat/completions` that accepts a +`devin-session-token`. `api.devin.ai` is a different product: cloud Devin +**sessions** (`/v1/sessions`, `/v3/organizations/{org}/sessions`) authenticated +with `cog_` service keys, which creates an agent VM rather than returning a +completion. + +Every client that wanted an OpenAI surface built the translator itself, in front +of Connect-RPC. That is exactly what opencodex already is, so the question +"can we use it as an API instead of ACP" resolves to "yes, over the transport the +`devin` adapter already owns." + +## The convergent pattern + +1. obtain a `devin-session-token$` — by RegisterUser, by CLI OAuth, or by + reading it from disk; +2. send it as Metadata `api_key` to `api_server_url`, default + `https://server.codeium.com` — **not** `api.devin.ai`; +3. chat over `exa.api_server_pb.ApiServerService/GetChatMessage`; +4. list models over `GetCascadeModelConfigs` (or `GetCliModelConfigs`), optionally + minting a `user_jwt` first. + +Implementations reviewed at source level: `rsvedant/opencode-windsurf-auth` (the +70-star original opencodex's cloud-direct client is derived from, MIT notice in +`index.ts`), its live fork `sudokar/opencode-devin-bridge`, `ktappdev/pi-windsurf`, +`CaiJingLong/devin-gateway`, `leookun/devin-2api`, `dwgx/WindsurfAPI` (2978 stars, +its `DEVIN_CONNECT=1` path), and `can1357/oh-my-pi`'s first-class Devin provider. + +Two divergences worth knowing, neither blocking: + +- `CaiJingLong/devin-gateway` lists models with **`GetCliModelConfigs`** rather + than `GetCascadeModelConfigs`. opencodex's existing catalog path works against + this account (229 models, measured in `005`), so no change; recorded in case a + future account type answers only the CLI variant. +- `oh-my-pi` calls **`AssignModel`** before chatting. opencodex does not and + streams fine, so it is not required for this surface. + +## Nobody else reads the CLI file for chat — and that is fine + +The repos that parse `credentials.toml` are usage monitors: +`wakamex/devin-cli-usage` (`windsurf_api_key` + `api_server_url` → +`SeatManagementService/GetUserStatus`), `robinebers/openusage`, and +`SammySnake-d/fast-context-mcp`. The chat clients each mint or store their own +token instead. + +So `011` is a new combination rather than a copied one: read the file the usage +tools read, then use it on the transport the chat clients use. Both halves are +independently attested, and `005` measured the join end to end. The reason nobody +published this combination is likely that the other projects are not already +holding a working cloud-direct client — opencodex is. + +## Key names confirmed by independent sources + +Cognition documents only that the CLI "stores your API token" in +`credentials.toml` and never names the keys. Three unrelated projects observe the +same two that matter: + +| key | role | +|---|---| +| `windsurf_api_key` | the durable credential, `devin-session-token$` | +| `api_server_url` | Cognition api-server; where GetChatMessage goes | + +`devin_webapp_host` and `devin_api_url` are the webapp and the session-REST +product; `011` reads neither. + +Token shapes in circulation: `devin-session-token$…` (current), `sk-ws-01-…` +(older Windsurf RegisterUser), `cog_…` (official Devin session API — different +product), `auth1_…` (web auth, not accepted as an api_key). `011`'s parser takes +the value verbatim and lets the server judge it, which is the right posture given +that spread. + +## Licensing note + +opencodex's `cloud-direct/` already carries the MIT attribution to +`rsvedant/opencode-windsurf-auth` in `index.ts`. `011` adds no new derived code — +it reads a local file and calls modules already in this tree — so no further +attribution is owed. + diff --git a/devlog/_fin/260912_devin_cli_account_login/007_reference_proxy_corroboration.md b/devlog/_fin/260912_devin_cli_account_login/007_reference_proxy_corroboration.md new file mode 100644 index 0000000000..0d7d1aac0d --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/007_reference_proxy_corroboration.md @@ -0,0 +1,39 @@ +# 007 — Independent corroboration from a working Devin CLI proxy + +A second reference implementation was supplied by the operator +(`server (1).mjs`, 851 lines, "Devin CLI proxy: OpenAI-compatible /v1 API backed +by `devin acp`"). It is an ACP proxy, so it is not the transport this unit +chose — but it independently confirms the credential half of `011`. + +## Same path, same key, same parse + +```js +// server (1).mjs:173-176 +? join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "devin", "credentials.toml") +: join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "devin", "credentials.toml")); +... +return readFileSync(path, "utf8").match(/windsurf_api_key\s*=\s*"([^"]+)"/)?.[1] ?? null; +``` + +That is the resolver `011` specifies, arrived at independently: `%APPDATA%` on +Windows, `$XDG_DATA_HOME ?? ~/.local/share` elsewhere, and a line match for +`windsurf_api_key` rather than a TOML dependency. Three sources now agree on the +path and key name — this file, `wakamex/devin-cli-usage`, and the live probe in +`004` — so `011`'s parser is not a guess. + +Its `readFileSync(...).match(...) ?? null` also returns null rather than throwing +on a missing file, which is the shape `011` uses for +`readDevinCliCredentialFile`. + +## What it does NOT corroborate + +It reads the key and then still spawns `devin acp` (`:329`), keeping one live +child per session and compacting inside it. So it is evidence for where the +credential lives, not for what to do with it. `005` is the evidence that the same +key works directly against `server.codeium.com`, which this proxy never tries. + +Worth noting for anyone comparing: an ACP proxy inherits the CLI's agent loop and +its per-session process, which is what makes session lifecycle, compaction and +`--permission-mode` its own problem — the same class of defect PR #4332 fixed in +opencodex's ACP adapter. The cloud route has none of that surface. + diff --git a/devlog/_fin/260912_devin_cli_account_login/010_phase1_cli_login.md b/devlog/_fin/260912_devin_cli_account_login/010_phase1_cli_login.md new file mode 100644 index 0000000000..1aef9e65cb --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/010_phase1_cli_login.md @@ -0,0 +1,158 @@ +# 010 — Phase 1: the Devin CLI login entry + +**Work-phase:** wp2. **Write set:** `src/oauth/devin-cli.ts` (NEW), +`tests/providers/devin-cli-login.test.ts` (NEW). Nothing else. + +Rewritten after audit rounds 1 and 2. Superseded guidance from the first draft: +inherited stdio (audit blocker 3) and an `XDG_CONFIG_HOME` credential path +(wrong, measured in `004`). + +## NEW `src/oauth/devin-cli.ts` + +```ts +/** + * Devin CLI account login. + * + * The installed CLI owns its own credential. It writes + * \`$XDG_DATA_HOME/devin/credentials.toml\` — measured, not assumed — and that file + * holds a real \`windsurf_api_key\`. opencodex deliberately never reads it: the + * whole point of this provider is that the child authenticates itself, and + * lifting that key would hand the proxy a Cognition credential it has no reason + * to hold. + * + * What this module produces is an ACCOUNT ROW. The dashboard Accounts tab is + * built from OAUTH_PROVIDERS, so a provider absent from that map cannot appear + * there however it is classified. The stored \`access\` is the marker below — a + * non-secret constant, present because normalizeCredential drops any credential + * whose \`access\` is not a string. It is never a bearer token: the devin-cli + * adapter is runTurn-only, ignores provider.apiKey, and sends empty headers. + */ +export const DEVIN_CLI_SESSION_MARKER = "devin-cli-local-session"; +``` + +### `devinCliCredentialsPath(env, platform)` + +Measured layout (`004`), not the config dir: + +- override `OPENCODEX_DEVIN_CLI_CREDENTIALS` (absolute only) +- Windows `%APPDATA%/devin/credentials.toml` +- otherwise `${XDG_DATA_HOME ?? ~/.local/share}/devin/credentials.toml` + +Used for a presence check only; the bytes are never read. + +### `readDevinCliSignedInState(deps)` + +```ts +export interface DevinCliLoginDeps { + resolveBinary?: () => string | undefined; + credentialsPath?: () => string; + exists?: (path: string) => boolean; + /** Fire-and-wait. Enough for the `auth status` probe, which takes no input. */ + run?: (bin: string, args: string[]) => Promise<{ code: number; stdout: string; stderr: string }>; + /** + * Interactive spawn, required for `auth login`. + * + * `run` cannot express that flow and an earlier draft wrongly reused it: the + * login child must receive a one-time code AFTER it has printed a URL, so the + * caller needs a live stdin handle and an incremental stdout stream, not a + * buffered result. The audit caught the draft specifying both kiro's + * `stdin: "ignore"` and a paste into that same child. + */ + spawnInteractive?: (bin: string, args: string[]) => DevinCliLoginChild; +} +export interface DevinCliLoginChild { + /** Called with each chunk of stdout/stderr as it arrives. */ + onOutput(listener: (chunk: string) => void): void; + /** Writes the pasted code; the implementation appends the newline. */ + writeLine(text: string): void; + /** Resolves with the exit code. */ + wait(): Promise; + /** Terminates the child and its group, for the deadline path. */ + kill(): void; +} +export interface DevinCliSignedInState { signedIn: boolean; reason?: "not-installed" | "signed-out" } +``` + +1. binary missing -> `{ signedIn: false, reason: "not-installed" }` +2. `devin auth status` — confirmed present and non-interactive. **Exit code is + authoritative**; there is no `--json` (`devin auth status --json` fails with + `unexpected argument`), so the prose is only a tiebreaker when the exit code + is ambiguous. Exit 0 -> signed in. +3. non-zero exit but the credential file exists -> signed in, so a future wording + or exit-code change degrades to the file check instead of locking the user out +4. otherwise `{ signedIn: false, reason: "signed-out" }` + +No `identity` field. `004` measured that neither `auth status` nor +`credentials.toml` exposes an account address, so inventing one would be a lie. + +### `loginDevinCli(ctrl, opts, deps)` + +1. binary missing -> throw with `DEVIN_CLI_INSTALL_HINT` reused verbatim from + `src/adapters/devin-cli/binary.ts`. +2. signed in and `!opts?.forceLogin` -> return the credential; import-first, no + browser, the kiro shape. +3. signed out -> `spawnInteractive(bin, ["auth", "login"])` with **all three + streams piped**: `stdio: ["pipe", "pipe", "pipe"]`. + + Kiro's runner uses `stdin: "ignore"` (`src/oauth/kiro.ts:151-156`) and an + earlier draft of this document copied it. That is wrong here, and the audit + caught it: kiro's CLI completes on its own and kiro then imports the token, + whereas this flow has to hand a one-time code BACK to the child. With stdin + ignored the child waits for a paste that can never arrive and the login hangs + until the deadline. Inheriting the proxy's stdio is equally wrong for the + opposite reason — dashboard login is `POST /api/oauth/login` inside a launchd + process with no TTY. + + So: piped stdin to write the code, piped stdout **and stderr** to find the + prompt. Measured shape of that prompt (`004`): + `Visit https://app.devin.ai/auth/cli/continue?...&cli_pkce_marker=1 to sign in, then copy the code and paste it below.` + Scrape the URL, hand it to `ctrl.onAuth({ url, instructions })`, take the code + from `ctrl.onManualCodeInput()`, and write it to stdin followed by a newline. + The CLI answers `Login successful! Credentials stored.` on success. +4. If no URL appears within the deadline, abort the child and throw a message + naming the manual path: run `devin auth login` in a terminal, then press Login + again — the import-first branch will pick it up. + +Returns: + +```ts +{ + access: DEVIN_CLI_SESSION_MARKER, + refresh: DEVIN_CLI_SESSION_MARKER, + expires: Number.MAX_SAFE_INTEGER, + source: "local-cli", +} +``` + +No `email`/`accountId` (see `004`). Marker duplicated and a MAX expiry, matching +the `devin` durable-key shape; `refresh: ""` would trip `detectOAuthWarning`. + +### `refreshDevinCliToken()` + +Throws `invalid_grant: the Devin CLI owns its own session. Run devin auth login again.` + +## NEW `tests/providers/devin-cli-login.test.ts` + +Registered in `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json` under `providers`. + +1. binary absent -> login throws, message carries the install hint +2. `auth status` exit 0 -> signed in; credential is marker/marker/MAX, carries no + email or accountId, and `exists` was never called on the credential file + contents (presence only) +3. `auth status` non-zero but file present -> still signed in (wording-change guard) +4. `auth status` non-zero and no file -> signed out +5. signed out + login -> `spawnInteractive` receives `["auth","login"]`, the fake + child emits the measured `Visit ...` line, that URL reaches `onAuth`, and + the code from `onManualCodeInput` arrives at `writeLine`. Asserts the child is + never handed the proxy's own streams +8. a login whose stdin is not writable fails loudly rather than hanging — the + regression guard for the `stdin: "ignore"` mistake this phase already made once +6. login producing no URL before the deadline -> throws naming the terminal fallback +7. `refreshDevinCliToken` rejects with `invalid_grant` + +## Acceptance + +- No import from `src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts`. +- `bun test tests/providers/devin-cli-login.test.ts` green. +- `src/adapters/devin-cli/` untouched in this phase. diff --git a/devlog/_fin/260912_devin_cli_account_login/011_phase1_credential_import.md b/devlog/_fin/260912_devin_cli_account_login/011_phase1_credential_import.md new file mode 100644 index 0000000000..26783c266f --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/011_phase1_credential_import.md @@ -0,0 +1,221 @@ +# 011 — Phase 1 (revised): import the CLI's credential + +**Supersedes `010`.** `005` measured that the Devin CLI stores a real Cognition +key, so this phase is a file import, not an interactive login. + +**Work-phase:** wp2. **Write set:** `src/oauth/devin-cli.ts` (NEW), `src/oauth/devin.ts` (export +`identityFromApiKey`, add the `providerId` parameter), `src/adapters/devin.ts` +(consume it), `src/adapters/registry.ts` + `src/server/adapter-resolve.ts` + +`src/server/responses/core.ts` (thread `providerId`), +`tests/providers/devin-cli-login.test.ts` (NEW). + +## NEW `src/oauth/devin-cli.ts` + +```ts +/** + * Devin CLI credential import. + * + * The installed CLI writes \`$XDG_DATA_HOME/devin/credentials.toml\` after + * \`devin auth login\`, and the \`windsurf_api_key\` in it is an ordinary + * \`devin-session-token$\` — the same shape RegisterUser returns for + * \`ocx login devin\`, and the same one the cloud-direct client already speaks. + * Measured: it mints a user_jwt, opens the 229-model catalog, and streams chat. + * + * So this is kiro's import-first login with the same substance: read a signed-in + * local CLI's own store and adopt the token, rather than starting a browser flow + * the CLI has already completed. + */ +``` + +### `devinCliCredentialsPath(env, platform)` + +- override `OPENCODEX_DEVIN_CLI_CREDENTIALS` (absolute only) +- Windows `%APPDATA%/devin/credentials.toml` +- otherwise `${XDG_DATA_HOME ?? ~/.local/share}/devin/credentials.toml` + +Measured in `004`. Not the config dir. + +### `readDevinCliCredentialFile(deps)` + +Parses only the two keys it needs, with a minimal line matcher rather than a TOML +dependency — the file is flat and adding a parser for four keys is not worth it: + +``` +windsurf_api_key = "devin-session-token$..." +api_server_url = "https://server.codeium.com" +``` + +Returns `{ apiKey, apiServerUrl }` or `undefined` when the file is absent or +either key is missing. **Never logs either value.** + +### `loginDevinCli(ctrl, opts, deps)` + +1. file missing or unparseable -> throw, message naming + `DEVIN_CLI_INSTALL_HINT` plus "run `devin auth login`, then press Login again". + No browser is ever opened: there is nothing for opencodex to authorize. +2. `resolveDevinApiBaseUrl(apiServerUrl)` — reuse + `src/oauth/devin/api-base.ts` verbatim. The host comes off disk and then + receives the key, so it passes the same allowlist as the RegisterUser host. + An unallowlisted value falls back to the default rather than exfiltrating. +3. Return the credential, reusing the newly exported `identityFromApiKey` from + `src/oauth/devin.ts` so an email or sub in the JWT becomes the account identity: + +```ts +{ + access: apiKey, + refresh: apiKey, // durable-key pattern; "" trips detectOAuthWarning + expires: Number.MAX_SAFE_INTEGER, + source: "local-cli", + apiBaseUrl: resolvedHost, + ...identityFromApiKey(apiKey), +} +``` + +`ctrl.onProgress?.("Imported the signed-in Devin CLI session.")`; `onAuth` is +never called, which is the shape `startLoginFlow` already handles for local +imports. + +### MODIFY `src/oauth/devin.ts` — make host resolution provider-aware + +The import stores `apiBaseUrl`, but nothing reads it. `createDevinAdapter` calls +`resolveDevinApiServer(provider.baseUrl)` (`src/adapters/devin.ts:219`), and that +helper is hard-coded to one credential: + +```ts +// src/oauth/devin.ts:26-31 — today +export function resolveDevinApiServer(configuredBaseUrl?: string): string { + return ( + validateDevinApiBaseUrl(getCredential("devin")?.apiBaseUrl) ?? + validateDevinApiBaseUrl(configuredBaseUrl) ?? + DEVIN_DEFAULT_API_SERVER + ); +} +``` + +Left alone, a `devin-cli`-only user is pinned to the default host regardless of +what their CLI recorded, and a user signed into both providers would send the CLI +key to whatever tenant the browser-login `devin` credential named — an EU or +FedStart account crossed with a US one. Two accounts, one host. + +```diff +-export function resolveDevinApiServer(configuredBaseUrl?: string): string { ++export function resolveDevinApiServer(configuredBaseUrl?: string, providerId = "devin"): string { + return ( +- validateDevinApiBaseUrl(getCredential("devin")?.apiBaseUrl) ?? ++ validateDevinApiBaseUrl(getCredential(providerId)?.apiBaseUrl) ?? + validateDevinApiBaseUrl(configuredBaseUrl) ?? + DEVIN_DEFAULT_API_SERVER + ); + } +``` + +### The adapter has no provider id today — this is the plumbing + +An earlier draft said "the adapter passes the provider it was built for" without +checking that it can. It cannot: `createDevinAdapter` receives only +`OcxProviderConfig`, which has no id, and after the flip both `devin` and +`devin-cli` share `adapter: "devin"`, so the factory cannot infer the store key. +The audit was right that the edit was unimplementable as written. + +The name IS available at the call sites — `route.providerName` at +`src/server/responses/core.ts:1436` and `:4171` — so it only has to be threaded: + +```diff + // src/adapters/registry.ts + export interface AdapterFactoryContext { + cacheRetention?: AdapterCacheRetention; ++ /** ++ * The configured provider row this adapter serves. Needed when one adapter ++ * backs two provider ids whose credentials differ — `devin` and `devin-cli` ++ * share a transport and a token format but sign in to different accounts and ++ * can sit on different tenants. ++ */ ++ providerId?: string; + } +``` + +```diff + // src/server/adapter-resolve.ts +-export function resolveAdapter(providerConfig: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { +- return createRegisteredAdapter(providerConfig, { cacheRetention }); ++export function resolveAdapter( ++ providerConfig: OcxProviderConfig, ++ cacheRetention?: "none" | "short" | "long", ++ providerId?: string, ++) { ++ return createRegisteredAdapter(providerConfig, { cacheRetention, ...(providerId ? { providerId } : {}) }); + } +``` + +Both `core.ts` call sites pass `route.providerName`. The parameter is optional and +every other adapter ignores it, so no existing behaviour moves. This touches +`core.ts` but does not make it reach `src/lab/`, which is the boundary `000` set. + +`createDevinAdapter(provider, context)` then resolves with +`context.providerId ?? "devin"`, preserving today's behaviour for any caller that +does not supply one. + +One easily-missed hop, flagged by the final audit: the registry factory currently +drops the context on the floor, so the field would arrive nowhere. + +```diff + // src/adapters/registry.ts + devin: { + wire: "devin", + mutation: "codex-owned", +- create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createDevinAdapter(provider), ++ create: (provider: OcxProviderConfig, context: AdapterFactoryContext) => createDevinAdapter(provider, context), + }, +``` + +`createRegisteredAdapter` already forwards `context` to `definition.create`, so +this one line is the whole remaining gap. Test 9 fails without it, which is the +point of routing that test through the factory. + +`identityFromApiKey` is likewise private at `src/oauth/devin.ts:45`; export it +rather than copying the JWT decode into a second file. + +### `refreshDevinCliToken()` + +Throws `invalid_grant: the Devin CLI owns this session. Run devin auth login again.` +Same posture as `refreshDevinToken`. + +### What is deleted relative to `010` + +`DEVIN_CLI_SESSION_MARKER`, `spawnInteractive`, `DevinCliLoginChild`, the PKCE +URL scraper, and the deadline path. None of them has a reason to exist once the +credential is readable. + +## NEW `tests/providers/devin-cli-login.test.ts` + +Registered in `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json` under `providers`. + +1. file absent -> throws, message carries the install hint +2. file present -> credential is `access === refresh === `, + `expires === Number.MAX_SAFE_INTEGER`, `source === "local-cli"` +3. identity: a key whose JWT carries an email surfaces it; one without does not + invent one +4. `api_server_url` pointing at a non-Cognition host -> falls back to + `DEVIN_DEFAULT_API_SERVER`, and the off-allowlist host never reaches the + credential +5. malformed file (missing `windsurf_api_key`) -> throws rather than returning a + half credential +6. neither the key nor the host appears in anything passed to `onProgress` +7. `refreshDevinCliToken` rejects with `invalid_grant` +8. `resolveDevinApiServer(undefined, "devin-cli")` returns the host stored on the + devin-cli credential and does NOT read the `devin` credential +9. **through the adapter, not only the helper**: build the adapter with + `createRegisteredAdapter(devinRow, { providerId: "devin-cli" })` while a + `devin` credential names a different allowlisted tenant, and assert the RPC + host is the CLI one. A green helper test alone would not have caught the + crossed-tenant bug, which is exactly what the audit said + +## Acceptance + +- No import from `src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts`. +- `bun test tests/providers/devin-cli-login.test.ts` green. +- A live import against this machine's signed-in CLI yields a credential that + streams a real turn — the `CLIKEY-OK` probe in `005`, repeated through the + module rather than through a scratch script. + diff --git a/devlog/_fin/260912_devin_cli_account_login/020_phase2_reclassify.md b/devlog/_fin/260912_devin_cli_account_login/020_phase2_reclassify.md new file mode 100644 index 0000000000..857a6d074a --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/020_phase2_reclassify.md @@ -0,0 +1,127 @@ +# 020 — Phase 2: reclassify local to oauth + +**Work-phase:** wp3. **Write set:** `src/providers/registry.ts`, +`src/oauth/index.ts`, `src/providers/devin-cli-authmode-migration.ts` (NEW), +`src/providers/model-rename-startup.ts`, `src/adapters/devin-cli/adapter.ts` +(comment only), `tests/providers/devin-cli-adapter.test.ts`, NEW migration test. + +Rewritten after audit rounds 1 and 2. Folded: `dashboardPreset: false` (blocker +2, missing from the first draft) and the honest upgrade story (blocker 1). + +Atomic: `oauthConfig("devin-cli")` throws unless the registry row already says +`authKind: "oauth"` (`src/providers/derive.ts:350-353`), so the registry edit and +the OAUTH_PROVIDERS entry land in one commit. + +## MODIFY `src/providers/registry.ts` + +```diff +- // 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. ++ // Drives the locally installed Devin CLI. The CLI owns its own credential and ++ // authenticates itself inside the child, so the proxy still never holds a ++ // Devin token and the adapter never reads one at request time. ++ // ++ // `oauth` classifies the ACCOUNT, not the transport. This is not a local ++ // runtime: unlike Ollama or LM Studio it cannot answer at all until a vendor ++ // account is signed in, and `local` grouped it with things that have no ++ // account. It is also the only classification under which the row reaches the ++ // dashboard Accounts tab, which is built from OAUTH_PROVIDERS. + id: "devin-cli", +- authKind: "local", ++ authKind: "oauth", + featured: false, +- dashboardPreset: true, ++ // Off, like `devin`. `deriveProviderPresets` keys the preset ++ // catalog off this flag, so leaving it true would draw the row twice: once as ++ // an Accounts login row and again as a preset tile. ++ dashboardPreset: false, +``` + +`note` keeps "no API key is stored by opencodex" — still true — and gains +"Sign in from the dashboard, or run `devin auth login` yourself; opencodex only +records that the CLI session exists." + +## MODIFY `src/oauth/index.ts` + +```ts +"devin-cli": { + login: (ctrl, opts) => loginDevinCli(ctrl, opts), + refresh: (rt, signal, credential) => refreshDevinCliToken(rt, signal, credential), + providerConfig: oauthConfig("devin-cli"), + defaultModel: oauthDefaultModel("devin-cli"), + // No endpoint to refresh against: the CLI owns the session. Same posture as + // `devin` and `orcarouter-oauth`. + defaultRefreshPolicy: "disabled", +}, +``` + +Not in `FORCE_REFRESH_PROVIDERS`: a 401 cannot arrive from a provider that never +sends a token. + +## NEW `src/providers/devin-cli-authmode-migration.ts` + +`src/server/auth-cors.ts:731-737` rejects a saved row whose `authMode === "local"` +once the registry entry is not local, and `derive.ts:217-231` seeded exactly that +value into every config saved while devin-cli was local. + +```ts +export function projectDevinCliAuthMode(config: OcxConfig) { + const prov = config.providers?.["devin-cli"]; + if (!prov || prov.adapter !== "devin-cli" || prov.authMode !== "local") return { config, changed: false, warnings: [] }; + prov.authMode = "oauth"; + return { config, changed: true, warnings: ["rewrote devin-cli authMode local -> oauth: the registry no longer classifies it as local, and the management write boundary fails closed on the mismatch."] }; +} +``` + +Guarded exactly like `projectStaleContextWindows`: exact old value, adapter still +`devin-cli`, nothing else touched. Composed into `projectStartupConfigRepairs`. + +**What this migration does NOT do.** It cannot mint a credential. +`projectStartupConfigRepairs` is a synchronous projector persisted through +`mutatePersistedConfig`, which writes `config.json` and never the auth store, and +`getValidAccessTokenSnapshot` reads the auth store. An earlier draft claimed a +boot import would keep existing installs working; the audit disproved it. The +honest upgrade story is therefore: + +> After upgrading, an existing `devin-cli` user's first turn returns +> `OAuthLoginRequiredError` until they sign in once — one click in the dashboard +> Accounts tab, or `ocx login devin-cli`. Because login is import-first and the +> CLI is already signed in, that click completes without a browser. + +This ships in the release note and in the provider docs. wp4 verifies both the +401-before and the success-after on this machine. + +## MODIFY `tests/providers/devin-cli-adapter.test.ts` + +```diff +- test("is a local provider that stores no credential", () => { ++ test("is an account provider whose adapter still holds no credential", () => { + const entry = PROVIDER_REGISTRY.find((row) => row.id === "devin-cli"); + expect(entry?.adapter).toBe("devin-cli"); +- expect(entry?.authKind).toBe("local"); +- expect(entry?.dashboardPreset).toBe(true); ++ // `oauth` classifies the account, not the request path. ++ expect(entry?.authKind).toBe("oauth"); ++ // Off, or the row is drawn twice — Accounts login row plus preset tile. ++ expect(entry?.dashboardPreset).toBe(false); ++ expect(OAUTH_PROVIDERS["devin-cli"]).toBeDefined(); +``` + +New assertion that the runtime posture is unchanged: extend the existing +handshake test so `DEVIN_CLI_SESSION_MARKER` never appears in the child's env or +in anything written to its stdin. + +## MODIFY `src/adapters/devin-cli/adapter.ts` (comment only) + +Header gains: the dashboard now carries an account row recording that a CLI +session exists; it never produces a credential this adapter reads. + +## Acceptance + +- `bun test tests/providers/devin-cli-adapter.test.ts tests/providers/devin-cli-login.test.ts` green. +- A config carrying `authMode: "local"` is repaired; proven by a focused test and + a dry run against the real saved config. +- `listOAuthProviders()` includes `devin-cli`; `deriveProviderPresets()` does not. + diff --git a/devlog/_fin/260912_devin_cli_account_login/021_phase2_oauth_and_transport.md b/devlog/_fin/260912_devin_cli_account_login/021_phase2_oauth_and_transport.md new file mode 100644 index 0000000000..2f55642826 --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/021_phase2_oauth_and_transport.md @@ -0,0 +1,151 @@ +# 021 — Phase 2 (revised): oauth classification and the cloud transport + +**Supersedes `020`.** The marker, the 401-until-you-click upgrade story, and the +whole reason those existed are gone (`005`). + +**Work-phase:** wp3. **Write set:** `src/providers/registry.ts`, +`src/oauth/index.ts`, `src/providers/devin-cli-authmode-migration.ts` (NEW), +`src/providers/model-rename-startup.ts`, +`tests/providers/devin-cli-adapter.test.ts`, NEW migration test. + +## MODIFY `src/providers/registry.ts` + +```diff +- // 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. ++ // The signed-in Devin CLI as an account source. The CLI writes a ++ // `devin-session-token$` to its own credentials.toml, which is the same ++ // credential RegisterUser hands `ocx login devin` and which the cloud-direct ++ // client already speaks, so this provider imports that token and streams over ++ // Connect-RPC like its browser-login sibling. ++ // ++ // It is NOT a local runtime. Unlike Ollama or LM Studio it cannot answer ++ // without a vendor account, and `local` grouped it with things that have no ++ // account at all. `oauth` is also the only classification that reaches the ++ // dashboard Accounts tab, which is built from OAUTH_PROVIDERS. + id: "devin-cli", +- label: "Devin CLI (local)", +- adapter: "devin-cli", +- baseUrl: "https://cli.devin.ai", +- authKind: "local", ++ label: "Devin CLI", ++ adapter: "devin", ++ baseUrl: DEVIN_DEFAULT_API_SERVER, ++ authKind: "oauth", + featured: false, +- dashboardPreset: true, ++ // Off, like `devin`. `deriveProviderPresets` keys the preset catalog off this ++ // flag, so leaving it true would draw the row twice: an Accounts login row and ++ // a preset tile. ++ dashboardPreset: false, ++ liveModels: true, ++ modelContextWindows: DEVIN_MODEL_CONTEXT_WINDOWS, +``` + +`models` / `defaultModel` move from the eleven-entry CLI roster to the cloud +roster, because discovery is now live against the account's own catalog. The +context windows come from `ClientModelConfig` field #18 through the same live +path the `devin` provider uses, so this phase also retires the hand-maintained +`DEVIN_CLI_MODEL_CONTEXT_WINDOWS` table. + +### The ACP adapter stays registered, but `devin-cli` can no longer reach it + +An earlier draft called this an escape hatch — "set `"adapter": "devin-cli"` in +your row and keep ACP". The audit disproved it. `routedProviderConfig` pins the +adapter from the registry whenever the row's name matches a registry id: + +``` +// src/router.ts:373-376 +const resolved: OcxProviderConfig = { ...provider, adapter: registryEntry.adapter, baseUrl, ... +``` + +and `providerMatchesRegistryTransport` (`src/providers/registry.ts:3530-3536`) +returns true for anything that is not a `key` destination with +`preserveCustomDestination`, so an oauth row is always pinned. Model discovery +pins the same way (`src/oauth/index.ts:1110-1123`), and `upsertOAuthProvider` +(`:1463-1490`) copies `adapter` from the preset on the first login, so the saved +row is rewritten too. + +So the honest statement is: **after this phase the `devin-cli` PRESET is the cloud +transport, and nothing named `devin-cli` runs ACP.** The adapter itself stays +registered and tested, and is reachable from a differently named custom row: + +```json +"providers": { "devin-acp": { "adapter": "devin-cli", "baseUrl": "https://cli.devin.ai" } } +``` + +That is a real capability, not a fig leaf: a custom id is not a registry id, so no +pin applies. + +### The migration therefore has to warn, not just rewrite + +An operator who deliberately chose ACP would otherwise switch transports silently +on upgrade. `projectDevinCliAuthMode` gains a second, non-mutating job: when the +saved row still carries `adapter: "devin-cli"`, emit a startup warning naming the +change and the exact `devin-acp` snippet above. It does not attempt to rename the +row — a rename would move a provider the user's model ids point at. + +## MODIFY `src/oauth/index.ts` + +```ts +"devin-cli": { + login: (ctrl, opts) => loginDevinCli(ctrl, opts), + refresh: (rt, signal, credential) => refreshDevinCliToken(rt, signal, credential), + providerConfig: oauthConfig("devin-cli"), + defaultModel: oauthDefaultModel("devin-cli"), + // The CLI owns the session and Cognition exposes no refresh endpoint. Same + // posture as `devin` and `orcarouter-oauth`. + defaultRefreshPolicy: "disabled", +}, +``` + +Atomic with the registry edit: `oauthConfig("devin-cli")` throws at module load +when `deriveOAuthProviderConfig` returns undefined (`src/oauth/index.ts:204-207`), +which it does unless `authKind` is already `"oauth"` — the filter is at +`src/providers/derive.ts:350-353`, the throw is in `oauthConfig`. + +## NEW `src/providers/devin-cli-authmode-migration.ts` + +`src/server/auth-cors.ts:731-737` rejects a saved row whose `authMode === "local"` +once the registry entry is not local, and `derive.ts:217-231` seeded exactly that +value while devin-cli was local. Rewrite `local` -> `oauth`, guarded on the exact +old value, composed into `projectStartupConfigRepairs` beside the two migrations +already there. + +Unlike `020`, this pass no longer has to apologise for a credential it cannot +mint: the token arrives from the import at login. + +It must not repeat the hatch claim either. The migration leaves the saved +`adapter` field in place, but **that field does not choose the transport** for a +registry-named row — routing pins it (`src/router.ts:373-376`). Leaving the value +alone preserves nothing except a signal the warning can detect. An existing ACP +user keeps ACP only by moving to a custom-named row, which is what the warning +tells them to do. + +## MODIFY `tests/providers/devin-cli-adapter.test.ts` + +```diff +- test("is a local provider that stores no credential", () => { ++ test("is an account provider sourced from the installed CLI", () => { + const entry = PROVIDER_REGISTRY.find((row) => row.id === "devin-cli"); +- expect(entry?.adapter).toBe("devin-cli"); +- expect(entry?.authKind).toBe("local"); +- expect(entry?.dashboardPreset).toBe(true); ++ expect(entry?.adapter).toBe("devin"); ++ expect(entry?.authKind).toBe("oauth"); ++ expect(entry?.dashboardPreset).toBe(false); ++ expect(OAUTH_PROVIDERS["devin-cli"]).toBeDefined(); +``` + +The ACP `describe` blocks stay exactly as they are: that adapter is still +registered and still has to work for anyone who selects it explicitly. + +## Acceptance + +- `bun test tests/providers/devin-cli-adapter.test.ts tests/providers/devin-cli-login.test.ts` green. +- `listOAuthProviders()` includes `devin-cli`; `deriveProviderPresets()` does not. +- A saved `authMode: "local"` row is repaired, proven by a focused test and a dry + run against the real config. + diff --git a/devlog/_fin/260912_devin_cli_account_login/030_phase3_surface_and_land.md b/devlog/_fin/260912_devin_cli_account_login/030_phase3_surface_and_land.md new file mode 100644 index 0000000000..c5def2e7b8 --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/030_phase3_surface_and_land.md @@ -0,0 +1,89 @@ +# 030 — Phase 3: surface, docs, and landing + +**Work-phase:** wp4. **Write set:** `gui/src/pages/providers-shared.ts`, +`docs-site/` English + 7 locales, `structure/` docs, PR. + +Rewritten after audit rounds 1 and 2. The first draft targeted +`gui/src/provider-icons.ts`, which is the wrong surface (blocker 4). + +## GUI — the label lives in OAUTH_LABELS + +Accounts rows resolve their title through `oauthLabel(id)` → +`OAUTH_LABELS[id] ?? id` (`gui/src/pages/providers-shared.ts:49-59`, consumed at +`gui/src/pages/providers-page-utils.ts:21-23`). `formatProviderDisplayName` +already returns "Devin CLI" but oauth rows never call it, so without a new entry +the row reads `devin-cli`. + +```diff + const OAUTH_LABELS: Record = { + ... ++ "devin-cli": "Devin CLI", + }; +``` + +The icon needs nothing: `devin-cli` already maps to `devin.svg`. + +### The `local` badge disappears on its own + +`ProviderCatalog.tsx`'s badge ladder draws the amber `modal.badge.local` chip +from `p.auth === "local"`, and `derive.ts:613` derives that from `authKind`. +Once the registry says `oauth` the chip is gone and the row is an Accounts login +row instead — which is the mark the operator asked to have removed. Nothing to +edit; wp4 verifies it rather than assuming it. + +### Rail, not only the modal + +`gui/src/provider-workspace/auth.ts:21-22` returned `null` for local, and +`kind.ts:12-21` classified the row as kind `local`. Under `oauth` both change: +the row becomes a login kind and an auth surface is drawn. Check that surface +does not offer an API-key field for a provider that takes no key — that would be +a regression this unit introduced. + +## Docs + +English: + +- `docs-site/src/content/docs/reference/adapters.md:458-459` — "none held by + opencodex ... stores no key and asks for none." Keep the true half, add the + account row and the one-time sign-in. +- `docs-site/src/content/docs/guides/providers.md:196` — same sentence in the + table. + +Locale copies of that sentence, all of which assert opencodex stores no key (still +true; the sign-in sentence is added): `ko:117`, `ja:119`, `zh-cn:111`, +`zh-tw:116`, `fr:130`, `ru:128`, `tr:143`. + +Upgrade note, from `020`: an existing user's first turn after upgrade returns +`OAuthLoginRequiredError` until one sign-in. Import-first makes that click +complete without a browser. + +## structure/ + +`structure/AGENTS.md:49` binds `src/oauth/` and `src/providers/` changes to +`runtime.md`, `subagents.md`, `transports/inventory.md` and +`providers/xai-grok.md` — not only `adapters/registry.md`, which the first draft +named alone. Read each and update any sentence this change falsifies; +`structure/adapters/registry.md:16-25` says the two Devin adapters have +"separate credentials", which stays true and gains the account row. + +## Proof against the running service + +1. `GET /api/oauth/providers` lists `devin-cli`. +2. `GET /api/provider-presets` does NOT list it (blocker 2 regression guard). +3. The Accounts tab renders a **Devin CLI** row beside devin, with no `local` + badge — screenshot, which the PR needs anyway because `gui/` changed. +4. `ocx login devin-cli` completes without a browser against the already + signed-in CLI, and a `codex exec -m devin-cli/swe-2` turn answers afterwards. +5. The 401-before / success-after upgrade behaviour is exercised, not asserted. + +## Landing + +Branch from a freshly fetched `origin/dev`, push `--no-verify`, PR into `dev` +with the screenshot, hosted CI green on the exact head, merge, prove ancestry +from a fetched `origin/dev`. + +## Acceptance + +Every goalplan criterion met with fresh evidence, including an explicit statement +of the one-time sign-in required after upgrade. + diff --git a/devlog/_fin/260912_devin_cli_account_login/031_phase3_surface_and_land.md b/devlog/_fin/260912_devin_cli_account_login/031_phase3_surface_and_land.md new file mode 100644 index 0000000000..d6fc0d7bc5 --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/031_phase3_surface_and_land.md @@ -0,0 +1,67 @@ +# 031 — Phase 3 (revised): surface, docs, and landing + +**Supersedes `030`.** Same surfaces, new transport, and the proof list changes +because there is now a real credential to prove with. + +**Work-phase:** wp4. **Write set:** `gui/src/pages/providers-shared.ts`, +`docs-site/` English + 7 locales, `structure/` docs, PR. + +## GUI + +Accounts rows title through `oauthLabel(id)` → `OAUTH_LABELS[id] ?? id` +(`gui/src/pages/providers-shared.ts:49-59`, consumed at +`gui/src/pages/providers-page-utils.ts:21-23`). Without an entry the row reads +`devin-cli`. + +```diff + const OAUTH_LABELS: Record = { ++ "devin-cli": "Devin CLI", + }; +``` + +The icon already maps to `devin.svg`. The amber `local` badge disappears on its +own once `auth` stops being `"local"`; verify rather than assume. Check the rail +too: `provider-workspace/auth.ts` and `kind.ts` both change branch under +`oauth`, and the auth surface must not offer an API-key field for a row whose +credential is imported. + +## Docs + +The English sentences at `docs-site/src/content/docs/reference/adapters.md:458-459` +and `docs-site/src/content/docs/guides/providers.md:196` currently describe a +stdio ACP provider that holds no key. Both halves change: the preset now imports +the CLI's token and streams over Connect-RPC. The seven locale copies of the +table row follow (`ko:117`, `ja:119`, `zh-cn:111`, `zh-tw:116`, `fr:130`, +`ru:128`, `tr:143`). + +Say plainly what is imported and what is not: opencodex adopts the +`windsurf_api_key` the CLI already wrote, and never reads anything else from that +file. + +Also document the surviving ACP escape hatch: an explicit +`"adapter": "devin-cli"` still drives `devin acp`. + +`structure/AGENTS.md:49` binds `src/oauth/` and `src/providers/` changes to +`runtime.md`, `subagents.md`, `transports/inventory.md` and +`providers/xai-grok.md`. `structure/adapters/registry.md:16-25` says the two +Devin adapters have "separate transports, separate credentials" — the credential +half is now false for the preset and must be corrected. + +## Proof against the running service + +1. `GET /api/oauth/providers` lists `devin-cli`; `GET /api/provider-presets` does not. +2. `ocx login devin-cli` imports without opening a browser, against the already + signed-in CLI. +3. `codex exec -m devin-cli/` answers — the `CLIKEY-OK` shape, now through + the shipped provider rather than a scratch script. +4. The model list is the live catalog, and a spot-checked context window matches + `ClientModelConfig` field #18 rather than a static table. +5. Accounts tab screenshot: a **Devin CLI** row beside Devin, no `local` badge. + The PR needs it anyway because `gui/` changed. + +## Landing + +Branch from a freshly fetched `origin/dev`, push `--no-verify`, PR into `dev` +with the screenshot, hosted CI green on the exact head, merge, prove ancestry from +a fetched `origin/dev`. + diff --git a/devlog/_fin/260912_devin_cli_account_login/090_outcome.md b/devlog/_fin/260912_devin_cli_account_login/090_outcome.md new file mode 100644 index 0000000000..f1b6cfa9b5 --- /dev/null +++ b/devlog/_fin/260912_devin_cli_account_login/090_outcome.md @@ -0,0 +1,62 @@ +# 090 — Outcome + +**Merged to `dev` as `b09ef15c6f` (PR #4335).** PR head `2930a0a3bc` is an +ancestor of a freshly fetched `origin/dev`. + +## What shipped + +`devin-cli` is an account provider that imports the credential the installed +Devin CLI already holds and streams over Cognition's Connect-RPC api-server. It +appears in the dashboard Accounts tab beside Devin, with no `local` badge, and is +gone from the preset tabs. + +Supporting changes that were not obvious at the start: tenant selection became +provider-scoped, because `resolveDevinApiServer` read a fixed `devin` credential +slot and would have crossed two accounts onto one host as soon as a second +provider shared the adapter. That required threading a provider id through +`AdapterFactoryContext`, `resolveAdapter` and both `core.ts` call sites. + +## Evidence + +| Check | Result | +| --- | --- | +| `GET /api/oauth/providers` | includes `devin-cli` | +| `GET /api/provider-presets` | excludes it | +| `ocx login devin-cli` | `{"loggedIn":true,"source":"local-cli"}`, no browser | +| `GET /v1/models` | 42 `devin-cli/*` rows from live discovery | +| `codex exec -m devin-cli/swe-2` | `CLOUD-OK` | +| context windows | `swe-2` 262,000 · Claude/GPT 1,000,000 · Gemini/GLM/Kimi 1,048,576 · Grok 500,000, from field #18 | +| dashboard | Accounts tab screenshot, Devin CLI row signed in | + +Focused tests 128 pass / 0 fail, 18 of them new. GUI 1963 pass / 0 fail. +`structure:check` and `privacy:scan` pass. Hosted CI green on `2930a0a3bc`. + +## What did not work, and what killed it + +LOOP-PESSIMIST-01. The first design was wrong and took three audit rounds to +die. It kept the ACP transport and invented a marker credential so the provider +could have an account row without holding a token. Reviewers killed it in stages: +the request path would have 401'd every turn until someone clicked Login; the +`stdin` design could not deliver the one-time code it also required; the label +surface was the wrong file. + +What actually ended it was not an argument but a measurement. Reading the CLI's +`credentials.toml` showed an ordinary `devin-session-token`, and feeding it to +the client already in this tree returned a real answer. Every blocker downstream +of "there is no credential we may hold" then evaporated, including the two that +had already been patched around. + +The lesson worth carrying: three rounds were spent refining a design whose +premise nobody had tested, and the test took one minute. When a plan's central +constraint is an assumption about someone else's system, measure it before +designing around it. + +## Residual + +- The seven `two-lock xAI refresh` failures seen while checking this work + reproduce on pristine `origin/dev` and are unrelated; they remain open. +- `identityFromApiKey` returned nothing for this account's token, so the Accounts + row shows "signed in" without an address. That is what the CLI itself can say. +- Multi-account is out of scope: an identity-less credential replaces the active + slot rather than adding one. + 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..949ee387b1 --- /dev/null +++ b/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md @@ -0,0 +1,353 @@ +# 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 `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. 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 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`. 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 +`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. 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 + +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`. + +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. + +## 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. + +## 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. + +## 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. + +## 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`. + +## 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 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 +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. 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..fd0b21c651 --- /dev/null +++ b/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md @@ -0,0 +1,421 @@ +# 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. + +## 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 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, +`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: + +``` +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 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}\`` +- 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` — 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/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) + +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 + +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. + +## 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 + 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 + +## 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. + +### 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. + +### 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. 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..dcf7ac9924 --- /dev/null +++ b/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md @@ -0,0 +1,239 @@ +# 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. + +## 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. + +## 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. + +### 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. + +### 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. 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..810e5256db --- /dev/null +++ b/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md @@ -0,0 +1,395 @@ +# 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. + +## 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. + +### 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. + +## 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. + +### 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. + +## 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. + +### 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`. 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..08953085b7 --- /dev/null +++ b/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md @@ -0,0 +1,491 @@ +# 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. + +## 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. + +### 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. + +### 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. + +### 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. + +## 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. + +### 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. + +## 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. + +### 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. + +### 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. + 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 0000000000..f6c6be4743 Binary files /dev/null and b/devlog/_plan/260911_account_pool_unification/assets/wp5b-pool-settings.png differ 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..5804297c4d --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md @@ -0,0 +1,144 @@ +# 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. + +## 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`. +- 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 | +| 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. + +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 +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 + 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/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. 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..e765707cee --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md @@ -0,0 +1,132 @@ +# 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 + +**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. 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. + +### 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`. +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. 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. 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.** 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..023d9182d8 --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/020_phase2_responses_identity.md @@ -0,0 +1,85 @@ +# 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** — `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. + +## 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 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 +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. 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..6ff3c4a1f0 --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/030_phase3_landing.md @@ -0,0 +1,239 @@ +# 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. +**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`.** 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; ++ // 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; ++ } ++ 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", + framesReceived: this.framesReceived, + elapsedMs: Date.now() - this.turnStartedAt, ++ graceMs: graceMsOverride ?? this.activeClientToolFinalizeGraceMs, ++ checkpointGraceExtended: this.checkpointGraceExtended, + }); + this.cancelCursorRun(); +- }, this.activeClientToolFinalizeGraceMs); ++ }, graceMsOverride ?? this.activeClientToolFinalizeGraceMs); + } +``` + +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. + +**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. + +### 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`. + +## 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 +`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 +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. 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/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_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/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/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/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/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..2194f3c901 --- /dev/null +++ b/devlog/_plan/260911_ws_commit_boundary/000_plan.md @@ -0,0 +1,56 @@ +# 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, 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 any core.ts change beyond the two marker guards named in 025. + +## 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..36dbd8b533 --- /dev/null +++ b/devlog/_plan/260911_ws_commit_boundary/020_design_record.md @@ -0,0 +1,95 @@ +# 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 (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. + +## 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`). + +## 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 + 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. + 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`. + 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. + 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. + 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. + 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/devlog/_plan/260912_accounts/000_plan.md b/devlog/_plan/260912_accounts/000_plan.md new file mode 100644 index 0000000000..df54929c99 --- /dev/null +++ b/devlog/_plan/260912_accounts/000_plan.md @@ -0,0 +1,48 @@ +# Accounts work is delivered as independent policy and lifecycle changes + +Readers: the integration maintainer deciding which PRs can land and which issue acceptance remains open. OAuth callback retirement is independent of pool scheduling; history precedes capacity estimation; dedicated native-main reauthorization precedes its dashboard control. Existing reset activation, reset-credit operation identity, and canonical Fake-IP transport are preserved and verified rather than reimplemented. + +## Execution contract + +Satisfy-spec HOTL, triggered by the accounts-lane delegation on 2026-09-12. Scope: PRs #4280/#4080 and issues #3375/#3376/#4211/#3781/#3898. Goal: reviewed, attributed implementation PRs and final-tip hosted CI evidence with truthful remaining acceptance. Non-goals: merges, releases, service/account/config/network changes, native GitHub stacks, local product test execution of any size, heavy local build/typecheck/install. Existing credential/tool scope only; no user-imposed token/time/agent-count cap. + +Verification: source inspections and `git diff --check` are text checks only; regression test sources run on GitHub-hosted CI at each final cumulative tip. Intermediate cycle C records source review and deferred remote evidence, never local test passes. Stop: all implementation/disposition and final CI criteria met, or actual inaccessible field acceptance distinctly recorded. Outcomes: DONE for demonstrated delivered scope; PARTIAL/NEEDS_HUMAN for authenticated field or maintainer security acceptance still missing; actual tool rejections retained without bypass. Main owns implementation; inherited-model read-only agents advise on design, reflect the concrete plan, and independently audit A. No native architect role is exposed, so none is claimed. No extra setup is required by that limitation. + +Memory artifacts: this numbered unit, session-bound goalplan/ledger, and task-local `.tmp/accounts-20260912/000_handoff.md`. Security analysis stays only in scratch. Escalation: actual tool permission denial or new out-of-scope action; main reclaims failed read-only work after two distinct failed dispatches, retaining any independence gap. + +## Delivery map + +| Cycle | Outcome | Dependency | Branch relationship | +| --- | --- | --- | --- | +| roadmap | Lock these documents, no product edit | none | local docs checkpoint | +| callback | Carry latest #4280 with author credit | roadmap | independent dev PR | +| eligibility | Automatic pool selection honors excluded plans; explicit route preserved | roadmap | independent dev PR | +| reset | Carry #4080 reset-first ordering | roadmap | independent dev PR | +| generic-family | Family headroom and cooldown context | roadmap | independent dev PR | +| lifecycle | Generic affinity and classified recovery | generic-family | child of generic-family | +| generic-health | Selection reasons and health presentation | lifecycle | child of lifecycle | +| warmup | Durable one-shot zero-usage activation | roadmap | independent dev PR | +| history | Bounded raw quota observations, generation-safe retention | roadmap | independent dev PR | +| capacity | Estimated capacity with evidence/sample count | history | child of history | +| tun | Safe probe failure classification and consumer projection | roadmap | independent dev PR | +| reauth-api | Dedicated native-main device grant persistence and CLI | roadmap | independent dev PR | +| reauth-ui | Main-card start/poll/cancel | reauth-api | child of reauth-api | +| final | Repair hosted final-tip CI, collect reviews and disposition | all implementation | no merge | + +Per-phase decade documents carry before/after contracts and conditional acceptance. Every later P revalidates source anchors. Ordinary manual chains express only real dependencies. `.github/workflows/ci.yml` runs pull_request without a base filter; no workflow modification or cancellation is authorized. Source ownership comes from `structure/manifest.json` and `structure/INDEX.md`; update all owners when their area changes, preserving relevant facts with cross-links. + +## Current evidence and limitations + +Baseline `origin/dev`: 69e3dcda755a52feb1327edad6c8ea6cefd6e871. PR #4280 live head: 1f826d92c7205f31ce174bbd987c04b2b08f7da4; its follow-up includes 404 closure and all three OAuth structure owners. PR #4080 live head: ecf6b4e48a4c2992c296fada2caf6a8132313eaa. Both remain open. Fresh source/issue snapshots are in scratch; historic CI claims in PR bodies are contributor evidence only. + +`cxc map src/codex --limit 18` is unavailable in the installed plugin (requires a source checkout); use source ownership and bounded text searches instead. Session is bound to this managed worktree and host goal exists; hooksVerified=false does not prove Stop continuation. Local tests/build/typecheck/install: NOT RUN. Authenticated TUN field acceptance cannot be inferred from injected-DNS tests. + +## Source reconciliation decisions + +#4238 already implements the excludedPlans selector; this unit completes reasons and removes automatic all-excluded fallback, preserving explicit routing and native-main exemption. #2562 latest maintainer comment chooses generic pooling, so both Google-specific routers remain design inputs. Generic work is split into family context → lifecycle → health presentation; a separate warmup cycle covers one-shot zero-usage scheduling. These units are registered in the same goalplan. #3588 reset activation and manual reset operation-id are already implemented. + +Two design follow-ups encountered inherited-model capacity errors; one same-handle retry was requested, no model/settings were changed. Independent A audit remains required. + +## Roadmap cycle outcome + +Independent design reflection and A re-audit passed with the source restrictions in 001_roadmap_audit.md. B freezes the contracts as documentation only. C checks document paths/numbering and git whitespace; local product suites NOT RUN. D next direction: execute 010_callback.md independently, then the remaining dependency-ordered cycles. Runtime behavior has not improved yet; the rejected hypotheses were native history identity by sentinel alone, attempt timing inferred from untimed attempts, and one-shot implying one physical request through a retrying primitive. diff --git a/devlog/_plan/260912_accounts/001_roadmap_audit.md b/devlog/_plan/260912_accounts/001_roadmap_audit.md new file mode 100644 index 0000000000..794bd2144f --- /dev/null +++ b/devlog/_plan/260912_accounts/001_roadmap_audit.md @@ -0,0 +1,11 @@ +# Roadmap audit locks implementation boundaries + +The source audit separates landed work from remaining acceptance. Pool design reflection (Pauli), eligibility/native-main reflection (Singer), and TUN reflection (Faraday) all aligned after concrete amendments. These are inherited-model independent reads; no native architect role or runtime execution is claimed. + +Independent A reviewer Leibniz found three blockers: native history identity across offline login replacement, capacity attempt timing and truncated ledger attribution, and a warmup primitive that retries despite a one-attempt promise. Main accepted all three. Native history is not persisted in this slice, stored-pool history binds generation, capacity uses whole contained request intervals and rejects incomplete evidence, and scheduled warmup explicitly disables model fallback. Focused re-audit returned VERDICT: PASS on 2026-09-12, against 69e3dcda755a52feb1327edad6c8ea6cefd6e871. + +Reader result: separate PRs deliver callback transport, policy reasons/selection, reset ordering, generic lifecycle, quota history/capacity, diagnostic classification, and native-main reauth. Source-backed findings justify each slice; next step is the independent callback implementation cycle. No implementation or remote verification exists yet. Local tests/build/typecheck/install: NOT RUN by user instruction. Source and doc checks do not establish runtime behavior. + +Remaining acceptance constraints: authenticated TUN field evidence; native-main cross-restart history/token capacity intentionally omitted; generic recovery requires positive provider/post-refresh evidence (permanent refresh rejection and sidecar auth without such evidence stay terminal); one-shot warmup supports stored pool only. All remain visible in final issue dispositions and are not silently marked complete. + +C correction: first git diff --cached --check rejected spaces in blank lines of the quoted public patch, so the chained commit did not run. Those documentation-only spaces were removed before retry. The B-to-C narrative mentioned a commit prematurely; the actual commit and receipt follow this correction. diff --git a/devlog/_plan/260912_accounts/010_callback.md b/devlog/_plan/260912_accounts/010_callback.md new file mode 100644 index 0000000000..57cb4130c8 --- /dev/null +++ b/devlog/_plan/260912_accounts/010_callback.md @@ -0,0 +1,239 @@ +# Retire every OAuth callback response connection + +Cycle callback; C4 auth transport. Independent of account pool features. Existing public #4280 is the change source; source read: `src/oauth/callback-server.ts:177`, `tests/oauth/oauth-callback-server.test.ts:1`. No-op leaves pooled connections reaching retired handlers; reuse the contributor patch rather than introduce a second listener implementation. + +MODIFY `src/oauth/callback-server.ts`: add private `closingResponse(body, status, contentType = "text/html")`; both 404 and callback success/error return it. Before: ordinary Response headers contain only Content-Type, 404 has no explicit headers. After: each path includes `Connection: close`; state validation and graceful listener shutdown stay intact. +MODIFY `tests/oauth/oauth-callback-server.test.ts`: sequential fixed-port login and held-exchange favicon scenarios from #4280, with deterministic flow-publication barriers and cancellation cleanup rather than new polling sleeps. +MODIFY `structure/runtime.md`, `structure/transports/inventory.md`, `structure/providers/xai-grok.md`: carry the contributor's invariant and owner links. Public troubleshooting docs describe repeat login connection retirement if needed. + +The exact reviewed public diff is reproduced below as the implementation contract. Credit: Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>. Retain latest source author commits in provenance; no original PR edits/closure. + +```diff +diff --git a/src/oauth/callback-server.ts b/src/oauth/callback-server.ts +index dc49d5fcd2..2727f8362c 100644 +--- a/src/oauth/callback-server.ts ++++ b/src/oauth/callback-server.ts +@@ -37,6 +37,27 @@ function errorHtml(message: string): string { + + export type CallbackResult = { code: string; state: string }; + ++/** ++ * Every response this listener sends ends its connection. ++ * ++ * The preferred callback port is FIXED per provider, so a later login listens on the same ++ * number — but a keep-alive socket stays bound to the flow that served it, and stopping that ++ * listener does not close an already-established connection. A client reusing the socket would ++ * hand the NEXT login's callback to the RETIRED flow, which rejects the unknown state as a CSRF ++ * mismatch while the live flow waits for a callback it can no longer receive. ++ * ++ * This is not limited to the callback itself: a browser that fetches `/favicon.ico` after the ++ * success page pools the socket on the 404, which is why the policy belongs to EVERY response ++ * rather than the callback path. Nothing here benefits from reuse — exactly one callback is ++ * expected per flow — so route every response through this helper. ++ */ ++function closingResponse(body: string, status: number, contentType = "text/html"): Response { ++ return new Response(body, { ++ status, ++ headers: { "Content-Type": contentType, "Connection": "close" }, ++ }); ++} ++ + /** + * The redirect URI advertised to providers must stay `localhost` (it is what the OAuth + * apps have registered), but Windows commonly resolves `localhost` to `::1` first while +@@ -177,7 +198,7 @@ export abstract class OAuthCallbackFlow { + #handleCallback(req: Request, expectedState: string): Response { + const url = new URL(req.url); + if (url.pathname !== this.callbackPath) { +- return new Response("Not Found", { status: 404 }); ++ return closingResponse("Not Found", 404, "text/plain"); + } + + const code = url.searchParams.get("code"); +@@ -214,10 +235,7 @@ export abstract class OAuthCallbackFlow { + }); + } + +- return new Response(ok ? SUCCESS_HTML : errorHtml(errMessage), { +- status: ok ? 200 : consumeFlow ? 500 : 400, +- headers: { "Content-Type": "text/html" }, +- }); ++ return closingResponse(ok ? SUCCESS_HTML : errorHtml(errMessage), ok ? 200 : consumeFlow ? 500 : 400); + } + + #waitForCallback(expectedState: string): Promise { +diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md +index 765b85a763..16d1322412 100644 +--- a/structure/providers/xai-grok.md ++++ b/structure/providers/xai-grok.md +@@ -15,6 +15,10 @@ Grounded in the open-sourced official client (xai-org/grok-build); unit + eviden + `~/.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"`. ++- **Browser login callback:** Grok's browser login uses the shared `OAuthCallbackFlow` listener ++ on a per-provider FIXED loopback port, so every response it sends closes its connection. A ++ retired flow that kept a pooled socket would capture the NEXT login's callback and reject it ++ as a state mismatch; see `src/oauth/callback-server.ts`. + - **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 +diff --git a/structure/runtime.md b/structure/runtime.md +index 3099c13bfd..f31080646d 100644 +--- a/structure/runtime.md ++++ b/structure/runtime.md +@@ -139,7 +139,7 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an + | --- | --- | + | `src/providers/registry.ts` | Canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata. | + | `src/providers/derive.ts` | Enrichment from provider presets into user config. | +-| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. | ++| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. | + | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | + | `src/adapters/openai-chat.ts` | OpenAI-compatible Chat Completions bridge. | + | `src/adapters/anthropic.ts` | Anthropic Messages bridge. | +diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md +index dc9af564d6..11cb227b01 100644 +--- a/structure/transports/inventory.md ++++ b/structure/transports/inventory.md +@@ -20,6 +20,7 @@ surface is listed here so a maintainer can find the owner without grepping: + | 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. | ++| OAuth login callback (inbound) | `src/oauth/callback-server.ts` | The only inbound transport this area owns: a short-lived loopback listener on a per-provider FIXED port. Exactly one callback is expected per flow, so EVERY response closes its connection — a retired flow must never keep a pooled socket that would capture the next login's callback. | + | 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/tests/oauth/oauth-callback-server.test.ts b/tests/oauth/oauth-callback-server.test.ts +index a327e1df0d..a46a2a04f3 100644 +--- a/tests/oauth/oauth-callback-server.test.ts ++++ b/tests/oauth/oauth-callback-server.test.ts +@@ -29,6 +29,16 @@ class ManualFallbackFlow extends OAuthCallbackFlow { + + const ctrl: OAuthController = {}; + ++/** Keeps the listener alive across the token exchange so stray requests can reach it. */ ++class SlowExchangeFlow extends ManualFallbackFlow { ++ holdExchange?: Promise; ++ ++ override async exchangeToken(code: string, state: string, redirectUri: string): Promise { ++ await this.holdExchange; ++ return super.exchangeToken(code, state, redirectUri); ++ } ++} ++ + describe("OAuth callback server defaults", () => { + test("binds callback listeners to numeric loopback by default", () => { + const flow = new TestFlow(ctrl, 54545, "/callback"); +@@ -116,4 +126,103 @@ describe("OAuth callback server defaults", () => { + blocker.stop(true); + } + }); ++ ++ test("a retired flow cannot serve the next login on the same callback port", async () => { ++ // The preferred callback port is fixed per provider, so consecutive logins listen on the ++ // same number. Stopping a listener does not close a connection that is already open, so a ++ // client that pools the socket would deliver the SECOND login's callback to the FIRST ++ // flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. ++ const port = await freeLoopbackPort(); ++ const options = { ++ preferredPort: port, ++ callbackPath: "/callback", ++ callbackHostname: "127.0.0.1", ++ callbackBindHostname: "127.0.0.1", ++ }; ++ const deliver = async (state: string): Promise => { ++ const url = new URL(`http://127.0.0.1:${port}/callback`); ++ url.searchParams.set("code", "authorization-code"); ++ url.searchParams.set("state", state); ++ const res = await fetch(url); ++ await res.text(); ++ return res.status; ++ }; ++ ++ const first = new ManualFallbackFlow(ctrl, options); ++ const firstLogin = first.login(); ++ await waitForState(() => first.generated?.state); ++ const firstState = first.generated!.state; ++ expect(await deliver(firstState)).toBe(200); ++ await firstLogin; ++ ++ const second = new ManualFallbackFlow(ctrl, options); ++ const secondLogin = second.login(); ++ await waitForState(() => second.generated?.state); ++ const secondState = second.generated!.state; ++ expect(secondState).not.toBe(firstState); ++ // Served by the LIVE flow, so the retired state is now an unknown one. ++ expect(await deliver(firstState)).toBe(400); ++ expect(await deliver(secondState)).toBe(200); ++ await secondLogin; ++ expect(second.exchanged?.state).toBe(secondState); ++ }); ++ ++ test("a non-callback request cannot pin the socket to the retiring flow", async () => { ++ // A browser that asks for /favicon.ico after the success page would pool the socket on the ++ // 404 while exchangeToken() is still running, which re-pins it to the flow that is about to ++ // retire. The close policy therefore belongs to EVERY response, not just the callback path. ++ const port = await freeLoopbackPort(); ++ const options = { ++ preferredPort: port, ++ callbackPath: "/callback", ++ callbackHostname: "127.0.0.1", ++ callbackBindHostname: "127.0.0.1", ++ }; ++ const deliver = async (state: string): Promise => { ++ const url = new URL(`http://127.0.0.1:${port}/callback`); ++ url.searchParams.set("code", "authorization-code"); ++ url.searchParams.set("state", state); ++ const res = await fetch(url); ++ await res.text(); ++ return res.status; ++ }; ++ ++ // The exchange is held open so the listener is still up for the stray request, which is ++ // exactly the window the reproduction describes. ++ const exchanging = Promise.withResolvers(); ++ const first = new SlowExchangeFlow(ctrl, options); ++ first.holdExchange = exchanging.promise; ++ const firstLogin = first.login(); ++ await waitForState(() => first.generated?.state); ++ expect(await deliver(first.generated!.state)).toBe(200); ++ const favicon = await fetch(`http://127.0.0.1:${port}/favicon.ico`); ++ await favicon.text(); ++ expect(favicon.status).toBe(404); ++ exchanging.resolve(); ++ await firstLogin; ++ ++ const second = new ManualFallbackFlow(ctrl, options); ++ const secondLogin = second.login(); ++ await waitForState(() => second.generated?.state); ++ // Without the close policy on the 404 this is answered by the retired flow and returns 400. ++ expect(await deliver(second.generated!.state)).toBe(200); ++ await secondLogin; ++ expect(second.exchanged?.state).toBe(second.generated!.state); ++ }); + }); ++ ++/** A port that is free right now; the flows bind it themselves, so it must not stay held. */ ++async function freeLoopbackPort(): Promise { ++ const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, reusePort: false, fetch: () => new Response("probe") }); ++ const { port } = probe; ++ probe.stop(true); ++ return port; ++} ++ ++async function waitForState(read: () => string | undefined, timeoutMs = 5_000): Promise { ++ const deadline = Date.now() + timeoutMs; ++ while (read() === undefined) { ++ if (Date.now() >= deadline) throw new Error("timed out waiting for the login flow to publish its state"); ++ await Bun.sleep(5); ++ } ++} + +``` + +Acceptance: first login succeeds, retired state is rejected by live listener (400), live state succeeds (200); favicon during held token exchange returns 404 without trapping the next flow. Failure/malformed callback paths close their connection too. Regression source is mandatory, local runtime execution NOT RUN. Hosted final-tip CI must cover oauth callback/bind and OrcaRouter provider suites. Security review checks unchanged state/PKCE, loopback destinations, no credential disclosure. Public code already describes the issue; additional security analysis goes to scratch only. + +P stale check: whole contributor patch fails only at inventory table context because the API-key row changed. Selected source/test/runtime/xai hunks pass `git apply --check`. During B retain current inventory rows and append the new callback row after OAuth failover manually; do not overwrite current transport contracts. + +Callback P revalidation after roadmap D: next direction is independent callback carry. Source hunks still apply; #4280 remains open at the same 1f826d92c head. Replace contributed polling helper with onAuth Promise.withResolvers readiness, AbortController deadline and finally cleanup settling held exchanges and login promises. Keep runtime Connection: close unconditional for both paths; no forced fetch header masks the defect. Shorten helper comment while preserving retirement rationale. Native role unavailable; inherited-model consultation is explicitly authorized. Product tests NOT RUN; source audit then remote CI. + +Callback design ALIGNED (Lagrange) and independent A PASS (Leibniz): readiness resolves in onAuth queueMicrotask, login rejection rejects readiness; deadlines armed after handler registration; finally resolves held exchange, aborts flows, clears timers and settles login promises. Add response-only close-header checks alongside behavioral oracles. diff --git a/devlog/_plan/260912_accounts/011_callback_delivery.md b/devlog/_plan/260912_accounts/011_callback_delivery.md new file mode 100644 index 0000000000..23fa061b4a --- /dev/null +++ b/devlog/_plan/260912_accounts/011_callback_delivery.md @@ -0,0 +1,9 @@ +# Callback retirement implementation and evidence + +Carried #4280 at 1f826d92c7205f31ce174bbd987c04b2b08f7da4 by luvs01. Every callback-listener response uses a closing response helper; state validation, HTML escaping and graceful shutdown stay unchanged. Runtime, xAI and transport inventory ownership docs are updated. Tests retain two fixed-port flow scenarios and add malformed/provider-error response checks; onAuth microtask readiness and per-flow abort/finally cleanup replace polling waits. + +Necessity/source search: closingResponse and loopbackBindHostnames in callback-server.ts; no existing response-closing owner found. Reuse existing OAuthCallbackFlow and ManualFallbackFlow. The only new production helper is private and joins two response sites. Local tests/build/typecheck/install: NOT RUN. git diff --check is a whitespace check only. Independent implementation/security read and hosted CI follow publication; no bug-fixed claim until execution evidence exists. + +This first delivery includes the accounts roadmap documentation checkpoint; product delta is callback-only. Other features remain unimplemented and their docs describe pending work. Original #4280 stays open for coordinator disposition after integration. No merge is performed here. + +Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> diff --git a/devlog/_plan/260912_accounts/020_eligibility.md b/devlog/_plan/260912_accounts/020_eligibility.md new file mode 100644 index 0000000000..94719a4f58 --- /dev/null +++ b/devlog/_plan/260912_accounts/020_eligibility.md @@ -0,0 +1,19 @@ +# Finish automatic plan policy and visible exclusion reasons + +Cycle eligibility; C3 selection policy. Depends only on roadmap, independent dev PR. #4238 already added excludedPlans; do not reimplement its selector. Source: routing.ts:1044-1090 and 1326; explicit fixedAccountId path auth-context.ts:826/915. + +MODIFY `src/codex/routing.ts`: export the existing normalized policy predicate (or move the pure plan calculation into `src/codex/plan.ts` and reuse it). Add the predicate to BOTH configured-account fallback guards at preview :2152 and detailed resolve :2391. Before, an all-excluded pool returns its excluded active row; after, ordinary selection returns null/none. Explicit fixed routes retain existing auth, pause, entitlement checks. Native __main__ remains exempt, avoiding physical auth reads on selection-only paths. + +```diff +- && !isCodexAccountPaused(config, active) ++ && !isCodexAccountPaused(config, active) ++ && !isCodexAccountPlanExcluded(config, active) +``` + +MODIFY `src/codex/auth-api.ts`: poolAccountDto adds optional `selectionExcludedReason: "plan_excluded"`, derived from the SAME predicate and config, never from credential health; include current plan already in DTO. MODIFY `src/cli/account-api.ts` AccountRow/CodexAccountDto mapping and `src/cli/account.ts` statusText to show `not-auto-selected(plan=)`. MODIFY `gui/src/components/codex-account-pool-types.ts`, pool-card badge in `codex-account-pool-cards.tsx`, and all locale catalogs: separate localized reason; do not mutate paused/needsReauth and do not disable explicit routing. Unknown plan and empty policy remain eligible; reauth renewal clears the reason dynamically. + +Field chain: existing excludedPlans config create/save/load → same normalized predicate → account DTO JSON → CLI/GUI optional union → status and badge. No new config field or minimumPlan ordering. Enforcing tier: runtime automatic selection only; explicit fixed account intentionally bypasses this selection rule, not auth; residual unknown-plan and native-main exemptions documented, no hard account-block claim. + +MODIFY existing `tests/codex-integration/codex-pool-plan-exclusion.test.ts`: replace last-account soft fallback test with none/preview none; test normalized plan update and explicit fixed route. Extend account API/CLI and card tests for reason and renewal clearing. Sync ownership docs and providers configuration pages that describe the old soft exception. Retain source attribution of #4238; no recarry of already-landed commits. Local tests/build/typecheck NOT RUN. Hosted CI plus rendered artifact from final tip supplies execution proof. + +Exclusion reason derives from the routing config plan, not a display-only freshly observed plan if persistence failed. This preserves truth between selection and explanation. diff --git a/devlog/_plan/260912_accounts/030_reset.md b/devlog/_plan/260912_accounts/030_reset.md new file mode 100644 index 0000000000..875a6dccc5 --- /dev/null +++ b/devlog/_plan/260912_accounts/030_reset.md @@ -0,0 +1,16 @@ +# Add Codex reset-first through the canonical pool settings API + +Cycle reset; C3 scheduling. Independent of quota history and eligibility. Carry #4080 at ecf6b4e48a4c2992c296fada2caf6a8132313eaa, credited to Terry Tan . Its public diff is a design input, with mandatory canonical-contract adaptation below. Do not enable reset-first for Anthropic or generic OAuth pools. + +MODIFY source paths in #4080: `src/codex/routing.ts`, `src/codex/pool-rotation.ts`, `src/codex/auth-api.ts`, `src/types/config.ts`, `src/cli/account-extended.ts`, `src/cli/account.ts`; retain existing priority, eligibility, threshold, and healthy affinity. New Codex strategy sorts earliest FUTURE short/weekly reset after filtering, then usage and stable order. Unknown/elapsed reset is not preferred. Threshold zero disables usage filtering while retaining ordering; exhausted-account behavior remains existing safe fallback. + +Additional MODIFY `src/oauth/pool-settings-capability.ts` and `src/server/management/oauth-account-routes.ts`: use a Codex-specific parser that accepts reset-first; canonical PUT /api/pool/settings and GET normalization must preserve it. Generic/Anthropic parsers keep rejecting reset-first. Update `gui/src/account-pool-strategy.ts`, strategy controls/settings, `gui/src/pool-settings.ts` types, locale translations and config docs from #4080 for the canonical route. + +```diff +- strategy: normalizeAccountPoolStrategy(config.accountPoolStrategy) ++ strategy: normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) +``` + +Field chain: CLI/GUI strategy creation → canonical PUT parser → config.accountPoolStrategy write → config load + canonical GET parser → pool rotation/preview/failover, CLI and GUI display. Audit every existing strategy comparison/default, not just the union. No schema migration or new dependency. Exact contributor diff remains `.tmp/accounts-20260912/pr4080.diff` during planning; changes are adapted to current callers before B. + +Extend regression sources for canonical PUT/GET/save/reload, legacy endpoint, non-Codex rejection, tied/missing/elapsed resets, threshold zero, priorities, affinity and failover. Existing #4080 test cases are retained/adapted. Update all source ownership docs; screenshot of final rendered strategy control is included with PR. Local suites/build/typecheck/install NOT RUN; final head hosted CI supplies proof. #3376 remains partial until history/capacity; monthly/Anthropic/latest-first scope is reported separately. diff --git a/devlog/_plan/260912_accounts/040_generic_family.md b/devlog/_plan/260912_accounts/040_generic_family.md new file mode 100644 index 0000000000..24ee77cfc8 --- /dev/null +++ b/devlog/_plan/260912_accounts/040_generic_family.md @@ -0,0 +1,16 @@ +# Scope generic quota evidence and cooldowns by model family + +Cycle generic-family; C3, foundation for lifecycle. Extend existing generic pool; do not stack Google-specific #2562/#3283. Current `src/oauth/generic-account-failover.ts:200` already activates kernel strategies; quota threshold remains unused. #4299 narrow head 4583f9793295f75d4bf69d0bfb0900a550bc05bd supplies family-ranking input; adapt with Co-authored-by: chilung when reused. + +MODIFY `src/oauth/account-quota-rank.ts`: optional requested-model context chooses matching Antigravity Gem/Cla windows only, preserving no-model existing behavior and conservative unknown evidence. MODIFY generic-account-failover.ts health key/eligibility/ranking/fill-first: quota cooldown key is provider+account+known family, auth failures remain global. Thread model context into both kernel fill-first headroom and quota branch. `autoSwitchThreshold` is consumed by quota selection; zero disables proactive usage threshold, not upstream exhaustion. Reactive quorum activation stays unchanged; provider/global flags affect proactive preference only. + +```ts +type GenericQuotaScope = "account" | "gemini" | "claude"; +type GenericSelectionContext = { modelId?: string; sessionKey?: string; now: number }; +``` + +MODIFY `src/server/responses/core.ts` initial preference and every generic retry site to pass actual routed modelId; snapshot admission remains guarded and uses account-matched routing metadata. No extra Lab import. Field chain: route.modelId creation → in-memory context only → no disk serialization → headroom and health scope consumers. No provider error body's arbitrary string is allowed as a family identifier; family mapping is bounded known model semantics. + +Extend existing generic failover and account quota rank tests, plus a real handleResponses regression with opposing Gem/Cla windows. Assert Claude quota cooldown leaves Gemini usable, global auth exclusion blocks both, unknown model remains conservative, threshold zero semantics, kernel fill-first consumes same family. Sync `src/oauth/`, `src/server/` ownership docs and operating config docs. Local runtime suites NOT RUN; hosted final cumulative lifecycle tip verifies the foundation. + +Reflection REF-01 accepted. GenericQuotaScope is account|gemini|claude. GenericSelectionContext carries modelId?, sessionKey?, now. Thread it through headroom/exhaustion/ranking/eligibility/fill-first/initial-preference/rotation/Retry-After. Global cooldown blocks every family; known-family cooldown blocks that family; unknown context considers all relevant cooldowns. Clear/reconcile removes every scope. Core anchors: initial 4350; rotation 5542,6480,6853,7587,7998; refresh 5420,7377; admission 4054,4149,4413. Revalidate anchors at each P. Sidecar uses its actual routed model identity; absence uses account-conservative quota scope. diff --git a/devlog/_plan/260912_accounts/041_generic_lifecycle.md b/devlog/_plan/260912_accounts/041_generic_lifecycle.md new file mode 100644 index 0000000000..322a2fcaea --- /dev/null +++ b/devlog/_plan/260912_accounts/041_generic_lifecycle.md @@ -0,0 +1,25 @@ +# Bind admitted generic accounts and classify recoverable failures + +Cycle lifecycle depends on generic-family. C4 credential/retry. Existing owners: `generic-account-failover.ts`, `oauth/store.ts`, `server/responses/core.ts:4054/4149/5537`, `oauth/anthropic-routing.ts:759` provides a commit-after-resolution precedent. + +MODIFY generic-account-failover.ts: bounded process-local conversation affinity keyed by provider/session identity, with explicit idle TTL and entry cap; no conversation id means no affinity. Look up only live non-reauth accounts, release on expiry/removal/credential-generation change or classified failure, bind admitted account after guarded snapshot application rather than proposal. Extend note-success/selection context to carry actual account generation. Preserve explicit account selectors and manual active selection semantics; never persist conversation bodies. + +MODIFY existing generic recovery branches in core.ts: retain one same-request retry budget and existing no-output replay boundary. Handle only post-refresh account authentication rejection and provider-classified quota/account 403; ordinary permission/region/policy 403 stays terminal. Classifier uses existing provider error/code owners, not arbitrary text heuristics. Auth failure marks only rejected credential generation unhealthy, quota failure records family cooldown. Retry snapshot token/project/routing metadata must all describe the chosen account. + +```ts +// The exact evidence-discriminated GenericOAuthFailure union is specified below. +``` + +No stored broad rotateOn flag is introduced until its every consumer is grounded; defaults express only classified safe recovery. Field chain: adapter response classifier → internal failure object → existing recovery dispatch (not persisted credentials) → health/affinity invalidation and request attempt recovery marker. Add a bounded explicit recovery-kind union only if required, update log normalizer/GUI label/serialization together. + +Tests: multi-turn affinity holds; removal/reauth/expiry releases; proposed stale credential never binds; post-refresh 401 rotates one account; quota-classified 403 rotates; unrelated 403 does not; no rotation after downstream output; budget exhaustion terminates; initial and continuation paths match. New test files join both layout registries. Credential threat model and draft details stay in `.tmp/`; public unit contains safe design only. All source ownership docs updated. Local tests NOT RUN; hosted final cumulative tip plus independent security review required. + +Reflection REF-01 overrides the earlier broad failure type and family-affinity key. Affinity is provider+session, not family: a model change retains account if eligible for that family, otherwise releases it. Cap 2048 entries, idle TTL 30 minutes, prune on write and deletion/generation change. Context now is passed from one request clock. The only classified failure union is: +```ts +type GenericOAuthFailure = + | {kind:"terminal"} + | {kind:"auth"; status:401; evidence:"post-refresh-401"; generation:string} + | {kind:"auth"; status:403; evidence:"provider-account-credential"; generation:string} + | {kind:"quota"; status:403|429; scope:"account"|"gemini"|"claude"; retryAfter?:string}; +``` +First 401 uses existing refresh. Refresh transport error never marks unhealthy. Provider-account 403 requires existing closed code classification; when absent it is terminal. One request-wide recovery budget and unconditional client-output committed marker prohibits new dispatch after any output, including continuation and sidecar. Preserve sidecar quota-only callback unless its existing error contract can carry authenticated post-refresh evidence; record terminal auth limitation rather than invent evidence. Snapshot generation type is revalidated against OAuthAccessSnapshot before implementation. diff --git a/devlog/_plan/260912_accounts/042_generic_health.md b/devlog/_plan/260912_accounts/042_generic_health.md new file mode 100644 index 0000000000..464e6ea17e --- /dev/null +++ b/devlog/_plan/260912_accounts/042_generic_health.md @@ -0,0 +1,13 @@ +# Show the existing pool's selection and health state + +Cycle generic-health depends on lifecycle. Existing pseudonymous account attribution at `src/providers/label.ts:41`, usage log serialization at `src/usage/log.ts:554`, summaries at `src/usage/summary.ts:1237` are reused, not recreated. + +MODIFY existing OAuth health DTO/projector and `src/server/management/oauth-account-routes.ts` to expose bounded selection reason and health/cooldown scope alongside per-account quota. MODIFY `gui/src/hooks/useProviderAccountPools.ts` typed account projection, shared current/all-account card renderer and locale catalogs for reason. CLI account status uses same closed reason. Aggregate pool counts by healthy/cooling/reauth and known/unknown quota; do not sum unlike family/window percentages into fictitious capacity. + +```ts +type GenericSelectionReason = "affinity" | "manual" | "quota" | "round-robin" | "fill-first" | "auth-failover" | "quota-failover"; +``` + +Creation: admitted generic selector; serialization: authenticated account DTO and required per-attempt usage history; deserialization: typed optional client fields; consumers: status/account card/aggregate counts. No raw user/account/credential identifiers added to logs. Tests cover missing legacy fields, successful recovery clearing error, removed accounts, family-specific cooldown display and stale response merge. Source/structure/user docs align. Local suites/build NOT RUN; hosted final cumulative tip and rendered account card required. + +Reflection REF-02 accepted: request-history reason is REQUIRED. Add optional accountSelectionReason/accountQuotaScope to PersistedUsageAttempt in src/usage/log.ts and normalize/serialize closed unions. Stamp after admission per attempt in src/server/request-log.ts and core, preserving prior attempts. Update request-history API/client detail renderer and all localized labels. Legacy rows omit safely. Regression source covers persistence/reload, multi-account retries, and no overwrite of earlier reason. diff --git a/devlog/_plan/260912_accounts/045_warmup.md b/devlog/_plan/260912_accounts/045_warmup.md new file mode 100644 index 0000000000..bd8d304f47 --- /dev/null +++ b/devlog/_plan/260912_accounts/045_warmup.md @@ -0,0 +1,21 @@ +# Schedule one zero-usage account activation durably + +Cycle warmup independent of generic lifecycle. Existing `src/codex/quota-auto-refresh.ts` persists reset-boundary activation in codexQuotaAutoRefresh (#3588); preserve it. `src/quota/reset-seen-store.ts` owns reset-observer baselines and deduplication only. `src/codex/warmup.ts` remains invocation owner. Stable reset-credit operation IDs already exist and need no replacement. + +Extend the existing activation scheduler/store with an explicit one-shot target timestamp for a selected zero-usage account, using codexQuotaAutoRefresh as specified below. Creation must be authenticated CLI/API with account identity, dueAt and stable operation handle; persist pending/running/completed state before dispatch. The exact schema and scheduler boundary are specified below. No live account warmup is executed in this task. + +Before: scheduler acts only on observed reset boundaries. After: a persisted one-shot request can activate a confirmed zero-usage eligible account at dueAt once, survives restart, and is cancelled/invalidated on account deletion or credential replacement. Never spend reset credits or infer user consent from login presence. Integration tests use injected clock/transport; assert duplicate submissions, restart, removal, failure/cancel, non-zero usage, and one dispatch at due time. All local suites NOT RUN. API/CLI contract and scheduler source owners updated; exact due-time semantics remain subject to source-grounded P revalidation. + +## Concrete scheduler contract + +Use `src/codex/quota-auto-refresh.ts:272` minute sweep and its existing `warmAccount` owner. MODIFY `src/types/config.ts:786` and strict `src/config.ts:931` entry schema with optional `oneShot: { operationId: string; dueAt: number; credentialGeneration: number; status: "pending" | "claimed" | "completed" | "uncertain" | "cancelled" | "failed" }`. This slice supports stored pool accounts only; native-main requires its separate ownership flow and is excluded. dueAt is finite milliseconds, future and within 30 days; operationId validated UUID. No new timer/store/service. Config persistence is the current scheduler authority, so claim synchronously with mutatePersistedConfig before dispatch; failure to persist causes no warmup. A claimed row after restart becomes uncertain and is not automatically retried. Completed/failed/cancelled state stays as one bounded row until explicit replacement; same operationId retries return that state. + +NEW dedicated strict handler `src/codex/warmup-schedule-api.ts` for PUT/GET/DELETE `/api/codex-auth/warmup-schedule` (account id request/query required), registered next to existing account routes. PUT validates current stored generation, non-paused/non-reauth/non-validation-pending pool membership and a fresh measured zero usage snapshot before writing. GET returns only operationId/dueAt/status; DELETE changes pending to cancelled and rejects claimed. CLI `ocx account warmup --at --operation-id ` is dispatched through existing `src/cli/account.ts` and `src/cli/account-auth.ts`; capability/help maps updated. + +At each due sweep, refresh stale quota first, then require all measured gating windows zero with no exhausted/unknown primary reading; recheck membership, current credential generation, plan eligibility and spending intent immediately before claim. Nonzero/mismatched/deleted accounts settle failed/cancelled without dispatch. One-shot does not enable recurring fiveHour/weekly booleans. At most one upstream attempt per operation: dispatch outcome settles completed/failed; crash after claim becomes uncertain for explicit operator reconciliation, never exactly-once success claimed. Recovery DTO/copy explains that claimed is not verified success. This avoids the impossible guarantee of atomically committing local config and remote spending. + +Field chain: strict API/CLI input→mutatePersistedConfig→strict config load→existing minute sweep→status read. New status values update every schema/consumer/default switch; deletion reconciliation removes account-owned schedule. Tests register both layout maps and cover API idempotence, nonzero/unknown quota, stale generation, restart pending versus claimed, failed persistence, cancellation and exactly one attempted dispatch with injected clock. + +Reflection REF-03: activation persistence is codexQuotaAutoRefresh, not reset-seen-store (observer only). Extend existing config-routes authenticated settings handling for schedule fields where possible; dedicated schedule handler delegates same validated mutation owner. Final oneShot status vocabulary is pending|claimed|completed|uncertain|cancelled|failed. Claimed on hydrate becomes uncertain; never automatic resend. Failed completion persistence retries the marker only, not upstream work. Concurrent recurring/one-shot due work shares one invocation under same eligible generation and uses same completion result. Tests add crash-after-claim/send, failed completion write, simultaneous due and cancellation during async metadata. This final vocabulary supersedes the earlier shorter type. + +A3 accepted: scheduled one-shot uses an explicit single-attempt option `allowModelFallback?: boolean` on CodexWarmupOptions in src/codex/warmup.ts. warmCodexAccount defaults remain unchanged; when false, propagate the first result and never enter FALLBACK_MODELS. Existing warmAccount passes false for a claimed one-shot (including shared recurring work); ordinary manual/recurring defaults retain existing bounded fallback. Test physical fetch call count on 400/404 and partial completion, not only warmAccount invocation count. diff --git a/devlog/_plan/260912_accounts/050_history.md b/devlog/_plan/260912_accounts/050_history.md new file mode 100644 index 0000000000..fab9e603be --- /dev/null +++ b/devlog/_plan/260912_accounts/050_history.md @@ -0,0 +1,23 @@ +# Retain bounded raw quota observations + +Cycle history; C3 persistence. Independent of reset-first strategy. Source: `src/codex/quota.ts:265` commits merged snapshots, `:678` persists latest-only, `:735` clears; `src/codex/quota-types.ts:1` defines quota windows. New history attaches only after writer-generation and native-main identity guards. No new dependency or optional subsystem import on the core path. + +MODIFY `src/codex/quota.ts`: extend version-1 quota cache with optional bounded per-account history; store fresh raw observation fields (not carried windows) alongside updatedAt, and preserve explicit window reset identity. Credits-only writes do not append samples. Hydrate only validated bounded numeric rows, ignore malformed input, and deep-copy returned arrays. Bound both per-account samples (200) and retained age (30 days). Clear/reconcile removes matching history; unknown legacy files yield empty history. A stale main writer cannot append; native identity change clears old main observations before accepting new ones. + +Before: +```ts +type QuotaDiskFile = { version: 1; quotas: Record; mainPolicyQuota?: MainPolicyQuota }; +``` +After: +```ts +type QuotaDiskFile = { version: 1; quotas: Record; mainPolicyQuota?: MainPolicyQuota; history?: Record }; +export function getAccountQuotaHistory(accountId: string): StoredAccountQuota[]; +``` + +MODIFY `src/codex/auth-api.ts` account quota DTO to expose requested bounded history through a protected read route, preserving existing DTO compatibility. MODIFY CLI account quota read path to support history display/JSON with existing management transport. No secret/claim/tag is recorded; account key is the same local cache key, never an upstream bearer. Add tests in the existing quota cache test owner (or register a new domain test in both layout maps), plus protected API/CLI contract cases. Sync all `src/codex/` ownership docs using relevant statement or a cross-link; configuration docs explain retention and that snapshots alone do not establish token capacity. + +Field chain: creation is guarded quota commit; serialization is existing atomic quota-cache writer; deserialization is bounded validated hydrate; consumers are copied history getter, authenticated API/CLI, then capacity in the next cycle. Acceptance: old cache compatibility; 201 observations retain 200; credits-only and stale generations append none; different reset windows stay distinguishable; main identity change and removal discard old rows; corrupt/unbounded disk input is ignored/bounded. Local runtime checks NOT RUN; hosted quota/API/CLI regression suite at final history/capacity tip. + +Reflection REF-04: fixed aggregate bounds: 64 account identities, 4096 rows, 2 MiB serialized history payload and 4 MiB whole cache read bound. During append/hydrate evict oldest observed rows, tie-break account key; prune accounts absent from authoritative roster. Never include dynamic raw account identities in logs. History retains actual per-window provenance (response-header or WHAM where available), reset boundary and window family; partial inherited values do not count. Overlarge/malformed cache read fails to empty history without blocking newest quota. Tests include many-account overflow, byte overflow, deterministic ties and remove/restart. + +A1 accepted: native main history is deliberately NOT hydrated from disk in this slice. It can be sampled in-process only after identity observation and cleared on identity change; persistence omits __main__. Pool history envelopes bind stable configured account identity and stored credential generation, pruning mismatches on hydrate. This avoids attributing offline identity replacements to an old main label. Acceptance explicitly covers main replacement while stopped and account-id reuse. Main cross-restart history remains a documented limitation; bounded durable history is provided for stored pool accounts. diff --git a/devlog/_plan/260912_accounts/060_capacity.md b/devlog/_plan/260912_accounts/060_capacity.md new file mode 100644 index 0000000000..931e0b6646 --- /dev/null +++ b/devlog/_plan/260912_accounts/060_capacity.md @@ -0,0 +1,18 @@ +# Estimate observed effective capacity without claiming an upstream limit + +Cycle capacity depends on history. Source: `src/usage/log.ts` already persists accountLogLabel, timestamp, reported/estimated usage and per-attempt attribution; `src/codex/account-label.ts` owns safe labels. Use those existing records instead of storing credentials or duplicating request attribution. + +NEW `src/codex/quota-capacity.ts`: a pure estimator receives copied raw history and account-attributed reported usage observations. For each short/weekly/monthly window, pair adjacent fresh percentage observations only when reset identity matches, time increases and percentage delta is positive. Sum reported token usage in that interval, count per-attempt records once, exclude estimated/local/unattributed usage and reset/refund crossings. Estimate tokens per full window as observedTokens * 100 / percentageDelta; aggregate defensible intervals with median and report sampleCount plus observed-token lower-bound caveat. No valid interval returns null, never zero or a fabricated capacity. Bounded scan is invoked on management request, never routing; estimation is informational and does not overrule live quota. + +```ts +export type CodexCapacityEstimate = { + window: "short" | "weekly" | "monthly"; + estimatedTokens: number; + sampleCount: number; + confidence: "observed-lower-bound"; +}; +``` + +MODIFY history read API/CLI projection to attach per-window estimates with sample count and caveat; expose an existing account-card detail surface only if it can be honestly rendered and verified. Field chain: pure estimator creation; API JSON serialization; existing typed CLI/client deserialization; explicit informational display consumers. No persisted estimate schema needed. Tests feed independently hand-calculated intervals, 0% delta, reset rollover, missing timestamps/identity, cross-account records, retries, estimated usage, and extreme numeric input. Sync quota/usage ownership docs and user configuration guidance. Full closure of #3376 requires both history and meaningful capacity; reset-first alone stays partial. Local suites NOT RUN; hosted final cumulative tip is the verifier. + +A2 accepted: use readUsageSnapshotForManagement; if truncatedPrefixBytes>0, entriesTruncated, entriesDropped>0, missing revision, or invalid timing then return insufficient-evidence with no estimate. Treat each request as interval [timestamp, timestamp+durationMs] (request-log.ts:1039/1072); include only requests wholly contained in a quota-observation interval. Boundary-spanning requests contribute nothing. For included requests count reported physical attempts matching the exact pool label once; do not count both request total and attempts. Without attempts accept request-level reported usage only with matching label and no recovery ambiguity. Native main is excluded from token capacity because its historical label cannot establish identity after replacement. Current pool logLabel must be unique; legacy fallback labels/id reuse require insufficient evidence unless continuity is proven by history generation. Same-reset positive deltas only. Hand-worked boundary-spanning, truncation, missing identity and retry rows are mandatory regression fixtures. diff --git a/devlog/_plan/260912_accounts/070_tun.md b/devlog/_plan/260912_accounts/070_tun.md new file mode 100644 index 0000000000..d37cd09632 --- /dev/null +++ b/devlog/_plan/260912_accounts/070_tun.md @@ -0,0 +1,23 @@ +# Preserve canonical transport and classify failed quota reads + +Cycle tun; C3 account diagnostic, independent dev branch. #3799 and #3872 are ancestors of baseline; no re-carry. Source: `src/providers/quota.ts:2795`, quota cache :1570/:2093/:2162; `src/server/management/oauth-account-routes.ts:325`; shared account view `ProviderAccountQuota.tsx:8`. + +MODIFY `src/providers/quota-types.ts` dependency-free contract: +```ts +export const QUOTA_FAILURE_CODES = ["account_unavailable", "access_denied", "rate_limited", "upstream_error", "redirect_blocked", "destination_blocked", "dns_failed", "timeout", "transport_error", "response_unusable"] as const; +export type QuotaFailureCode = typeof QUOTA_FAILURE_CODES[number]; +export function parseQuotaFailureCode(value: unknown): QuotaFailureCode | undefined { + return QUOTA_FAILURE_CODES.find(code => code === value); +} +// AccountQuotaFields gains quotaFailure?: QuotaFailureCode. +``` + +MODIFY `src/providers/quota.ts`: private classified Antigravity probe returns available quota+source or unavailable failure+legacy null/throw disposition. Public fetchAntigravityUsageQuota retains existing null/rejection behavior. Summary redirect/401/403 terminates without fallback; any other failure tries existing models fallback; final attempt determines category, successful fallback clears failure. Classify ProviderOutboundPolicyError→destination_blocked; DestinationDnsResolutionError→dns_failed; PinnedHttpError timeout codes→timeout, output_byte_limit→response_unusable; DOMException TimeoutError→timeout; remaining errors→transport_error. Never use message regexes. JSON/body failures stay response_unusable since readQuotaJson cannot distinguish timeout from malformed data. + +Before: cache failure stores `{ts, quota: lastGood, unavailable: true}`. After: adds only closed `quotaFailure`, no error object/message/body/URL. Credential/project preparation failures are account_unavailable, not reauth verdicts. Cache generation/inflight/TTL guards remain. Success constructs fresh entry without failure. Persist only existing quota projection; diagnostic codes remain transient. Extend account result and API projection only when unavailable; stale identity/config projection omits category. + +MODIFY GUI `components/provider-workspace/types.ts`, `hooks/useProviderAccountPools.ts`: parse incoming code; enriched success/pending clears it, roster-only refresh preserves it only for same id/mode, late merge explicitly copies it, local API failure clears old upstream diagnosis. `ProviderAuthPanel.tsx` forwards to `ProviderAccountQuota.tsx` for all accounts; current account whole-row pass-through stays intact. Add localized pws.quotaFailure keys to all locale files. CLI AccountRow/raw DTO/projector and quotaText show safe category, preserving generic fallback for unknown values. + +Field chain: private probe→transient cache→account results→authenticated API JSON→enum-normalized client/CLI→current/all-account quota text. Ranking/health/history do not consume it. Tests: each enum trigger, summary failure/fallback success, final-attempt precedence, stale bars, recovery, cross-account isolation, stale-config, late response, unknown wire code, and secret-free projection. Existing provider account quota fixtures supply transport injection; new files require both layout entries. Fix inventory's stale IPv6 proxy-only sentence and update every touched area owner. Local suites/build NOT RUN; hosted backend/GUI checks and rendered final-tip artifact. Authenticated TUN observation remains unmet until an authorized operator supplies exact SHA, proxy/TUN mode and sanitized successful refresh; no network/account changes here. + +Reflection TUN-R01/R02 accepted. HTTP 300–399→redirect_blocked, 401/403→access_denied, 429→rate_limited, other non-2xx→upstream_error; success with unusable quota→response_unusable. Keep providerRedirectError cancellation and discard its message. Neither status establishes plan or reauth. fetchAntigravityQuota may reuse the private probe preserving null/rejection and success source; ProviderQuota/ProviderQuotaReport gain no diagnostic field, report-only views remain generic. getCachedProviderAccountQuota returns last-good quota only. diff --git a/devlog/_plan/260912_accounts/080_reauth_api.md b/devlog/_plan/260912_accounts/080_reauth_api.md new file mode 100644 index 0000000000..10370f304f --- /dev/null +++ b/devlog/_plan/260912_accounts/080_reauth_api.md @@ -0,0 +1,29 @@ +# Reauthenticate the existing native main identity with device code + +Cycle reauth-api; C4, independent dev branch. Preserve /api/codex-auth/login rejection of __main__. Allow existing native-main credentials in every runtime role, with no codex binary/keyring requirement. Same-identity reauth only; account switching remains the native profile workflow. Main device service does not call startLoginFlow(chatgpt), whose completion persists into the OAuth store. + +MODIFY `src/oauth/chatgpt-device.ts`: factor the private grant exchange to retain raw validated token payload for a new native-only result. Existing loginChatGPTDevice still projects OAuthCredentials and returns no id_token. New loginChatGPTNativeDevice returns `{credential: OAuthCredentials, idToken: string}` only in process; reject missing access/refresh/id token or mismatched token account identity. Device callback remains human URL/code, opaque device_auth_id private. + +MODIFY `src/codex/main-account.ts`: new beginNativeMainReauth captures existing MainAuthJsonCredential snapshot into a closure without exposing it to callers; returned commit accepts complete native device tokens. At commit acquire existing withNativeMainExclusiveClaim after authorization, verify startup/recovery and in-process admission fence, assert original path/hash/inode before atomic rename, require same chatgpt account identity. Write access_token, refresh_token, id_token, account_id together, preserving allowed root metadata. Check cancellation/current-flow before entering commit and before rename. Advance mutation epoch and reconcile same-account runtime/quota/reauth state explicitly. Never retain the old identity token beside new credentials. No claim held during human polling. + +NEW `src/codex/main-device-reauth.ts`: one process-owned active flow, opaque UUID, AbortController and bounded terminal retention; injectable login/commit dependencies for tests. Start/status/cancel return only flowId, status, verificationUrl, deviceCode and closed safe failure code. Superseded/cancelled completion may not publish. Terminal data clears URL/code when no longer useful. No tokens/emails/raw account IDs in DTO/log/error. + +NEW `src/codex/main-device-reauth-api.ts`: dedicated handler for POST/GET/DELETE `/api/codex-auth/main/reauth-device`, exact opaque flow query for status/cancel, strict request keys and safe 400/404/409 errors. Register at existing management registry/auth handler boundary (read latest dispatch before B); existing management auth/origin/session controls remain authoritative. No CLI direct account file write. + +MODIFY `src/cli/account-main.ts`: `reauth --device [--no-wait]`, `reauth status --flow `, `reauth cancel --flow ` via same management API; reject extra args before start. Register capability/help and regenerate skill surface using source-only tooling if needed. Blocking wait bounded by service flow expiry; --no-wait returns handle/code and follow-up commands. + +Field chain: device token creation→native private commit only, never API serialization; flow DTO created by service→management JSON→CLI/GUI typed parsing→human code/status. Tests: same-account success without codex/keyring, wrong identity, missing token fields, cancelled late result, concurrent file replace/refresh/profile switch, pending recovery, atomic write failure, same-account quarantine clearing, no pool-row mutation, secret-free all routes, unauthorized endpoints. Security draft stays scratch; implementation and regression diff may be published. Sync all src/codex/src/oauth/src/cli/src/server ownership docs and public headless recovery instructions. Local suites/build/typecheck/install NOT RUN; hosted final API/UI tip and independent security review required. + +Reflection native publication contract: pin NativeProfileContext once. Use a short owner/shared operation for preparation; capture original bytes and dev/ino from the SAME opened descriptor using an additive snapshot variant of native-profile-store.ts readBounded, preserving no-follow/regular-file/size bounds and wrapper compatibility. Closure retains this original snapshot throughout login. Explicitly assertNativeMainOwner at preparation and commit; withNativeMainOwnerOperation tracks work but does not replace this assertion. Acquire exclusive claim after human authorization, then recheck recovery, flow, cancellation and ownership before rename. Missing owner/claim fails safely, no NativeProfileManager/keyring enrollment. Tests include same-byte replacement, capture-time replacement, in-place edits, deletion/nonregular/symlink, cancellation waiting for claim, missing owner and unsupported claim. + +```ts +type MainDeviceReauthStatus = + | {flowId: string; status: "pending"; verificationUrl: string; deviceCode: string} + | {flowId: string; status: "committing"} + | {flowId: string; status: "succeeded"; credentialUpdated: true} + | {flowId: string; status: "cancelled"} + | {flowId: string; status: "failed"; credentialUpdated?: true; code: "identity_mismatch" | "credential_changed" | "native_main_unavailable" | "device_authorization_failed" | "publication_failed" | "reconciliation_failed"}; +``` +Cancellation after publication returns succeeded, never cancelled. Post-publication reconciliation failure reports credentialUpdated=true/reconciliation_failed, no rollback claim or automatic retry. Tokens/snapshots stay private. Start waits for human-code publication or terminal result so pending always has URL/code. One active flow rejects overlapping start (409), terminal retention 5 minutes, grant deadline 15 minutes inherited from device owner. + +Reflection residual accepted: reconciliation_failed is a distinct DTO union member requiring credentialUpdated:true. Cancellation after publication preserves either succeeded or reconciliation_failed, never overwrites reconciliation failure and never reports cancelled. diff --git a/devlog/_plan/260912_accounts/090_reauth_ui.md b/devlog/_plan/260912_accounts/090_reauth_ui.md new file mode 100644 index 0000000000..9e32ba6c1b --- /dev/null +++ b/devlog/_plan/260912_accounts/090_reauth_ui.md @@ -0,0 +1,9 @@ +# Put native-main device reauth on the main card + +Cycle reauth-ui depends on reauth-api. C4 auth UI. Existing main-card uses only expired-token text at `gui/src/components/codex-account-pool-main-card.tsx:184`. Preserve pool Add/Re-login and native profile picker. + +NEW `gui/src/components/use-main-device-reauth.ts`: dedicated hook with start/poll/cancel methods using native-only namespace, flowId ownership and abort/unmount cleanup. Normalize closed status/error payloads; never accept arbitrary verification URLs (only known device verification destination from backend contract), no token/account-id fields. Poll only matching active flow and stop on terminal status; late responses from replaced flow ignored. + +MODIFY main-card component: button Re-login with device code; after start show known verification URL, human code/copy and polite pending status, cancel action; success refreshes main account state. Keep layout consistent with current card. Do not reuse AddCodexAccountModal or reauthAccountId=__main__. Add exact locale keys for all shipped languages, update prop owners/types and backend error copy. Terminal failure is actionable and safe; do not automatically retry login or switch identity. + +Field chain: dedicated API DTO→hook validated state→main-card only; no persistence of device code in browser storage. Existing parent refresh callback re-fetches main status on completion. Regression source verifies correct route, code display, cancel ownership, stale poll, success refresh, no pool Add invocation, keyboard and error states. Hosted rendered screenshots required for PR; obtain built artifacts from final hosted CI instead of local product build. Sync GUI owners and headless dashboard docs. Local tests/build NOT RUN. API→UI ordinary manual chain, merge reserved to coordinator. diff --git a/devlog/_plan/260912_accounts/100_final.md b/devlog/_plan/260912_accounts/100_final.md new file mode 100644 index 0000000000..709dc240d7 --- /dev/null +++ b/devlog/_plan/260912_accounts/100_final.md @@ -0,0 +1,7 @@ +# Verify final branch tips and hand off integration evidence + +Cycle final consumes every delivered PR. No product change unless a concrete hosted CI/reviewer finding justifies a new repair cycle. Refresh PR head/base/native membership, CI run head SHA, all jobs and outstanding review threads. GitHub-hosted final tips are the user's execution verifier; intermediate runs may exist but are not claimed as tested by this task. Do not cancel workflows or modify protection. + +MODIFY this unit's numbered evidence/closure record and task-local handoff: one row per original issue/PR with LIVE/PARTIAL/SUPERSEDED/NOOP and exact remaining acceptance; one row per new PR with URL/base/head SHA/commits/coauthor/manual-chain order; final hosted run IDs/URLs/conclusions and unresolved security/review/field acceptance. Local suites/typecheck/build/install NOT RUN. No merge or original issue closure. + +Conditional repair: download exact failing job log, identify cause, amend owning phase plan, implement smallest correction in a fresh PABCD cycle, push --no-verify and verify new final head. Source-only checks are labeled text checks, not suite evidence. After unchanged final tip's checks pass, stop retesting and collect final handoff. Do not mark goal complete while required implementation is absent. Field acceptance has separate evidence status and cannot be replaced by mocks. diff --git a/devlog/_plan/260912_cache_lane/000_plan.md b/devlog/_plan/260912_cache_lane/000_plan.md new file mode 100644 index 0000000000..ac31af2b2d --- /dev/null +++ b/devlog/_plan/260912_cache_lane/000_plan.md @@ -0,0 +1,23 @@ +# Cache lane roadmap + +Three independent fixes address optional helper admission, final OpenCode Go conversation affinity, and explicitly enabled Claude instruction stabilization. Hermes cache observations are investigated separately: missing inbound identity is not proof of proxy loss, and a shared prefix is not a conversation. + +Satisfy-spec HOTL, triggered by the authorized cache lane assignment. Scope: PRs #4118/#4050/#4052 and issue #3433. No local tests of any size, build/typecheck/install, service changes, merges, closures, releases, workflow or permission changes. Commits and --no-verify pushes plus ordinary PR creation are authorized. Existing tool/account scope only; no user-set time/token/agent cap. Main implements; inherited-model subagents review. Native architect selection is unavailable; supported independent design review records that limitation. + +Verification: git diff --check for textual integrity; independent source review; GitHub hosted Cross-platform CI at each independent final PR tip. Local product checks are NOT RUN. Source/applicability checks do not prove runtime behavior. Stop after concrete dispositions, final hosted evidence and durable handoff; field evidence or review/access gaps remain explicit, never a false fix. Tool gate denial is reported without bypass. Two failed independent reviewer contexts return work to main; implementation remains main-owned. + +Existing layout: src/server (wire bridges), src/claude (translator), src/providers (Go transport), tests/{responses,providers,claude-integration,codex-integration}, structure (contracts), docs-site (user guidance). Reuse these owners; no new framework or runtime abstraction. + +Work phases, each a full P-A-B-C-D cycle: +- roadmap: docs only; lock all following plans. +- claim: 010, independent dev PR for #4118. +- affinity: 020, independent dev PR for #4050; prerequisite request-lane allocator is already on dev. +- prefix: 030, independent dev PR for #4052 with an actual default-off configuration boundary. +- hermes: 040, independent contract evidence for #3433, no invented identity. +- verify: 050, inspect hosted results, repair confirmed scoped failures in added cycles, hand off exact heads. + +The implementation order is a work ledger, not a false PR dependency. No native stack requested. Each independent PR is its own final tip. A repair that depends on a delivered implementation may be a child layer. + +Source inventory and raw latest GitHub evidence stay in .tmp/cache-handoff/. Public source PRs are the provenance; measurements are author-reported and are not reproduced here. Unpublished security notes stay in scratch. Source ownership updates accompany each actual patch. + +Design disposition: accept CACHE-D01 through D05. D04 uses the existing Claude configuration argument as its single control; no separate conflicting translator option. D05 covers underscore session_id and hyphenated session/thread pair separately. Native architect role not selected; inherited supported subagent performed actual design review. diff --git a/devlog/_plan/260912_cache_lane/010_claim.md b/devlog/_plan/260912_cache_lane/010_claim.md new file mode 100644 index 0000000000..afb97b03b1 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/010_claim.md @@ -0,0 +1,28 @@ +# Claim deferral + +Prerequisite: roadmap; origin/dev baseline. Independent PR. Carry source #4118 at fc8c03833e9ffd0f2bfd30f5ef7de19425c87645, preserving author trailers. + +- MODIFY `docs-site/src/content/docs/fr/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/ja/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/ko/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/ru/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/tr/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/zh-cn/reference/proxy-formats.md` +- MODIFY `docs-site/src/content/docs/zh-tw/reference/proxy-formats.md` +- MODIFY `src/server/chat-completions.ts` +- MODIFY `src/server/responses/core.ts` +- MODIFY `src/vision/plan.ts` +- MODIFY `src/web-search/index.ts` +- MODIFY `structure/providers/openai-tiers.md` +- MODIFY `tests/codex-integration/bearer-admission-routed-provider.test.ts` +- MODIFY `tests/vision/vision-cache.test.ts` +- MODIFY `tests/web-search/web-search.test.ts` + +Before: caller-auth noncanonical Chat eagerly claims stored main; helper admission does not share all terminal/routed/search exclusions. After: only non-caller-auth keeps early enrichment; carry `allowStoredOpenAiSidecarAuth` privately, then claim before reading main only when a canonical Direct helper candidate is actually needed. Snapshot stays separate from primary/retry credentials. Share routed-vision eligibility and tool-choice exclusions. Preserve loopback hostname/listener fields. + +Activation: held keyless Cursor request without helper leaves main request count zero and profile switch succeeds; Direct helper carries main only to helper wire; Pool/exact account and excluded tool choices retain behavior. Auth review required. + +Exact executable delta is the public diff at https://github.com/lidge-jun/opencodex/pull/4118.diff captured locally in .tmp/cache-handoff/pr-4118.diff; git apply --check exited 0 on baseline. Read and adapt source context before application. No source deletion. Add concise current-contract references to all mapped source ownership docs, with canonical details in structure/data-planes/inbound-compat.md and structure/providers/openai-tiers.md (claim) or structure/transports/responses.md (affinity). + +C: git diff --check plus independent review; local tests NOT RUN. Runtime acceptance deferred to final hosted tip CI. D records implementation and pending remote evidence, not test success. diff --git a/devlog/_plan/260912_cache_lane/020_affinity.md b/devlog/_plan/260912_cache_lane/020_affinity.md new file mode 100644 index 0000000000..8ccf8fc363 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/020_affinity.md @@ -0,0 +1,16 @@ +# Final Go affinity + +Prerequisite: roadmap; origin/dev baseline. Independent PR. Carry source #4050 at e5c2411f7b35c6265aacce19f66f13eace544579, preserving author trailers. + +- MODIFY `docs-site/src/content/docs/guides/providers.md` +- MODIFY `src/server/claude-messages.ts` +- MODIFY `src/server/responses/core.ts` +- MODIFY `tests/providers/opencode-go-session-header.test.ts` + +Before: preliminary Claude route injects Go identity into replay headers. After: derive validated lane with explicit session > Go header > valid Claude metadata > original request allocation, carry `claudeGoAffinity` in HandleResponsesOptions through combo recursion and consume only at final Go normalization. Never synthesize shared system hash identity or leak Go-only headers to non-Go. + +Activation: existing two-wire/random/failover matrix gains metadata, explicit-header precedence, malformed/shared identity and independent sessionless controls; operator override wins. No public option or serialization: private in-memory options, recursion spreads options, final transport consumes. + +Exact executable delta is the public diff at https://github.com/lidge-jun/opencodex/pull/4050.diff captured locally in .tmp/cache-handoff/pr-4050.diff; git apply --check exited 0 on baseline. Read and adapt source context before application. No source deletion. Add concise current-contract references to all mapped source ownership docs, with canonical details in structure/data-planes/inbound-compat.md and structure/providers/openai-tiers.md (claim) or structure/transports/responses.md (affinity). + +C: git diff --check plus independent review; local tests NOT RUN. Runtime acceptance deferred to final hosted tip CI. D records implementation and pending remote evidence, not test success. diff --git a/devlog/_plan/260912_cache_lane/025_affinity_native.md b/devlog/_plan/260912_cache_lane/025_affinity_native.md new file mode 100644 index 0000000000..0d22be757a --- /dev/null +++ b/devlog/_plan/260912_cache_lane/025_affinity_native.md @@ -0,0 +1,17 @@ +# Final native affinity after preliminary Go route + +Previous D: prefix implemented; confirmed P2 on #4340 requires correction before integration. Source https://github.com/lidge-jun/opencodex/pull/4340#discussion_r3995130580. Class C3 transport identity; same authorized runtime/no-local-suites/no-merge scope. This extends the existing affinity PR, not a new independent feature. + +MODIFY src/server/claude-messages.ts: remove preliminary `if (nativeRoute && !opencodeGoRoute)` session_id synthesis. Retain validated metadata UUID privately as new HandleResponsesOptions.claudeNativeSessionId, alongside claudeGoAffinity. Do not derive from system fallback. Explicit session_id is forwarded as before and wins. + +MODIFY src/server/responses/core.ts: add optional `claudeNativeSessionId?: string` to internal options. Create a private `withClaudeNativeSession(headers, provider, sessionId)` helper that returns headers unchanged unless canonical OpenAI, private value present, and no explicit session_id/session-id/thread-id header. Then clone Headers and set only the cloned session_id. Apply to both finalAuth.headers and finalAuth.callerAuthHeaders after final auth resolution; alternate-account retries already consume callerAuthHeaders. Reapply to selectedForwardHeaders after a native credential refresh, whose replay result rebuilds from req. Never mutate req.headers. Policy/combo replay sees original headers and carries only the private option. Explicit underscore, hyphenated session and thread-only identity all prevent metadata synthesis. No public serialization: creation Claude handler -> recursive option spreads -> attempt-local auth/header copies -> canonical adapter. + +A audit corrections: reject request-header mutation because policy fallback reuses the same request. Reject caller JWT fixture because Claude drops caller auth. Use isolated stored main under an actual admitted turn; no ambient credentials. + +MODIFY tests/providers/opencode-go-session-header.test.ts: real handler random/failover Go preflight -> canonical ChatGPT fixture, valid metadata yields expected UUID, explicit native header wins, no metadata/shared-system cannot synthesize. Mock outbound fetch; isolate OPENCODEX_HOME and CODEX_HOME, store synthetic main JWT/account and use tryAdmitTurn lease with real handler logIds so existing claimed-main enrichment is reached. Add canonical failure then noncanonical policy fallback control with original request.headers unchanged; existing runPolicyFallbackHops fixture may be used to inspect header-copy boundary. Retain final non-Go no-header controls. Hosted CI only; local product checks NOT RUN. Assert actual session_id and prompt_cache_key at outbound boundary, not source text. + +MODIFY structure/data-planes/inbound-compat.md final affinity contract to describe private native lane at final canonical destination; mapped links already exist. Preserve source authors. C source audit + diff check, then exact final-tip hosted run tracked in verification cycle. D records missed earlier review scenario and repair head. + +Test placement amendment: NEW tests/claude-integration/claude-native-affinity.test.ts and both layout mappings instead of enlarging the existing 600-line Go suite. Same real-handler matrix plus policy wrapper with real core and controlled trace. + +C review correction: normalizeLogConversationId hashes its input, so native projection retains raw validated UUID separately; only metadataGoLane uses normalized hash. Preserve fixed historical UUID oracle, no cache-identity migration. diff --git a/devlog/_plan/260912_cache_lane/026_affinity_adapt.md b/devlog/_plan/260912_cache_lane/026_affinity_adapt.md new file mode 100644 index 0000000000..ad0eb164a8 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/026_affinity_adapt.md @@ -0,0 +1,7 @@ +# Repaired affinity current-dev adaptation + +Previous D: prefix adaptation completed. Live #4340 now CONFLICTING with current dev. Class C2 same owned-branch adaptation, no local suites/build/typecheck/install, no merge. Rebase own three commits after30d5016a onto5042a376. Preserve exact original affinity runtime patch and native-repair delta at37a4e6b65, all credits. No changes to other lane branches. + +MODIFY conflict resolutions in13 mapped structure docs: union complete new-base helper contracts with original Go affinity links/section. Runtime.md also preserves newer continuation paragraph. src/server/responses/core.ts and layout files auto-merge, independently compare patch additions/deletions to old range. Later native-repair append may conflict at inbound-compat tail; preserve both current-base/Go/native paragraphs exactly. Add this026 checkpoint only. + +C compares old30d5016a..37a4e6b65 to new5042a376..newhead, exact runtime/tests range-diff and doc-union source audit. Push no-verify with exact old-head force lease, then new-tip hosted CI; parent owns merge. diff --git a/devlog/_plan/260912_cache_lane/027_affinity_slot.md b/devlog/_plan/260912_cache_lane/027_affinity_slot.md new file mode 100644 index 0000000000..c5f76acecf --- /dev/null +++ b/devlog/_plan/260912_cache_lane/027_affinity_slot.md @@ -0,0 +1,3 @@ +# Serial affinity integration slot + +Parent pinned dev10c73569e9141f61c363b5fb61963d5c27e174d9 after4342 and reserved affinity-first integration. Previous D Hermes contract complete, live acceptance open. Rebase only own four commits after5042a376 onto parent-pinned10c73569; old headf58cb87b1c. Same026 audited append-union mechanism and no local suite/build/typecheck/install/merge. Keep all current-base source/docs and preserve own authored runtime/test bytes, credits and025/026 records. Conflict resolution scope is mapped structure docs; stop to audit unexpected runtime conflicts. Range-diff confirms source/tests unchanged; independent reviewer checks exact resulting doc union and head. Lease push pinned to oldf58cb, parent merges next. Prefix stays untouched until parent gives next base. diff --git a/devlog/_plan/260912_cache_lane/030_prefix.md b/devlog/_plan/260912_cache_lane/030_prefix.md new file mode 100644 index 0000000000..fead938ce8 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/030_prefix.md @@ -0,0 +1,13 @@ +# Explicit Claude prefix stabilization + +Prerequisite roadmap; independent dev PR. Reimplement #4052 at 43def4039ba60039df9a2a91fb6352b91ba74d70; preserve Warexpor and Cursor Agent credit. Do not copy binary paper or unverified measurements. + +NEW src/claude/inbound-cache-stabilize.ts: adopt source helper's complete trailing exact total_tokens/two TaskCreate matchers and fenced-range parser, including unclosed fence through EOF. Source full text is in .tmp/cache-handoff/pr-4052.diff. MODIFY src/claude/inbound.ts: read `cc?.stabilizePromptCache === true` from the existing Claude config parameter, defaulting stabilization off, relocate only when true, append latest dynamic notice as user input, use stabilized instructions for opted-in Desktop cache key; preserve original systemParts hashing otherwise. + +MODIFY src/types/config.ts OcxClaudeCodeConfig: add `stabilizePromptCache?: boolean` with default false and role-change warning. Serialization/deserialization: existing config JSON save/load retains the boolean; no new wire option; malformed non-true values do not activate. KEEP src/server/claude-messages.ts existing three-argument translation call, which already passes config.claudeCode. Never use unconditional true or infer opt-in from metadata, endpoint or text. Configuration is operator-owned and opt-in applies to translated Messages traffic; native passthrough stays unchanged. + +NEW tests/claude-integration/claude-inbound-cache-stabilize.test.ts: adopt translator/helper controls; replace source-phrase assertion with real handler outbound capture proving default/unset/false retain exact suffix and original key, true relocates, fences preserve content, both TaskCreate shapes peel, metadata session key stays stable. MODIFY scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json to register new file. Add save/load and malformed-value control where existing config fixture permits. + +MODIFY docs-site/src/content/docs/guides/claude-code.md and applicable translated pages: document default-off claudeCode.stabilizePromptCache, changed message role/key scope and no hit-rate guarantee. MODIFY structure/data-planes/inbound-compat.md/config.md and other mapped source-owner docs with canonical contract references. No GUI control, new dependency, automatic user config edit or cache-key-to-session synthesis. + +C: textual diff/source review only locally; all product tests NOT RUN. Hosted tests must observe actual outbound instructions/input/key, not phrase presence. Independent reviewer must confirm that earlier #4052 HTTP activation blocker is resolved. D publishes exact implementation with remote evidence pending until verification phase. diff --git a/devlog/_plan/260912_cache_lane/035_prefix_adapt.md b/devlog/_plan/260912_cache_lane/035_prefix_adapt.md new file mode 100644 index 0000000000..e48750d0d8 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/035_prefix_adapt.md @@ -0,0 +1,7 @@ +# Prefix current-dev adaptation + +Previous D: reverse native affinity correction reviewed. Parent requested current-dev integration of own prefix branch #4347. Class C2 adaptation; all existing no-local-suites/no-merge restrictions apply. Safe rebase own prefix commit df5853600a onto fetched origin/dev, dropping no product change. Existing roadmap commit already integrated via #4338. Record old/new immutable refs. No other branch/worktree edits. + +MODIFY only conflict resolutions in structure/clients/claude-desktop.md, structure/data-planes/inbound-compat.md, structure/runtime.md: preserve latest dev appended helper contract AND prefix opt-in section/link. Runtime prefix delta remains byte-identical. Test layout maps auto-merge retaining both sides' entries. No new implementation. + +Independent design/audit: confirm union of append-only docs is correct; compare old base..old tip to new base..new tip by file and range-diff, disclose every changed patch. Existing independent source review at df585 remains valid only for unchanged authored bytes, and conflict interdiff needs a separate inherited reviewer. C text diff check and new exact head hosted CI tracking; no local product tests. Push --force-with-lease tied to old prefix head and --no-verify; parent owns merge. diff --git a/devlog/_plan/260912_cache_lane/036_prefix_linear.md b/devlog/_plan/260912_cache_lane/036_prefix_linear.md new file mode 100644 index 0000000000..43bf8c3dfe --- /dev/null +++ b/devlog/_plan/260912_cache_lane/036_prefix_linear.md @@ -0,0 +1,13 @@ +# Linear canonical suffix parsing + +Previous D: affinity integrated by parent; prefix serial base81f0c78d7a2bf56e759511e89f450c7d49e0a42e. New public reviews on4347 discussion_r3995155701/P1 and3995155709/P2 require code repair, not only adaptation. C3 bounded parser performance/correctness. Same no-local-suites/build/typecheck/install/no-merge scope; hosted CI is runtime verifier. + +First rebase own prefix commits after5042a376 onto pinned81f0c78d. Union three mapped structure conflicts preserving base affinity/helper paragraphs and prefix opt-in contract; original runtime delta unchanged before repair. Preserve Warexpor/Cursor credit. + +MODIFY src/claude/inbound-cache-stabilize.ts: replace repeated whole-rest regex scans and slices with one forward fence-range scan plus backward line cursor. Each backward iteration strips trailing horizontal/line whitespace, isolates one complete single line via lastIndexOf newline, accepts only exact total_tokens with digits + one ASCII space + tokens left or either full exact TaskCreate paragraph, and checks monotonically decreasing fence-range cursor. On recognized line move end cursor before separator newlines, retain only latest total/nudge. Slice original instructions once at final end; no match returns original bytes. Fence opener/closer behavior retained. No whole shrinking-string rescan or per-footer copy. No multiline/tab/formfeed inside canonical tag. + +MODIFY tests/claude-integration/claude-inbound-cache-stabilize.test.ts: add controls for newline/tab/formfeed between digits and tokens left, exact single-space positive, CRLF outer separators, many consecutive footer sequence (20k) retains exact prefix/latest footer. No tight wall-clock benchmark; normal hosted test timeout plus source complexity review validates bounded work. Preserve existing fence/default-off/HTTP/header/config tests. + +Update structure/data-planes/inbound-compat.md to state single-line canonical notices and linear scan. Exact old runtime-delta comparison binds inherited source review, new parser receives independent A and implementation source audit. GitHub hosted finaltip CI after --no-verify lease push. Parent owns merge and review-thread resolution. Safe public final handoff will be exported to tracked devlog at verification completion; scratch raw evidence retained locally only. + +Design dispositions LINEAR01-04 accepted. Keep committed `end` unchanged until notice acceptance. A speculative `lineEnd` skips only complete LF/CRLF sequences; candidate horizontal padding trims only ASCII space/tab by indices. Do not cross whitespace-only lines, consume lone CR or trim unmatched prefix. After matching, commit end before immediately preceding LF/CRLF runs; failed candidate returns prior committed prefix unchanged. Compare exact nudge strings and anchored digits + literal single space. Fence index decreases only. Add preserved two-space prefix, malformed-before-valid, whitespace-only separator, loneCR and many closed fences+footers controls. diff --git a/devlog/_plan/260912_cache_lane/037_prefix_slot.md b/devlog/_plan/260912_cache_lane/037_prefix_slot.md new file mode 100644 index 0000000000..0d97df1bfc --- /dev/null +++ b/devlog/_plan/260912_cache_lane/037_prefix_slot.md @@ -0,0 +1,9 @@ +# Final prefix slot and durable evidence + +Parent pinned dev81f6cd5915ca59f784a584d8cd739adff55c9bd0 after Cline4371, holding other structure merges. Previous D: linear canonical parser repair e1d262acee freshly source-audited PASS0, no local product execution. Class C2 adaptation/docs, no local suites/build/typecheck/install/no merge. + +Rebase own three commits after81f0c78d onto81f6cd5915, preserving exact repair/source/test bytes and all parent-base source/docs. Conflict union only in mapped docs; unexpected runtime conflicts need independent review. Compare oldbase81f0..e1d262 against newbase81f6..newhead; source/test patches identical. Lease push remains pinned to remote e1c92f10c3, which was intentionally kept during repair. + +MODIFY050_verify.md: .tmp is scratch, not sole durable evidence. NEW060_handoff.md safe tracked index includes original dispositions, branches/PRs, known source-audit and CI references, remaining3433 live acceptance, local suites NOT RUN, cycle list and parent-only integration. Private paths/raw evidence stay in scratch. Export current exact SHA/CI/source-review data to4347 PR body on publication, then update only PR body with terminal results; this preserves final source head without a self-referential new commit. Keep parent integration annotations when updating PR body. Final verification must inspect the exported body before closing goal. + +Independent conflict audit binds prior fresh linear-parser PASS to final head; no old pre-P1/P2 PASS stands in for repair. Parent decides review-thread resolution and merge. diff --git a/devlog/_plan/260912_cache_lane/040_hermes.md b/devlog/_plan/260912_cache_lane/040_hermes.md new file mode 100644 index 0000000000..de79dc1d7e --- /dev/null +++ b/devlog/_plan/260912_cache_lane/040_hermes.md @@ -0,0 +1,11 @@ +# Hermes identity boundary + +Prerequisite roadmap; independent path from Claude changes. Latest issue #3433 comment 5556427205 and controlled sample 5551855276 establish no measured inbound identity, not a dropped value. Preserve issue OPEN disposition. + +MODIFY tests/responses/chat-completions-endpoint.test.ts or a registered adjacent contract file: use the existing real Chat handler + mocked Responses upstream. Send synthetic session A on two growth turns and session B on a fresh turn; cross body prompt_cache_key present/absent with session_id present/absent. Assert captured outbound session_id and body key are exactly caller supplied; absent remains absent; shared key is not converted into session_id. Use fixture identity distinct from raw personal data, and compare at actual adapter fetch boundary. Existing src/chat/inbound.ts copies prompt_cache_key; Chat FORWARD_HEADERS and openai-responses adapter forward session_id. No runtime mutation unless this controlled contract reveals a specific defect. + +MODIFY canonical inbound contract docs to distinguish stable client conversation identity, request-scoped lane and prompt prefix. Durable scratch evidence names public comment URLs, actual test command coverage and limitations. Real Hermes same-conversation/fresh-session identifier and outbound capture from its running client are unavailable unless provided by existing public evidence; synthetic regression proves transport contract only. Do not claim actual client identity was observed, cache hits improved or #3433 solved. + +C hosted final tip executes the contract; local suite NOT RUN. D records exactly what is proven and remaining controlled live-client comparison. + +Execution refinement: NEW tests/responses/chat-conversation-affinity.test.ts and register it in both layout maps. Invoke actual Chat handler with synthetic caller JWT against canonical ChatGPT Responses config in isolated homes. Mock only outbound fetch, record Headers/body. Two header shapes (underscore session_id, hyphen session-id/thread-id), key present/absent, A/A/B growing messages; explicit request-id differs per turn. Absent identity controls prove shared key never becomes a session. This fixture establishes OCX preservation, not actual Hermes emission. No runtime patch unless evidence finds loss. diff --git a/devlog/_plan/260912_cache_lane/041_hermes_ci_refresh.md b/devlog/_plan/260912_cache_lane/041_hermes_ci_refresh.md new file mode 100644 index 0000000000..5c7f1cb3d9 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/041_hermes_ci_refresh.md @@ -0,0 +1,11 @@ +# Hermes hosted-CI refresh after shared fixture repair + +Resume preserves the same worktree and session. The host goal is blocked and persisted phase remains C; no goal/FSM reset, reactivation or completion is claimed. This file records the authorized remaining work, not a new completed cycle. + +The original Hermes head `b254efc8385ce2a9dc34b9a5ac7d2a449605d75d` failed gates on the Combo active-reactivation fixture, while four Linux and two macOS product shards succeeded. Shared repair #4390 is now integrated as `20861aebf56c6f8ec2b0d8d04d1d0b54441650bb` and its exact hosted CI `34688482827` succeeds. Affinity/prefix old runs failed restore/Cline fixtures subsequently repaired by that same PR. Old failed runs remain failed. + +Rebase the one owned Hermes test-only commit from `e4ee8c54` onto current `origin/dev` at `392e182a00` (record full SHA in handoff). Read-only merge-tree reports no conflict. Preserve the 99-line runtime-boundary test and both mappings; no Combo, Cline, restore or provider source edits. Compare old/new authored source deltas and obtain an inherited-model independent source audit. Append terminal evidence to `060_handoff.md`, preserving real Hermes acceptance as open. Push only existing Hermes branch with `--no-verify` and exact old-head lease; no new task/worktree or recreation of merged evidence PR #4377. Bind new final hosted CI to new Hermes SHA. Parent owns integration and chooses any source collision slot. + +Local suites/focused/GUI/build/typecheck/install remain NOT RUN. The successful shared integration CI proves its own cumulative source tree, not the old failed PR heads. A new Hermes tip must be independently verified remotely before a passing delivery claim. + +Parent integration-slot update: final pinned base is `c311f9bf7f5003af29fa8e7ebc2f2b5db20267f6`, including the subsequently integrated pnpm and Devin fixture corrections. Rebase the two owned commits from `392e182a` without runtime changes. Original 99-line test and mappings must remain byte-identical; source review is renewed for final head. Prior run `34693156321` at `524afd8d80` is superseded evidence only, never final-tip proof. Parent is holding the Hermes slot. Persisted phase C and blocked host goal stay unchanged. diff --git a/devlog/_plan/260912_cache_lane/050_verify.md b/devlog/_plan/260912_cache_lane/050_verify.md new file mode 100644 index 0000000000..c72c571585 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/050_verify.md @@ -0,0 +1,11 @@ +# Hosted verification and delivery + +Prerequisites: independent implementation PRs. Scratch .tmp/cache-handoff/050_handoff.md records actual worktree, branches/PR URLs/full head SHA, source dispositions/credits, cycle receipts, remaining acceptance and reviews. Capture gh pr view/checks and gh run view JSON at each final independent tip; ordinary manual children only for real correction dependencies. No native membership mutation. + +No product source changes planned here. If CI exposes a scoped defect, append a numbered repair plan and full PABCD cycle before implementation, then verify new exact head. Hosted workflow definition determines jobs actually executed; skipped/cancelled runs are never passes. No automatic workflow cancellation or protection edit. Local tests/build/typecheck/install remain NOT RUN. Only source/diff checks may be wrapped in cxc receipt and must retain their true label. + +C: final head matches hosted run headSha; successful required jobs and skipped jobs recorded individually. D: finish handoff with source review gaps and Hermes field residual, no merge/issue closure. Parent decides integration. + +Durable delivery: 060_handoff.md is the tracked safe index. Before any scratch cleanup, export exact final PR/head/CI and source-review evidence into the #4347 PR body, preserving parent annotations, and read it back. Terminal CI updates change that body only, so the verified source head remains stable. Private paths and raw security analysis never enter the public index or PR. Scratch is not the sole retained handoff. + +Execution amendment: final evidence updates are committed on `codex/260912-60plus-cache-evidence` in the same managed worktree. This branch contains the tracked delivery artifact, with no new product logic. Product verification remains bound to the four delivered source heads, and the evidence branch's documentation checks are reported separately. Source-delta gating is not bypassed with a manufactured code change. diff --git a/devlog/_plan/260912_cache_lane/060_handoff.md b/devlog/_plan/260912_cache_lane/060_handoff.md new file mode 100644 index 0000000000..d6522bec9f --- /dev/null +++ b/devlog/_plan/260912_cache_lane/060_handoff.md @@ -0,0 +1,40 @@ +# Cache lane handoff index + +The cache lane contains independent dev PRs, with no native stack or artificial dependency chain. Main implementation used managed worktree slot `7e43`; the coordination task owns integration and original-PR closures. This index preserves source dispositions. Exact final-head CI and source-review snapshots are exported to the [prefix PR description](https://github.com/lidge-jun/opencodex/pull/4347) and read back at delivery; private execution paths and raw scratch evidence are excluded. + +| Source | Delivered PR / branch | Outcome and remaining evidence | +| --- | --- | --- | +| #4118 | [#4338](https://github.com/lidge-jun/opencodex/pull/4338), `codex/260912-60plus-cache-claim` | Claim deferral adopted. Parent integrated as `75d3e5c78f9ac7fc7125ee27294a962456bf32bb` ; the source PR is closed/unmerged, and the closer is not established. Exact carried head `d27db6dd56c481572728bc99043e2c528f11e1bc`: [hosted CI 34673563105](https://github.com/lidge-jun/opencodex/actions/runs/34673563105) SUCCESS, 19 jobs successful / 2 skipped. | +| #4050 | [#4340](https://github.com/lidge-jun/opencodex/pull/4340), `codex/260912-60plus-cache-affinity` | Go affinity adopted and reverse Go-to-ChatGPT native identity repaired. Parent integrated as `81f0c78d7a2bf56e759511e89f450c7d49e0a42e`. Final source head `d354924f0af38a48f5768fca0cd09c5145bdb4bd`, fresh repair/conflict audit PASS, blocker 0. [Hosted CI 34674962749](https://github.com/lidge-jun/opencodex/actions/runs/34674962749) pending at this checkpoint. | +| #4052 | [#4347](https://github.com/lidge-jun/opencodex/pull/4347), `codex/260912-60plus-cache-prefix` | Reimplemented behind literal-true operator configuration; ordinary callers preserve roles and keys. Fresh public P1/P2 findings superseded the first helper approval. The repaired parser uses decreasing line/fence cursors and exact single-space grammar; fresh algorithm audit PASS at `e1d262acee27d55388cccdb96430669e944fbd90`. Final repaired source head `4f6cd1ad3f0215f9cbfd5be53a55b5b6f7cd90f0` is integrated by the parent as `489af939bc68b665bfb2c3226a34267098838ab8`. The parent resolved both public findings after reading the repair. [Hosted CI 34675829597](https://github.com/lidge-jun/opencodex/actions/runs/34675829597) remains pending at this checkpoint. Final terminal evidence is exported in the PR body. | +| #3433 | [#4365](https://github.com/lidge-jun/opencodex/pull/4365), `codex/260912-60plus-cache-hermes` | Transport contract tests only, no runtime synthesis. Exact source head `b254efc8385ce2a9dc34b9a5ac7d2a449605d75d`, independent source audit PASS, blocker 0. [Hosted CI 34674763850](https://github.com/lidge-jun/opencodex/actions/runs/34674763850) pending at this checkpoint. Live issue acceptance below remains open. | + +## Hermes acceptance still open + +The [latest controlled field-presence observation](https://github.com/lidge-jun/opencodex/issues/3433#issuecomment-5551855276) omitted measured identity fields. The [maintainer follow-up](https://github.com/lidge-jun/opencodex/issues/3433#issuecomment-5556427205) requests a real client-assigned identity stable within a conversation and distinct for a fresh conversation, then comparison at the outbound boundary. Synthetic A/A/B fixtures verify the OCX Direct transport contract when executed; they do not prove actual Hermes emission, Pool cohort stability or improved cache hits. #3433 is not solved by the Claude PRs. #3719 thinking replay is separate. + +## Review and attribution + +Carry commits preserve luvs01 credit for #4118; David Wang plus original GPT-6 Astra/Claude Fable trailers for #4050; Warexpor and Cursor Agent for #4052. No code from other authors is relabeled as sole authorship. + +The first affinity source review missed reverse native routing; the repair was independently reviewed before parent integration. The first prefix helper review missed quadratic scanning and broad inner whitespace; it was superseded by a fresh algorithm review after correction. A green original-PR run or plan approval never substitutes for final implementation review. Outstanding GitHub objections and final-tip status are re-read before the coordinating maintainer's decision. + +## Execution record + +Each work phase used its own persisted P-A-B-C-D cycle: roadmap, claim, affinity, prefix, native-affinity repair, prefix adaptation, affinity adaptation, Hermes contract, serial affinity slot, linear prefix repair, final prefix slot, then hosted-evidence verification. Source and conflict reviews used inherited-model read-only subagents; no native architect role was claimed. + +Local tests of every size, build, typecheck and install were **NOT RUN** by explicit instruction. Local receipts contain text/applicability checks only. Product execution belongs to the linked GitHub-hosted runs; skipped Windows full shards and macOS control are not passing executions. Pushes used `--no-verify`, with exact old-head leases for owned branch rebases. This task did not merge, release, publish packages, restart services or change user runtime settings. + +## Final evidence-cycle record + +The final evidence B phase produces this tracked update as its documentation artifact. An earlier C transition was rejected by SOURCE-DELTA-01 because only scratch metadata and the PR body had changed; that rejected transition did not advance the FSM. The evidence branch now records the actual delivered source heads, parent integrations and unproven closure attribution. It changes no runtime code. Hosted terminal results must still be read before this cycle closes; an evidence document is not a product-test pass. + +## Resumed terminal-CI reconciliation + +The original final-tip results are now terminal: affinity run `34674962749` FAILED (native restore/injection fixtures); prefix run `34675829597` FAILED (the same restore family plus Cline registry/CLI/localization/icon/test-layout expectations); Hermes run `34674763850` FAILED (Combo active-reactivation GUI fixture). Their passing cache assertions do not make those runs green. Claim run `34673563105` remains SUCCESS and was not rerun. + +Shared fixture repair #4390 is merged at `20861aebf56c6f8ec2b0d8d04d1d0b54441650bb`, containing the delivered affinity and repaired prefix heads by verified Git ancestry. Its [hosted CI 34688482827](https://github.com/lidge-jun/opencodex/actions/runs/34688482827) succeeded: 19 jobs successful, 2 skipped. This is new cumulative integration evidence, not a relabeling of the old failed results. Cache runtime sources were not rewritten to fix another lane's failure. + +The remaining open Hermes PR #4365 is refreshed onto `392e182a004d61b38c7cf652642e63b9a11d9a65` without conflicts, preserving its test-only delta. Its new final head and hosted outcome are exported to that PR description and the scratch handoff after source audit and publication. Merged evidence PR #4377 is left intact. The host goal is blocked; persisted phase C is preserved and neither is claimed completed. Local product suites/build/typecheck/install remain NOT RUN. + +Final Hermes slot: the coordinator pinned `c311f9bf7f5003af29fa8e7ebc2f2b5db20267f6` after the pnpm/Devin fixture corrections. The test-only PR is rebased onto that fixed base without conflicts or runtime edits. Its exact final-head source review and hosted run replace the earlier `524afd8d80` candidate evidence in the PR description. The previous failed runs remain historical failures, and actual Hermes client-field acceptance remains open. diff --git a/devlog/_plan/260912_campaign_ci_fixtures/000_plan.md b/devlog/_plan/260912_campaign_ci_fixtures/000_plan.md new file mode 100644 index 0000000000..c3d4ba1884 --- /dev/null +++ b/devlog/_plan/260912_campaign_ci_fixtures/000_plan.md @@ -0,0 +1,12 @@ +# Campaign integration fixture repairs + +Hosted CI exposed incomplete Cline registration follow-through and restore fixtures that no longer exercise the documented atomic refusal contract. This change repairs those contracts without changing credential or restore behavior. + +- Trigger/evidence: Cross-platform CI run 34676570087, head 954b1da7804110e440acc9d244e70f32f2aa9aae. Linux, macOS and dashboard gate failures are retained as the failing baseline evidence; no local reproduction is claimed. +- Cline: correct the lightweight CLI count to fifteen, preserve exact registry equality, recognize only the Cline product-name keys as intentional English, document reuse of its existing mark, and align client/writer test seeds with their committed domain. +- Restore: assert unsuccessful all-skipped results and unchanged artifacts after refusal; retain exact pre-operation config/profile/journal snapshots when damaged defaults prevent restoration. Canonicalize temporary homes and target the production profile path so macOS fault injection and manifest lookup actually reach the intended boundary. Assert a matching injected read and default manifest visibility. +- Non-goals: no runtime restore/auth changes, test skips, weaker error/preservation assertions, new dependencies, local tests/build/typecheck/install, release or deployment. +- Verification: git diff --check for text; independent source review of Cline and restore slices; final-head hosted CI must execute the unchanged failure paths and pass before completion. Local product execution remains NOT RUN. +- Stop: the original named failures pass at the published final head and no new blocking finding remains. An unrelated CI failure is investigated separately, not waived here. + +The shared baseline also includes the independently reviewed Combo reactivation correction from #4385. It explicitly runs the actual activation callback and preserves the cached quota evidence, dirty draft and Save-state assertions. This known scheduling defect must not remain in the baseline supplied to other campaign PRs. The #4385 source commit is preserved by merge; close that duplicate delivery only after this combined baseline lands. diff --git a/devlog/_plan/260912_catalog_lane_readiness/000_plan.md b/devlog/_plan/260912_catalog_lane_readiness/000_plan.md new file mode 100644 index 0000000000..f961b79e73 --- /dev/null +++ b/devlog/_plan/260912_catalog_lane_readiness/000_plan.md @@ -0,0 +1,11 @@ +# Catalog chain readiness + +Review the existing catalog chain without duplicating its implementation. The lane produces a precise integration handoff; an additional product PR exists only if a concrete defect or necessary regression gap remains. + +Loop: satisfy-spec, triggered by the catalog lane delegation. Class C3 review; security changes would promote their slice to C4. Goal: readiness for #4325 -> #4328 -> #4331. Non-goals: merging, original branch writes, issue closure, release, services and configuration. Local suites of every size are NOT RUN, including wrappers; large local install/build/typecheck are also excluded. Only task-owned files and scoped commits/push --no-verify/PR creation are authorized. Existing GitHub credentials only; no user token/time/agent-count bound. + +Verifier: live gh PR/review/run JSON and Git ancestry/source inspection observe exact catalog tips; product suites execute only on hosted CI. Text checks observe these documents, never establish product test success. Stop: durable exact-head evidence/disposition plus honest remaining acceptance. Outcomes: DONE for completed readiness scope, NOOP for existing sufficient implementation, NEEDS_HUMAN for unresolved integration decisions, BLOCKED only for demonstrated unavailable prerequisites. Memory artifact: this unit and ignored .tmp/catalog-review/HANDOFF.md. Escalation: original-task write collision or necessary authority beyond scope goes to parent; two failed distinct reviewer calls are reclaimed with independent-review gap recorded. + +Dependency order: roadmap (010), scoped review/coverage (020), final hosted CI and GUI evidence (030). No native stacks. Preserve public author commits. Source of truth: structure/gui-and-management-api.md, changed only if a product contract changes. No new fields, enums, enforcement or interfaces planned. Native architect role is not exposed; supported inherited-model design review and reflection provide consultation per explicit user direction, without claiming native role selection. User explicitly instructs independent work to continue when tools are unavailable. + +Parent scope correction: existing owner is merging its own chain; this lane reconciles read-only, with own follow-up only for a concrete newly verified defect. Supported inherited-model independent design review replaces the unavailable native-role transport per explicit user direction, without claiming native architect selection. diff --git a/devlog/_plan/260912_catalog_lane_readiness/010_roadmap.md b/devlog/_plan/260912_catalog_lane_readiness/010_roadmap.md new file mode 100644 index 0000000000..51958d8370 --- /dev/null +++ b/devlog/_plan/260912_catalog_lane_readiness/010_roadmap.md @@ -0,0 +1,2 @@ +# Roadmap documentation cycle +NEW 000_plan.md and decade documents 010/020/030 in this unit; before: absent; after: outcome, authority, exact read targets and acceptance. NEW .tmp/catalog-review/HANDOFF.md: identity, current PR states and evidence pointers. No product delta. Check: read all four documents and git diff --check; confirm every phase has real outputs and user restrictions. D locks this roadmap and directs the next cycle to review exact tip source. diff --git a/devlog/_plan/260912_catalog_lane_readiness/011_design_reflection.md b/devlog/_plan/260912_catalog_lane_readiness/011_design_reflection.md new file mode 100644 index 0000000000..51fe050002 --- /dev/null +++ b/devlog/_plan/260912_catalog_lane_readiness/011_design_reflection.md @@ -0,0 +1,2 @@ +# Design review dispositions +Inherited-model read-only reviewer Hilbert supplied CAT-DEC-01..06. Main accepts evidence ownership (01), exact-head provenance (02), no-suite restrictions (05). Amended collision boundary (03) to require a concrete new defect plus owner/head refresh and parent coordination. Amended consultation (04) to distinguish supported independent design reflection from unavailable native architect role. Amended integration authority (06) to preserve separately authorized original-owner merges and parent-only follow-up integration. Reflection recheck requested after amendments. diff --git a/devlog/_plan/260912_catalog_lane_readiness/020_review.md b/devlog/_plan/260912_catalog_lane_readiness/020_review.md new file mode 100644 index 0000000000..38c3be5e77 --- /dev/null +++ b/devlog/_plan/260912_catalog_lane_readiness/020_review.md @@ -0,0 +1,4 @@ +# Scoped review and coverage cycle +Depends on roadmap. READ exact #4331 head gui/src/components/AddProviderModal.tsx, provider-catalog/ProviderCatalog.tsx, CatalogAccountRow.tsx, ProviderNoteModal.tsx, provider-presets.ts, gui/tests/provider-catalog-search.test.tsx and tests/gui/provider-workspace-data.test.ts. READ #4328 diff and live review threads. NEW .tmp/catalog-review/020_review.md: file:line findings, dispositions, remaining acceptance and attribution. Before: no independent lane review; after: a checked result against the pinned SHA. + +Potential MODIFY gui/tests/provider-catalog-search.test.tsx only for a concrete defect absent from the existing owner work, after refreshing owner/head evidence and parent coordination. A coverage gap alone is not authority to duplicate the owner delivery. Activate Escape with a nonempty query then empty query; expect query clear before modal close. Activate ArrowDown from search with a disabled first account control and later enabled controls; expect the first enabled result to receive focus. Also inspect no-actionable-result behavior and note-popup -> query -> dialog Escape ordering. If required, append a precise repair work-phase at P and use a separate task-owned child branch of the refreshed final tip; never edit the original branch. Reuse existing tests and source docs, no speculative abstraction. No test execution locally. Check: independent source review, diff --check and GitHub-hosted CI for any new code. If no patch is justified, record NOOP explicitly. diff --git a/devlog/_plan/260912_catalog_lane_readiness/022_keyboard_repair.md b/devlog/_plan/260912_catalog_lane_readiness/022_keyboard_repair.md new file mode 100644 index 0000000000..ff3a9ebcf1 --- /dev/null +++ b/devlog/_plan/260912_catalog_lane_readiness/022_keyboard_repair.md @@ -0,0 +1,13 @@ +# Keyboard repair plan + +Previous D locked the docs-only roadmap; source reconciliation now establishes one new defect. The original owner merged #4331 at 9a37813593514c2d90b1ebac129c4541fd2a9af4; its reviewed source tip a154645d76e98af199fd79aff8c8d393afaf30ab passed hosted CI 34672274572. This task rebased only its own unpushed roadmap commit onto that dev tip. Parent was notified of the new distinct defect; original task scope readback shows only tab overflow, description disclosure and popup focus repairs. + +Class C1 behavioral patch plus existing-test coverage; no new abstraction, type, field, token, endpoint, UI copy or dependency. Do-nothing would retain a broken keyboard path; configuration cannot change the selector; reuse the existing handler and test mount. Product diff is confined to the existing selector. + +MODIFY gui/src/components/provider-catalog/ProviderCatalog.tsx:207: before querySelector("button, a[href]"); after querySelector("button:not(:disabled), a[href]"). CSS :disabled also excludes a disabled fieldset descendant, while preserving actionable anchors. + +MODIFY gui/tests/provider-catalog-search.test.tsx: append behavior tests using current mount/type/search helpers. Busy openai Codex row (logged out, onAccountLogin supplied), query nvidia: disabled account button is first in DOM, ArrowDown must focus NVIDIA preset and prevent default. Same busy account with unmatched query and no other actionable row: focus stays on search and default remains untouched. Empty results: same no-op. A normal preset-only query checks normal first-result focus. All tests dispatch a bubbling/cancelable KeyboardEvent from the focused input inside act. No sleep helper or exported test-only production function. + +MODIFY structure/gui-and-management-api.md Add provider row: ArrowDown focuses first enabled result action; no available action leaves input focus unchanged. MODIFY docs-site/src/content/docs/guides/web-dashboard.md Add provider row with the same keyboard behavior, translated pages must not contradict (they currently say nothing about this shortcut). + +Verification: git diff --check for patch formatting ONLY, independent source audit, GitHub-hosted Cross-platform CI at the exact published head. No local test/build/typecheck/install. Read hosted preview artifact from that run if GUI evidence requires it; serve artifact in scratch without product build, no live proxy mutation. No original branch/PR mutation, merge or auto-merge. Existing author commits stay in ancestry. Follow-up ordinary PR targets dev because original chain is now merged. diff --git a/devlog/_plan/260912_catalog_lane_readiness/030_evidence.md b/devlog/_plan/260912_catalog_lane_readiness/030_evidence.md new file mode 100644 index 0000000000..763f2c5965 --- /dev/null +++ b/devlog/_plan/260912_catalog_lane_readiness/030_evidence.md @@ -0,0 +1,6 @@ +# Final evidence and handoff cycle +Depends on reviewed source/repair disposition. READ live gh pr view for #4325/#4328/#4331 and any follow-up, GraphQL reviewThreads, gh run view for exact head, workflow triggers and Git ancestry. READ screenshots carried by the source PR using local git blobs; observe them with image viewer, distinguish screenshot commit from final source head. NEW .tmp/catalog-review/030_evidence.md with CI run IDs/URLs, job outcomes, missing/skipped distinctions, GUI provenance and outstanding reviews. MODIFY .tmp/catalog-review/HANDOFF.md from preliminary to complete: worktree/branch, own phase/cycle evidence, all original PR dispositions, final chain/head SHAs, authors, remaining acceptance and local tests NOT RUN. No product change. Check: exact SHA equality between PR and CI plus fresh PR state; do not claim intermediate tips passed. This lane never merges or retargets. Reconcile the original-chain delivery by its separately authorized existing owner; parent controls additional follow-up integration. + +Inspect exact-tip hosted dashboard preview artifact if available; compare narrow-width tabs and popup focus against historical screenshots. If preview cannot be exercised within authorized no-build/no-install scope, leave final-tip dynamic GUI acceptance explicitly unmet, not inferred from old PNGs. + +Record observation timestamp, base SHA, workflow event/run attempt and merge commit. Head/base movement triggers reconciliation refresh. diff --git a/devlog/_plan/260912_cline_client/000_plan.md b/devlog/_plan/260912_cline_client/000_plan.md new file mode 100644 index 0000000000..db1e08735d --- /dev/null +++ b/devlog/_plan/260912_cline_client/000_plan.md @@ -0,0 +1,24 @@ +# Cline integration roadmap + +Cline users need a reversible connection and routed model list. This unit connects the current Cline CLI storage contract through the existing integration operations and dashboard. Cline stores connection settings and the model catalog separately, so one operation must snapshot and restore both files. + +Loop: satisfy-spec, HOTL; trigger: #4214 and delegated lane=cline. Scope: config exporters, integration writer/reader/journal projection, existing CLI/catalog/dashboard registries, translated copy, source fixtures. Non-goals: legacy extension storage migration, Cline process control, user configuration changes during development, merges/releases, new dependencies. All local product suites, builds/typecheck/install are NOT RUN by instruction; regression execution belongs to final-tip GitHub hosted CI. Text checks and independent source audits are allowed. No user token/time/agent caps; existing tool/account scope only. + +Outcome: DONE requires source-backed contracts, actual implementation, separate PABCD cycles, independent reviews, final cumulative head CI, PR and durable handoff. Unavailable tools or genuine external blockers are recorded without claiming completion. Main implements; inherited read-only subagents review. Native architect role selection is unavailable; the explicit parent instruction authorizes supported spawn for actual design and reflection reviews. No role installation or settings changes. + +Sources: [001_contract.md](001_contract.md). Repository owners: src/clients/config-export.ts, src/integrations/{registry,state,writer,journal,store,config-io}. Existing config builder, exact fragment ownership, atomic file writes and journal are reused. No-op/manual-only configuration cannot meet sync/undo; a separate standalone configuration engine is unnecessary. + +| Cycle | Deliverable | Dependency | Design | +| --- | --- | --- | --- | +| roadmap | Audited docs only | none | This roadmap and all decade docs | +| contract | Pure Cline documents, paths, paired journal adapter and regression fixtures | roadmap | 010_contract.md | +| surfaces | Catalog refresh, CLI help and existing dashboard exposure | contract | 020_surfaces.md | +| verification | Independent audit, fixes, PR publication and final-tip hosted CI | surfaces | 030_verification.md | + +PR decision: one cohesive Cline PR unless the audited paired-file foundation is independently useful and large enough to split. Ordinary manual chain only if split; no native stacks. Intermediate commits may be pushed without waiting on CI. Parent owns merge. + +Verification: git diff --check (text only), source review, final-head Cross-platform CI (tests/typecheck/GUI build/lint). Source tests use temporary home/store; no real Cline data. Fixture cases: missing/partial install; malformed, non-regular or foreign edited files; wrong version; foreign provider preserved; owned model removal/port change; second-file failure; bookkeeping failure; interrupted transaction and drift; exact two-file restore including absence. A Cline restart is required after externally written catalogs. Live client process behavior is source-backed, not a claimed local canary. + +Public source docs update structure/clients/integrations.md plus CLI/UI owning docs for changed surfaces; public user workflow resides in docs-site. Unreleased security analysis stays under .tmp/cline. Durable handoff: .tmp/cline/handoff.md. + +Design reflection: ALIGNED D09/D10. R02 accepted: no unattended Cline refresh; explicit sync only with stopped-client precondition. R03 accepted: journal presence never blesses a mixed pair or inconsistent ownership. Verify both intended bytes and final record before clearing a committed marker. diff --git a/devlog/_plan/260912_cline_client/001_contract.md b/devlog/_plan/260912_cline_client/001_contract.md new file mode 100644 index 0000000000..bbd9cd3c0d --- /dev/null +++ b/devlog/_plan/260912_cline_client/001_contract.md @@ -0,0 +1,14 @@ +# Cline source contract + +Upstream revision: cline/cline cfe9cadab99617d5013bf89f07b079d105057791, read 2026-09-12. Scope is the current CLI/shared SDK provider store. Legacy VS Code globalState/secrets storage is not the same contract and is not detected as compatible. + +- sdk/packages/shared/src/storage/paths.ts:152-185,424-430 resolves CLINE_PROVIDER_SETTINGS_PATH; otherwise CLINE_DATA_DIR/settings/providers.json; otherwise CLINE_DIR/data/settings/providers.json; otherwise ~/.cline/data/settings/providers.json. Relative overrides are rejected by OpenCodex because its cwd is not Cline's cwd. +- sdk/packages/core/src/types/provider-settings.ts:33-68 defines version=1, optional lastUsedProvider, modes={}, providers[id]={settings,updatedAt,tokenSource}. settings.provider is the provider ID; protocol openai-responses, client openai, baseUrl, apiKey and model are accepted. +- sdk/packages/core/src/services/llms/provider-settings.ts:155-199,224-315 maps protocol openai-responses to the OpenAI handler while retaining the custom provider ID and namespaced model. +- sdk/packages/core/src/services/providers/local-provider-registry.ts:48-121 defines sibling models.json: version=1, providers[id]={provider:{name,baseUrl,protocol,client,defaultModelId},models:{id:{name,contextWindow,modalities,supportsVision}}}. +- The same file:689-716 caches model-file loading per process. Restart Cline after external updates; do not claim a running picker is live-synchronized. + +CLINE-D09 accepted: settings.modelCatalog.url is not an OpenAI /v1/models endpoint. Its loader expects models.dev data, and picker paths do not forward that setting. A single providers.json does not meet full catalog acceptance. +CLINE-D10 accepted: write providers.json plus sibling models.json as one recoverable journal operation. Each rename is atomic; a filesystem has no atomic rename across both files. Stop Cline before apply/refresh/restore and restart after. Interrupted writes require durable recovery and foreign edits must refuse recovery. + +All paths above are pinned under https://github.com/cline/cline/blob/cfe9cadab99617d5013bf89f07b079d105057791/ . No upstream implementation is copied; schema-shaped fixtures use synthetic data. No original carry PR or author credit applies. #3833 is a Command Code reference, not a dependency. diff --git a/devlog/_plan/260912_cline_client/009_roadmap_result.md b/devlog/_plan/260912_cline_client/009_roadmap_result.md new file mode 100644 index 0000000000..d5fe20dcb0 --- /dev/null +++ b/devlog/_plan/260912_cline_client/009_roadmap_result.md @@ -0,0 +1,5 @@ +# Roadmap cycle result + +Docs-only roadmap locked after inherited source/design review and independent A audit. Accepted D09/D10, excluded unattended refresh, strengthened journaled recovery recognition, and added target collision refusal. Existing CLI syntax is --client cline / restore --op; no positional alias is invented. + +Source audit confirms two files are needed. No product files changed in this cycle. git diff --check is the permitted document whitespace check; local product tests are NOT RUN. Next cycle executes 010_contract.md; downstream implementation and hosted proof remain open. The one-file modelCatalog URL hypothesis did not survive upstream inspection. diff --git a/devlog/_plan/260912_cline_client/010_contract.md b/devlog/_plan/260912_cline_client/010_contract.md new file mode 100644 index 0000000000..d822cd1881 --- /dev/null +++ b/devlog/_plan/260912_cline_client/010_contract.md @@ -0,0 +1,26 @@ +# Contract and paired-file operations + +Depends on roadmap. C4 care for reversible config mutation; no local execution. Use existing journal and ownership, not a second ownership database. + +NEW src/clients/config-export/cline.ts: typed pure buildClineClientConfig, summarizeCline and buildClineContribution. Build {settings:{version:1,modes:{},providers:{opencodex:{settings:{provider:'opencodex',protocol:'openai-responses',client:'openai',apiKey:LOOPBACK_API_KEY_PLACEHOLDER,baseUrl},updatedAt:stable schema timestamp,tokenSource:'manual'}}},catalog:{version:1,providers:{opencodex:{provider:{name:'OpenCodex',baseUrl,protocol:'openai-responses',client:'openai'},models}}}}. Namespaced routed IDs are model-map keys. Preserve authoritative context and modality metadata; omit invented costs/output limits. Do not manage lastUsedProvider: users select OpenCodex explicitly (cline --provider opencodex --model provider/model), preserving their default. Empty catalog exports no invented default/model. Managed paths are settings.providers.opencodex and catalog.providers.opencodex; schema envelope defaults are initialized only when absent and retained on disable. + +MODIFY src/clients/config-export/contracts.ts: append cline to ExportClientId. Creation: EXPORT_CLIENTS; serialization: builder/JSON download; deserialization: isExportClientId and journal isIntegrationClientId; consumers: integration registries, CLI export, management route and browser lists in 020. +MODIFY src/clients/config-export.ts: import/register cline builder and append EXPORT_CLIENTS.cline; export path helpers using existing absoluteClientPath. destination is providers.json; filename cline-config-bundle.json; format json; hint explains two document members and restart, no standalone direct-file claim; loopbackOnly true. +MODIFY src/integrations/registry.ts: append cline paths, detect settings directory, sibling lock .lock. Resolve explicit provider-file override first and derive catalog from dirname; no user discovery scanning. + +NEW src/integrations/cline-document.ts: private codec between two raw file strings (snapshot bundle) and logical settings/catalog objects (managed fragments). Reads preserve exact raw bytes and file absence. Parsing invokes existing safe strict JSON parser for each member; reject invalid/non-object schema and unsupported version. Rendering initializes missing version=1/modes={} and serializes both native files, then canonicalizes the raw-string snapshot envelope. Never serialize journal-only metadata into Cline files. +NEW src/integrations/cline-io.ts: adapt the existing IntegrationIO only for cline and the resolved primary path. Secondary path is dirname(primary)/models.json; no caller-provided arbitrary secondary path. Reject primary path named models.json (case-insensitive), and refuse symlink/non-regular members so two logical members cannot alias one target. Read/stat inspect both members; write uses existing atomic writer for each, compensates on any failure. Snapshot remains the existing journal's one bundle snapshot. Persist a private pending transaction before first rename, including original/result bundle and prior ownership; clear only after journal append. Recover an interrupted operation only on an explicit mutation, only if every current file is exactly original or intended result; if its opId is already journaled, recognize completion only when BOTH intended bytes and final ownership match; otherwise retain unsafe pending state. Foreign edits or invalid pending data refuse and retain recovery evidence. Read-only status never performs recovery. Report partial compensation failure truthfully. +MODIFY src/integrations/config-io.ts: optional begin/finish transaction hooks on IntegrationIO; existing clients unaffected. +MODIFY src/integrations/writer.ts: resolve Cline adapter after path resolution, invoke begin hook before commit; use client codec at parse/render boundaries; finish after journal or successful compensation; preserve existing state/refusal logic. Restore reads the same bundle and journal. Coordinated mutation locks encompass recovery and commit. +MODIFY src/integrations/state.ts: same adapter/codec for classification; pending state unsafe; no mutation on read. Journal API's matchesOperationResult projection reads through the adapter so restore eligibility describes both files. +MODIFY structure/clients/integrations.md: state two-file ownership and transaction/restart/rollback contract. + +NEW tests/clients/cline-client.test.ts: source-shaped generation, env precedence, no secrets, model removal/metadata, initialized install and missing install. NEW tests/clients/cline-writer.test.ts: temporary home and store with real writer, both-file exact restore, absent member restore, invalid JSON/schema, occupied provider refusal/explicit overwrite, foreign edits, second-file and journal failures, crash recovery and foreign-edit refusal. Register both paths in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json. Extend existing exact client-list assertions without deleting checks. + +Activation evidence is final-tip hosted tests. Locally only diff/text audit; code unverified until hosted result. Pending marker is an early recovery mechanism, not cross-process exclusion against Cline: Cline does not share OpenCodex's lock. User-visible contract requires it stopped; filesystem rename cannot prevent a non-cooperating writer. No claim of simultaneous two-file visibility. + +A synthesis: accepted target collision guard and regression; post-journal marker cleanup is best-effort and must not cause compensation. Normal Cline settings saves update updatedAt/model, so declare only settings.providers.opencodex.updatedAt and settings.providers.opencodex.settings.model as refreshable; preserve selected model while still in desired catalog, remove it if no longer routed. All connection/auth metadata remains protected. + +Contract P resumes previous D: "Docs-only roadmap complete; next contract cycle" (009). No product source changed since source inspection. Implementation uses existing JSON format plus client-specific parse/render dispatch, avoiding a new public ConfigFormat enum. Export is a human-readable {settings,catalog} document bundle; internal journal envelope stores each raw file string for exact restore. This distinction is explicitly documented and tested. Path helper implementation additionally rejects case-insensitive models.json collision. No product command is executed locally. + +Implementation delta: cline builder/paths, private raw pair codec, paired IO and pending journal hooks are implemented; normal model/timestamp writes use the existing narrow refreshable-path mechanism. Existing lifecycle tests now read Cline's logical pair through the production adapter instead of treating its primary file as the whole document. Source checks only so far; hosted suite remains in verification cycle. New tests cover file absence, unsafe version/nonregular member, explicit conflict overwrite, model retirement, foreign edits, write/bookkeeping failure and pending recovery. diff --git a/devlog/_plan/260912_cline_client/019_contract_result.md b/devlog/_plan/260912_cline_client/019_contract_result.md new file mode 100644 index 0000000000..77d93bddca --- /dev/null +++ b/devlog/_plan/260912_cline_client/019_contract_result.md @@ -0,0 +1,5 @@ +# Contract implementation checkpoint + +The Cline builder, path resolver, raw-byte pair projection, pending recovery and existing writer/journal integration are implemented. Regression source covers normal lifecycle, native schema refusal, two-file compensation and recoverable interruption. Independent implementation review is in flight and is a required input to the final verification cycle. + +Local product tests, typecheck, builds and installation: NOT RUN by explicit user instruction. This checkpoint asserts code/source completion only; runtime correctness remains unverified until final cumulative hosted CI. git diff --check is the permitted text check. Next cycle wires existing CLI/dashboard surfaces and explicit catalog sync. No PR or merge yet. diff --git a/devlog/_plan/260912_cline_client/020_surfaces.md b/devlog/_plan/260912_cline_client/020_surfaces.md new file mode 100644 index 0000000000..6114503acf --- /dev/null +++ b/devlog/_plan/260912_cline_client/020_surfaces.md @@ -0,0 +1,14 @@ +# Existing CLI, catalog and dashboard surfaces + +Depends on contract; re-read source after 010 before B. Extend existing entry maps, no new endpoint/component architecture. + +Keep Cline out of unattended catalog-refresh default ids because the client must be stopped. MODIFY explicit sync lists src/cli/dispatch.ts and src/server/management/config-routes.ts: append cline. Keep lazy owned-only refresh and foreign-edit refusal; regression in tests/clients/cline-writer.test.ts verifies unowned untouched, owned refresh changes both files, model removal and endpoint changes. +MODIFY src/cli/registry.ts export usage/summary: append cline/Cline. Existing integration verbs remain --client cline and restore --op ID; documentation must not invent positional arguments. Update focused CLI export/list expectations and existing sync source fixtures. + +MODIFY gui/src/pages/integrations/integration-api.ts client tuple, integration-tabs.ts TABS/FILE_CLIENTS, gui/src/app-routing.ts hash list, overview-clients.ts label map, FileIntegrationPage.tsx semantics/label maps, gui/src/components/apikeys-workspace/client-config-clients.ts CLIENTS/labels/marks, gui/src/components/integration-marks.ts exhaustive map: append cline. Reuse existing page and consequence/rollback dialogs. Reuse the existing gui/public/provider-icons/cline-color.svg already used by provider-icons.ts; no new brand asset. +MODIFY all gui/src/i18n locale modules: append integrations.tab.cline, integrations.semantics.cline, api.clientConfig.clientCline. Copy states: current Cline CLI provider store; both files; stop before mutations, restart after; Undo restores both originals; default provider remains user-controlled. Parent handoff records these exact shared-file touches. +MODIFY docs-site/src/content/docs/guides/integrations.md: documented installation contract, env precedence, --client verbs, conflict opt-in, --op restore/drift, two-file export format and running-client limitation. Update structure/runtime.md and structure GUI/CLI ownership docs where applicable with factual links to canonical integration contract. + +Verification: existing GUI client-list/route/i18n assertions extended for cline. No local GUI tests/build. Final hosted GUI build/lint/tests; obtain hosted screenshot artifact where available and inspect it. If unavailable report missing visual evidence rather than fabricate screenshot. No real user server configuration is used for capture. + +Surfaces P resumes 019: "next cycle wires existing CLI/dashboard surfaces and explicit catalog sync." Source lists still end in omo; append Cline only. Existing cline-color.svg is reused. Public guide path verified as guides/integrations.md. Independent core audit corrections remain mandatory in the final verification cycle; these list/copy changes do not depend on its implementation details. diff --git a/devlog/_plan/260912_cline_client/029_surfaces_result.md b/devlog/_plan/260912_cline_client/029_surfaces_result.md new file mode 100644 index 0000000000..eecf696e87 --- /dev/null +++ b/devlog/_plan/260912_cline_client/029_surfaces_result.md @@ -0,0 +1,5 @@ +# Surface wiring checkpoint + +Cline CLI appears in existing integration, export, route and mark registries. Nine dashboard locales explain the paired-file stop/restart and Undo contract. English and existing translated integration guides identify the current CLI schema. Explicit sync includes previously owned Cline files; unattended catalog refresh excludes Cline. + +The existing committed Cline color mark is reused. No layout or new UI component was introduced. Local GUI tests/build/typecheck and product suites are NOT RUN. git diff --check is text-only evidence. Runtime and rendered verification remain on the final cumulative hosted tip; core recovery review corrections remain mandatory in the next cycle. Next direction: inspect the two concrete recovery findings, repair with regression source, then publish and track CI. diff --git a/devlog/_plan/260912_cline_client/030_verification.md b/devlog/_plan/260912_cline_client/030_verification.md new file mode 100644 index 0000000000..30abc74225 --- /dev/null +++ b/devlog/_plan/260912_cline_client/030_verification.md @@ -0,0 +1,13 @@ +# Independent audit and final hosted tip + +Depends on surfaces. Audit all acceptance rows against final source and tests; use a fresh inherited read-only subagent (no local tests/build/install, no user data or writes). Fold correct findings into code and docs in this cycle; add further cycles if a new implementation unit is needed. Security analysis stays in .tmp/cline. + +MODIFY only files implicated by concrete findings. git diff --check is whitespace evidence, never test evidence. Commit scoped source and docs; git push --no-verify origin HEAD. Create ordinary PR with base dev (or actual parent branch if audited split), filling Summary/Verification/Checklist and linking #4214 without claiming unresolved acceptance. No original PR exists; no carried author identity invented. + +Read current head/base/reviews and CI workflow event/ref. Track final cumulative SHA through hosted Cross-platform CI; record run URL/ID, actual head and check results. Do not cancel runs or modify workflow/protection. Fix attributable final failures and repeat at the resulting head. No suite can be hidden in a receipt helper; receipt can capture permitted source checks or read-only GitHub CI verification, labeled accurately. + +MODIFY .tmp/cline/handoff.md immediately after each deliverable: worktree/branch, PABCD cycle/phase, PR URL/full head SHA/order, issue disposition, remaining acceptance, attribution, hosted run evidence, NOT RUN local checks and unresolved reviewer findings. Final completion requires all recorded criteria; CI pending is not success. Parent performs all merges and decides issue closure. + +Verification P resumes 029's next direction. Concrete private audit repair plan is .tmp/cline/repair-plan.md; final source proof must include strict recovery metadata and history reads. Two accepted independent findings are being corrected before publication. This cycle retains all original CI, source, rollback and handoff criteria. + +Source review also found the shared export dialog described every download as a single native file. The Cline branch now uses localized two-document merge instructions and download announcement, and suppresses the irrelevant missing-key hint. Other clients retain their existing copy. A rendered component regression in client-config-panel.test.tsx covers this conditional branch on hosted CI. This is a correctness fix to the existing export surface, not a new export mechanism. diff --git a/devlog/_plan/260912_cline_client/039_gui_evidence.md b/devlog/_plan/260912_cline_client/039_gui_evidence.md new file mode 100644 index 0000000000..a31987f7aa --- /dev/null +++ b/devlog/_plan/260912_cline_client/039_gui_evidence.md @@ -0,0 +1,25 @@ +# Cline component render evidence + +Source commit: 75a8ec8f78718f25343344506e8a5536ca0245a4. GUI tree: bd02a7729603ac669acf47988b065bd8d04dd617. + +The actual FileIntegrationPage and ClientConfigDialog components were rendered in Chrome at +1280 × 773 CSS pixels, DPR 2, with the repository stylesheet, LanguageProvider and committed +Cline mark. The surrounding header labels the view synthetic. Fetch was replaced with fixed +fictional status/history; no real Cline configuration or running OpenCodex API was accessed. + +The source-only preview entry was bundled with Bun in 20 ms using existing React 19.2.8, +without install, typecheck or product build scripts. This is manual component render evidence, +not a test-suite pass, live client canary, or full dashboard build. Local suites remain NOT RUN. + +Observed: Cline label/mark, applied status, localized two-file stop/restart explanation, primary +path and Undo history rendered without clipping. Export dialog shows the settings/catalog bundle. + +![Cline integration with synthetic state](evidence/cline-integration.png) + +The export dialog was scrolled to its instructions. Observed both destination file names, the +journaled integration recommendation and stop/restart explanation; the single-file merge hint +and missing-admission-key hint are absent for Cline. + +![Cline two-file export instructions](evidence/cline-export.png) + +Export capture refreshed after removing the irrelevant Set-the-key heading. GUI tree: a070b75cabe1974e59d407c595709d1ffb3c4a58. Same synthetic harness; entry bundling took 19 ms. No local test execution. diff --git a/devlog/_plan/260912_cline_client/evidence/cline-export.png b/devlog/_plan/260912_cline_client/evidence/cline-export.png new file mode 100644 index 0000000000..24ab32d0e9 Binary files /dev/null and b/devlog/_plan/260912_cline_client/evidence/cline-export.png differ diff --git a/devlog/_plan/260912_cline_client/evidence/cline-integration.png b/devlog/_plan/260912_cline_client/evidence/cline-integration.png new file mode 100644 index 0000000000..eba8e8252e Binary files /dev/null and b/devlog/_plan/260912_cline_client/evidence/cline-integration.png differ diff --git a/devlog/_plan/260912_codex_gpt54_retirement/000_plan.md b/devlog/_plan/260912_codex_gpt54_retirement/000_plan.md new file mode 100644 index 0000000000..2f9e01b486 --- /dev/null +++ b/devlog/_plan/260912_codex_gpt54_retirement/000_plan.md @@ -0,0 +1,108 @@ +# gpt-5.4 / gpt-5.4-mini retirement on the Codex login surface + +## Objective + +OpenAI retired `gpt-5.4` and `gpt-5.4-mini`. Remove them from the Codex (ChatGPT +OAuth) login surface of this proxy: the native catalog, everything that projects it +(`/v1/models`, the dashboard picker, the desktop projection, Claude discovery), and +every opencodex-owned default that still dispatches one of the two slugs. + +The replacement floor is `gpt-5.6-luna` — it is now the cheapest native model on the +ChatGPT login lane, so every helper/sidecar/warmup default lands there. The login +provider's own `defaultModel` moves there too: `gpt-5.6-sol` was considered because it +is priority 1 in the pinned snapshot and `gpt-5.4` held a general-purpose role, but the +owner chose luna so a default that nobody asked for stays the cheapest live model +(owner decision, 2026-09-12). + +## Constraints and scope boundary + +In scope: `src/codex/**`, `src/oauth/**`, `src/vision/**`, the Codex-login parts of +`src/server/**`, `src/cli/**`, `src/types/**`, `src/lib/shadow-call.ts`, `gui/`, +`docs/` (the maintainer-facing pages, not only `docs-site/`), `docs-site/` (all locales), +`structure/`, `scripts/release-notes.ts`, and `tests/`. + +Out of scope, deliberately: + +- Third-party vendor rosters that publish their own snapshots — `github-copilot` + (`src/providers/registry.ts`), `cursor` (`src/adapters/cursor/*`), `codebuddy`, + `opencode`, `command-code`, and `scripts/model-metadata.source.json`. This follows + the `deepseek-v4-pro` precedent (`e86ab5bd8d`): a first-party retirement notice does + not end a vendor's deployment, and deleting their row would strip a live route's + context window and effort ladder while the model keeps arriving from `/models`. +- Historical pricing in `src/usage/expected-prices.ts` and its tests. Past usage rows + still have to cost correctly after the model stops being routable. +- Slugs that only look related: `gpt-5.4-nano`, `gpt-5.4-pro`, `gpt-5.4-high`, + `openai/gpt-5.4-mini` (OpenRouter metadata). None are part of this retirement. + +No push, PR, merge, release, or deploy. Local commits only. + +## Evidence gathered at P + +Three read-only `xai/grok-4.6` verifier subagents swept the tree in parallel. Their +combined inventory: 146 files, 592 `gpt-5.4*` hits, of which the Codex-login-owned +set is the one this unit changes. + +Structural findings that shape the phase order: + +1. `NATIVE_OPENAI_MODELS` (`src/codex/catalog/native-models.ts:156`) is the single + membership list. `SUPPORTED_NATIVE_OPENAI_SLUGS`, `nativeModelRows`, + `nativeOpenAiSlugs`, `accountBoundNativeOpenAiSlugsBySelector`, + `filterSupportedNativeSlugs`, `model-routes.ts` `supportedNative`, and + `CANONICAL_NATIVE_CATALOG_CONTENT_POLICY.nativeBackfillSlugs` all derive from it. + Removing the two slugs there propagates to every projection without further edits. +2. Persisted BARE rows clean themselves up. Once the slugs leave the list, + `isUnsupportedOpenAiNativeSlug` returns true for `gpt-5.4` and `gpt-5.4-mini` and the + canonical merge runs `unsupportedNativeEntries: "drop"` (`sync.ts:847`, filter at + `1068-1070`), so a user's on-disk catalog loses them on the next sync. + Account-namespaced rows are a different path: that predicate returns false for any slug + containing `/` (`metadata.ts:113`), so `team/gpt-5.4` is not dropped by it. Those rows + stop being *generated* because `accountBoundNativeOpenAiSlugsBySelector` and + `availableAccountNativeSlugs` both seed from `NATIVE_OPENAI_MODELS`. wp2 must prove what + happens to an already-persisted `selector/gpt-5.4` row with a focused test rather than + assuming it disappears. +3. `UPSTREAM_NATIVE_ENTRIES` never contained either slug — `upstreamNativeEntryForSlug` + admits only `gpt-5.6-*` and self-described natives — so deleting the two pinned rows + in `src/codex/data/upstream-models.json` changes capability fallbacks only, not the + sync-replacement authority. `gpt-5.2` and `codex-auto-review` stay pinned, which is + why `SELF_DESCRIBED_NATIVE_OPENAI_MODELS` must remain an explicit allowlist. +4. The defaults are independent of the catalog list and fail separately. Warmup + (`src/codex/warmup.ts:30`), the token guardian (`src/oauth/token-guardian.ts:55`), + and the vision describer (`src/vision/plan.ts:14`) all still dispatch + `gpt-5.4-mini` and would 404 after retirement regardless of catalog membership. +5. The startup sidecar migration (`src/server/index.ts:686`) rewrites a *stored* + `gpt-5.4-mini` to `gpt-5.6-luna`, but an unset vision model never equals that + string, so it falls through to `DEFAULT_VISION_MODEL` and still calls the retired + model. That gap is the reason wp3 exists as its own cycle. +6. `DEFAULT_SHADOW_SOURCE_MODELS` is already `["gpt-5.6-luna"]`. `gpt-5.4-mini` there + is an *inbound* prefix for Codex 0.144.x helper calls, not a dispatch target, so it + stays documented as a restore option and is not treated as a retired default. + +## Work-phase map + +| Phase | Unit doc | Outcome | Depends on | +|---|---|---|---| +| wp1 | this document | Roadmap locked, scope boundary recorded | — | +| wp2 | `010_catalog_removal.md` | The two slugs leave the native catalog and its pinned metadata | wp1 | +| wp3 | `020_defaults_repoint.md` | Every opencodex-owned default moves to a live slug | wp1 | +| wp4 | `030_surfaces_and_gate.md` | GUI, docs locales, structure docs, full gate, closing record | wp2, wp3 | + +wp2 and wp3 touch disjoint files and could run in either order; wp2 runs first because +its membership decision is what the wp3 tests assert against. + +## Risks + +- **Over-removal.** Deleting a vendor roster row would break a live Copilot or Cursor + route. Mitigation: the scope boundary above, plus a final `rg` sweep that expects + vendor hits to remain. +- **Under-removal.** A default left on `gpt-5.4-mini` turns into a silent 404 on every + warmup or image description. Mitigation: wp3 enumerates each default site explicitly. +- **Test churn masking a real break.** ~278 test hits are in scope. Mitigation: each + test edit is classified as membership (must change), floor (must repoint), or + historical (must not change), and the full suite is the closing gate. + +## Acceptance + +DONE requires: no retired slug in a Codex-login-owned surface, every default on a live +slug, `bun run typecheck` clean, the focused domain suites green, the full +`bun run test` green, `bun run structure:check` green, and this unit carrying a closing +record with quoted evidence. diff --git a/devlog/_plan/260912_codex_gpt54_retirement/010_catalog_removal.md b/devlog/_plan/260912_codex_gpt54_retirement/010_catalog_removal.md new file mode 100644 index 0000000000..2e479cadc5 --- /dev/null +++ b/devlog/_plan/260912_codex_gpt54_retirement/010_catalog_removal.md @@ -0,0 +1,125 @@ +# wp2 — Remove the retired slugs from the Codex-login native catalog + +Membership only. No default moves here; that is wp3. + +## MODIFY src/codex/catalog/native-models.ts + +`NATIVE_OPENAI_MODELS` line 156. Before: + +```ts + "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", +``` + +After: + +```ts + "gpt-5.5", "gpt-5.3-codex-spark", +``` + +Two comments name the removed slugs as examples and must stop doing so, because after +this change they would describe a list that no longer holds them: + +- line 75, in the `SELF_DESCRIBED_NATIVE_OPENAI_MODELS` doc comment: the sentence "the + pin also holds `gpt-5.5`, `gpt-5.4` and `gpt-5.4-mini`" stays true of the snapshot, but + the retired slugs are no longer admission candidates at all. Rewrite it around the pins + that remain reachable — `gpt-5.5`, `gpt-5.2`, `codex-auto-review` — so the reason the + allowlist is explicit rather than structural survives the retirement. +- line 175, in `NATIVE_MAIN_DRAIN_SENTINEL_MODELS`: drop `gpt-5.4` and + `gpt-5.4-mini` from the "would have widened the sentinel to" enumeration, leaving + `gpt-5.5` and `gpt-5.3-codex-spark`. The set itself is unchanged — neither slug + was ever a member. + +## MODIFY src/codex/catalog/metadata.ts + +Line 165, `NATIVE_OPENAI_CONTEXT_OVERRIDES`: DELETE +`"gpt-5.4": { contextWindow: 1_000_000, maxContextWindow: 1_000_000 },`. This was the +only 1M native; `gpt-5.4-mini` has no entry and took its window from the pin. + +Line 123, the operating-cap comment: the sentence "and gpt-5.4 runs 272,000 against +1,000,000" describes a row that is going away. Rewrite it to cite only the GPT-5.6 +slugs it already discusses. + +Line 540, the `upstreamNativeEntryForSlug` allowlist comment: "would also admit +gpt-5.5/gpt-5.4/gpt-5.4-mini" becomes "would also admit gpt-5.5/gpt-5.2/codex-auto-review". +The behaviour is unchanged; the example set follows the pins that can still be reached. + +## KEEP src/codex/data/upstream-models.json (audit reversal) + +The plan originally called for deleting the two pinned objects (`"slug": "gpt-5.4"` at +line 460, `"slug": "gpt-5.4-mini"` at line 565). The A phase reversed that. + +This file is a snapshot of upstream's bundled catalog, and `metadata.ts:580` states the +contract: "The pinned JSON is left byte-identical to upstream; only the projection fills +in." The snapshot already carries `gpt-5.2` and `codex-auto-review`, neither of which is +in `NATIVE_OPENAI_MODELS`, so a pinned row has never been an exposure decision. + +Nothing re-exposes a retired slug from the snapshot alone. `PINNED_NATIVE_CAPABILITY_ENTRIES` +and `UPSTREAM_NATIVE_ENTRIES` are both built by iterating `NATIVE_OPENAI_MODELS`, so once +the slugs leave that list their pinned rows are never looked up. The one consumer that +reads raw snapshot rows, `GATED_MODEL_CLIENT_VERSION_FLOOR` in +`src/codex/model-entitlements.ts:135`, filters on `ACCOUNT_GATED_NATIVE_OPENAI_MODELS` +(Daybreak only) and never sees them. + +Deleting ~205 lines of upstream-owned JSON would change no behaviour while breaking the +file's fidelity to its source and churning `reserve-catalog-lifecycle.test.ts` and +`codex-model-entitlements.test.ts`, which read it directly. Membership is the lever. + +## MODIFY src/codex/catalog/parsing.ts, src/codex/catalog/effort.ts, src/codex/catalog/sync.ts + +Comment-only. Each names `gpt-5.4` or `gpt-5.4-mini` as the illustrative "older native" +(`parsing.ts:521` preserved-row cap, `effort.ts:62` xhigh clamp, `sync.ts:263` mock +max/ultra). Replace the examples with `gpt-5.5` / `gpt-5.3-codex-spark`. No predicate +changes: the clamp keys on "is not a gpt-5.6 native", so a request that still names a +retired slug is still clamped correctly. + +## Tests + +Membership assertions that must drop the slugs: + +- `tests/codex-integration/codex-catalog.test.ts` — `filterSupportedNativeSlugs` + expectation at 6954 and the visibility inputs at 6943-6944; delete the "native gpt-5.4 + uses its 1M context window override" test at 3459-3466 with the override itself. + The "preserved gpt-5.4-mini rows get the openai cap" test at 3640-3678 cannot simply be + kept: it feeds a preserved row through `mergeCatalogEntriesForSync`, and after membership + removal that row is a droppable unsupported native. Run it first — if it drops, repoint + the fixture onto a surviving non-overridden native so the #1430 cap regression keeps its + coverage. Line 3686 (`nativeOpenAiContextWindow("gpt-5.4", 272_000)`) names a retired + slug in a test about the generic cap, so repoint it too. + KEEP 3817 (negative: the slugs must not leak into `UPSTREAM_NATIVE_ENTRIES` — still true + and now trivially so), KEEP the Nova1 routed alias fixtures at 1175-1205 and the cursor + rows at 4340-4893. +- `codex-catalog-sync-hardening.test.ts` 118-140, 275-281, 373-376 — repoint the native + fixtures onto `gpt-5.5` / `gpt-5.6-luna`; 729-765 likewise. +- `codex-catalog-golden.test.ts` 41 and the `"gpt-5.4@9"` golden projection at 76. +- `codex-catalog-model-picker-order.test.ts` 153-167. +- `codex-catalog-restore.test.ts` — the hide/priority/window fixtures listed in the + audit; the 1M expectation at 398 goes with the override. +- `native-model-toggle.test.ts` 71-72 and 299. KEEP 79 (`cursor/gpt-5.4` proves vendor + slugs are ignored by native visibility) and 234-243 (preserved compact-limit map). +- `model-visibility-management-api.test.ts` 376-436 — a removed slug is no longer a + valid native visibility target, so these move to a surviving native. +- `codex-convergence-account-selectors.test.ts`, `codex-auth-context.test.ts`, + `codex-metadata-integrity.test.ts`, `codex-v2-gate.test.ts`, `effort-policy.test.ts` + 281-315 — these use the slug as a live native request id; repoint to `gpt-5.5`. + `effort-policy.test.ts` 435-439 keeps testing the clamp, on a surviving old native. +- `tests/claude-integration/` — `claude-models-discovery.test.ts` expected roster, + `claude-model-info.test.ts` (its "only authoritative 1M native" claim dies with the + override), `claude-context-windows.test.ts` 24-29, `claude-inbound.test.ts`. +- `tests/clients/desktop-3p.test.ts` 215-221 — same 1M native subject. + +Do not touch: `tests/usage/**`, `tests/providers/**` vendor suites, +`tests/fixtures/commandcode-models.json`, `tests/responses/responses-shadow-intercept.test.ts`, +`tests/routing/subagent-*` (operator rosters and a negative sentinel assertion). + +Four more in-scope files the first sweep left unclassified: +`tests/codex-integration/codex-app-server-processes.test.ts:491-500` KEEP (the +`codex --config model=...` fixture exercises a command-line detector; any token works), +`tests/codex-integration/slug-codec.test.ts:106` KEEP (codec round-trip pair, not +membership), `tests/responses/empty-completion-guard.test.ts:467` KEEP (string formatting), +`tests/vision/vision-eligibility.test.ts:225` REPOINT to `gpt-5.6-luna` — unlike the +OpenRouter rows at 22-26 this one is the native eligibility subject and belongs to wp3. + +## Proof for this phase + +`bun test tests/codex-integration tests/claude-integration tests/clients` green, plus +`bun run typecheck`. diff --git a/devlog/_plan/260912_codex_gpt54_retirement/020_defaults_repoint.md b/devlog/_plan/260912_codex_gpt54_retirement/020_defaults_repoint.md new file mode 100644 index 0000000000..06943334e0 --- /dev/null +++ b/devlog/_plan/260912_codex_gpt54_retirement/020_defaults_repoint.md @@ -0,0 +1,112 @@ +# wp3 — Move every opencodex-owned default off the retired slugs + +Every lane goes to `gpt-5.6-luna`, including the login provider default (owner +decision, 2026-09-12). Each site below dispatches a real request today and would 404 +after retirement. + +## MODIFY src/oauth/index.ts + +Line 331, the `chatgpt` OAuth definition: `defaultModel: "gpt-5.4"` becomes +`defaultModel: "gpt-5.6-luna"`. + +Be accurate about what this constant does, because the first draft of this plan was not: +`upsertOAuthProvider` returns at `src/oauth/index.ts:1476` for `chatgpt`, so the value is +never persisted onto a provider row, and `src/cli/models.ts:139`, `src/cli/provider.ts` +and `src/server/fast-row.ts:133` all read `config.providers[*].defaultModel` rather than +this constant. It is the ChatGPT login definition's declared default, not a live 404 +dispatch path. It still moves: leaving a retired slug as the login surface's stated +default is wrong on its own terms, and any future consumer would inherit it. + +## MODIFY src/oauth/token-guardian.ts + +Line 55, `DEFAULTS.codexWarmupModel`: `"gpt-5.4-mini"` becomes `"gpt-5.6-luna"`. +Line 87 reads a stored override first, so a user who explicitly set +`tokenGuardian.codexWarmupModel: "gpt-5.4-mini"` keeps calling the retired model. +Extend the existing startup migration (below) to rewrite that stored value too. + +## MODIFY src/codex/warmup.ts + +Line 30: `const DEFAULT_MODEL = "gpt-5.4-mini"` becomes `"gpt-5.6-luna"`. +Line 31: `FALLBACK_MODELS = ["gpt-5.5", "gpt-5.6-luna"]` becomes `["gpt-5.5"]` — luna +is now the primary, and the loop already skips a fallback equal to the primary, so +leaving it would be dead weight that reads as a second chance. + +## MODIFY src/vision/plan.ts + +Line 14: `const DEFAULT_VISION_MODEL = "gpt-5.4-mini"` becomes `"gpt-5.6-luna"`. +Lines 71 and 87 consume it and need no edit. This is the one that the startup sidecar +migration cannot reach: an unset `visionSidecar.model` never equals the old string, so +today an untouched install still describes images with a retired model. + +After this, `src/vision/eligibility.ts:51` and `src/vision/backends.ts:51` — both +already `gpt-5.6-luna` — agree with the runtime instead of contradicting it. + +## MODIFY src/server/management/config-routes.ts + +Lines 823-824, the vision effort-table normalization: both `"gpt-5.4-mini"` literals +become `"gpt-5.6-luna"`. Lines 724 and 963 are already luna and stay. + +## MODIFY src/cli/config-command.ts + +Line 148: `const model = vision.model || "gpt-5.4-mini"` becomes `"gpt-5.6-luna"`, and +the line 147 comment that names the old bounded default follows it. + +## MODIFY src/server/index.ts + +KEEP the sidecar migration block at 686-701 — it is the only thing that rewrites a +stored `gpt-5.4-mini` for existing users, and its destination is already luna. Two +changes: + +1. Extend it to `config.tokenGuardian?.codexWarmupModel === "gpt-5.4-mini"`, which is + currently not migrated and is a live dispatch path. +2. Correct the comment: it claims "explicit user choices are preserved", but the check + is exact equality, so an explicitly chosen `gpt-5.4-mini` is rewritten too. After + retirement that is the right behaviour; the comment should say so rather than + describe a guarantee the code does not make. + +The `SIDECAR_MIGRATION_CUTOFF` date gate stays as-is. + +## MODIFY src/types/config.ts, src/types/tools.ts, src/types/request.ts + +Doc comments only, but they are the published contract: + +- `config.ts:1125` — "Default gpt-5.4-mini" for `codexWarmupModel` becomes luna. +- `config.ts:604` and `:614` — the shadow-intercept comments claim both slugs are + defaults while the code ships luna only. Correct them to state the default is + `gpt-5.6-luna` and `gpt-5.4-mini` is an opt-in `sourceModels` value for 0.144.x + clients. +- `tools.ts:16` and `request.ts:103` — the synthetic web_search comments still name a + "gpt-5.4-mini sidecar"; `src/web-search/index.ts:23` has been luna for a while. + +## KEEP src/lib/shadow-call.ts + +`DEFAULT_SHADOW_SOURCE_MODELS` stays `["gpt-5.6-luna"]` and the 0.144.x note stays. +This list is what the proxy *intercepts*, not what it sends: a 0.144.x client emitting +`gpt-5.4-mini` helper calls is exactly who benefits from an intercept, and an operator +can restore the prefix through `sourceModels`. Adding it back to the default would +change intercept behaviour for every install, which is a separate decision from +retiring the model. + +## Tests + +- `tests/codex-integration/warmup.test.ts` 97-141, `codex-warmup.test.ts` 38/55, + `token-guardian.test.ts` 253, `codex-quota-auto-refresh-main-admission.test.ts` 290 — + the warmup chain becomes `gpt-5.6-luna` then `gpt-5.5`. +- `tests/vision/**` — `sidecar-abort.test.ts` (21 fixtures), `vision-reasoning-contract.test.ts` + (14, retune to luna's ladder), `sidecar-settings-vision-filter.test.ts` 137/189, + `sidecar-settings-vision-controls.test.ts` 89, `vision-anthropic.test.ts` 419. + KEEP `vision-eligibility.test.ts` 22-26 (OpenRouter `openai/gpt-5.4-mini` metadata). +- `tests/web-search/web-search.test.ts` — 26 settings fixtures move to luna. +- `tests/server/server-combo-failover-e2e.test.ts` — 10 live-forward model ids. +- A new regression for the widened migration: a stored + `tokenGuardian.codexWarmupModel: "gpt-5.4-mini"` is rewritten to luna at startup. +- `tests/vision/vision-eligibility.test.ts:225` — the native eligibility subject moves to + luna; 22-26 stay (OpenRouter `openai/gpt-5.4-mini` metadata). +- KEEP `tests/server/config.test.ts` 98 and `server-startup-reconcile-resilience.test.ts` 57 + (legacy roster inputs), `tests/server/api-debug.test.ts` (log-parser fixture), + `tests/responses/**` (shadow restore hatch), `tests/usage/**` (historical pricing). + +## Proof for this phase + +`bun test tests/vision tests/web-search tests/server tests/codex-integration` green, +plus `bun run typecheck`. diff --git a/devlog/_plan/260912_codex_gpt54_retirement/030_surfaces_and_gate.md b/devlog/_plan/260912_codex_gpt54_retirement/030_surfaces_and_gate.md new file mode 100644 index 0000000000..e3c6615fb2 --- /dev/null +++ b/devlog/_plan/260912_codex_gpt54_retirement/030_surfaces_and_gate.md @@ -0,0 +1,96 @@ +# wp4 — GUI, docs, structure docs, and the closing gate + +## MODIFY gui/src + +- `gui/src/pages/dashboard-overview-sections.tsx:447` — `sidecar?.vision.model ?? "gpt-5.4-mini"` + becomes `?? "gpt-5.6-luna"`, matching the runtime default from wp3. Left alone, the + dashboard would display a retired model for an unset vision config. +- `gui/src/pages/api-keys-panels.tsx:322,330` — the copy-paste curl samples name + `gpt-5.4`; move them to `gpt-5.6-luna` so a user pasting the sample gets a live model. +- KEEP `gui/src/pages/shadow-call-source.ts:5` — 0.144.x history, already luna in code. + +## MODIFY gui/tests + +Vision floor: `vision-sidecar-dashboard.test.tsx` 39/107/307/311/372/377, +`vision-reasoning-contract.test.ts` 15/19 → luna. +Native examples: `api-access-models.test.ts`, `apikeys-actions.test.tsx`, +`apikeys-model-test-wire.test.tsx`, `apikeys-models-states.test.tsx`, +`client-config-panel.test.tsx`, `subagents-fallback.test.tsx` → a surviving native. +KEEP `models-native-group-controls.test.ts` (custom, non-native row id) and +`shadow-call-source.test.ts` (explicit override rendering). + +## MODIFY docs-site (English first, then the seven locales) + +Each family below changes in `docs/` and in `fr`, `ja`, `ko`, `ru`, `tr`, `zh-cn`, +`zh-tw`. The English page is the source; a locale must not disagree with it. + +- `guides/codex-app-models.md` — drop `gpt-5.4` and `gpt-5.4-mini` from the native + fallback set. zh-tw `guides/sub-agent-surface.md:218` additionally carries an effort + row for the retired natives that English does not have; delete that row. +- `getting-started/quickstart.md` — the advertised five native picker models still end + in `gpt-5.4-mini`, while `DEFAULT_SUBAGENT_MODELS` is already Astra/sol/terra/luna/5.5. + Make the docs match the shipped default. +- `guides/codex-integration.md` — the account-verification warmup sentence becomes + "defaults to `gpt-5.6-luna`, retries with `gpt-5.5`". +- `reference/configuration/providers.md` — `codexWarmupModel` default cell → luna. +- `reference/configuration/server.md` — the vision `model?` default cell → luna. KEEP + the "legacy explicit `gpt-5.4-mini` migrates on start" sentence; that is still true. +- `guides/sidecars.md` — the code fallback → luna; keep the migration sentence. +- `reference/configuration/agents.md` and `guides/sub-agent-surface.md` — the + `subagentModelFallback` examples name a retired model; move to luna. +- `docs-site/src/components/Landing.astro` 89/320 — the marketing line advertises a + "gpt-5.4-mini sidecar" in every translated string; update all of them together. +- KEEP the Copilot mixed-wire lists in `guides/providers.md` and + `reference/configuration/providers.md`; those describe a vendor roster this unit does + not touch. KEEP the shadow-intercept restore notes, but fix zh-tw + `reference/cli/providers-accounts.md:283`, which states the default is both slugs + while English says luna only. +- `guides/codex-integration.md` explicit-account example `work/gpt-5.4` → `work/gpt-5.5`, + so no page advertises a retired slug even as an illustration. + +## MODIFY docs/ (maintainer-facing, separate from docs-site) + +- `docs/shadow-call-intercept.md:14-17` — says the default source-prefix set is + `gpt-5.4-mini` and `gpt-5.6-luna`. `DEFAULT_SHADOW_SOURCE_MODELS` is luna-only, so this + page is already wrong today. State luna as the default and `gpt-5.4-mini` as the 0.144.x + restore value, matching the correction in `src/types/config.ts` from wp3. +- `docs/codex-app-model-catalog.md:111` — uses `gpt-5.5`/`gpt-5.4` as the example of + snapshot entries that are staler than the installed catalog. Replace the retired half of + the example. + +## MODIFY structure/ + +- `structure/ops/service-and-sidecars.md:52` — vision default cell → `gpt-5.6-luna`. +- `structure/gui-and-management-api.md:129` — the shadow `sourceModels` sentence says + the default is `gpt-5.4-mini` + `gpt-5.6-luna`; the code ships luna only. Correct the + default and keep mini as the documented restore value. + +## MODIFY scripts/release-notes.ts + +Line 1137 — `process.env.OPENAI_MODEL ?? "gpt-5.4"` becomes `?? "gpt-5.6-luna"`. The +tool is maintainer-facing but would fail against a retired model. +KEEP `scripts/model-metadata.source.json` entirely: 129 hits across vendor snapshots +plus openai/openai-codex pricing rows, none of which are the Codex-login catalog. + +## Closing gate + +1. `bun run typecheck` +2. `bun run test` (full suite, PR-ready gate) +3. `bun run lint:gui` and the GUI tests +4. `bun run structure:check` +5. Final `rg "gpt-5\\.4"` sweep, read against an explicit allowlist rather than an + expectation of zero hits. Survivors that are CORRECT and must remain: + vendor rosters (`src/providers/registry.ts` Copilot, `src/adapters/cursor/*`, + `src/providers/codebuddy-models.ts`, `scripts/model-metadata.source.json`, + `tests/providers/**`, `tests/fixtures/commandcode-models.json`, the Copilot/mixed-wire + docs pages); historical pricing (`src/usage/expected-prices.ts`, `tests/usage/**`, + `docs-site/src/data/frontier-benchmarks.json` benchmark rows); + generated metadata (`src/generated/model-metadata.ts`); different slugs + (`-nano`, `-pro`, `-high`, `openai/gpt-5.4-mini`, `cursor/gpt-5.4`); the shadow-intercept + restore hatch (`src/lib/shadow-call.ts`, `tests/responses/**`, the sourceModels docs); + negative and fixture assertions (`tests/routing/subagent-*`, `tests/server/api-debug.test.ts`, + `tests/server/config.test.ts`, `server-startup-reconcile-resilience.test.ts`, + `codex-app-server-processes.test.ts`, `slug-codec.test.ts`, `empty-completion-guard.test.ts`, + `codex-catalog.test.ts:3817`); and this devlog unit. A hit outside that list is a defect. + +Receipts go to `.tmp/`, and `040_done.md` records the outcome with quoted evidence. diff --git a/devlog/_plan/260912_codex_gpt54_retirement/040_done.md b/devlog/_plan/260912_codex_gpt54_retirement/040_done.md new file mode 100644 index 0000000000..1c2d396f43 --- /dev/null +++ b/devlog/_plan/260912_codex_gpt54_retirement/040_done.md @@ -0,0 +1,92 @@ +# Closing record + +## Outcome + +DONE. `gpt-5.4` and `gpt-5.4-mini` no longer exist on the Codex (ChatGPT OAuth) login +surface, and every default that used to dispatch one of them now uses `gpt-5.6-luna`. +Delivered as PR #4327 against `dev`, final head `32bd5417cb`, CI green. + +Commits: `a8b26e1342` (this roadmap), `5d664b1a6b` (the retirement), +`fa1fe32890` (a combo-alias fixture CI caught), `fe01c0f605` (the resurrection guard a +review caught), `32bd5417cb` (the migration extracted and tested). + +## What changed against the plan + +Two reversals, both from audit rather than from build convenience. + +The pinned rows in `src/codex/data/upstream-models.json` stayed. The plan called for +deleting them; the A phase found the file is upstream's snapshot by contract +(`metadata.ts:580`) and already carries rows this runtime does not expose (`gpt-5.2`, +`codex-auto-review`). Both maps built from it iterate `NATIVE_OPENAI_MODELS`, so the +rows are unreachable once membership is gone. Deleting ~205 lines would have changed +no behaviour. + +The maintainer-facing `docs/` tree was missing from the original scope entirely. The +independent reviewer caught it: `docs/shadow-call-intercept.md` still claimed the +default intercept set was both slugs when the code had been luna-only for a while, and +`docs/codex-app-model-catalog.md` used `gpt-5.4` as a staleness example. + +## What CI caught that reading did not + +Two defects survived the audit and the six parallel test workers, and were found only +by pushing: + +1. `tests/codex-integration/codex-catalog.test.ts` had a native-alias combo fixture + targeting `codex/gpt-5.4-mini`. Once membership was gone that alias had no native + capabilities to inherit. `gpt-5.5` carries an identical pinned shape (272k window, + `low..xhigh`, default `medium`, text and image), so every asserted value held after + repointing. +2. `enforce-target` fails on any `gui/` path change without screenshot evidence. There + is no visual change here, so it was waived through the repository's documented + maintainer-comment mechanism with a note stating exactly what the `gui/src` diff is. + +## What the final review caught that CI did not + +CI was green and the change was still wrong in one place. An independent reviewer read +the pushed diff and found that removing the slugs from `NATIVE_OPENAI_MODELS` does not +keep them out: an account-bound observation deliberately admits any native that is NOT +in `SUPPORTED_NATIVE_OPENAI_SLUGS`, which is how a genuinely new upstream model reaches +one entitled account early. A retired slug fails that same test, so a stale +`selector/gpt-5.4` row persisted in a user's catalog or models cache would have been +re-observed as an unknown native and synthesized straight back into the picker, one sync +after the removal took it out. + +`RETIRED_NATIVE_OPENAI_MODELS` is the distinction the code was missing: unknown-and-new +is admitted, known-and-dead is refused. The guard sits in `observedAccountBoundNativeSlug` +because every observation path funnels through it. + +This is the residual wp2 recorded as needing proof rather than assumption, and it is the +reason that residual was worth writing down: no test covered it, so no test failed. + +The same review noted the widened startup migration had no test at all. It is now +`src/codex/retired-model-migration.ts`, shaped like the existing +`runClaudeAuthModeMigration`, with tests for the three stored slugs, sibling-key +survival, idempotence, and leaving any other model alone. + +## Verification and its limits + +The owner instructed mid-loop that the local suite must not be run on this machine; a +baseline run confirmed why, reporting ~279 failures unrelated to this change. So +`bun run typecheck`, `bun run test`, `bun run structure:check` and the dashboard lint +are **NOT RUN locally**, and the evidence is repository CI against the final head. +`bun run privacy:scan` and `tests/ci-workflows/repo-hygiene.test.ts` were run before +that instruction arrived, on the devlog commit, and both passed. + +What this does not prove: nothing here exercised a live ChatGPT account. That the +retired slugs now 404 upstream is the premise of the task, not something this unit +verified. + +## What did not improve, and what would falsify this + +The `desktop-3p` 1M-native regression lost its positive control. `gpt-5.4` was the only +native with a 1M window, so the test that proved a provider cap can take `supports1m` +away now only proves no native ever gets it. If a 1M native returns, that test should +regain a positive case rather than stay an absence check. + +The account-namespaced question turned out to be the real defect rather than a caveat, +and it is now fixed and tested. What remains unproven is the disk side: a persisted +`selector/gpt-5.4` row is no longer re-admitted as evidence, but +`isUnsupportedOpenAiNativeSlug` still returns false for any slug containing `/`, so the +stale row itself is not actively deleted from a user's catalog file. It stops being +regenerated and stops being observed; whether it lingers in a file until the next full +rewrite was not measured against a real installation. diff --git a/devlog/_plan/260912_combo_carry/000_plan.md b/devlog/_plan/260912_combo_carry/000_plan.md new file mode 100644 index 0000000000..80e1cd6d07 --- /dev/null +++ b/devlog/_plan/260912_combo_carry/000_plan.md @@ -0,0 +1,15 @@ +# Combo quota carry roadmap + +Carry #4090 followed by the editor delta of #4105 so routing uses explicit inference evidence for the current credential and Combo editing only blocks on fresh confirmed exhaustion. Preserve luvs01 contribution and latest omitted-projection correction. + +Satisfy-spec HOTL requested by the parent coordinator. Scope: runtime quota publication/cache/selection; management projection; Combo editor; existing regression tests; translated guides and structure owners. No merge, original closure, release, service or config changes. No local suites of any size, build, typecheck or install. Existing GitHub account/tool scope only; no user-set token/time/agent bound. Main owns implementation, read-only agents audit. + +Order: roadmap docs-only cycle -> runtime carry -> editor carry -> final cumulative hosted verification. Source commits and executable diffs are in 010 and 020. Current original heads: #4090 c061316722cddf716c830fdc60bd74d237c78af5; #4105 50296f181eb41b582c3f7c04f80335ab7f78bab3. Skip #4105 prerequisite 6292b3c; carry af2ceb8 and 50296f1 above the latest #4090. + +Verification: git diff --check (text only), source inspection, existing .github/workflows/ci.yml workflow_dispatch at final tip with full lane if supported. Local product validation NOT RUN by explicit user instruction. Conditional acceptance: pool/OAuth/changed credential/static auth/display-only -> no provider veto; explicit same-credential exhausted inference -> veto; stale/malformed/missing projection -> editor remains enabled; fresh exhausted every target -> Save/Create disabled; expiry/visibility/refresh -> re-evaluate. + +DONE means PR chain, final head and hosted run evidence, matching GUI capture and explicit security review are durable in .tmp/combo-handoff/HANDOFF.md. Unresolved external gates are reported with evidence, never weakened into success. Parent owns merge and original disposition. Main reclaims failed audit transport but records the independence gap. Architect-specific role is unavailable in exposed schema; no installation or fabricated role is attempted. + +Private review working notes stay in .tmp; public roadmap only cites already-public source changes. SoT: current structure runtime and GUI/management owners replace retired structure/04_transports-and-sidecars.md. Every mapped owner is reviewed for applicability and gets a scoped pointer where necessary. + +Roadmap audit: independent inherited-model reviewer Linnaeus returned VERDICT: PASS, zero blockers. Carry boundaries verified; use genuinely bound evidence for changed-key regression because test-only seeding bypasses binding comparison. Dispatch runner labels must be recorded truthfully. Supported independent design consultation is in flight under the user's clarified instruction; native architect role is not claimed. diff --git a/devlog/_plan/260912_combo_carry/010_runtime.md b/devlog/_plan/260912_combo_carry/010_runtime.md new file mode 100644 index 0000000000..c8778af957 --- /dev/null +++ b/devlog/_plan/260912_combo_carry/010_runtime.md @@ -0,0 +1,884 @@ +# runtime carry + +MODIFY exactly the paths in this public source diff. Apply each original commit in order with attribution. Resolve retired structure document into current runtime.md and gui-and-management-api.md; never restore the retired file. Earlier phase dependency: roadmap. + +Before/after source contract (review against current dev at P; source diff is the executable carry input): + +```diff +diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md +index 9785135d53..9434a1e98a 100644 +--- a/docs-site/src/content/docs/fr/guides/combos.md ++++ b/docs-site/src/content/docs/fr/guides/combos.md +@@ -190,6 +190,8 @@ indique la réinitialisation de fenêtre à venir la plus proche (cinq heures, h + Le fournisseur dont le quota se renouvelle en premier est ainsi sollicité. Les cibles dépourvues de données de quota + récentes et les égalités conservent l’ordre de configuration. `weight` et `stickyLimit` n’affectent pas cette stratégie. + ++Ce classement et l’exclusion des fournisseurs avant l’envoi exigent des limites récentes d’inférence de modèles applicables dans leur ensemble à l’unique clé API actuelle. Les résumés OAuth ou du compte courant, les routes transmettant les identifiants de l’appelant, les configurations à plusieurs clés et les instantanés dont les identifiants ou la destination ont changé servent uniquement à l’affichage pour cette décision préalable. Il en va de même lorsque les en-têtes `Authorization`, `x-api-key` ou `x-goog-api-key` remplacent les identifiants ; les fenêtres réservées à la recherche ou à MCP sont exclues. Si aucune cible admissible n’a de réinitialisation applicable, l’ordre de configuration prévaut. La sélection des comptes et les nouvelles tentatives appliquent toujours leurs limites habituelles. ++ + ## Que se passe-t-il lorsqu'une cible échoue + + Les échecs d’un combo se répartissent entre ceux qui entraînent un **basculement** et les échecs **terminaux**. +diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md +index db94da045f..c7d076d9b7 100644 +--- a/docs-site/src/content/docs/guides/combos.md ++++ b/docs-site/src/content/docs/guides/combos.md +@@ -202,6 +202,8 @@ shows the soonest upcoming window reset (five-hour, weekly, monthly, or custom). + provider that refreshes first. Targets without fresh quota data, and ties, keep configuration + order. Weights and `stickyLimit` do not affect this strategy. + ++This ranking and provider exclusion before dispatch require fresh model-inference limits that apply to the current single API key as a whole. OAuth/current-account summaries, caller-forward routes, multiple keys, and snapshots with changed credentials or destinations are display-only for this early decision. The same applies when `Authorization`, `x-api-key`, or `x-goog-api-key` headers override credentials; search-only and MCP-only windows are excluded. If no eligible target has an applicable reset, configuration order wins. Account selection and retries still enforce their normal limits. ++ + ## What happens when a target fails + + Combo failures are divided into **hop** failures and **terminal** failures. +diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md +index 655dae2232..f6eca53214 100644 +--- a/docs-site/src/content/docs/ja/guides/combos.md ++++ b/docs-site/src/content/docs/ja/guides/combos.md +@@ -113,6 +113,8 @@ ocx combo set balanced \ + + `reset-window` は、キャッシュされたプロバイダーのクォータスナップショットで、次回のウィンドウリセット(5 時間、週次、月次、またはカスタム)が最も早い適格なターゲットへ、各リクエストをルーティングします。これにより、最初にクォータが補充されるプロバイダーを先に使用します。新しいクォータデータがないターゲットと、リセット時刻が同じターゲットでは、構成順序が維持されます。`weight` と `stickyLimit` はこの戦略に影響しません。 + ++この順位付けと送信前のプロバイダー除外には、現在の単一 API キー全体に適用される最新のモデル推論制限が必要です。OAuth/現在のアカウントの概要、呼び出し元の認証情報を転送するルート、複数キー、認証情報や送信先が変わったスナップショットは、この事前判断では表示専用です。`Authorization`、`x-api-key`、`x-goog-api-key` ヘッダーで認証情報を上書きする場合も同様で、検索専用および MCP 専用ウィンドウは対象外です。適用可能なリセット情報を持つ適格な対象がなければ、設定順序を使用します。アカウント選択と再試行には引き続き通常の制限が適用されます。 ++ + ## ターゲットが失敗すると何が起こるか + + コンボ障害は、**ホップ** 障害と **ターミナル** 障害に分類されます。 +diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md +index 633feb838b..71e557f25c 100644 +--- a/docs-site/src/content/docs/ko/guides/combos.md ++++ b/docs-site/src/content/docs/ko/guides/combos.md +@@ -119,6 +119,8 @@ ocx combo set balanced \ + + `reset-window`는 캐시된 공급자 할당량 스냅샷에서 가장 가까운 다음 기간 재설정(5시간, 주간, 월간 또는 사용자 지정)이 표시되는 적합한 대상으로 각 요청을 라우팅합니다. 이렇게 하면 가장 먼저 새로 충전되는 공급자를 사용합니다. 최신 할당량 데이터가 없는 대상과 동률인 대상은 설정 순서를 유지합니다. `weight`와 `stickyLimit`은 이 전략에 영향을 주지 않습니다. + ++이 순위 결정과 전송 전 공급자 제외에는 현재 단일 API 키의 전체 모델 추론에 적용되는 최신 한도 정보가 필요합니다. OAuth·현재 계정 요약, 호출자 인증을 전달하는 경로, 여러 키, 인증 정보나 목적지가 달라진 스냅샷은 이 사전 판단에서 표시 용도로만 사용합니다. `Authorization`, `x-api-key`, `x-goog-api-key` 헤더로 인증을 덮어쓰는 경우도 같으며, 검색 전용·MCP 전용 기간은 제외합니다. 적격 대상 중 적용 가능한 초기화 정보가 없으면 설정 순서를 따릅니다. 실제 계정 선택과 재시도에는 기존 제한이 계속 적용됩니다. ++ + ## 대상 실패 시 동작 + + 콤보 실패는 **홉** 실패와 **종결** 실패로 나뉩니다. +diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md +index 3d4820e521..868416ae5b 100644 +--- a/docs-site/src/content/docs/ru/guides/combos.md ++++ b/docs-site/src/content/docs/ru/guides/combos.md +@@ -150,6 +150,8 @@ ocx combo set balanced \ + данных о квоте, а также цели с одинаковым временем сброса сохраняют порядок конфигурации. Значения + `weight` и `stickyLimit` не влияют на эту стратегию. + ++Для этого ранжирования и исключения провайдеров до отправки нужны свежие лимиты инференса моделей, применимые к единственному текущему API-ключу в целом. Сводки OAuth и текущего аккаунта, маршруты с передачей учётных данных вызывающей стороны, несколько ключей и снимки с изменившимися учётными данными или адресом назначения служат только для отображения при этом предварительном решении. То же относится к переопределению учётных данных заголовками `Authorization`, `x-api-key` или `x-goog-api-key`; окна только для поиска или MCP исключаются. Если ни у одной допустимой цели нет подходящего времени сброса, используется порядок конфигурации. При выборе аккаунта и повторных попытках по-прежнему действуют обычные ограничения. ++ + ## Что происходит, когда цель сбоит + + Сбои в combo делятся на **hop**-сбои и **terminal**-сбои. +diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md +index 8b67170068..520c157e83 100644 +--- a/docs-site/src/content/docs/tr/guides/combos.md ++++ b/docs-site/src/content/docs/tr/guides/combos.md +@@ -218,6 +218,8 @@ kullanılır. Güncel kota verisi bulunmayan hedeflerde ve eşitliklerde + yapılandırma sırası korunur. `weight` değerleri ve `stickyLimit` bu stratejiyi + etkilemez. + ++Bu sıralama ve gönderim öncesi sağlayıcı elemesi, mevcut tek API anahtarının model çıkarımı kullanımının tamamına uygulanan güncel sınırlara dayanır. OAuth veya geçerli hesap özetleri, çağıranın kimlik bilgilerini ileten rotalar, birden fazla anahtar ve kimlik bilgileri ya da hedefi değişmiş anlık görüntüler, bu ön kararda yalnızca görüntüleme amaçlıdır. `Authorization`, `x-api-key` veya `x-goog-api-key` başlıkları kimlik bilgilerini geçersiz kıldığında da aynı kural uygulanır; yalnızca arama veya MCP için olan pencereler hariç tutulur. Uygun hedeflerin hiçbirinde geçerli sıfırlama bilgisi yoksa yapılandırma sırası kullanılır. Hesap seçimi ve yeniden denemelerde normal sınırlar uygulanmaya devam eder. ++ + ## Bir hedef başarısız olduğunda ne olur? + + Kombo hataları **atlama (hop)** hataları ve **uç (terminal)** hatalar olarak +diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md +index fea189deb3..d84efca472 100644 +--- a/docs-site/src/content/docs/zh-cn/guides/combos.md ++++ b/docs-site/src/content/docs/zh-cn/guides/combos.md +@@ -139,6 +139,8 @@ ocx combo set balanced \ + + `reset-window` 会将每个请求路由到合格目标中,其缓存的提供商额度快照显示下一个窗口最早重置者(五小时、每周、每月或自定义窗口)。这样会优先消耗最先刷新额度的提供商。没有最新额度数据的目标以及并列目标会保持配置顺序。`weight` 和 `stickyLimit` 不影响此策略。 + ++此排序和发送前的提供商排除,需要适用于当前单个 API 密钥全部模型推理的最新限额信息。OAuth/当前账户摘要、转发调用方凭据的路由、多密钥以及凭据或目标地址已改变的快照,在这项提前判断中仅供显示。通过 `Authorization`、`x-api-key` 或 `x-goog-api-key` 请求头覆盖凭据时也适用相同规则;仅用于搜索或 MCP 的窗口不参与判断。如果所有符合条件的目标都没有适用的重置时间,则按配置顺序选择。实际账户选择和重试仍执行正常限制。 ++ + ## 目标失败时会发生什么 + + combo 失败分为 **跳转** 失败和 **终止** 失败。 +diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md +index bb8ef901f9..d82b399e6f 100644 +--- a/docs-site/src/content/docs/zh-tw/guides/combos.md ++++ b/docs-site/src/content/docs/zh-tw/guides/combos.md +@@ -154,6 +154,8 @@ ocx combo set balanced \ + + `reset-window` 將每個請求路由至快取供應商配額快照顯示下一個時段最早重設的合格目標(五小時、每週、每月或自訂)。這會優先使用最早重新取得額度的供應商。沒有最新配額資料的目標,以及發生平手時,皆維持設定順序。`weight` 與 `stickyLimit` 不影響此策略。 + ++此排序與傳送前的供應商排除,需要適用於目前單一 API 金鑰全部模型推論的最新限額資訊。OAuth/目前帳戶摘要、轉送呼叫者憑證的路由、多金鑰,以及憑證或目的地位址已變更的快照,在這項預先判斷中僅供顯示。透過 `Authorization`、`x-api-key` 或 `x-goog-api-key` 標頭覆寫憑證時也適用相同規則;僅供搜尋或 MCP 使用的時段不參與判斷。若所有符合條件的目標都沒有適用的重設時間,則依設定順序選擇。實際帳戶選擇與重試仍套用一般限制。 ++ + ## 目標失敗時會發生什麼 + + Combo 失敗分為**跳轉**失敗與**終端**失敗。 +diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts +index 71ea750b6e..9627bf396d 100644 +--- a/src/combos/resolve.ts ++++ b/src/combos/resolve.ts +@@ -1,7 +1,6 @@ + import type { OcxComboTarget, OcxConfig } from "../types"; +-import { getCachedProviderQuota } from "../providers/quota-routing-cache"; ++import { getCachedProviderRoutingQuota } from "../providers/quota-routing-cache"; + import type { ProviderQuota } from "../providers/quota-types"; +-import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; + import { sleepWithAbort } from "../lib/upstream-retry"; + import { + coolComboTarget, +@@ -65,9 +64,7 @@ function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget, now: + if (!Object.hasOwn(config.providers, target.provider)) return false; + const provider = config.providers[target.provider]; + if (!provider || provider.disabled === true) return false; +- // Native account selection owns model-scoped quota; a provider summary cannot veto it. +- return isCanonicalOpenAiForwardProvider(provider) +- || !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now); ++ return !cachedProviderQuotaIsExhausted(getCachedProviderRoutingQuota(target.provider, provider, now), now); + } + + function quotaWindowExhausted(percent: number | undefined, resetAt: number | undefined, now: number): boolean { +@@ -181,6 +178,7 @@ function smoothWeightedIndex( + * unknown (Infinity). + */ + function resetWindowIndex( ++ config: OcxConfig, + targets: Required[], + eligible: (target: Required) => boolean, + now = Date.now(), +@@ -190,7 +188,9 @@ function resetWindowIndex( + for (let index = 0; index < targets.length; index++) { + const target = targets[index]!; + if (!eligible(target)) continue; +- const remaining = quotaResetRemainingMs(getCachedProviderQuota(target.provider, now), now); ++ const remaining = quotaResetRemainingMs( ++ getCachedProviderRoutingQuota(target.provider, config.providers[target.provider], now), now, ++ ); + // Strict comparison deliberately retains configured order for ties, + // including the no-snapshot fallback where every value is Infinity. + if (selected < 0 || remaining < smallestRemaining) { +@@ -276,7 +276,7 @@ export function pickComboTarget( + } + } + } else if (combo.strategy === "reset-window") { +- targetIndex = resetWindowIndex(combo.targets, eligible, now); ++ targetIndex = resetWindowIndex(config, combo.targets, eligible, now); + } else { + targetIndex = combo.targets.findIndex(eligible); + } +diff --git a/src/providers/quota-routing-cache.ts b/src/providers/quota-routing-cache.ts +index 065d7338ca..1e45d60065 100644 +--- a/src/providers/quota-routing-cache.ts ++++ b/src/providers/quota-routing-cache.ts +@@ -1,15 +1,53 @@ ++import { createHash } from "node:crypto"; ++import type { OcxProviderConfig } from "../types"; + import type { ProviderQuota, ProviderQuotaReport } from "./quota"; ++import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store"; ++import { getProviderRegistryEntry } from "./registry"; + +-const quotaCache = new Map(); ++export interface ProviderQuotaRoutingEvidence { ++ quota: ProviderQuota; ++ binding: string; ++} ++ ++type CachedQuota = { ++ quota: ProviderQuota; ++ routing?: ProviderQuotaRoutingEvidence | { quota: ProviderQuota; testOnly: true }; ++}; ++ ++const quotaCache = new Map(); ++ ++/** Private cache identity; neither key material nor this digest enters management reports. */ ++export function providerQuotaRoutingBinding( ++ name: string, ++ provider: OcxProviderConfig, ++ credential = resolveProviderApiKey(provider.apiKey)?.trim(), ++): string | null { ++ if ((provider.authMode ?? "key") !== "key" || !credential) return null; ++ // Registry-owned OAuth/forward rows normalize saved authMode before dispatch. ++ // A key probe must not constrain that later account selection. ++ const entry = getProviderRegistryEntry(name); ++ if (entry && (entry.authKind === "oauth" || entry.authKind === "forward") ++ && !providerUsesKeyAuthOverride(entry, provider, credential)) return null; ++ // Static auth headers can replace or combine with the probed API-key header. ++ // Its semantics belong to the adapter, so it is not provider-wide quota evidence. ++ if (Object.keys(provider.headers ?? {}).some(header => ++ ["authorization", "x-api-key", "x-goog-api-key"].includes(header.toLowerCase()))) return null; ++ return createHash("sha256").update(JSON.stringify([ ++ name, provider.adapter, provider.baseUrl, credential, ++ ])).digest("hex"); ++} + + export function clearCachedProviderQuotas(): void { + quotaCache.clear(); + } + +-export function replaceCachedProviderQuotas(reports: ProviderQuotaReport[]): void { ++export function replaceCachedProviderQuotas( ++ reports: ProviderQuotaReport[], ++ routingEvidence?: WeakMap, ++): void { + quotaCache.clear(); + for (const report of reports) { +- quotaCache.set(report.provider, report.quota); ++ quotaCache.set(report.provider, { quota: report.quota, routing: routingEvidence?.get(report) }); + } + } + +@@ -18,15 +56,34 @@ export function getCachedProviderQuota( + now: number, + maxAgeMs = 30 * 60_000, + ): ProviderQuota | null { +- const quota = quotaCache.get(provider); ++ const quota = quotaCache.get(provider)?.quota; + if (!quota) return null; + if (now - quota.updatedAt > maxAgeMs) return null; + return quota; + } + ++/** Only inference-wide evidence for this sole credential may rank or veto a whole provider. */ ++export function getCachedProviderRoutingQuota( ++ name: string, ++ provider: OcxProviderConfig | undefined, ++ now: number, ++ maxAgeMs = 30 * 60_000, ++): ProviderQuota | null { ++ if (!provider || provider.disabled === true || (provider.authMode ?? "key") !== "key") return null; ++ // An active-key report cannot speak for the other keys the dispatcher may select. ++ if ((provider.apiKeyPool?.length ?? 0) > 1) return null; ++ const routing = quotaCache.get(name)?.routing; ++ if (!routing || now - routing.quota.updatedAt > maxAgeMs) return null; ++ const binding = providerQuotaRoutingBinding(name, provider); ++ if (!binding || (!("testOnly" in routing) && routing.binding !== binding)) return null; ++ return routing.quota; ++} ++ + export function setCachedProviderQuotaForTests( + provider: string, + quota: ProviderQuota, + ): void { +- quotaCache.set(provider, quota); ++ // Unit tests deliberately assert the supplied quota's scope. Production publication ++ // requires the producer's private, credential-bound evidence map above. ++ quotaCache.set(provider, { quota, routing: { quota, testOnly: true } }); + } +diff --git a/src/providers/quota.ts b/src/providers/quota.ts +index 69ee60626c..6909e46131 100644 +--- a/src/providers/quota.ts ++++ b/src/providers/quota.ts +@@ -39,7 +39,9 @@ import { + } from "./quota-wire"; + import { + clearCachedProviderQuotas, ++ providerQuotaRoutingBinding, + replaceCachedProviderQuotas, ++ type ProviderQuotaRoutingEvidence, + } from "./quota-routing-cache"; + import { + aggregateCodexPoolCapacity, +@@ -103,6 +105,7 @@ const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`; + const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS; + const nativeMainReportGenerations = new WeakMap(); + const accountReportCurrent = new WeakMap boolean>(); ++const routingEvidence = new WeakMap(); + let providerQuotaBeforePublishForTests: (() => void | Promise) | null = null; + + /** Test-only seam for identity/config invalidation after probes but before publication. */ +@@ -447,7 +450,9 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro + ? { expiresAt: normalizedExpiry } + : {}; + if (unlimited) { +- return report(provider, "a6api:billing", { ++ // Every row is an API-credit constraint on inference, so the display quota is also ++ // the routing projection. Passing it explicitly is the opt-in. ++ const quota: ProviderQuota = { + creditsUsd: { + used: 0, + limit: 0, +@@ -458,7 +463,8 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro + }, + customWindows: [{ label: "Unlimited API credits", percent: 0 }], + updatedAt: Date.now(), +- }); ++ }; ++ return keyReport(provider, "a6api:billing", quota, config, apiKey, quota); + } + const limitUsd = firstFinite(subscription, ["hard_limit_usd"]); + const grantedUnits = firstFinite(token, ["total_granted"]); +@@ -481,7 +487,7 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro + const percent = normalizePercent((usedUsd / limitUsd) * 100); + if (percent === undefined) return TERMINAL_QUOTA_FAILURE; + const label = `API credits ($${remainingUsd.toFixed(2)} of $${limitUsd.toFixed(2)} remaining)`; +- return report(provider, "a6api:billing", { ++ const quota: ProviderQuota = { + creditsUsd: { + used: usedUsd, + limit: limitUsd, +@@ -491,7 +497,9 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro + }, + customWindows: [{ label, percent }], + updatedAt: Date.now(), +- }); ++ }; ++ // The credit balance funds inference itself, so display and routing scope agree. ++ return keyReport(provider, "a6api:billing", quota, config, apiKey, quota); + } + + function parseOpenCodeGoUsageWindow(value: unknown): { percent: number; resetAt?: number } | null { +@@ -539,7 +547,7 @@ async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig) + } : {}), + updatedAt: Date.now(), + }; +- return report(provider, "opencode-go:usage", quota); ++ return keyReport(provider, "opencode-go:usage", quota, config, apiKey, quota); + } + + /** +@@ -583,10 +591,13 @@ async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig) + if (percent === undefined) return null; + const remaining = Math.max(0, limit - used); + const label = `API credits ($${remaining.toFixed(2)} of $${limit.toFixed(2)} remaining)`; +- return report(provider, "openrouter:key-info", { ++ // The per-key spending cap stops every request this credential can make, so the ++ // whole report is inference-wide routing evidence. ++ const quota: ProviderQuota = { + customWindows: [{ label, percent }], + updatedAt: Date.now(), +- }); ++ }; ++ return keyReport(provider, "openrouter:key-info", quota, config, apiKey, quota); + } + + /** +@@ -685,7 +696,7 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro + windows += 1; + } + } +- return windows > 0 ? report(provider, "cline:plan-usage-limits", quota) : null; ++ return windows > 0 ? keyReport(provider, "cline:plan-usage-limits", quota, config, apiKey, quota) : null; + } + + /** +@@ -757,7 +768,7 @@ async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig + } + const body = asRecord(await readQuotaJson(response)); + const quota = parseOllamaCloudQuota(body); +- return quota ? report(provider, "ollama-cloud:usage", quota) : null; ++ return quota ? keyReport(provider, "ollama-cloud:usage", quota, config, apiKey, quota) : null; + } + + /** +@@ -887,10 +898,18 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi + // model window — for example a plan reporting only the monthly MCP `TIME_LIMIT` row. + // Returning `null` here would preserve the previous token windows for up to 30 minutes + // and keep quota-aware routing acting on a report the provider has already superseded. +- return quota ? report(provider, "zai:quota-limit", quota) : AUTHORITATIVE_EMPTY_QUOTA; ++ return quota ++ ? keyReport(provider, "zai:quota-limit", quota, config, apiKey, quota) ++ : AUTHORITATIVE_EMPTY_QUOTA; + } + const legacy = parseZaiQuotaLegacyFields(data); +- return legacy ? report(provider, "zai:quota-limit", legacy) : null; ++ if (!legacy) return null; ++ // The legacy monthly figure also carries MCP usage; it is display evidence, not ++ // proof that model inference is unavailable. Modern TOKEN_LIMIT rows above are scoped. ++ const inferenceQuota = { ...legacy }; ++ delete inferenceQuota.monthlyPercent; ++ delete inferenceQuota.monthlyResetAt; ++ return keyReport(provider, "zai:quota-limit", legacy, config, apiKey, inferenceQuota); + } + + /** +@@ -1073,7 +1092,9 @@ async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): + quota.customWindows = [...(quota.customWindows ?? []), { label: "Search hourly", percent: searchHourly }]; + windows += 1; + } +- return windows > 0 ? report(provider, "synthetic:quotas", quota) : null; ++ const inferenceQuota = { ...quota }; ++ delete inferenceQuota.customWindows; // search.hourly does not constrain model inference. ++ return windows > 0 ? keyReport(provider, "synthetic:quotas", quota, config, apiKey, inferenceQuota) : null; + } + + /** +@@ -1185,6 +1206,31 @@ function report( + }; + } + ++/** ++ * Publish a credential-bound report, and routing evidence only when the producer ++ * hands over its inference-only projection. ++ * ++ * The projection is deliberately not defaulted to the display quota. A producer must ++ * decide that its rows really do constrain inference on the probed credential; omitting ++ * the argument leaves the report display-only, so a new producer cannot inherit ++ * provider-veto authority merely by calling this helper. Ownership alone is not the ++ * scope decision: providerQuotaRoutingBinding resolving is necessary, never sufficient. ++ */ ++function keyReport( ++ provider: string, ++ source: string, ++ quota: ProviderQuota, ++ config: OcxProviderConfig, ++ probedCredential: string, ++ inferenceQuota?: ProviderQuota, ++): ProviderQuotaReport | null { ++ const result = report(provider, source, quota); ++ if (!result || !inferenceQuota) return result; ++ const binding = providerQuotaRoutingBinding(provider, config, probedCredential); ++ if (binding) routingEvidence.set(result, { quota: inferenceQuota, binding }); ++ return result; ++} ++ + function tagNativeMainReport( + value: ProviderQuotaReport | null, + generation: number, +@@ -1193,6 +1239,27 @@ function tagNativeMainReport( + return value; + } + ++/** ++ * Test-only seam: publish exactly as a credential-bound producer does, and hand back the ++ * routing evidence the publication actually attached. ++ * ++ * Live producers all pass a projection today, so no probe fixture can prove the OTHER half ++ * of the contract: that omitting it stays display-only. Routing an omitted argument through ++ * the real helper keeps that provable, and a re-introduced `= quota` default would be ++ * observed here (a defaulted parameter also fires for an explicitly undefined argument). ++ */ ++export function publishKeyReportForTests( ++ provider: string, ++ source: string, ++ quota: ProviderQuota, ++ config: OcxProviderConfig, ++ probedCredential: string, ++ inferenceQuota?: ProviderQuota, ++): { report: ProviderQuotaReport | null; routing: ProviderQuotaRoutingEvidence | undefined } { ++ const result = keyReport(provider, source, quota, config, probedCredential, inferenceQuota); ++ return { report: result, routing: result ? routingEvidence.get(result) : undefined }; ++} ++ + function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolean { + const generation = nativeMainReportGenerations.get(value); + return (generation === undefined || isMainAccountIdentityGenerationLive(generation)) +@@ -1888,7 +1955,7 @@ export function reconcileProviderAccountQuotaRows(context: GenerationContext): n + const reports = cache.response.reports.filter(report => context.providerNames.has(report.provider)); + removed += cache.response.reports.length - reports.length; + cache = { ...cache, response: { ...cache.response, reports } }; +- replaceCachedProviderQuotas(reports); ++ replaceCachedProviderQuotas(reports, routingEvidence); + } + liveAccountQuotaKeys = new Set(context.oauthAccountKeys); + liveProviderQuotaKeys = new Set(context.providerNames); +@@ -2317,7 +2384,7 @@ async function fetchKimiQuota(provider: string, config: OcxProviderConfig, acces + }); + if (!response.ok) return null; + const quota = parseKimiQuotaPayload(await readQuotaJson(response)); +- return quota ? report(provider, "kimi:usages", quota) : null; ++ return quota ? keyReport(provider, "kimi:usages", quota, config, accessToken, quota) : null; + } + + /** +@@ -2444,7 +2511,7 @@ async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig + const fiveHour = parseCommandCodeWindow(limits?.fiveHour); + const weekly = parseCommandCodeWindow(limits?.weekly); + const creditsUsd = await fetchCommandCodeSpend(bearer, credits, orgQuery); +- return report(provider, "command-code:credits", { ++ const quota: ProviderQuota = { + ...(fiveHour ? { + fiveHourPercent: fiveHour.percent, + ...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}), +@@ -2455,7 +2522,9 @@ async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig + } : {}), + ...(creditsUsd ? { creditsUsd } : {}), + updatedAt: Date.now(), +- }); ++ }; ++ // Rolling windows and the credit balance both gate inference on this bearer. ++ return keyReport(provider, "command-code:credits", quota, config, bearer, quota); + } + + /** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */ +@@ -2964,7 +3033,9 @@ async function maybeFetchProviderQuota( + // probe to run — the row is the active account's last in-band observation. + if (provider.authMode === "oauth" && hasPassiveAccountQuota(name)) return fetchPassiveProviderQuota(name); + const reader = keyQuotaReaderForProvider(name, provider); +- return reader ? reader(name, provider) : null; ++ // Keep destination/auth fields bound to the same request as the reader's captured ++ // bearer, even if the live provider object changes while the quota probe awaits. ++ return reader ? reader(name, { ...provider }) : null; + } catch { + return null; + } +@@ -3151,7 +3222,7 @@ export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh + ) { + const reports = response.reports.filter(item => mayCommitProviderQuotaKey(item.provider, writerGeneration)); + cache = { key, ts: Date.now(), response: { ...response, reports } }; +- replaceCachedProviderQuotas(reports); ++ replaceCachedProviderQuotas(reports, routingEvidence); + notifyProviderQuotaSnapshot(reports, config); + } + return response; +diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md +index 3aac18b6ef..23ab3bb17e 100644 +--- a/structure/04_transports-and-sidecars.md ++++ b/structure/04_transports-and-sidecars.md +@@ -1638,6 +1638,40 @@ retried. Guarded paths: the ChatGPT passthrough and generic adapter fetch in + fallback. Adapters with their own `fetchResponse` (kiro, cursor, google) keep their own retry + policies; kiro imports the shared abort/sleep helpers from this module. + ++## Cached quota used by Combo selection ++ ++Provider quota reports describe the observed account, model group, or service window; they are ++not automatically proof that every request through the provider is unavailable. Before account ++selection, Combo exclusion and `reset-window` ranking consume only the producer's inference-wide ++subset for the current single API key. Synthetic search windows and legacy ZAI MCP monthly data ++remain display rows, while credential-wide key limits such as the OpenRouter spending cap remain ++eligible for early exclusion. ++ ++Routing evidence is published only when a producer hands the reporting helper its inference-only ++projection. Omitting that argument leaves the report display-only, so a new quota producer cannot ++inherit provider-veto authority merely by reporting through the credential-bound helper, and ++ownership by itself is never the scope decision. The producer records the subset it opts into in ++a private WeakMap bound to the provider name, adapter, destination and captured probe credential. ++Publication retains that evidence without adding it to report JSON. ++ ++The cache getter rechecks the live key, effective registry authentication, static ++credential headers, key-pool size and freshness. OAuth/current-account reports, caller-forward ++routes and ambiguous credential scopes cannot rank or veto the provider before its normal ++account selection. Restoring a matching configuration may reuse still-fresh evidence; a new ++credential cannot inherit another key's cap. The same getter controls immediate selection, ++bounded cooldown waiting and reset-window ordering. This does not override explicit eligibility, ++target cooldowns, account admission or response-driven retry rules. ++ ++```text ++[Decision Log] ++- 목적과 의도: Keep account-, model- and service-scoped quota from disabling an otherwise usable Combo provider while retaining valid single-key inference caps. ++- 기존 구현 및 제약 조건: The routing cache retained only the display quota and treated any exhausted window as a provider-wide veto before account/key selection. ++- 검토한 주요 대안: Remove quota pruning entirely; infer scope from display labels; or require producer-owned inference scope and current credential binding. ++- 선택한 방식: Publish private scoped evidence only for a producer that explicitly supplies its inference projection, and validate it in both provider exclusion and reset-window ranking. ++- 다른 대안 대신 이 방식을 선택한 이유: Display labels cannot prove credential ownership, while deleting the gate would lose valid OpenRouter and other single-key caps. ++- 장점, 단점 및 영향: Scoped/ambiguous reports become unknown for early routing and may require normal dispatch to establish availability; actual account and retry limits remain authoritative. ++``` ++ + ## Same-provider combo quota fallback + + For a failover combo with multiple models on the same Codex-login OpenAI provider, a pre-stream +diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts +index 98174c3848..76e4f26ca9 100644 +--- a/tests/codex-integration/combos.test.ts ++++ b/tests/codex-integration/combos.test.ts +@@ -910,7 +910,7 @@ describe("combo failure policy and advancement", () => { + expect(sleeps).toEqual([1_000]); + }); + +- test("still filters exhausted quota on a noncanonical forward destination", () => { ++ test("does not infer provider-wide quota from a noncanonical forward row without a credential", () => { + const now = 50_000; + const config = baseConfig({ + providers: { +@@ -927,7 +927,8 @@ describe("combo failure policy and advancement", () => { + + const pick = pickComboTarget(config, "free", { now }); + +- expect(pick?.target.provider).toBe("b"); ++ // This is quota selection, not proof that this custom forward route can authenticate. ++ expect(pick?.target.provider).toBe("a"); + }); + + test("retains caller eligibility restrictions for native targets", () => { +@@ -1150,6 +1151,20 @@ describe("deterministic combo selection", () => { + }); + }); + ++ test.each(["oauth", "header", "key-pool"])("reset-window does not rank an inapplicable snapshot: %s", kind => { ++ const now = Date.now(); ++ const config = baseConfig({ combos: { free: { strategy: "reset-window", targets: [ ++ { provider: "a", model: "m1" }, { provider: "b", model: "m2" }, ++ ] } } }); ++ setCachedProviderQuotaForTests("a", { updatedAt: now, weeklyResetAt: now + 2_000 }); ++ setCachedProviderQuotaForTests("b", { updatedAt: now, weeklyResetAt: now + 1_000 }); ++ expect(pickComboTarget(config, "free", { now })?.target.provider).toBe("b"); ++ if (kind === "oauth") config.providers.b!.authMode = "oauth"; ++ else if (kind === "header") config.providers.b!.headers = { Authorization: "Bearer different-key" }; ++ else config.providers.b!.apiKeyPool = [{ id: "one", key: "one" }, { id: "two", key: "two" }]; ++ expect(pickComboTarget(config, "free", { now })?.target.provider).toBe("a"); ++ }); ++ + test("reset-window treats elapsed resets as unknown and falls back to configured order", () => { + const now = Date.now(); + const config = baseConfig({ +diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts +index 56d69951d5..0f7ea31451 100644 +--- a/tests/providers/provider-quota.test.ts ++++ b/tests/providers/provider-quota.test.ts +@@ -18,10 +18,14 @@ import { + parseXaiCreditsResponse, + QUOTA_RESPONSE_MAX_BYTES, + readProviderQuotaJsonForTests, ++ publishKeyReportForTests, + setAntigravityAccountQuotaTransportForTests, + setProviderQuotaBeforePublishForTests, + } from "../../src/providers/quota"; + import type { OcxConfig } from "../../src/types"; ++import { clearComboTargetCooldowns, coolComboTarget, pickComboTarget, pickComboTargetWithWait } from "../../src/combos"; ++import { routedProviderConfig } from "../../src/router"; ++import { buildOpenAIChatPassthroughRequest } from "../../src/adapters/openai-chat"; + import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; + import { repoPath } from "../helpers/repo-root"; + const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +@@ -95,6 +99,7 @@ beforeEach(() => { + }); + + afterEach(() => { ++ clearComboTargetCooldowns(); + for (const key of proxyKeys) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; +@@ -703,6 +708,241 @@ describe("fetchProviderQuotaReports", () => { + } as OcxConfig; + } + ++ function quotaCombo(config: OcxConfig): OcxConfig { ++ const provider = config.defaultProvider; ++ return { ++ ...config, ++ providers: { ++ ...config.providers, ++ fallback: { adapter: "openai-chat", baseUrl: "https://fallback.example/v1", apiKey: "fallback-key" }, ++ }, ++ combos: { "quota-scope": { strategy: "failover", targets: [ ++ { provider, model: "primary-model" }, { provider: "fallback", model: "fallback-model" }, ++ ] } }, ++ }; ++ } ++ ++ test("routing quota scope keeps Synthetic search exhaustion out of model selection", async () => { ++ globalThis.fetch = (async () => Response.json({ ++ data: { rollingFiveHourLimit: 20, weeklyTokenLimit: 30, search: { hourly: 100 } }, ++ })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("synthetic", "https://api.synthetic.new/v2")); ++ const reports = await fetchProviderQuotaReports(config, true); ++ expect(reports.reports[0]?.quota.customWindows?.[0]?.percent).toBe(100); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("synthetic"); ++ }); ++ ++ test("routing quota scope keeps ZAI legacy MCP exhaustion out of model selection", async () => { ++ globalThis.fetch = (async () => Response.json({ ++ success: true, data: { fiveHourPercent: 20, weeklyPercent: 30, monthlyMCPUsage: 100 }, ++ })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("zai", "https://api.z.ai/api/coding/paas/v4")); ++ const reports = await fetchProviderQuotaReports(config, true); ++ expect(reports.reports[0]?.quota.monthlyPercent).toBe(100); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("zai"); ++ }); ++ ++ test("routing quota scope keeps a key-bound display-only report out of model selection", async () => { ++ // MiniMax publishes its Token Plan countdown through the display-only path. The provider ++ // is single-key `key` auth, so ownership alone would resolve a routing binding; without ++ // an inference projection the exhausted row must still not rank or veto the target. ++ globalThis.fetch = (async () => Response.json({ ++ success: true, data: { remains_time: 0, total_time: 1_000_000_000 }, ++ })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("minimax", "https://api.minimax.io/v1")); ++ const reports = await fetchProviderQuotaReports(config, true); ++ expect(reports.reports[0]?.quota.customWindows?.[0]?.percent).toBe(100); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("minimax"); ++ }); ++ ++ test("routing quota scope retains the OpenRouter single-key spending cap", async () => { ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ }); ++ ++ test("keyReport publishes routing evidence only for an explicit inference projection", () => { ++ // The MiniMax case above rides the display-only `report()` path, so it would still pass if ++ // `keyReport`'s projection were quietly defaulted back to the display quota. This drives the ++ // credential-bound helper directly: the binding resolves for BOTH calls (same single-key ++ // provider and probed credential), so the only variable left is the projection itself. ++ const provider = keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1").providers.openrouter!; ++ const exhausted = { monthlyPercent: 100 }; ++ ++ const omitted = publishKeyReportForTests("openrouter", "openrouter:key-info", exhausted, provider, "openrouter-secret"); ++ expect(omitted.report?.quota.monthlyPercent).toBe(100); ++ expect(omitted.routing).toBeUndefined(); ++ ++ const projected = { monthlyPercent: 100 }; ++ const explicit = publishKeyReportForTests( ++ "openrouter", "openrouter:key-info", exhausted, provider, "openrouter-secret", projected, ++ ); ++ expect(explicit.routing?.quota).toBe(projected); ++ expect(typeof explicit.routing?.binding).toBe("string"); ++ }); ++ ++ test("routing quota scope does not apply a probed key cap to an Authorization override", async () => { ++ const probeAuth: Array = []; ++ globalThis.fetch = (async (_input, init) => { ++ probeAuth.push(new Headers(init?.headers).get("authorization")); ++ return Response.json({ data: { limit: 20, limit_remaining: 0 } }); ++ }) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1", "spent-A")); ++ config.providers.openrouter!.headers = { Authorization: "Bearer live-B" }; ++ await fetchProviderQuotaReports(config, true); ++ const request = buildOpenAIChatPassthroughRequest(routedProviderConfig("openrouter", config.providers.openrouter!), { ++ messages: [{ role: "user", content: "synthetic" }], ++ }, "primary-model", false); ++ expect(probeAuth).toEqual(["Bearer spent-A"]); ++ expect(new Headers(request.headers).get("authorization")).toBe("Bearer live-B"); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); ++ ++ delete config.providers.openrouter!.headers; ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ }); ++ ++ test("routing quota scope rechecks an Authorization override added after publication", async () => { ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1", "spent-A")); ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ config.providers.openrouter!.headers = { aUtHoRiZaTiOn: "Bearer live-B" }; ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); ++ delete config.providers.openrouter!.headers; ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ }); ++ ++ test("routing quota scope does not apply a probed key cap to an Anthropic x-api-key override", async () => { ++ const probeAuth: Array = []; ++ globalThis.fetch = (async (_input, init) => { ++ probeAuth.push(new Headers(init?.headers).get("authorization")); ++ return Response.json({ usage: { limit: "100", used: "100" } }); ++ }) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("kimi-code", "https://api.kimi.com/coding/v1", "spent-A")); ++ config.providers["kimi-code"]!.adapter = "anthropic"; ++ config.providers["kimi-code"]!.headers = { "x-api-key": "live-B" }; ++ await fetchProviderQuotaReports(config, true); ++ expect(probeAuth).toEqual(["Bearer spent-A"]); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("kimi-code"); ++ delete config.providers["kimi-code"]!.headers; ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ }); ++ ++ test.each(["key", "omitted", "custom-key"])("routing quota scope follows effective Kimi authentication: %s", async mode => { ++ globalThis.fetch = (async () => Response.json({ usage: { limit: "100", used: "100" } })) as typeof fetch; ++ const name = mode === "custom-key" ? "kimi-code" : "kimi"; ++ const config = quotaCombo(keyQuotaConfig(name, "https://api.kimi.com/coding/v1", "spent-A")); ++ if (mode === "omitted") delete config.providers[name]!.authMode; ++ expect(routedProviderConfig(name, config.providers[name]!).authMode).toBe(mode === "custom-key" ? "key" : "oauth"); ++ const report = await fetchProviderQuotaReports(config, true); ++ expect(report.reports[0]?.quota.weeklyPercent).toBe(100); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe(mode === "custom-key" ? "fallback" : "kimi"); ++ }); ++ ++ test("routing quota scope keeps an exhausted Gemini group from vetoing an Antigravity Claude target", async () => { ++ await saveCredential("google-antigravity", { ++ access: "synthetic-agy-access", refresh: "synthetic-agy-refresh", ++ expires: Date.now() + 3600_000, projectId: "synthetic-project", ++ }); ++ const resetTime = new Date(Date.now() + 3600_000).toISOString(); ++ setAntigravityAccountQuotaTransportForTests({ ++ resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), ++ pinnedPost: async url => { ++ expect(url.endsWith("retrieveUserQuotaSummary")).toBe(true); ++ return Response.json({ groups: [ ++ { displayName: "Gemini Models", buckets: [{ window: "5h", remainingFraction: 0, resetTime }] }, ++ { displayName: "Claude and GPT models", buckets: [{ window: "5h", remainingFraction: 1, resetTime }] }, ++ ] }); ++ }, ++ }); ++ const config = quotaCombo({ defaultProvider: "google-antigravity", providers: { ++ "google-antigravity": { adapter: "google", authMode: "oauth", baseUrl: "https://daily-cloudcode-pa.googleapis.com" }, ++ } } as OcxConfig); ++ config.combos!["quota-scope"]!.targets[0]!.model = "claude-sonnet-4.6"; ++ const report = await fetchProviderQuotaReports(config, true); ++ expect(report.reports[0]?.quota.customWindows).toEqual([ ++ { label: "Gem", percent: 100, resetAt: Date.parse(resetTime) }, ++ { label: "Cla", percent: 0, resetAt: Date.parse(resetTime) }, ++ ]); ++ expect(pickComboTarget(config, "quota-scope")?.target).toMatchObject({ provider: "google-antigravity", model: "claude-sonnet-4.6" }); ++ }); ++ ++ test("routing quota scope keeps an active Anthropic account report out of whole-provider selection", async () => { ++ await saveCredential("anthropic", { ++ access: "synthetic-claude-access", refresh: "synthetic-claude-refresh", expires: Date.now() + 3600_000, ++ }); ++ globalThis.fetch = (async input => { ++ expect(String(input)).toBe("https://api.anthropic.com/api/oauth/usage"); ++ return Response.json({ five_hour: { utilization: 100, resets_at: new Date(Date.now() + 3600_000).toISOString() } }); ++ }) as typeof fetch; ++ const config = quotaCombo({ defaultProvider: "anthropic", providers: { ++ anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com/v1" }, ++ } } as OcxConfig); ++ const report = await fetchProviderQuotaReports(config, true); ++ expect(report.reports[0]?.quota.fiveHourPercent).toBe(100); ++ // Account selection and its exhaustion rules still decide whether this route can dispatch. ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("anthropic"); ++ config.providers.fallback!.disabled = true; ++ const now = Date.now(); ++ const waits: number[] = []; ++ coolComboTarget("quota-scope", config.combos!["quota-scope"]!.targets[0]!, { now, cooldownMs: 1_000 }); ++ const afterWait = await pickComboTargetWithWait(config, "quota-scope", { ++ now, waitForCooldownMs: 1_000, sleep: async ms => { waits.push(ms); }, ++ }); ++ expect(waits).toEqual([1_000]); ++ expect(afterWait?.target.provider).toBe("anthropic"); ++ }); ++ ++ test("routing quota scope retains a verified cap through a transient refresh failure", async () => { ++ let transient = false; ++ globalThis.fetch = (async () => transient ++ ? new Response("unavailable", { status: 503 }) ++ : Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ transient = true; ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ }); ++ ++ test("routing quota scope stops vetoing the provider when a second key is added", async () => { ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); ++ await fetchProviderQuotaReports(config, true); ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("fallback"); ++ config.providers.openrouter!.apiKeyPool = [ ++ { id: "old", key: "openrouter-secret" }, { id: "new", key: "second-key" }, ++ ]; ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); ++ }); ++ ++ test("routing quota scope rejects a cached cap after the active key changes", async () => { ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1")); ++ await fetchProviderQuotaReports(config, true); ++ config.providers.openrouter!.apiKey = "replacement-key"; ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); ++ }); ++ ++ test("routing quota scope rejects a cached cap after an env key resolves differently", async () => { ++ const previous = process.env.OCX_TEST_ROUTING_QUOTA_KEY; ++ try { ++ process.env.OCX_TEST_ROUTING_QUOTA_KEY = "first-key"; ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const config = quotaCombo(keyQuotaConfig("openrouter", "https://openrouter.ai/api/v1", "$OCX_TEST_ROUTING_QUOTA_KEY")); ++ await fetchProviderQuotaReports(config, true); ++ process.env.OCX_TEST_ROUTING_QUOTA_KEY = "replacement-key"; ++ expect(pickComboTarget(config, "quota-scope")?.target.provider).toBe("openrouter"); ++ } finally { ++ if (previous === undefined) delete process.env.OCX_TEST_ROUTING_QUOTA_KEY; ++ else process.env.OCX_TEST_ROUTING_QUOTA_KEY = previous; ++ } ++ }); ++ + test("OpenRouter quota renders a credit window against the per-key cap", async () => { + const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { +``` + +## Current-dev consumer amendment + +`src/combos/resolve.ts:126` has a newer catalog `quotaInactiveReason` consumer. MODIFY its loop from separate native-forward exemption + `getCachedProviderQuota(target.provider, now)` to `getCachedProviderRoutingQuota(target.provider, provider, now)`. Unknown routing evidence returns undefined, explicit exhausted evidence retains no_credit. MODIFY the existing quota inactive tests (locate with rg quotaInactiveReason tests) to use credential-bearing provider fixtures and prove display-only/mismatched evidence cannot mark catalog rows inactive. Update stale explanatory comments to reference the scoped cache. This preserves consistency after removed imports and is necessary current-dev integration, not unrelated catalog redesign. + +## Design reflection D5 amendment + +Accept Bohr D1-D6 with D5 corrected: MODIFY `getCachedProviderRoutingQuota` to return null for nonfinite/negative/future timestamps or age `>= maxAgeMs`, aligning runtime/catalog with the editor's exclusive deadline. Retain display getter compatibility. MODIFY `tests/codex-integration/catalog-zero-credit-picker.test.ts` with genuine WeakMap publication plus positive control; then mutate apiKey/baseUrl/adapter independently and require undefined inactivity. Test timestamp at exactly 30 minutes, future, negative and NaN as unknown. This is the shared scoped evidence boundary, not a new auth flow. No local suites; hosted final tip owns execution. diff --git a/devlog/_plan/260912_combo_carry/020_editor.md b/devlog/_plan/260912_combo_carry/020_editor.md new file mode 100644 index 0000000000..fe03bd8982 --- /dev/null +++ b/devlog/_plan/260912_combo_carry/020_editor.md @@ -0,0 +1,1091 @@ +# editor carry + +MODIFY exactly the paths in this public source diff. Apply each original commit in order with attribution. Resolve retired structure document into current runtime.md and gui-and-management-api.md; never restore the retired file. Earlier phase dependency: runtime. + +Before/after source contract (review against current dev at P; source diff is the executable carry input): + +```diff +diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md +index 9434a1e98a..9073372327 100644 +--- a/docs-site/src/content/docs/fr/guides/combos.md ++++ b/docs-site/src/content/docs/fr/guides/combos.md +@@ -281,9 +281,7 @@ Ouvrez le tableau de bord local et choisissez **Modèles → Combos**. L'espace + combos, et son sélecteur de cible exclut les modèles désactivés et les combos imbriqués. + + Chaque cible affiche aussi un badge de quota en direct : **Disponible**, **Quota épuisé** ou **Quota inconnu**. +-Enregistrer et Créer ne sont désactivés que lorsque chaque cible activée dispose de preuves fraîches et complètes +-que son quota est épuisé. Les données manquantes, obsolètes, mal formées ou agrégées de façon incomplète restent +-inconnues et ne verrouillent jamais un contrôle. La récupération du quota réactive automatiquement l’action. ++L’éditeur bloque Enregistrer et Créer pour une raison de quota uniquement lorsque chaque cible utilisable dispose d’une confirmation serveur encore valide indiquant que la limite d’inférence liée à ses identifiants configurés est épuisée. Les quotas de compte, de modèle, de recherche et de MCP fournis uniquement à titre d’affichage, ainsi que les informations de routage absentes ou expirées, ne déclenchent pas ce blocage. Le blocage expire à la réinitialisation applicable ou à l’expiration de la validité des données et fait l’objet d’une nouvelle vérification lorsque la page devient active ou visible ; Actualiser recharge à la fois les données des combos et les quotas. + + ### CLI + +diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md +index c7d076d9b7..ef5ccde16c 100644 +--- a/docs-site/src/content/docs/guides/combos.md ++++ b/docs-site/src/content/docs/guides/combos.md +@@ -349,10 +349,7 @@ task workflow. + Open the local dashboard and choose **Models → Combos**. The workspace creates, edits, renames, and removes + combos, and its target picker excludes disabled models and nested combos. + +-Each target also shows a live quota badge: **Available**, **Out of quota**, or **Quota unknown**. Save and +-Create are disabled only when every enabled target has fresh, complete evidence that its quota is exhausted. +-Missing, stale, malformed, or incomplete aggregate evidence stays unknown and never locks a control. Polling +-continues while the workspace is visible, so recovery automatically restores the action. The dashboard ++Each target also shows a live quota badge: **Available**, **Out of quota**, or **Quota unknown**. The editor blocks Save and Create for quota only when every usable target has a current server-confirmed exhausted inference limit for its configured credential. Display-only account, model, search and MCP quota, or missing or expired routing evidence, does not cause this block. The block expires at the applicable reset or freshness boundary and is rechecked when the page becomes active or visible; Refresh reloads both Combo data and quota. The dashboard + editor does not yet expose `cooldownMs` or `waitForCooldownMs`; use the configuration file or management + API until the follow-up UI work lands. + +diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md +index f6eca53214..70c902ec6e 100644 +--- a/docs-site/src/content/docs/ja/guides/combos.md ++++ b/docs-site/src/content/docs/ja/guides/combos.md +@@ -182,8 +182,7 @@ v1/base/v2 モードと完全な暗号化タスクのワークフローについ + ローカル ダッシュボードを開き、**Models → コンボ**を選択します。ワークスペースはコンボを作成、編集、名前変更、削除し、そのターゲット ピッカーは無効なモデルとネストされたコンボを除外します。 + + 各ターゲットには **利用可能**、**クォータを使い切りました**、**クォータ不明** のライブバッジも表示されます。 +-保存と作成が無効になるのは、有効な全ターゲットについて、クォータ枯渇を示す新鮮で完全な証拠がある場合だけです。 +-欠落、古い、不正、または不完全な集約データは不明のままで、操作をロックしません。クォータが回復すると操作は自動で再び有効になります。ダッシュボードのエディターではまだ `cooldownMs` と `waitForCooldownMs` を設定できません。後続の UI 作業が完了するまでは、構成ファイルまたは管理 API を使用してください。 ++エディターがクォータを理由に保存と作成をブロックするのは、使用可能なすべてのターゲットについて、設定された認証情報の推論上限に達したことを示す、サーバーによる確認が現在も有効な場合だけです。表示専用のアカウント・モデル・検索・MCP クォータや、ルーティングの根拠情報の欠落・期限切れによって、このブロックが発生することはありません。ブロックは該当するリセット時刻またはデータの有効期限に解除され、ページがアクティブになるか表示状態になると再確認されます。「更新」はコンボデータとクォータの両方を再読み込みします。ダッシュボードのエディターではまだ `cooldownMs` と `waitForCooldownMs` を設定できません。後続の UI 作業が完了するまでは、構成ファイルまたは管理 API を使用してください。 + + ### CLI + +diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md +index 71e557f25c..80eac32c2d 100644 +--- a/docs-site/src/content/docs/ko/guides/combos.md ++++ b/docs-site/src/content/docs/ko/guides/combos.md +@@ -187,9 +187,7 @@ v1/base/v2 모드와 암호화된 작업의 전체 흐름은 [Sub-agent Surface] + + 로컬 대시보드를 열고 **Models → Combos**를 선택합니다. 워크스페이스는 콤보를 만들고, 편집하고, 이름을 바꾸고, 제거할 수 있으며, 대상 선택기에서는 비활성 모델과 중첩 콤보를 제외합니다. + +-각 대상에는 **사용 가능**, **할당량 소진**, **할당량 알 수 없음** 실시간 배지도 표시됩니다. 저장과 만들기 버튼은 +-활성화된 모든 대상에 할당량 소진을 입증하는 최신의 완전한 증거가 있을 때만 비활성화됩니다. 누락되거나 오래되거나 +-형식이 잘못되었거나 집계가 불완전한 데이터는 알 수 없음으로 남으며 버튼을 잠그지 않습니다. 할당량이 복구되면 버튼도 자동으로 다시 활성화됩니다. 대시보드 편집기에서는 아직 `cooldownMs`나 `waitForCooldownMs`를 설정할 수 없습니다. 후속 UI 작업이 완료될 때까지 구성 파일이나 관리 API를 사용하세요. ++각 대상에는 **사용 가능**, **할당량 소진**, **할당량 알 수 없음** 실시간 배지도 표시됩니다. 편집기는 사용 가능한 모든 대상에 대해 설정된 인증 정보의 추론 한도가 소진되었다는 서버 확인이 현재 유효할 때만 할당량을 이유로 저장과 만들기를 차단합니다. 표시 전용 계정·모델·검색·MCP 할당량이나 누락되거나 만료된 라우팅 근거 정보는 이 차단을 일으키지 않습니다. 차단은 해당 한도의 초기화 시점이나 데이터 유효기간이 끝나면 해제되며 페이지가 활성화되거나 표시될 때 다시 확인됩니다. 새로 고침은 콤보 데이터와 할당량을 모두 다시 불러옵니다. 대시보드 편집기에서는 아직 `cooldownMs`나 `waitForCooldownMs`를 설정할 수 없습니다. 후속 UI 작업이 완료될 때까지 구성 파일이나 관리 API를 사용하세요. + + ### CLI + +diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md +index 868416ae5b..b873f4ed03 100644 +--- a/docs-site/src/content/docs/ru/guides/combos.md ++++ b/docs-site/src/content/docs/ru/guides/combos.md +@@ -234,9 +234,7 @@ effort вызывающей стороне и цели. + переименовывать и удалять combo, а селектор целей исключает отключённые модели и вложенные combo. + + У каждой цели также отображается актуальный значок квоты: **Доступно**, **Квота исчерпана** или **Квота неизвестна**. +-Кнопки сохранения и создания отключаются только тогда, когда для всех включённых целей есть свежие и полные +-данные об исчерпании квоты. Отсутствующие, устаревшие, некорректные или неполные агрегированные данные остаются +-неизвестными и никогда не блокируют управление. Восстановление квоты автоматически снова включает действие. Редактор дашборда пока не предоставляет `cooldownMs` и `waitForCooldownMs`; до появления соответствующего UI используйте файл конфигурации или Management API. ++Редактор блокирует сохранение и создание из-за квоты только тогда, когда для каждой пригодной к использованию цели есть действующее подтверждение сервера об исчерпании лимита инференса для настроенных учётных данных. Квоты аккаунта, модели, поиска и MCP, предназначенные только для отображения, а также отсутствующие или просроченные данные для принятия решения о маршрутизации не вызывают эту блокировку. Блокировка истекает при соответствующем сбросе квоты или окончании срока актуальности данных и проверяется повторно, когда страница становится активной или видимой; «Обновить» повторно загружает и данные combo, и квоты. Редактор дашборда пока не предоставляет `cooldownMs` и `waitForCooldownMs`; до появления соответствующего UI используйте файл конфигурации или Management API. + + ### CLI + +diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md +index 520c157e83..b8cd5bad0d 100644 +--- a/docs-site/src/content/docs/tr/guides/combos.md ++++ b/docs-site/src/content/docs/tr/guides/combos.md +@@ -312,9 +312,7 @@ hedef seçicisi ise devre dışı bırakılmış modelleri ve iç içe geçmiş + hariç tutar. + + Her hedef ayrıca canlı bir kota rozeti gösterir: **Kullanılabilir**, **Kota tükendi** veya **Kota bilinmiyor**. +-Kaydet ve Oluştur yalnızca etkin hedeflerin tamamı için kotanın tükendiğini gösteren güncel ve eksiksiz kanıt varsa +-devre dışı bırakılır. Eksik, eski, bozuk veya tamamlanmamış toplu kanıt bilinmiyor olarak kalır ve denetimleri asla +-kilitlemez. Kota yenilendiğinde işlem otomatik olarak yeniden etkinleşir. ++Düzenleyici, kota nedeniyle Kaydet ve Oluştur işlemlerini yalnızca kullanılabilir hedeflerin tümü için yapılandırılmış kimlik bilgisine ait çıkarım sınırının tükendiğini doğrulayan geçerli sunucu bilgisi varsa engeller. Yalnızca görüntüleme amaçlı hesap, model, arama ve MCP kotaları ya da eksik veya süresi dolmuş yönlendirme kanıtları bu engellemeye neden olmaz. Engelleme, ilgili sıfırlama zamanında veya verinin güncellik süresi dolduğunda sona erer ve sayfa etkin ya da görünür olduğunda yeniden kontrol edilir; Yenile, hem kombo verilerini hem de kotaları yeniden yükler. + + ### CLI + +@@ -411,4 +409,3 @@ Hata hedefe özgü olmaktan ziyade uç (terminal) bir hataydı. Geçersiz girdiy + düzeltin, aşırı büyük bir bağlamı azaltın, bir politika reddini işleyin veya + reddedilen istek kaynağını düzeltin. Kombolar bu durumlar için atlama yapmaz. + +- +diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md +index d84efca472..abe32ae786 100644 +--- a/docs-site/src/content/docs/zh-cn/guides/combos.md ++++ b/docs-site/src/content/docs/zh-cn/guides/combos.md +@@ -211,9 +211,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 + + 打开本地 dashboard 并选择 **Models → Combos**。该工作区可以创建、编辑、重命名和删除 combo,其目标选择器会排除已禁用的模型和嵌套 combo。 + +-每个目标还会显示实时额度徽章:**可用**、**额度已用尽**或**额度未知**。只有当所有已启用目标都有最新、 +-完整的额度耗尽证据时,保存和创建操作才会被禁用。缺失、过期、格式错误或聚合不完整的证据会保持为未知, +-绝不会锁定控件。额度恢复后,操作会自动重新启用。dashboard 编辑器目前还不能设置 `cooldownMs` 或 `waitForCooldownMs`;在后续 UI 完成前,请使用配置文件或管理 API。 ++每个目标还会显示实时额度徽章:**可用**、**额度已用尽**或**额度未知**。只有当每个可用目标均有当前有效的服务器确认,表明其所配置凭据的推理限额已耗尽时,编辑器才会因额度而禁止保存和创建。仅供显示的账户、模型、搜索和 MCP 额度,以及缺失或已过期的路由依据,都不会触发此限制。此限制会在适用的重置时间或数据有效期结束时解除,并在页面变为活动或可见状态时重新检查;刷新会同时重新加载 Combo 数据和额度。dashboard 编辑器目前还不能设置 `cooldownMs` 或 `waitForCooldownMs`;在后续 UI 完成前,请使用配置文件或管理 API。 + + ### CLI + +diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md +index d82b399e6f..ce3ad70a94 100644 +--- a/docs-site/src/content/docs/zh-tw/guides/combos.md ++++ b/docs-site/src/content/docs/zh-tw/guides/combos.md +@@ -219,9 +219,7 @@ Codex v2 子代理有一個重要限制([issue #92](https://github.com/lidge-j + + 開啟本機儀表板並選擇 **Combos**。該工作區可建立、編輯、重新命名與移除 combo,且其目標 picker 會排除已停用的模型與巢狀 combo。 + +-每個目標也會顯示即時額度徽章:**可用**、**額度已用盡**或**額度未知**。只有當所有已啟用目標都有最新、 +-完整的額度耗盡證據時,儲存與建立操作才會停用。缺失、過期、格式錯誤或聚合不完整的證據會維持未知, +-絕不會鎖住控制項。額度恢復後,操作會自動重新啟用。 ++每個目標也會顯示即時額度徽章:**可用**、**額度已用盡**或**額度未知**。只有當每個可用目標均有目前有效的伺服器確認,顯示其所設定憑證的推論限額已耗盡時,編輯器才會因配額而停用儲存與建立。僅供顯示的帳戶、模型、搜尋與 MCP 配額,以及缺失或已過期的路由依據,都不會觸發此限制。此限制會在適用的重設時間或資料有效期限結束時解除,並在頁面變為作用中或可見狀態時重新檢查;重新整理會同時重新載入 Combo 資料與配額。 + + ### CLI + +diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts +index bf8b881c55..b89bff656f 100644 +--- a/gui/src/combo-workspace-data.ts ++++ b/gui/src/combo-workspace-data.ts +@@ -4,6 +4,7 @@ + */ + + import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../../src/codex/catalog/native-models"; ++import { PROVIDER_QUOTA_MAX_AGE_MS } from "../../src/providers/quota-types"; + import type { TKey } from "./i18n/shared"; + + export { SUPPORTED_NATIVE_OPENAI_SLUGS }; +@@ -92,7 +93,7 @@ export type ComboQuotaState = "available" | "exhausted" | "unknown"; + export type ProviderQuotaStates = Readonly>; + + /** Matches the management endpoint's bounded last-good quota lifetime. */ +-export const COMBO_QUOTA_MAX_AGE_MS = 30 * 60_000; ++export const COMBO_QUOTA_MAX_AGE_MS = PROVIDER_QUOTA_MAX_AGE_MS; + + let comboTargetKeySeq = 0; + +@@ -282,133 +283,36 @@ function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; + } + +-function quotaTimestampIsFresh(value: unknown, now: number): boolean { +- const timestamp = finiteNumber(value); +- return timestamp !== null && now - timestamp < COMBO_QUOTA_MAX_AGE_MS; +-} +- +-function nonNegativeInteger(value: unknown): number | null { +- const number = finiteNumber(value); +- return number !== null && Number.isInteger(number) && number >= 0 ? number : null; +-} +- +-function aggregateWindowIsComplete(value: unknown, now: number): boolean { +- const window = recordFromUnknown(value); +- const usedPercent = finiteNumber(window?.usedPercent); +- return !!window +- && usedPercent !== null +- && usedPercent >= 0 +- && nonNegativeInteger(window.includedAccounts) !== null +- && (nonNegativeInteger(window.includedAccounts) ?? 0) > 0 +- && nonNegativeInteger(window.excludedAccounts) === 0 +- && window.incomplete === false +- && quotaTimestampIsFresh(window.updatedAt, now); +-} +- +-function aggregateEvidenceIsComplete(value: unknown, now: number): boolean { +- const aggregation = recordFromUnknown(value); +- if ( +- !aggregation +- || aggregation.kind !== "capacity-weighted-v1" +- || aggregation.scope !== "routable-known" +- || aggregation.presentation !== "aggregate" +- || aggregation.incomplete !== false +- ) return false; +- +- for (const key of [ +- "includedAccounts", +- "excludedAccounts", +- "unknownPlanAccounts", +- "missingQuotaAccounts", +- "pausedAccounts", +- "reauthAccounts", +- "staleQuotaAccounts", +- "partialWindowAccounts", +- ] as const) { +- if (nonNegativeInteger(aggregation[key]) === null) return false; +- } +- if ((nonNegativeInteger(aggregation.includedAccounts) ?? 0) === 0) return false; +- for (const key of [ +- "excludedAccounts", +- "unknownPlanAccounts", +- "missingQuotaAccounts", +- "pausedAccounts", +- "reauthAccounts", +- "staleQuotaAccounts", +- "partialWindowAccounts", +- ] as const) { +- if (aggregation[key] !== 0) return false; +- } +- +- let hasWindow = false; +- for (const key of ["fiveHour", "weekly", "monthly"] as const) { +- if (!Object.hasOwn(aggregation, key)) continue; +- if (!aggregateWindowIsComplete(aggregation[key], now)) return false; +- hasWindow = true; +- } +- if (Object.hasOwn(aggregation, "customWindows")) { +- if (!Array.isArray(aggregation.customWindows)) return false; +- for (const value of aggregation.customWindows) { +- const custom = recordFromUnknown(value); +- if (!custom || typeof custom.label !== "string" || !custom.label.trim()) return false; +- if (!aggregateWindowIsComplete(custom, now)) return false; +- hasWindow = true; +- } +- } +- return hasWindow; ++function routingQuotaFromReport(raw: Record, now: number): { ++ state: "available" | "exhausted"; ++ validUntil: number; ++} | null { ++ const routing = recordFromUnknown(raw.routingQuota); ++ if (!routing || (routing.state !== "available" && routing.state !== "exhausted")) return null; ++ const updatedAt = finiteNumber(routing.updatedAt); ++ const validUntil = finiteNumber(routing.validUntil); ++ if (updatedAt === null || updatedAt < 0 || updatedAt > now ++ || now - updatedAt >= COMBO_QUOTA_MAX_AGE_MS ++ || validUntil === null || validUntil <= now ++ || validUntil > updatedAt + COMBO_QUOTA_MAX_AGE_MS) return null; ++ return { state: routing.state, validUntil }; + } + + function quotaStateFromReport(raw: Record, now: number): ComboQuotaState { +- if (!quotaTimestampIsFresh(raw.updatedAt, now)) return "unknown"; +- const quota = recordFromUnknown(raw.quota); +- if (!quota || !quotaTimestampIsFresh(quota.updatedAt, now)) return "unknown"; +- if (raw.aggregation !== undefined && !aggregateEvidenceIsComplete(raw.aggregation, now)) return "unknown"; +- +- let hasEvidence = false; +- let exhausted = false; +- for (const key of ["fiveHourPercent", "weeklyPercent", "monthlyPercent"] as const) { +- if (!Object.hasOwn(quota, key)) continue; +- const percent = finiteNumber(quota[key]); +- if (percent === null || percent < 0) return "unknown"; +- hasEvidence = true; +- if (percent >= 100) exhausted = true; +- } +- for (const key of ["fiveHourResetAt", "weeklyResetAt", "monthlyResetAt"] as const) { +- if (Object.hasOwn(quota, key) && finiteNumber(quota[key]) === null) return "unknown"; +- } +- +- if (Object.hasOwn(quota, "customWindows")) { +- if (!Array.isArray(quota.customWindows)) return "unknown"; +- for (const value of quota.customWindows) { +- const window = recordFromUnknown(value); +- const percent = finiteNumber(window?.percent); +- if (!window || typeof window.label !== "string" || !window.label.trim() || percent === null || percent < 0) { +- return "unknown"; +- } +- if (Object.hasOwn(window, "resetAt") && finiteNumber(window.resetAt) === null) return "unknown"; +- hasEvidence = true; +- if (percent >= 100) exhausted = true; +- } +- } ++ return routingQuotaFromReport(raw, now)?.state ?? "unknown"; ++} + +- if (Object.hasOwn(quota, "creditsUsd")) { +- const credits = recordFromUnknown(quota.creditsUsd); +- if (!credits) return "unknown"; +- const used = finiteNumber(credits.used); +- const limit = finiteNumber(credits.limit); +- const remaining = finiteNumber(credits.remaining); +- const percent = finiteNumber(credits.percent); +- if (used === null || used < 0 || limit === null || limit < 0 || remaining === null || percent === null || percent < 0) { +- return "unknown"; +- } +- if (credits.unlimited !== undefined && typeof credits.unlimited !== "boolean") return "unknown"; +- if (Object.hasOwn(credits, "expiresAt") && finiteNumber(credits.expiresAt) === null) return "unknown"; +- hasEvidence = true; +- if (credits.unlimited !== true && remaining <= 0) exhausted = true; ++/** The next expiry also wakes the page when no poll response has arrived. */ ++export function nextProviderQuotaStateExpiration(reports: unknown, now = Date.now()): number | undefined { ++ if (!Array.isArray(reports)) return undefined; ++ let next: number | undefined; ++ for (const value of reports) { ++ const report = recordFromUnknown(value); ++ if (!report || typeof report.provider !== "string" || !report.provider.trim()) continue; ++ const routing = routingQuotaFromReport(report, now); ++ if (routing && (next === undefined || routing.validUntil < next)) next = routing.validUntil; + } +- +- if (!hasEvidence) return "unknown"; +- return exhausted ? "exhausted" : "available"; ++ return next; + } + + /** Fail-unknown parser for the live `/api/provider-quotas` report array. */ +diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx +index ca05ca8fd5..dee11a7e69 100644 +--- a/gui/src/pages/Combos.tsx ++++ b/gui/src/pages/Combos.tsx +@@ -5,6 +5,7 @@ import { + comboModelId, + parseComboList, + providerQuotaStatesFromReports, ++ nextProviderQuotaStateExpiration, + toPutBody, + } from "../combo-workspace-data"; + import { hideRedundantChatGptForwardProviders } from "../provider-workspace/catalog"; +@@ -239,12 +240,24 @@ export default function Combos({ + enabled: active, + }, + ); +- const providerQuotaStates = useMemo( +- () => quotaResource.lastAttemptOk +- ? providerQuotaStatesFromReports(quotaResource.data?.reports) +- : {}, +- [quotaResource.data, quotaResource.lastAttemptOk], +- ); ++ const [quotaNow, setQuotaClock] = useState(() => Date.now()); ++ const quotaReports = active && quotaResource.lastAttemptOk ? quotaResource.data?.reports : undefined; ++ const providerQuotaStates = providerQuotaStatesFromReports(quotaReports, quotaNow); ++ const quotaExpiry = nextProviderQuotaStateExpiration(quotaReports, quotaNow); ++ useEffect(() => { ++ if (!active) return; ++ const recheck = () => setQuotaClock(Date.now()); ++ // The render may cross this boundary before effects run. Keep its deadline and wake now. ++ // A new snapshot may be newer than this clock, so unknown state also gets one immediate check. ++ const timer = window.setTimeout(recheck, ++ quotaExpiry === undefined ? 0 : Math.max(0, quotaExpiry - Date.now())); ++ const onVisible = () => { if (document.visibilityState === "visible") recheck(); }; ++ document.addEventListener("visibilitychange", onVisible); ++ return () => { ++ window.clearTimeout(timer); ++ document.removeEventListener("visibilitychange", onVisible); ++ }; ++ }, [active, apiBase, quotaResource.data, quotaResource.lastAttemptOk, quotaExpiry]); + + const data = state.data ?? retainedData ?? undefined; + const combos = data?.combos ?? []; +@@ -361,7 +374,7 @@ export default function Combos({ + models={models} + cataloguedComboIds={cataloguedComboIds} + loading={false} +- onRefresh={() => resource.refresh()} ++ onRefresh={() => { resource.refresh(); quotaResource.refresh(); }} + onSave={saveCombo} + onRemove={removeCombo} + onAdd={() => setAdding(true)} +diff --git a/gui/tests/combo-workspace-dirty.test.tsx b/gui/tests/combo-workspace-dirty.test.tsx +index 1f3566dbbe..c82ac799d4 100644 +--- a/gui/tests/combo-workspace-dirty.test.tsx ++++ b/gui/tests/combo-workspace-dirty.test.tsx +@@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; + import { Window } from "happy-dom"; + import { act, StrictMode } from "react"; + import type { Root } from "react-dom/client"; +-import type { ComboItem } from "../src/combo-workspace-data"; ++import { type ComboItem, providerQuotaStatesFromReports } from "../src/combo-workspace-data"; + import ComboWorkspace from "../src/components/ComboWorkspace"; + import { LanguageProvider } from "../src/i18n/provider"; + +@@ -178,11 +178,14 @@ test("dirty Save disables for exhausted targets and re-enables on quota recovery + document.body.append(container); + const root = createRoot(container); + +- const render = (quotaState: "available" | "exhausted") => ( ++ const now = Date.now(); ++ const display = { provider: "openai", updatedAt: now, ++ quota: { updatedAt: now, customWindows: [{ label: "Search", percent: 100 }] } }; ++ const render = (routingQuota?: Record) => ( + + + ); + +- await act(async () => { root.render(render("exhausted")); }); ++ await act(async () => { root.render(render({ state: "exhausted", updatedAt: now, validUntil: now + 60_000 })); }); + await flushTimers(); + await act(async () => { railButton(container, "combo/alpha").click(); }); + await flushTimers(); +@@ -208,7 +211,11 @@ test("dirty Save disables for exhausted targets and re-enables on quota recovery + expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(true); + expect(container.textContent).toContain("All enabled targets are out of quota"); + +- await act(async () => { root.render(render("available")); }); ++ await act(async () => { root.render(render()); }); ++ expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(false); ++ expect(container.textContent).not.toContain("All enabled targets are out of quota"); ++ ++ await act(async () => { root.render(render({ state: "available", updatedAt: now, validUntil: now + 60_000 })); }); + expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(false); + expect(container.textContent).not.toContain("All enabled targets are out of quota"); + +diff --git a/gui/tests/combo-workspace-empty.test.tsx b/gui/tests/combo-workspace-empty.test.tsx +index 4fb8416067..ba6efc49d7 100644 +--- a/gui/tests/combo-workspace-empty.test.tsx ++++ b/gui/tests/combo-workspace-empty.test.tsx +@@ -5,6 +5,7 @@ import type { Root } from "react-dom/client"; + import { renderToStaticMarkup } from "react-dom/server"; + import ComboWorkspace from "../src/components/ComboWorkspace"; + import { LanguageProvider } from "../src/i18n/provider"; ++import { providerQuotaStatesFromReports } from "../src/combo-workspace-data"; + + const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + let previousGlobals: Record<(typeof globals)[number], unknown>; +@@ -134,11 +135,14 @@ test("first-combo Create disables only while every usable target is known exhaus + document.body.append(container); + const root = createRoot(container); + +- const render = (quotaState: "available" | "exhausted") => ( ++ const now = Date.now(); ++ const display = { provider: "openai", updatedAt: now, ++ quota: { updatedAt: now, customWindows: [{ label: "Search", percent: 100 }] } }; ++ const render = (routingQuota?: Record) => ( + + + ); + +- await act(async () => { root.render(render("exhausted")); }); ++ await act(async () => { root.render(render({ state: "exhausted", updatedAt: now, validUntil: now + 60_000 })); }); + await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 0)); }); + + const providerSelect = container.querySelector('select[aria-label="Provider"]')!; +@@ -167,7 +171,11 @@ test("first-combo Create disables only while every usable target is known exhaus + expect(createButton.disabled).toBe(true); + expect(container.textContent).toContain("All enabled targets are out of quota"); + +- await act(async () => { root.render(render("available")); }); ++ await act(async () => { root.render(render()); }); ++ expect(container.querySelector("#cwi-edit-create")!.disabled).toBe(false); ++ expect(container.textContent).not.toContain("All enabled targets are out of quota"); ++ ++ await act(async () => { root.render(render({ state: "available", updatedAt: now, validUntil: now + 60_000 })); }); + expect(container.querySelector("#cwi-edit-create")!.disabled).toBe(false); + expect(container.textContent).not.toContain("All enabled targets are out of quota"); + +diff --git a/gui/tests/page-loading-contract.test.tsx b/gui/tests/page-loading-contract.test.tsx +index 2ab6b88385..52a889fb28 100644 +--- a/gui/tests/page-loading-contract.test.tsx ++++ b/gui/tests/page-loading-contract.test.tsx +@@ -1,6 +1,6 @@ +-import { afterEach, beforeEach, expect, test } from "bun:test"; ++import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; + import { Window } from "happy-dom"; +-import { act } from "react"; ++import { act, useLayoutEffect } from "react"; + import type { Root } from "react-dom/client"; + import Combos from "../src/pages/Combos"; + import { LanguageProvider } from "../src/i18n/provider"; +@@ -204,3 +204,111 @@ test("Combos announces silent revalidation over cached content via aria-busy", a + await act(async () => { root.unmount(); }); + container.remove(); + }); ++ ++ ++test.each(["timer", "visible", "active", "commit-boundary"])("Combos expires a quota block before a new response: %s", async wake => { ++ const { createRoot } = await import("react-dom/client"); ++ const startedAt = Date.now(); ++ let now = startedAt; ++ const clock = spyOn(Date, "now").mockImplementation(() => now); ++ const schedule = testWindow.setTimeout.bind(testWindow); ++ const cancel = testWindow.clearTimeout.bind(testWindow); ++ const expiryTimers = new Set(); ++ let expire: (() => void) | undefined; ++ const scheduleSpy = spyOn(testWindow, "setTimeout").mockImplementation((callback, delay, ...args) => { ++ const timer = schedule(callback, delay, ...args); ++ if (delay === 123_456 && typeof callback === "function") { ++ expiryTimers.add(timer); ++ expire = () => callback(...args); ++ } ++ return timer; ++ }); ++ const cancelSpy = spyOn(testWindow, "clearTimeout").mockImplementation(timer => { ++ expiryTimers.delete(timer); ++ cancel(timer); ++ }); ++ const item = { id: "alpha", model: "combo/alpha", strategy: "failover", stickyLimit: 1, ++ targets: [{ provider: "keyed", model: "m1" }] }; ++ let quotaFetches = 0; ++ const workspaceFetches = new Map(); ++ const waitForAbort = (signal: AbortSignal | null | undefined) => new Promise((_resolve, reject) => { ++ if (signal?.aborted) reject(signal.reason); ++ else signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); ++ }); ++ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { ++ const url = String(input); ++ if (url.includes("/api/provider-quotas")) { ++ quotaFetches += 1; ++ if (quotaFetches > 1) return waitForAbort(init?.signal); ++ return Response.json({ reports: [{ provider: "keyed", updatedAt: startedAt, ++ quota: { updatedAt: startedAt, fiveHourPercent: 100 }, ++ routingQuota: { state: "exhausted", updatedAt: startedAt, validUntil: startedAt + 123_456 }, ++ }] }); ++ } ++ const count = (workspaceFetches.get(url) ?? 0) + 1; ++ workspaceFetches.set(url, count); ++ if (count > 1) return waitForAbort(init?.signal); ++ if (url.includes("/api/combos")) return Response.json({ combos: [item] }); ++ if (url.includes("/api/config")) return Response.json({ providers: { ++ keyed: { adapter: "openai-chat", authMode: "key", baseUrl: "https://provider.example/v1", defaultModel: "m1" }, ++ } }); ++ if (url.includes("/api/models")) return Response.json([ ++ { provider: "keyed", id: "m1" }, { provider: "combo", id: "alpha" }, ++ ]); ++ return new Response(null, { status: 404 }); ++ }) as typeof fetch; ++ const container = document.createElement("div"); ++ document.body.append(container); ++ const root = createRoot(container); ++ function ClockBoundary({ active, expireDuringCommit }: { active: boolean; expireDuringCommit: boolean }) { ++ useLayoutEffect(() => { ++ if (expireDuringCommit) now = startedAt + 123_456; ++ }, [expireDuringCommit]); ++ return ; ++ } ++ const render = (active = true, expireDuringCommit = false) => ++ ; ++ try { ++ await act(async () => { root.render(render()); }); ++ await act(async () => { await new Promise(resolve => schedule(resolve, 0)); }); ++ const rail = [...container.querySelectorAll(".combos-workspace-rail-row")] ++ .find(row => row.querySelector(".combos-workspace-rail-name")?.textContent === "combo/alpha"); ++ expect(rail).toBeDefined(); ++ await act(async () => { rail!.click(); }); ++ await act(async () => { await new Promise(resolve => schedule(resolve, 0)); }); ++ const alias = container.querySelector("#cwi-edit-alias")!; ++ await act(async () => { ++ Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(alias, "kept-draft"); ++ alias.dispatchEvent(new testWindow.Event("input", { bubbles: true })); ++ }); ++ expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(true); ++ expect(expire).toBeDefined(); ++ if (wake === "visible") { ++ Object.defineProperty(testWindow.document, "visibilityState", { configurable: true, value: "hidden" }); ++ await act(async () => { testWindow.document.dispatchEvent(new testWindow.Event("visibilitychange")); }); ++ } else if (wake === "active" || wake === "commit-boundary") { ++ await act(async () => { root.render(render(false)); }); ++ } ++ now = startedAt + 123_456 - (wake === "commit-boundary" ? 1 : 0); ++ await act(async () => { ++ if (wake === "timer") expire!(); ++ else if (wake === "visible") { ++ Object.defineProperty(testWindow.document, "visibilityState", { configurable: true, value: "visible" }); ++ testWindow.document.dispatchEvent(new testWindow.Event("visibilitychange")); ++ } else root.render(render(true, wake === "commit-boundary")); ++ }); ++ if (wake === "commit-boundary") { ++ await act(async () => { await new Promise(resolve => schedule(resolve, 0)); }); ++ } ++ expect(container.querySelector("#cwi-edit-alias")!.value).toBe("kept-draft"); ++ expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(false); ++ if (wake === "timer") expect(quotaFetches).toBe(1); ++ } finally { ++ await act(async () => { root.unmount(); }); ++ container.remove(); ++ scheduleSpy.mockRestore(); ++ cancelSpy.mockRestore(); ++ clock.mockRestore(); ++ } ++ expect(expiryTimers.size).toBe(0); ++}); +diff --git a/src/providers/quota-routing-cache.ts b/src/providers/quota-routing-cache.ts +index 1e45d60065..3acaf1be50 100644 +--- a/src/providers/quota-routing-cache.ts ++++ b/src/providers/quota-routing-cache.ts +@@ -3,6 +3,7 @@ import type { OcxProviderConfig } from "../types"; + import type { ProviderQuota, ProviderQuotaReport } from "./quota"; + import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store"; + import { getProviderRegistryEntry } from "./registry"; ++import { PROVIDER_QUOTA_MAX_AGE_MS } from "./quota-types"; + + export interface ProviderQuotaRoutingEvidence { + quota: ProviderQuota; +@@ -54,7 +55,7 @@ export function replaceCachedProviderQuotas( + export function getCachedProviderQuota( + provider: string, + now: number, +- maxAgeMs = 30 * 60_000, ++ maxAgeMs = PROVIDER_QUOTA_MAX_AGE_MS, + ): ProviderQuota | null { + const quota = quotaCache.get(provider)?.quota; + if (!quota) return null; +@@ -67,7 +68,7 @@ export function getCachedProviderRoutingQuota( + name: string, + provider: OcxProviderConfig | undefined, + now: number, +- maxAgeMs = 30 * 60_000, ++ maxAgeMs = PROVIDER_QUOTA_MAX_AGE_MS, + ): ProviderQuota | null { + if (!provider || provider.disabled === true || (provider.authMode ?? "key") !== "key") return null; + // An active-key report cannot speak for the other keys the dispatcher may select. +diff --git a/src/providers/quota-types.ts b/src/providers/quota-types.ts +index 873eb30221..e0bdf9cb4f 100644 +--- a/src/providers/quota-types.ts ++++ b/src/providers/quota-types.ts +@@ -8,6 +8,13 @@ + * blocks any later attempt to load one side without the other. + */ + ++export const PROVIDER_QUOTA_MAX_AGE_MS = 30 * 60_000; ++ ++/** Management-only eligibility evidence; private credential binding never leaves the server. */ ++export type ProviderRoutingQuota = ++ | { state: "unknown" } ++ | { state: "available" | "exhausted"; updatedAt: number; validUntil: number }; ++ + export interface ProviderQuotaWindow { + label: string; + percent: number; +diff --git a/src/providers/quota.ts b/src/providers/quota.ts +index ab23070bea..f6d96c5a35 100644 +--- a/src/providers/quota.ts ++++ b/src/providers/quota.ts +@@ -54,6 +54,7 @@ import type { + ProviderQuota, + ProviderQuotaCreditsUsd, + ProviderQuotaWindow, ++ ProviderRoutingQuota, + } from "./quota-types"; + import { + clearKiroAccountUsageState, +@@ -139,6 +140,8 @@ export interface ProviderQuotaReport { + source: string; + quota: ProviderQuota; + updatedAt: number; ++ /** Added by the management response projection, never stored on a cached report. */ ++ routingQuota?: ProviderRoutingQuota; + reverseEngineered?: boolean; + /** + * The row was OBSERVED in-band on a streaming turn rather than probed. +diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts +index 1439d7899c..847b240994 100644 +--- a/src/server/management/provider-routes.ts ++++ b/src/server/management/provider-routes.ts +@@ -57,6 +57,9 @@ 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 { getCachedProviderRoutingQuota } from "../../providers/quota-routing-cache"; ++import { PROVIDER_QUOTA_MAX_AGE_MS, type ProviderRoutingQuota } from "../../providers/quota-types"; ++import { cachedProviderQuotaIsExhausted } from "../../combos/resolve"; + import { clearKeyCooldowns } from "../../providers/key-failover"; + import { providerRequestPacingStatus } from "../../providers/request-pacing"; + import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +@@ -689,12 +692,54 @@ function canonicalOpenAiBudgetPatchError( + ?? providerEmptyToolOutputConfigError("openai", applied.next); + } + ++function providerRoutingQuota(config: OcxConfig, name: string, now: number): ProviderRoutingQuota { ++ const provider = hasOwnProvider(config.providers, name) ? config.providers[name] : undefined; ++ const quota = getCachedProviderRoutingQuota(name, provider, now); ++ if (!quota || !Number.isFinite(quota.updatedAt) || quota.updatedAt < 0 || quota.updatedAt > now ++ || now >= quota.updatedAt + PROVIDER_QUOTA_MAX_AGE_MS) return { state: "unknown" }; ++ ++ // Removing search/MCP windows may leave only a timestamp. That is not inference evidence. ++ const percentages = [quota.fiveHourPercent, quota.weeklyPercent, quota.monthlyPercent, ++ ...(quota.customWindows ?? []).map(window => window.percent)]; ++ const hasPercentage = percentages.some(value => typeof value === "number" && Number.isFinite(value) && value >= 0); ++ const credits = quota.creditsUsd; ++ const hasCredits = credits !== undefined && Number.isFinite(credits.percent) ++ && credits.percent >= 0 && Number.isFinite(credits.remaining); ++ if (!hasPercentage && !hasCredits) return { state: "unknown" }; ++ ++ const state = cachedProviderQuotaIsExhausted(quota, now) ? "exhausted" : "available"; ++ let validUntil = quota.updatedAt + PROVIDER_QUOTA_MAX_AGE_MS; ++ if (state === "exhausted") { ++ const resets = [quota.fiveHourResetAt, quota.weeklyResetAt, quota.monthlyResetAt, ++ ...(quota.customWindows ?? []).map(window => window.resetAt)] ++ .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset) ++ && reset > now && reset < validUntil) ++ .sort((left, right) => left - right); ++ // Reuse dispatch's predicate: another exhausted window or USD cap may still block. ++ for (const reset of resets) { ++ if (!cachedProviderQuotaIsExhausted(quota, reset)) { ++ validUntil = reset; ++ break; ++ } ++ } ++ } ++ return { state, updatedAt: quota.updatedAt, validUntil }; ++} ++ + export async function handleProviderRoutes(ctx: ManagementContext): Promise { + const { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; + + if (url.pathname === "/api/provider-quotas" && req.method === "GET") { + const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true"; +- return jsonResponse(await fetchProviderQuotaReports(config, forceRefresh)); ++ const snapshot = await fetchProviderQuotaReports(config, forceRefresh); ++ const now = Date.now(); ++ return jsonResponse({ ++ ...snapshot, ++ reports: snapshot.reports.map(report => ({ ++ ...report, ++ routingQuota: providerRoutingQuota(config, report.provider, now), ++ })), ++ }); + } + + if (url.pathname === "/api/provider-request-pacing" && req.method === "GET") { +diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md +index ecade01c78..f91195e8bb 100644 +--- a/structure/04_transports-and-sidecars.md ++++ b/structure/04_transports-and-sidecars.md +@@ -1657,6 +1657,15 @@ credential cannot inherit another key's cap. The same getter controls immediate + bounded cooldown waiting and reset-window ordering. This does not override explicit eligibility, + target cooldowns, account admission or response-driven retry rules. + ++The management quota response projects a separate `routingQuota` from this evidence after each ++probe or cached read, using the current provider row. It contains only a state, observation time ++and `validUntil`; the cached display report and private binding remain unchanged. Known states ++expire after 30 minutes or, for exhaustion, when the dispatch predicate first clears at a reset ++boundary. Multiple windows and USD blockers use that same predicate. The Combo editor uses only ++this projection for quota-based Save/Create blocking and treats missing, invalid or expired ++evidence as unknown. It schedules the rendered expiry even when that deadline passes before ++effects run, rechecks on activation/visibility, and refreshes quota alongside Combo data. ++ + ```text + [Decision Log] + - 목적과 의도: Keep account-, model- and service-scoped quota from disabling an otherwise usable Combo provider while retaining valid single-key inference caps. +diff --git a/tests/gui/combo-workspace-data.test.ts b/tests/gui/combo-workspace-data.test.ts +index e3d340f2f8..e754020636 100644 +--- a/tests/gui/combo-workspace-data.test.ts ++++ b/tests/gui/combo-workspace-data.test.ts +@@ -13,6 +13,7 @@ import { + isValidComboId, + parseComboList, + providerQuotaStatesFromReports, ++ nextProviderQuotaStateExpiration, + toPutBody, + updateComboAliasDraft, + validateComboDraft, +@@ -42,6 +43,31 @@ function quotaReport( + }; + } + ++describe("server-scoped Combo quota", () => { ++ test("display exhaustion without routing authority stays unknown", () => { ++ expect(providerQuotaStatesFromReports([ ++ quotaReport("oauth", { fiveHourPercent: 100 }), ++ quotaReport("search", { customWindows: [{ label: "Search", percent: 100 }] }), ++ ], QUOTA_NOW)).toEqual({ oauth: "unknown", search: "unknown" }); ++ }); ++ ++ test("uses current server routing state instead of display windows", () => { ++ expect(providerQuotaStatesFromReports([ ++ quotaReport("search", { customWindows: [{ label: "Search", percent: 100 }] }, { ++ routingQuota: { state: "available", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 60_000 }, ++ }), ++ ], QUOTA_NOW)).toEqual({ search: "available" }); ++ }); ++ ++ test("expires routing authority at its reset boundary", () => { ++ expect(providerQuotaStatesFromReports([ ++ quotaReport("spent", { fiveHourPercent: 100 }, { ++ routingQuota: { state: "exhausted", updatedAt: QUOTA_NOW - 100, validUntil: QUOTA_NOW }, ++ }), ++ ], QUOTA_NOW)).toEqual({ spent: "unknown" }); ++ }); ++}); ++ + function combo(overrides: Partial = {}): ComboItem { + return { + id: "free", +@@ -297,87 +323,57 @@ describe("combo-workspace-data", () => { + ]); + }); + +- test("derives exhausted state from USD, percentage, and custom-window evidence", () => { ++ test("accepts known routing states independently of display data", () => { + expect(providerQuotaStatesFromReports([ +- quotaReport("usd", { +- creditsUsd: { used: 10, limit: 10, remaining: 0, percent: 100 }, ++ quotaReport(" keyed ", { fiveHourPercent: 0 }, { ++ routingQuota: { state: "exhausted", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 60_000 }, + }), +- quotaReport("percent", { fiveHourPercent: 100 }), +- quotaReport("custom", { customWindows: [{ label: "Daily", percent: 101 }] }), +- ], QUOTA_NOW)).toEqual({ +- usd: "exhausted", +- percent: "exhausted", +- custom: "exhausted", +- }); +- }); +- +- test("keeps unlimited credits available and stale or malformed evidence unknown", () => { +- expect(providerQuotaStatesFromReports([ +- quotaReport("unlimited", { +- creditsUsd: { used: 0, limit: 0, remaining: 0, percent: 0, unlimited: true }, ++ quotaReport("unlimited", { creditsUsd: { remaining: 0, unlimited: true } }, { ++ routingQuota: { state: "available", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 60_000 }, + }), +- quotaReport("stale", { weeklyPercent: 100 }, { updatedAt: QUOTA_NOW - 30 * 60_000 }), +- quotaReport("malformed", { fiveHourPercent: "100" }), +- quotaReport("missing", {}), +- ], QUOTA_NOW)).toEqual({ +- unlimited: "available", +- stale: "unknown", +- malformed: "unknown", +- missing: "unknown", +- }); ++ ], QUOTA_NOW)).toEqual({ keyed: "exhausted", unlimited: "available" }); ++ }); ++ ++ test("rejects malformed, future, stale and overlong routing lifetimes", () => { ++ const fresh = { state: "exhausted", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 1000 }; ++ const bad = [ ++ undefined, null, [], { ...fresh, state: "maybe" }, ++ { ...fresh, updatedAt: "100" }, { ...fresh, updatedAt: NaN }, ++ { ...fresh, updatedAt: -1 }, { ...fresh, updatedAt: QUOTA_NOW + 1 }, ++ { ...fresh, updatedAt: QUOTA_NOW - 30 * 60_000 }, ++ { ...fresh, validUntil: undefined }, { ...fresh, validUntil: Infinity }, ++ { ...fresh, validUntil: QUOTA_NOW }, { ...fresh, validUntil: QUOTA_NOW + 30 * 60_000 + 1 }, ++ ]; ++ for (const routingQuota of bad) { ++ expect(providerQuotaStatesFromReports([ ++ quotaReport("keyed", { weeklyPercent: 100 }, { routingQuota }), ++ ], QUOTA_NOW)).toEqual({ keyed: "unknown" }); ++ } + }); + +- test("trims provider ids and rejects incomplete aggregate quota evidence", () => { ++ test("complete display aggregates cannot authorize a provider-wide block", () => { + expect(providerQuotaStatesFromReports([ +- quotaReport(" openai ", { weeklyPercent: 75 }), + quotaReport("pool", { weeklyPercent: 100 }, { + aggregation: { +- kind: "capacity-weighted-v1", +- scope: "routable-known", +- presentation: "aggregate", +- incomplete: true, +- excludedAccounts: 1, +- unknownPlanAccounts: 0, ++ kind: "capacity-weighted-v1", scope: "routable-known", presentation: "aggregate", ++ incomplete: false, includedAccounts: 2, excludedAccounts: 0, unknownPlanAccounts: 0, ++ missingQuotaAccounts: 0, pausedAccounts: 0, reauthAccounts: 0, staleQuotaAccounts: 0, + partialWindowAccounts: 0, ++ weekly: { usedPercent: 100, includedAccounts: 2, excludedAccounts: 0, incomplete: false, updatedAt: QUOTA_NOW }, + }, + }), +- quotaReport("malformed-pool", { weeklyPercent: 100 }, { +- aggregation: { +- kind: "capacity-weighted-v1", +- scope: "routable-known", +- presentation: "aggregate", +- incomplete: false, +- }, +- }), +- quotaReport("complete-pool", { weeklyPercent: 100 }, { +- aggregation: { +- kind: "capacity-weighted-v1", +- scope: "routable-known", +- presentation: "aggregate", +- incomplete: false, +- includedAccounts: 2, +- excludedAccounts: 0, +- unknownPlanAccounts: 0, +- missingQuotaAccounts: 0, +- pausedAccounts: 0, +- reauthAccounts: 0, +- staleQuotaAccounts: 0, +- partialWindowAccounts: 0, +- weekly: { +- usedPercent: 100, +- includedAccounts: 2, +- excludedAccounts: 0, +- incomplete: false, +- updatedAt: QUOTA_NOW, +- }, +- }, +- }), +- ], QUOTA_NOW)).toEqual({ +- openai: "available", +- pool: "unknown", +- "malformed-pool": "unknown", +- "complete-pool": "exhausted", +- }); ++ ], QUOTA_NOW)).toEqual({ pool: "unknown" }); ++ }); ++ ++ test("conflicting duplicate rows stay unknown and the next valid expiry is selected", () => { ++ const rows = [ ++ quotaReport("keyed", {}, { routingQuota: { state: "available", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 5000 } }), ++ quotaReport("keyed", {}, { routingQuota: { state: "exhausted", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW + 1000 } }), ++ quotaReport("bad", {}, { routingQuota: { state: "exhausted", updatedAt: QUOTA_NOW, validUntil: QUOTA_NOW - 1 } }), ++ ]; ++ expect(providerQuotaStatesFromReports(rows, QUOTA_NOW)).toEqual({ keyed: "unknown", bad: "unknown" }); ++ expect(nextProviderQuotaStateExpiration(rows, QUOTA_NOW)).toBe(QUOTA_NOW + 1000); ++ expect(nextProviderQuotaStateExpiration(rows, QUOTA_NOW + 5000)).toBeUndefined(); + }); + + test("combo quota excludes disabled targets and disables only when every usable target is exhausted", () => { +diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts +index 09bfb1e67c..949791f2d6 100644 +--- a/tests/server/management-provider-validation.test.ts ++++ b/tests/server/management-provider-validation.test.ts +@@ -48,6 +48,8 @@ import { getAccountSet, saveCredential } from "../../src/oauth/store"; + import { fastPolicyForModel } from "../../src/providers/service-tier"; + import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; + import { removeTreeWithRetry } from "../helpers/remove-tree"; ++import { clearProviderQuotaCache, fetchProviderQuotaReports, setProviderQuotaBeforePublishForTests } from "../../src/providers/quota"; ++import { setCachedProviderQuotaForTests } from "../../src/providers/quota-routing-cache"; + + // Full-suite Windows load: startServer + multi-step provider PATCH/GET flows exceed the + // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). +@@ -136,6 +138,153 @@ afterEach(() => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + }); + ++describe("provider quota routing state", () => { ++ function quotaConfig(name = "openrouter", baseUrl = "https://openrouter.ai/api/v1"): OcxConfig { ++ return { port: 10100, defaultProvider: name, providers: { [name]: { ++ adapter: "openai-chat", authMode: "key", baseUrl, apiKey: "synthetic-probed-key", ++ } } }; ++ } ++ ++ async function readQuota(cfg: OcxConfig, force = false) { ++ const url = new URL(`http://localhost/api/provider-quotas${force ? "?refresh=1" : ""}`); ++ const response = await handleManagementAPI(new Request(url), url, cfg); ++ expect(response?.status).toBe(200); ++ return response!.json(); ++ } ++ ++ beforeEach(() => { ++ mkdirSync(TEST_DIR, { recursive: true }); ++ process.env.OPENCODEX_HOME = TEST_DIR; ++ clearProviderQuotaCache(); ++ setProviderQuotaBeforePublishForTests(null); ++ }); ++ ++ afterEach(() => { ++ clearProviderQuotaCache(); ++ setProviderQuotaBeforePublishForTests(null); ++ }); ++ ++ test("projects bound inference state without mutating display reports", async () => { ++ const cfg: OcxConfig = { ++ port: 10100, ++ defaultProvider: "openrouter", ++ providers: { ++ openrouter: { ++ adapter: "openai-chat", authMode: "key", ++ baseUrl: "https://openrouter.ai/api/v1", apiKey: "synthetic-probed-key", ++ }, ++ }, ++ }; ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ const url = new URL("http://localhost/api/provider-quotas"); ++ const response = await handleManagementAPI(new Request(url), url, cfg); ++ expect(response?.status).toBe(200); ++ const dto = await response!.json(); ++ const row = dto.reports.find((item: { provider: string }) => item.provider === "openrouter"); ++ expect(row.routingQuota).toEqual({ ++ state: "exhausted", updatedAt: row.quota.updatedAt, ++ validUntil: row.quota.updatedAt + 30 * 60_000, ++ }); ++ const cached = await fetchProviderQuotaReports(cfg, false); ++ expect(cached.reports[0]).not.toHaveProperty("routingQuota"); ++ expect(row.quota).toEqual(cached.reports[0]!.quota); ++ expect(JSON.stringify(dto)).not.toContain("synthetic-probed-key"); ++ expect(JSON.stringify(dto)).not.toContain("binding"); ++ }); ++ ++ test("single-key capacity recovers on refresh and an uncapped key drops its old cap", async () => { ++ const cfg = quotaConfig(); ++ let payload = { limit: 20 as number | null, limit_remaining: 0 }; ++ globalThis.fetch = (async () => Response.json({ data: payload })) as typeof fetch; ++ expect((await readQuota(cfg)).reports[0].routingQuota.state).toBe("exhausted"); ++ payload = { limit: 20, limit_remaining: 8 }; ++ expect((await readQuota(cfg, true)).reports[0].routingQuota.state).toBe("available"); ++ payload = { limit: null, limit_remaining: 0 }; ++ expect((await readQuota(cfg, true)).reports).toEqual([]); ++ }); ++ ++ test.each(["authorization", "x-api-key", "x-goog-api-key", "key-pool", "oauth"])( ++ "rechecks current credential scope: %s", async change => { ++ const cfg = quotaConfig(); ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ expect((await readQuota(cfg)).reports[0].routingQuota.state).toBe("exhausted"); ++ if (change === "key-pool") cfg.providers.openrouter!.apiKeyPool = [ ++ { id: "primary", key: "synthetic-probed-key" }, ++ { id: "secondary", key: "other-key" }, ++ ]; ++ else if (change === "oauth") cfg.providers.openrouter!.authMode = "oauth"; ++ else cfg.providers.openrouter!.headers = { [change]: "other-credential" }; ++ const dto = await readQuota(cfg); ++ expect(dto.reports.every((row: { routingQuota: { state: string } }) => row.routingQuota.state === "unknown")).toBe(true); ++ }, ++ ); ++ ++ test("reads a provider row replaced while the quota probe is awaiting publication", async () => { ++ const cfg = quotaConfig(); ++ let replaced = false; ++ globalThis.fetch = (async () => Response.json({ data: { limit: 20, limit_remaining: 0 } })) as typeof fetch; ++ setProviderQuotaBeforePublishForTests(() => { ++ cfg.providers.openrouter = { ...cfg.providers.openrouter!, apiKey: "replacement-key" }; ++ replaced = true; ++ }); ++ const dto = await readQuota(cfg); ++ expect(replaced).toBe(true); ++ expect(dto.reports.every((row: { routingQuota: { state: string } }) => row.routingQuota.state === "unknown")).toBe(true); ++ }); ++ ++ test("search-only and MCP-only display windows have no inference authority", async () => { ++ const cfg: OcxConfig = { port: 10100, defaultProvider: "synthetic", providers: { ++ ...quotaConfig("synthetic", "https://api.synthetic.new/v2").providers, ++ ...quotaConfig("zai", "https://api.z.ai/api/coding/paas/v4").providers, ++ } }; ++ globalThis.fetch = (async input => String(input).includes("synthetic") ++ ? Response.json({ data: { search: { hourly: 100 } } }) ++ : Response.json({ success: true, data: { monthlyMCPUsage: 100 } })) as typeof fetch; ++ const dto = await readQuota(cfg); ++ expect(dto.reports).toHaveLength(2); ++ expect(dto.reports.every((row: { routingQuota: { state: string } }) => row.routingQuota.state === "unknown")).toBe(true); ++ expect(dto.reports.find((row: { provider: string }) => row.provider === "synthetic").quota.customWindows[0].percent).toBe(100); ++ expect(dto.reports.find((row: { provider: string }) => row.provider === "zai").quota.monthlyPercent).toBe(100); ++ }); ++ ++ test("an exhausted OAuth account report stays display-only", async () => { ++ const cfg = quotaConfig("kimi", "https://api.kimi.com/coding/v1"); ++ cfg.providers.kimi!.authMode = "oauth"; ++ await saveCredential("kimi", { access: "synthetic-account-access", refresh: "synthetic-account-refresh", expires: Date.now() + 3600_000 }); ++ globalThis.fetch = (async () => Response.json({ usage: { limit: "100", used: "100" } })) as typeof fetch; ++ const dto = await readQuota(cfg); ++ expect(dto.reports[0].quota.weeklyPercent).toBe(100); ++ expect(dto.reports[0].routingQuota).toEqual({ state: "unknown" }); ++ }); ++ ++ test("cached responses respect reset boundaries, persistent blockers and evidence expiry", async () => { ++ const cfg = quotaConfig(); ++ let probes = 0; ++ globalThis.fetch = (async () => { ++ probes += 1; ++ return Response.json({ data: { limit: 20, limit_remaining: 0 } }); ++ }) as typeof fetch; ++ const first = await readQuota(cfg); ++ const now = Date.now(); ++ const quota = { updatedAt: now, fiveHourPercent: 100, fiveHourResetAt: now + 10_000, ++ weeklyPercent: 100, weeklyResetAt: now + 20_000 }; ++ setCachedProviderQuotaForTests("openrouter", quota); ++ expect((await readQuota(cfg)).reports[0].routingQuota.validUntil).toBe(now + 20_000); ++ setCachedProviderQuotaForTests("openrouter", { ...quota, creditsUsd: { used: 20, limit: 20, remaining: 0, percent: 100 } }); ++ expect((await readQuota(cfg)).reports[0].routingQuota.validUntil).toBe(now + 30 * 60_000); ++ setCachedProviderQuotaForTests("openrouter", { updatedAt: now, fiveHourPercent: 100, fiveHourResetAt: now - 1 }); ++ expect((await readQuota(cfg)).reports[0].routingQuota.state).toBe("available"); ++ setCachedProviderQuotaForTests("openrouter", { updatedAt: now, ++ creditsUsd: { used: 0, limit: 0, remaining: 0, percent: 0, unlimited: true } }); ++ expect((await readQuota(cfg)).reports[0].routingQuota.state).toBe("available"); ++ setCachedProviderQuotaForTests("openrouter", { ...quota, updatedAt: now - 30 * 60_000 }); ++ const stale = await readQuota(cfg); ++ expect(stale.reports[0].routingQuota).toEqual({ state: "unknown" }); ++ expect(stale.reports[0].quota).toEqual(first.reports[0].quota); ++ expect(probes).toBe(1); ++ }); ++}); ++ + describe("provider management validation", () => { + test("provider reload adopts only the validated disk row without rewriting config", async () => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); +``` diff --git a/devlog/_plan/260912_combo_carry/030_verification.md b/devlog/_plan/260912_combo_carry/030_verification.md new file mode 100644 index 0000000000..e13e814d63 --- /dev/null +++ b/devlog/_plan/260912_combo_carry/030_verification.md @@ -0,0 +1,15 @@ +# Final verification + +Depends on editor. MODIFY PR descriptions with exact source/head, manual chain map, attribution and hosted CI evidence; NEW .tmp/combo-handoff/HANDOFF.md, security review and screenshots. No planned product edits; any hosted failure requires a concrete P amendment before repair. + +Before: no same-repository carries, no final cumulative proof. After: bottom runtime PR -> editor child PR with editor-only delta; git merge-base --is-ancestor lower upper exits zero, gh pr view proves head/base, gh run view proves successful final-head hosted CI and run URL. Capture actual rendered Combo editor with synthetic fixture data using an existing runtime or hosted-built artifact (no local build/install). Fresh exhausted target disables Save; unknown or expired permits it. Screenshot file must be durably accessible and included in upper PR body. No screenshot waiver. + +Security review checks binding creation, private WeakMap retention, report serialization, key-pool/OAuth exclusions and invalidation against current configuration. Review is distinct from maintainer approval. CI negatives cover changed credentials, destination, auth, headers, key pools, display-only limits, fresh/expired/malformed evidence. All local tests NOT RUN. Record any unmet browser/architect/review gate honestly. + +## Audit repair amendment + +MODIFY lower-layer `tests/providers/provider-quota.test.ts`: explicit-projection test `exhausted` and `projected` objects each gain `updatedAt: Date.now()`. MODIFY lower-layer `tests/codex-integration/catalog-zero-credit-picker.test.ts`: display and bound report fixtures each gain `label: "Alpha"`. Keep runtime and assertions unchanged. Commit on own lower branch, rebase own editor branch onto the new lower tip, push lower normally and upper with explicit lease plus --no-verify. This preserves editor-only upper delta. Independent runtime security audit PASS; these low fixture contract findings are accepted. Recheck original current heads and final carry bases/head/CI; no merge. + +## Check-phase repair from independent editor audit + +Accepted Bacon P2: mixed retained/fresh report timestamps can leave fresh exhaustion unknown until an older deadline. MODIFY Combos quota loader to update its observation clock on each successful non-aborted snapshot before publication; preserve expiry/visibility effects. Add deterministic mixed-snapshot refresh regression in existing page-loading-contract test with a retained older row and fresh exhausted target, preserving a dirty alias. Update canonical GUI contract. This is C's observe-fix-recheck loop within the verification cycle, not an unrecorded new phase. No local suite. Re-push and replace final-tip CI evidence. diff --git a/devlog/_plan/260912_combo_carry/031_exhausted.png b/devlog/_plan/260912_combo_carry/031_exhausted.png new file mode 100644 index 0000000000..c01bafdad9 Binary files /dev/null and b/devlog/_plan/260912_combo_carry/031_exhausted.png differ diff --git a/devlog/_plan/260912_combo_carry/032_unknown.png b/devlog/_plan/260912_combo_carry/032_unknown.png new file mode 100644 index 0000000000..e469fd0495 Binary files /dev/null and b/devlog/_plan/260912_combo_carry/032_unknown.png differ diff --git a/devlog/_plan/260912_combo_carry/033_expired.png b/devlog/_plan/260912_combo_carry/033_expired.png new file mode 100644 index 0000000000..e469fd0495 Binary files /dev/null and b/devlog/_plan/260912_combo_carry/033_expired.png differ diff --git a/devlog/_plan/260912_combo_carry/034_render_evidence.md b/devlog/_plan/260912_combo_carry/034_render_evidence.md new file mode 100644 index 0000000000..3f1885699f --- /dev/null +++ b/devlog/_plan/260912_combo_carry/034_render_evidence.md @@ -0,0 +1,20 @@ +# Combo editor render evidence + +Rendered source b3175ae940cdb7855aa4959982b3a357c8a6fd89; GUI tree 39d746c992b2e10fc4c281169db3b83e89d40637. +Vite development server served this worktree using existing installed dependencies, with its cache +inside ignored scratch space. No install, build, typecheck or test suite was run. A loopback-only +synthetic API supplied a single-key OpenRouter target; no real account or user service was used. + +Browser viewport 1440x862, DPR2. Opened Models -> Combos -> review-combo and edited the public model +name to quota-review. DOM inspection read #cwi-edit-save.disabled; screenshots were opened and +visually inspected. No save request was submitted. + +| Input | Observed Save disabled | Image | +| --- | --- | --- | +| Fresh server-confirmed exhausted routingQuota | true | [Exhausted](031_exhausted.png) | +| routingQuota state unknown with exhausted display quota | false | [Unknown](032_unknown.png) | +| Exhausted routingQuota with past validUntil | false | [Expired](033_expired.png) | + +The initial fixture omitted the unrelated aliases defaults and caused a fixture rendering error; +the fixture was corrected to the API shape before these captures. These are rendered client +observations with synthetic API responses, not server integration or hosted CI proof. diff --git a/devlog/_plan/260912_combo_carry/040_pnpm_ci_repair.md b/devlog/_plan/260912_combo_carry/040_pnpm_ci_repair.md new file mode 100644 index 0000000000..2c5fec8f33 --- /dev/null +++ b/devlog/_plan/260912_combo_carry/040_pnpm_ci_repair.md @@ -0,0 +1,27 @@ +# Native-platform pnpm shim regression fixtures + +The final all-lane run found two pnpm shim tests failing on Windows. Both request Linux shim +semantics against Windows filesystem metadata. The tested source and fixture blobs are identical +at the Combo base, failing tip and current dev; this is source evidence of pre-existing code, not +an executed baseline reproduction. Hosted run34674363301 job103503916566 contains the failure. + +Scope: MODIFY tests/update/update-pnpm.test.ts only, plus this record. Production resolver, +POSIX executable-bit guard, declaration file and update/job.ts remain unchanged. Local suites, +build/typecheck/install remain NOT RUN; hosted CI observes the repair. No workflow changes. + +NEW local fixture helper emits actual-host launchers: POSIX ocx/opencodex scripts with executable +permissions; Windows cmd and PowerShell files for both commands. MODIFY the active-target and +alias cases to use the helper and verifier's real host default. The stale-target case replaces +both forms of only opencodex on Windows, preserving rejection coverage for both command names. +The two previously failing cases continue to run on every platform. + +NEW separate POSIX permission case: native valid target passes, removing one shim's execute bits +rejects that command, restoring permissions passes. This new filesystem-specific case runs on +POSIX only; NTFS cannot provide the claimed mode-bit contract. This is not a skip of either +failing test and does not weaken the production permission predicate. + +Preserve the existing explicit Windows cmd/PowerShell case. Reject the earlier proposed stat +injection: host-native fixtures preserve coverage without changing production APIs. Use a new +owned dev repair PR, independent read-only review, --no-verify push and repaired cumulative tip +hosted CI. Original Combo all-lane FAIL remains recorded. Windows shard3's separate devin CLI +discovery failure is handed to the parent for its owner; this pnpm change does not claim to fix it. diff --git a/devlog/_plan/260912_combo_carry/050_active_reactivation_repair.md b/devlog/_plan/260912_combo_carry/050_active_reactivation_repair.md new file mode 100644 index 0000000000..d6049fe85b --- /dev/null +++ b/devlog/_plan/260912_combo_carry/050_active_reactivation_repair.md @@ -0,0 +1,9 @@ +# Deterministic Combo reactivation expiry verification + +Hosted run 34674763850, job 103503977506 failed the active reactivation case because Save remained disabled. The test did not explicitly execute the activation effect's zero-delay callback. + +The fixture now captures cancellable immediate timers only during active and commit-boundary scenarios, commits inactive/active state synchronously to preserve the cached quota snapshot, requires one new callback and executes it inside act. The old expiry timer must be cancelled. Dirty alias preservation, initial disabled state, final enabled state, timer/visibility scenarios and cleanup assertions remain. Subsequent fetches stay unresolved so a new server response cannot satisfy the assertion. + +Independent source review found no blocker and traced the callback to the active-dependent effect in Combos.tsx. This covers reactivation while the resource cache survives; it does not claim coverage after cache eviction. + +Local tests, build, typecheck and installation: NOT RUN by maintainer instruction. git diff --check passes. Hosted CI at the published final head remains required before merge. diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/000_plan.md b/devlog/_plan/260912_grok_reset_coupon_gui/000_plan.md new file mode 100644 index 0000000000..e300fc35c0 --- /dev/null +++ b/devlog/_plan/260912_grok_reset_coupon_gui/000_plan.md @@ -0,0 +1,195 @@ +# Grok reset coupons — dashboard surface + +## Reader summary + +PR #4306 gave opencodex a Grok reset-coupon client, a journaled redemption ledger, +two management routes, and a CLI verb, but it deliberately left the dashboard out. +An operator who hits an xAI weekly limit therefore sees the same wall the Codex +pool showed before its ticket badge existed: the coupon is there, the proxy can +read and spend it, and nothing in the UI says so. This unit adds that surface — a +ticket badge per xAI OAuth account row in Providers > Accounts, and a dialog that +lists each coupon's validity window and redeems the nearest-expiry one. It changes +nothing on the server: both actions call routes that already shipped. + +This document is the post-audit contract. It supersedes its own first draft: the +architect review (`010`), the architect reflection (`020`), and the independent +audit (`030`) are folded into the decisions, file map, verifiers and criteria below. + +## Loop spec + +- **Loop archetype:** satisfy-spec. The contract is fixed by the merged management + routes and by the Codex reset-credit surface this mirrors. +- **Trigger:** user request on 2026-09-12 — "여기서 코덱스 처럼 리셋쿠폰 아이콘도 생기고 + 쓸수 있게해줘", pointing at the xAI Grok Accounts tab. +- **Goal:** an operator reads remaining Grok coupons and redeems one from the + dashboard, and is never told a redemption succeeded when it did not. +- **Non-goals:** no server change (routes, ledger, gRPC-Web client stay as merged); + no auto-redeem; no change to the Codex reset-credit surface; no new dependency; + no quota-probe change. +- **Verifier:** the table below. Each row records the command's exit code at plan + time, or "not run yet" where the artifact it observes lands in B, plus whether it + observes this unit's files. +- **Stop condition:** merged into `dev` with exact-head CI green and `dev` ancestry + proven. +- **Memory artifact:** `devlog/_plan/260912_grok_reset_coupon_gui/`, closing into + `devlog/_fin/` after the merge. +- **Expected terminal outcomes:** DONE on merge; BLOCKED if review requires the + server change this unit excluded; NEEDS_HUMAN if a second maintainer approval is + required and unavailable. +- **Escalation condition:** main reclaims a delegated slice after two distinct + agents fail its packet. Delegation is limited to locale catalogs and docs-site + locale text, which have disjoint write sets; moving implementation to a worker + would require a P-phase amendment. +- **Resource bounds:** none set by the user; no token or time budget is claimed. + +## Design decisions (post-audit) + +**D1 — eager read, bounded, with the cost stated.** Codex reset credits ride the +quota payload (`gui/src/codex-quota-utils.ts:21`), so its badge count is free. xAI +quota carries no equivalent, and the request is explicitly for a Codex-style badge +that shows the number, so lazy-on-open would ship a different feature. The panel +therefore reads `GET /api/grok/reset-coupons` once per account when the xAI +Accounts panel mounts. Honest cost: each read is a token refresh plus a live +gRPC-Web billing RPC with no server cache +(`src/server/management/grok-coupon-routes.ts:83`), React StrictMode makes that +**2N** reads for N accounts in development, and a panel remount re-reads because +this unit adds no TTL cache. The bound is a **three-at-a-time queue inside the +hook** — implemented, not asserted — plus the fact that only the currently open +provider's accounts are in the read set. Folding the count into the xAI quota probe +is the recorded follow-up. + +**D2 — roster epoch and per-account cancel tokens are separate.** A single scalar +generation cannot serve both: bumping it for one row's retry silently discards +every sibling read and strands those badges on the placeholder. The scalar stays +the roster epoch, bumped only by the effect and its cleanup; each in-flight read +additionally carries a per-account token, so one row's refresh or redemption never +cancels another row's read. + +**D3 — redemption truth comes from `code`, not from HTTP 200.** The ledger settles +failures terminally (`src/grok/reset-coupon-ledger.ts:133`) and the route replays a +settled record as HTTP 200 with `replayed: true` and the original code +(`src/server/management/grok-coupon-routes.ts:174`). A client that reads only +`replayed` announces a failed redemption as a completed reset. The hook therefore +returns the settled `code`; only `redeemed` is success, every other code routes +through the failure table. 409 clears the held operation id, `capacity` gets its +own retryable message, and no failure message claims a coupon was not consumed +unless that is known — `redeem_failed` can follow an upstream call that already +went out. + +**D4 — the operation id is client-minted or the request is refused.** The +idempotency the journal offers is only reachable when the client holds the id +across attempts. If `crypto` can produce neither `randomUUID` nor +`getRandomValues`, the dialog refuses to redeem and says so, instead of posting +without an id and letting the server mint a fresh one per attempt. + +**D5 — an aborted redemption is an unknown outcome, and the dialog stops posting.** +The 30 s bound can abort while the server is still calling RedeemReset against a +record that is still `open`, and an `open` record re-executes on the next attempt +(`src/grok/reset-coupon-ledger.ts:87`). A second POST therefore spends a second +coupon whether it carries a new id or the same one. After an abort the dialog +issues **no further consume request at all**: it holds the operation id, enters an +explicit unknown state, and offers exactly one action, re-reading the account. If +the coupon has disappeared it reports the coupon as consumed; if it is still listed +the state stays unresolved and the copy says so, pointing at a later re-read rather +than at a retry button. A new confirmation cannot be started while an unknown +outcome is outstanding. + +**D6 — one reauth predicate, and the OAuth surface gate is local.** The read set +and the badge use the same predicate, built from the same health state the row +renders (`showReauth`), so no row is fetched and then hidden. The enabling +condition names the OAuth surface directly rather than relying on the roster loader +three files away to leave `accounts` empty for key-auth xAI. + +**D7 — no new CSS.** Badge and dialog reuse `badge-clickable`, `credit-list`, +`credit-item`, `modal-overlay`, `modal-card` (`gui/src/styles.css:1065`). The +loading placeholder keeps the Codex pattern of an `aria-hidden` slot carrying a +literal `0` (`gui/src/components/codex-account-pool-helpers.tsx:34`), which is why +criterion 4 below is scoped to visible copy rather than to every glyph. + +## File change map + +| File | Change | +| --- | --- | +| `gui/src/hooks/useGrokResetCoupons.ts` | new — bounded per-account read queue (D1), roster epoch + per-account tokens (D2), settled-`code` redemption result (D3), abort reported distinctly (D5), NaN validity sorts last | +| `gui/src/components/provider-workspace/GrokResetCoupons.tsx` | new — badge and dialog; failure table incl. `capacity`; 409 clears the id; unknown-outcome state; unconditional `tokenId`; fail-closed when no id can be minted; `role="alert"` for failures; focus moves to the confirmation | +| `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` | wire the badge into xAI OAuth rows, host the dialog, single reauth predicate, OAuth-surface gate computed before the hook call | +| `gui/src/i18n/en.ts` | 36 `grokCoupon.*` keys (source of truth) | +| `gui/src/i18n/{de,fr,ja,ko,ru,tr,zh,zh-TW}.ts` | the same 36 keys, translated; zh-TW translates `couponNextBadge` rather than joining the keep-English allowlist | +| `gui/tests/grok-reset-coupons.test.tsx` | new — the activation cases below | +| `docs-site/src/content/docs/**/reference/management-api.md` | name the dashboard surface beside the coupon routes: English root + `ko`, `ja`, `zh-cn`, `zh-tw`, `fr`, `ru`, `tr` | +| `structure/providers/xai-grok.md` | record the dashboard surface under the coupon section | +| `structure/gui-and-management-api.md` | add the coupon routes and their GUI owner to the route/owner table (`structure/manifest.json:299` lists `gui/` under this doc) | + +Scope boundary — IN: the files above. OUT: `src/` (server, ledger, CLI), the Codex +reset-credit surface, `src/lab/`, quota probing, `gui/dist`, and +`gui/tests/locale-parity.test.ts` (no allowlist edit is needed once zh-TW +translates the badge word). + +## Verifier table + +| Command | Exit at plan time | Observes this change? | +| --- | --- | --- | +| `cd gui && bun test tests/locale-parity.test.ts` | 1 — `de key count: 2653` vs `2682` | yes: reads every `gui/src/i18n/*.ts` | +| `cd gui && bun test tests/i18n-locales.test.ts` | 1 — same key-set assertion | yes: compares each catalog to `en` | +| `cd gui && bun run lint` | 0 | yes: `oxlint .` covers `src/hooks` and `src/components`, including rules-of-hooks | +| `cd gui && bun run lint:i18n` | 0 | partly: `oxlint src/pages src/components …` sees the new component but **not** `src/hooks` or `src/i18n` (`gui/.oxlintrc.json` ignores `src/i18n/**`) | +| `cd gui && bun test tests/grok-reset-coupons.test.tsx` | file lands in B | yes: mounts `ProviderAuthPanel` with an xAI item | +| `cd gui && bun test tests` | not run yet | yes: full GUI suite | +| `cd gui && bun run build` | not run yet | yes: `tsc -b && vite build` over `gui/src` | +| `bun run structure:check` | 0 | yes: gates `structure/` doc-map and ownership for `gui/` | +| `bun run typecheck` and `bun run test` (root) | not run yet | PR-ready gate required by `AGENTS.md` | +| `rg -l 'reset-coupons' docs-site/src/content/docs` | 0 (16 files today) | human review: no automated gate reads docs-site locale prose | + +## Conditional paths and how C triggers them (C-ACTIVATION-GROUNDING-01) + +| Path | Trigger in the test | Observable proof | +| --- | --- | --- | +| Read failure | GET returns 502 | row renders `data-grok-coupon-badge="error"`; dialog offers retry | +| Auth failure on read | GET returns 401 `auth_failed` | dialog says sign in again, not "billing unavailable" | +| Replayed **failure** | consume returns 200 `{"replayed":true,"code":"redeem_failed"}` | failure message in the alert channel; no success claim | +| Replayed success | consume returns 200 `{"replayed":true,"code":"redeemed"}` | replay message, no second POST | +| Identity mismatch | consume returns 409 | failure message **and** the held operation id is cleared, proven by the next POST carrying a different id | +| Ledger capacity | consume returns 503 `capacity` | its own retryable message, distinct from the generic failure | +| Aborted redemption | consume never settles until the bound aborts | unknown-outcome state, a re-read, no new operation id | +| Aborted redemption issues no retry | after the abort, the dialog's only control is the re-read | no second POST to `/consume` is recorded by the fetch stub | +| Read queue bound | five-account roster with GETs held open | at most three `/reset-coupons` requests are in flight at any moment | +| Sibling reads survive | two accounts; row A retries while row B's read is in flight | row B still resolves to its count | +| Reauth row | account with `needsReauth` | no badge and no GET for that id | + +## Accept criteria + +1. An xAI OAuth row shows a ticket badge whose number equals `tokens.length` from + `GET /api/grok/reset-coupons?accountId=` for that row. +2. The dialog lists every coupon with its validity window, nearest expiry first, + and an unparsable `validityEnd` sorts last instead of being treated as nearest. +3. Redeeming posts `{accountId, tokenId, operationId}` with a UUIDv4 id and an + always-present `tokenId`; with no id mintable, the dialog refuses instead of + posting. +4. Every grok-specific visible string resolves through a `grokCoupon.*` key present + in all nine catalogs; shared `common.*` keys and the Codex-inherited + `aria-hidden` placeholder are the only exceptions. +5. `cd gui && bun test tests`, `bun run lint`, `bun run lint:i18n`, and + `bun run build` are green, and `bun run structure:check` passes. +6. The docs-site coupon rows name the dashboard surface in the English root and + every translated locale, verified by reading the eight files. +7. A replayed redemption whose `code` is not `redeemed` is reported as a failure. +8. A 409 identity mismatch clears the held operation id. +9. A 503 `capacity` reports its own retryable message, and no failure message + claims a coupon was not consumed unless that is known. +10. One row's retry or redemption never cancels another row's in-flight read. +11. An aborted redemption enters the unknown-outcome state, keeps its operation id, + re-reads the account, and issues no further consume request. +12. At most three coupon reads are in flight at once. + +## PR gate + +`AGENTS.md` requires `bun run typecheck` and `bun run test` before the PR is +review-ready, the repository PR template in full, and — because this PR is about +`gui` — **a screenshot of the UI change in the description** +(`.github/PULL_REQUEST_TEMPLATE.md:8`). The PR targets `dev`. + +## Source-of-truth sync (SOT-SYNC-01) + +`structure/providers/xai-grok.md` owns the Grok coupon contract and gains the +dashboard surface. `structure/gui-and-management-api.md` owns `gui/` per +`structure/manifest.json:299` and gains the coupon routes with their GUI owner. diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/005_status.md b/devlog/_plan/260912_grok_reset_coupon_gui/005_status.md new file mode 100644 index 0000000000..470176c606 --- /dev/null +++ b/devlog/_plan/260912_grok_reset_coupon_gui/005_status.md @@ -0,0 +1,45 @@ +# Unit status — Grok reset-coupon dashboard surface + +## wp1 — in Check + +**What shipped.** The xAI account rows in Providers > Accounts carry a ticket badge +with their remaining coupon count, and the badge opens a dialog that lists validity +windows and redeems the coupon closest to expiry. Server side is untouched: both +actions call the routes merged in #4306. + +**What the audit changed.** The first implementation would have told a user that a +failed redemption succeeded — the route replays a settled failure as HTTP 200 with +`replayed: true` — and would have retried an aborted redemption against a ledger +record that re-executes, spending a second coupon. Both are fixed; the second is +fixed by refusing to post again at all. A single generation counter would also have +let one row's retry strand its siblings' badges; reads now carry per-account tokens. + +**Evidence.** + +- `gui/tests/grok-reset-coupons.test.tsx` — 9 pass, covering badge counts, the + redeem body, replayed failure, 409 id clearing, 503 capacity, the aborted-unknown + state with no second POST, sibling-read survival, and the three-in-flight bound. +- `cd gui && bun test tests` — 1963 pass / 0 fail (pre-rebase tree). +- Receipt: `.codexclaw/evidence//test-receipt.json` over + `grok-reset-coupons` + `locale-parity` + `i18n-locales` — 23 pass / 0 fail. +- `bun run lint`, `lint:i18n`, `build`, `structure:check`, root `typecheck` — exit 0. +- Root `bun run test`: **NOT RUN.** Two local attempts died in a parallel worker with + SIGSEGV on `tests/routing/routing-policy-surface-parity.test.ts`, which passes + alone (6 pass); the user then instructed no further local suite runs, so exact-head + CI on #4330 is the authority. +- Live: a proxy built from this branch read the real account pool and rendered + 0 / 0 / 1 badges; the dialog listed the actual coupon expiring 2026-09-13. + +**Delivery.** Issue #4329, PR #4330 into `dev`, screenshots on the never-merged +`codex/pr-assets-grok-coupon-gui` branch. + +**Residual, carried not closed.** `src/grok/reset-coupon-ledger.ts:87` returns +`execute` for a record that is still `open`, so any client that retries a timed-out +redemption can spend a second coupon. This unit's client never retries, which is a +mitigation, not a fix. The route-side fix belongs to a follow-up against `src/`. + +**What did not improve.** The badge count still costs one billing RPC per account +per panel mount, with no TTL cache. Folding it into the xAI quota probe would make +it free, and that remains the recorded follow-up rather than something this unit +attempted. + diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/010_architect_dispositions.md b/devlog/_plan/260912_grok_reset_coupon_gui/010_architect_dispositions.md new file mode 100644 index 0000000000..008ed71b01 --- /dev/null +++ b/devlog/_plan/260912_grok_reset_coupon_gui/010_architect_dispositions.md @@ -0,0 +1,32 @@ +# Architect review dispositions (round 1) + +Reviewer: read-only architect subagent, 2026-09-12. Verdict text is reproduced in +`evidence/architect-round1.md`. Main owns the plan; each decision below is main's +disposition, not the reviewer's. + +| ID | Finding | Disposition | +| --- | --- | --- | +| D1 | Eager one-GET-per-account is the most expensive of three read strategies; lazy-on-open matches the Codex detail fetch | **Rebutted with an amendment.** The request is explicitly "코덱스처럼 리셋쿠폰 아이콘도 생기고" — a badge with no number until clicked does not satisfy it, and xAI quota carries no `resetCredits` equivalent to make the count free. Eager stays, bounded: at most three reads in flight, and the read set is only the accounts of the provider whose panel is open. Folding the count into the xAI quota probe is recorded as the follow-up. | +| D2 | One scalar generation ref serves as both roster epoch and per-request cancel token, so a single-account refresh or redeem silently discards sibling reads and strands their badges | **Folded.** The roster epoch stays a scalar bumped only by the effect and its cleanup; each in-flight read now carries a per-account token, so one row's retry cannot cancel another row's read. | +| D3a | A retried redemption against a still-`open` journal record executes again, so one confirmation can spend two coupons | **Acknowledged as a backend residual.** The `open` → `execute` path is the server's deliberate resumption branch (`src/grok/reset-coupon-ledger.ts:87`) and this unit does not touch `src/`. The client keeps redemption single-flight and the residual is recorded for a follow-up issue against the route. | +| D3b | A settled *failure* replays as HTTP 200 with `replayed: true`, and the client reads only that flag, so a failed redemption is announced as a successful one | **Folded — this was the worst defect.** The client now reads `code` out of the 200 body and treats only `redeemed` as success; any other replayed code routes through the failure table. | +| D3c | 409 identity mismatch never clears the held operation id, so "try again" reproduces the same 409 forever | **Folded.** The id is cleared on 409 and on any failure that makes it unusable. | +| D3d | 503 capacity arrives as code `capacity`, which has no mapping and falls back to copy claiming nothing was consumed | **Folded.** `capacity` gets its own retryable message, and the generic failure copy no longer asserts that no coupon was consumed, because `redeem_failed` can follow an upstream call that already went out. | +| D4a | The fetch filter tests `account.needsReauth` while the render guard uses `showReauth`, so a health-flagged row is fetched and never rendered | **Folded.** Both use one predicate built from `accountNeedsReauth`-equivalent health state. | +| D4b | `grokCouponsEnabled` does not reference `surface`, so API-key xAI is excluded only by accident | **Folded.** The gate now requires the OAuth surface locally. | +| D5a | Seven locale catalogs are missing all 29 keys; `tests/i18n-locales.test.ts` and `tests/locale-parity.test.ts` fail | **Folded** — already in the file-change map; confirmed failing at plan time (`de key count: 2653` vs `2682`). | +| D5b | Failure outcome uses `role="status"` where the panel's convention for failures is `role="alert"`; confirmation step does not move focus | **Folded.** Failures announce assertively and the confirmation step takes focus. | +| D6a | `byExpiry` sorts client-side while the server's no-token default picks upstream order, so `fifoNote` promises the client's rule | **Rebutted as written.** The dialog always sends an explicit `tokenId`, so the server's default ordering never applies to this surface; the promise the copy makes is the one the request enforces. | +| D6b | The GET's 400/401/502 collapse into one opaque error | **Folded in part.** The entry keeps the response status so the dialog can separate "sign in again" from an upstream billing failure; finer codes stay out of scope. | +| D6c | docs-site owes an update | **Folded** — already in the file-change map. | + +## Amendment to the plan + +D1's bound and D2's per-account token change `gui/src/hooks/useGrokResetCoupons.ts`; +D3b/D3c/D3d and D5b change `gui/src/components/provider-workspace/GrokResetCoupons.tsx`; +D4a/D4b change the wiring in `ProviderAuthPanel.tsx`. No new files, and the scope +boundary is unchanged: `src/` stays out. + +Two new locale keys follow from the dispositions: `grokCoupon.capacity` and +`grokCoupon.authExpired`, bringing the key set to 31. + diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/020_reflection_gaps.md b/devlog/_plan/260912_grok_reset_coupon_gui/020_reflection_gaps.md new file mode 100644 index 0000000000..0a21016154 --- /dev/null +++ b/devlog/_plan/260912_grok_reset_coupon_gui/020_reflection_gaps.md @@ -0,0 +1,37 @@ +# Architect reflection — remaining gaps and final dispositions + +Verdict: **ALIGNED**, with six residual gaps. All six are folded below; the plan's +file-change map and accept criteria in `000_plan.md` are amended accordingly. + +1. **Abort path for redemption (surviving edge of D3a).** After the 30 s bound + aborts, the outcome is unknown and the old code left a live "Use coupon" + button. Folded: an aborted redemption puts the dialog into an explicit unknown + state, re-reads the coupon list, and does not offer a same-id retry. The user + sees the refreshed count and decides from it. +2. **`byExpiry` NaN ordering.** Folded: an unparsable `validityEnd` sorts last + instead of collapsing the comparator to `0`, so a malformed timestamp cannot + make a confidently wrong coupon the "nearest expiry". +3. **Conditional `tokenId`.** Folded: the dialog refuses to redeem when it holds + no coupon id rather than posting without one and letting the server's + upstream-order default apply. This makes the D6a rebuttal an enforced invariant. +4. **C-activation coverage for the folded defects.** Folded into the verifier + contract: the GUI test must cover a replayed *failure* (200 with + `code: "redeem_failed"`, `replayed: true`), a 409 identity mismatch clearing the + held id, a 503 `capacity`, and a two-account roster where one row's retry must + not strand the other row's read. +5. **Accept criteria did not fail on regression.** Folded: criteria 7-10 below. +6. **Bookkeeping.** The key set is 31, not 29. No read cache or TTL is specified: + a panel remount re-reads, bounded by three concurrent reads and by the fact + that only the open provider's accounts are in the read set. That is accepted + cost, recorded rather than hidden. + +## Amended accept criteria (extends 000_plan.md) + +7. A replayed redemption whose `code` is not `redeemed` is reported as a failure, + never as a completed reset. +8. A 409 identity mismatch clears the held operation id so the next attempt is not + guaranteed to repeat it. +9. A 503 `capacity` reports its own retryable message, and no failure message + claims a coupon was not consumed unless that is known. +10. One row's retry or redemption never cancels another row's in-flight read. + diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/030_audit_round1.md b/devlog/_plan/260912_grok_reset_coupon_gui/030_audit_round1.md new file mode 100644 index 0000000000..e0a39e9717 --- /dev/null +++ b/devlog/_plan/260912_grok_reset_coupon_gui/030_audit_round1.md @@ -0,0 +1,44 @@ +# Independent audit round 1 — dispositions + +Auditor: independent adversarial subagent (xai/grok-4.6), read-only. +Verdict: **GAPS(8)**. All eight are folded; nothing is rebutted. + +| # | Blocker | Disposition | +| --- | --- | --- | +| 1 | `000_plan.md` still carried the pre-fold contract (29 keys, scalar cancel, `replayed` as success, "nothing was consumed" copy) while `020` claimed it was amended | **Folded.** `000_plan.md` is rewritten as the post-audit contract: D1-D7, a new file map, a new verifier table, a nine-row activation table, and twelve accept criteria. `010`/`020` remain as the consultation record. | +| 2 | File map missed `structure/gui-and-management-api.md` (owns `gui/` per `structure/manifest.json:299`), missed that D3b also changes the hook, and under-counted the keys | **Folded.** Both structure docs are in the map, the hook owns the settled-`code` result, and the key set is fixed at 36 including the unknown-outcome copy. zh-TW translates `couponNextBadge` so `locale-parity.test.ts` needs no allowlist edit. | +| 3 | Criteria were unobservable: criterion 4 was false (the dialog uses `common.*` and a literal `0`), criteria 7-10 lived only in `020`, and the new behaviors had no criteria | **Folded.** Criterion 4 is scoped to grok-specific visible copy with the shared keys and the inherited `aria-hidden` placeholder named as exceptions; criteria 7-12 are in `000_plan.md`. | +| 4 | Verifier table claimed observation it did not have: `lint:i18n` cannot see `src/hooks` or `src/i18n`, and nothing observed docs-site or structure | **Folded.** The table now records `lint:i18n` as partial, adds `bun run lint`, `bun test tests/i18n-locales.test.ts`, `bun run structure:check`, the root PR-ready gates, and marks docs-site prose as human review rather than a gate. | +| 5 | "Refuse a same-id retry" after an abort is the double-spend, not a mitigation: minting a new id while RedeemReset may still be executing against an `open` record spends a second coupon | **Folded, and the rule is inverted.** D5 now keeps the same operation id, blocks a new confirmation while the outcome is unknown, and offers only a re-read plus a same-id retry. The backend residual stays recorded, but the client no longer converts it into a second spend. | +| 6 | The "three in flight" bound existed only in prose, and the StrictMode cost was understated | **Folded.** The bound is a queue inside the hook with its own accept criterion, and D1 states the real cost: 2N reads under StrictMode, no TTL cache, re-read on remount. | +| 7 | `gui/AGENTS.md` PR-ready requires `bun run lint`; the root template requires a GUI screenshot | **Folded.** Both are in criterion 5 and in the new PR gate section. | +| 8 | The WIP could post without `operationId` (`newOperationId()` may return `undefined`), which breaks the whole D3 premise | **Folded.** D4 makes the id mandatory: no id, no POST, with user-visible copy. | + +Nits accepted: the "every command was run" line is replaced by a per-row exit +column; the `aria-hidden` `0` placeholder is now named in D7; the `i18n-locales` +path is corrected to `gui/tests/`; the OAuth-surface gate is required to be +computed before the hook call. `parseCoupons` rejecting a whole malformed list +stays as designed — a partially-parsed coupon list is worse than an error badge — +and is now stated rather than implicit. + + +## Audit round 2 — dispositions + +Verdict: **GAPS(2)**, both folded. + +1. *Same-id retry after an abort is still a second RedeemReset against an `open` + record.* Correct. D5 is inverted again: after an abort the dialog issues no + consume request at all. Its only control is a re-read; a coupon that disappears + is reported consumed, and a coupon still listed leaves the state unresolved with + copy that says so. +2. *Criterion 12 had no activation.* Folded: the activation table gains a + five-account roster with held-open GETs, proving at most three are in flight. + +Nit folded: the loop-spec no longer claims every verifier command was run; the +table's exit column carries the truth. + +Residual carried into the PR (not closed by this unit): a redemption whose journal +record is still `open` re-executes if anything ever retries it. This unit's client +never retries, so it cannot cause that spend, but the route's `open` -> `execute` +branch stays as merged and is recorded as the follow-up against `src/`. + diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/evidence/architect-round1.md b/devlog/_plan/260912_grok_reset_coupon_gui/evidence/architect-round1.md new file mode 100644 index 0000000000..e034a1f850 --- /dev/null +++ b/devlog/_plan/260912_grok_reset_coupon_gui/evidence/architect-round1.md @@ -0,0 +1,10 @@ +# Architect review round 1 (read-only subagent) + +Full verdict retained in the session transcript. Findings carried into +`010_architect_dispositions.md` as D1-D6 with main dispositions. Headline items: + +- D2: one scalar generation ref cancels sibling reads on a single-row refresh. +- D3b: a settled failure replays as HTTP 200 `replayed:true`; the client announced it as success. +- D3a: a still-`open` journal record re-executes, so one confirmation can spend two coupons (backend residual). +- D4a: fetch filter and render guard use different reauth predicates. +- D5a: seven locale catalogs missing all new keys; parity test fails. 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