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/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 24fff777be..d50b11a50c 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -85,6 +85,14 @@ résultats propres à chaque route, sans répéter ce tableau. | `GET /api/claude-desktop/status` | Inspecter le profil enregistré par rapport à celui appliqué et l'état du bureau | 400 échec de lecture de l'état | | `GET, PUT /api/claude-code` | Lire ou mettre à jour les paramètres de passerelle, de mode d'authentification, de correspondance des modèles, de contexte, d'agent et de service auxiliaire | 400 champ ou structure invalide | +Le tableau de bord pilote les deux chemins de coupon depuis **Providers > xAI Grok > Accounts** : chaque +ligne de compte connecté porte un badge de ticket indiquant le nombre de coupons restants, et le +badge ouvre une boîte de dialogue qui liste les fenêtres de validité et échange le coupon le plus +proche de l'expiration. La boîte de dialogue envoie un `operationId` émis par le client, et cesse +d'envoyer après un délai d'attente au lieu de réessayer, car un échange dont l'enregistrement du +journal est encore ouvert s'exécuterait de nouveau. `ocx account grok-reset-coupons` reste l'équivalent +en terminal. + Pour comprendre la liste de modèles et le comportement chiffré des tâches confiées aux agents d'exécution, voir [Surface des sous-agents](/fr/guides/sub-agent-surface/). diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 1ef8e49122..35ebec9131 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -71,6 +71,8 @@ Authorization: Bearer | `GET /api/claude-desktop/status` |保存済みプロファイルと適用済みプロファイルおよびデスクトップの健全性を検査する | 400 ステータス読み取り失敗 | | `GET, PUT /api/claude-code` |クロード コードのゲートウェイ、認証モード、モデル マップ、コンテキスト、エージェント、サイドカー設定の読み取りまたは更新 | 400 無効なフィールドまたは図形 | +ダッシュボードは **Providers > xAI Grok > Accounts** から両方のクーポン パスを操作します。サインイン済みの各アカウント行には残りのクーポン数を示すチケット バッジがあり、バッジは有効期限ウィンドウを一覧し、期限が最も近いクーポンを換金するダイアログを開きます。ダイアログはクライアントが発行した `operationId` を送り、再試行せずタイムアウト後に送信を止めます。ジャーナル記録がまだ開いている換金は再実行されてしまうためです。`ocx account grok-reset-coupons` はターミナル側の同等コマンドです。 + モデルロスターと暗号化されたワーカータスクの動作の背後にある概念については、「[サブエージェントサーフェス](/guides/sub-agent-surface/)」を参照してください。 ### クライアント統合のロールバックジャーナル diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index 1b1491da1e..c0056af61d 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -71,6 +71,12 @@ Authorization: Bearer | `GET /api/claude-desktop/status` | 저장된 프로필과 적용된 프로필, Desktop 상태를 확인합니다 | 400 상태 읽기 실패 | | `GET, PUT /api/claude-code` | Claude Code gateway, auth-mode, model-map, context, agent, sidecar 설정을 읽거나 갱신합니다 | 400 잘못된 필드 또는 형태 | +대시보드는 **Providers > xAI Grok > Accounts**에서 두 coupon 경로를 모두 사용합니다. 로그인한 각 +계정 행에는 남은 coupon 개수가 표시된 티켓 배지가 있으며, 이 배지는 유효 기간을 나열하고 만료가 +가장 가까운 coupon을 교환하는 대화 상자를 엽니다. 대화 상자는 클라이언트가 생성한 `operationId`를 +보내며, 재시도하는 대신 타임아웃 후 전송을 중단합니다. 저널 기록이 아직 열린 교환이 다시 실행되기 +때문입니다. `ocx account grok-reset-coupons`는 터미널 대응 명령으로 그대로 남습니다. + 모델 목록과 암호화된 worker-task 동작의 개념은 [Sub-agent Surface](/guides/sub-agent-surface/)를 참고하십시오. ### 클라이언트 연동 롤백 저널 diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index a90c7a7996..cd8b2450c2 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -85,6 +85,13 @@ route-specific results rather than repeating this table. | `GET /api/claude-desktop/status` | Inspect saved-versus-applied profile and Desktop health | 400 status read failure | | `GET, PUT /api/claude-code` | Read or update Claude Code gateway, auth-mode, model-map, context, agent, and sidecar settings | 400 invalid field or shape | +The dashboard drives both coupon paths from **Providers > xAI Grok > Accounts**: each +signed-in account row carries a ticket badge with its remaining coupon count, and the +badge opens a dialog that lists validity windows and redeems the coupon closest to +expiry. The dialog sends a client-minted `operationId`, and it stops sending after a +timeout instead of retrying, because a redemption whose journal record is still open +would execute again. `ocx account grok-reset-coupons` remains the terminal equivalent. + For the concepts behind the model roster and encrypted worker-task behavior, see [Sub-agent Surface](/guides/sub-agent-surface/). diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 60543058d9..e91a12222a 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -86,6 +86,14 @@ GUI-сессия в стиле loopback не выпускается. | `GET /api/claude-desktop/status` | Проверить согласованность saved-vs-applied profile и здоровье Desktop | 400 status read failure | | `GET, PUT /api/claude-code` | Прочитать или обновить настройки gateway, auth-mode, model-map, context, agent и sidecar для Claude Code | 400 invalid field or shape | +Дашборд управляет обоими путями купонов из **Providers > xAI Grok > Accounts**: каждая +строка вошедшего аккаунта несёт значок-билет с числом оставшихся купонов, а значок +открывает диалог, который показывает окна действия и обменивает купон, ближайший +к истечению срока. Диалог отправляет сгенерированный клиентом `operationId` и после +таймаута прекращает отправку вместо повторной попытки, потому что обмен, запись +журнала которого ещё открыта, выполнился бы снова. `ocx account grok-reset-coupons` +остаётся эквивалентом в терминале. + О принципах model roster и поведении encrypted worker-task см. [Поверхность подагентов](/guides/sub-agent-surface/). diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index 89ad0da30e..bfdd83dcd9 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -90,6 +90,15 @@ hatalar" sütunu bu tabloyu tekrarlamak yerine rotaya özgü ek sonuçları list | `GET /api/claude-desktop/status` | Kaydedilen ve uygulanan profili ve Desktop sağlığını inceleyin | 400 durum okuma hatası | | `GET, PUT /api/claude-code` | Claude Code ağ geçidi, kimlik doğrulama modu, model haritası, bağlam, ajan ve sidecar ayarlarını okuyun veya güncelleyin | 400 geçersiz alan veya şekil | +Kontrol paneli her iki kupon yolunu da **Providers > xAI Grok > Accounts** +üzerinden yürütür: oturum açmış her hesap satırı, kalan kupon sayısını gösteren +bir bilet rozeti taşır ve rozet, geçerlilik pencerelerini listeleyen ve süresi +dolmaya en yakın kuponu kullanan bir iletişim kutusu açar. İletişim kutusu +istemci tarafından üretilen bir `operationId` gönderir ve yeniden denemek yerine +zaman aşımından sonra göndermeyi durdurur; çünkü günlük kaydı hâlâ açık olan +bir kullanım yeniden yürütülür. `ocx account grok-reset-coupons` uçbirim +eşdeğeri olarak kalır. + Model kadrosunun ve şifrelenmiş çalışan görevi davranışının arkasındaki kavramlar için [Alt Ajan Arayüzü](/tr/guides/sub-agent-surface/) sayfasına bakın. diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index ef074c3d60..84afe751a3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -71,6 +71,12 @@ Authorization: Bearer | `GET /api/claude-desktop/status` | 检查已保存与已应用的配置文件以及 Desktop 健康状态 | 400 状态读取失败 | | `GET, PUT /api/claude-code` | 读取或更新 Claude Code 的网关、认证模式、模型映射、上下文、代理和 sidecar 设置 | 400 字段或结构无效 | +仪表板从 **Providers > xAI Grok > Accounts** 驱动这两条优惠券路径:每个已登录账号行 +都带有显示剩余优惠券数量的票据徽章,该徽章会打开一个对话框,列出有效期窗口并兑换 +最接近到期的优惠券。该对话框会发送客户端生成的 `operationId`,并在超时后停止发送而不是 +重试,因为 journal 记录仍处于打开状态的兑换会再次执行。`ocx account grok-reset-coupons` +仍然是对应的终端命令。 + 关于模型名录和加密工作任务行为的概念,请参见 [子代理界面](/guides/sub-agent-surface/)。 ### 客户端集成回滚日志 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index e8b0bbd4ef..8ea6cd79dd 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -71,6 +71,8 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `GET /api/claude-desktop/status` | 檢查已儲存 vs 已套用設定檔與 Desktop 健康 | 400 狀態讀取失敗 | | `GET, PUT /api/claude-code` | 讀取或更新 Claude Code 閘道、auth-mode、model-map、context、agent 與 sidecar 設定 | 400 無效欄位或結構 | +儀表板從 **Providers > xAI Grok > Accounts** 驅動這兩條 coupon 路徑:每個已登入帳號列都帶有票券徽章,顯示剩餘的 reset coupon 數量,徽章會開啟對話框,列出有效期間並兌換最接近到期的 reset coupon。該對話框會送出由客戶端鑄造的 `operationId`,並在逾時後停止送出而不重試,因為日誌記錄仍為開啟的兌換會再次執行。`ocx account grok-reset-coupons` 仍是終端機等價指令。 + 關於模型名冊與加密 worker-task 行為背後的概念,請見[子代理介面](/zh-tw/guides/sub-agent-surface/)。 ### 用戶端整合復原日誌 diff --git a/gui/src/components/provider-workspace/GrokResetCoupons.tsx b/gui/src/components/provider-workspace/GrokResetCoupons.tsx new file mode 100644 index 0000000000..d0deca4fd0 --- /dev/null +++ b/gui/src/components/provider-workspace/GrokResetCoupons.tsx @@ -0,0 +1,310 @@ +/** + * Grok reset-coupon badge and redemption dialog for xAI OAuth account rows. + * + * The dialog is deliberately conservative about the one irreversible thing it + * does. It always names the coupon it is spending, it holds one client-minted + * operation id per confirmation, and when a redemption aborts it stops posting + * entirely: the route re-executes a redemption whose journal record is still + * open, so a retry after a timeout can spend a second coupon. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useI18n, type Locale, type TFn, type TKey } from "../../i18n/shared"; +import { IconAlert, IconTicket } from "../../icons"; +import { daysUntil, formatCreditDate, formatCreditDateTime } from "../codex-account-pool-utils"; +import type { GrokCouponEntry, GrokResetCoupon, GrokResetCouponController } from "../../hooks/useGrokResetCoupons"; + +function couponsOf(entry: GrokCouponEntry | undefined): GrokResetCoupon[] { + return entry?.status === "ready" ? entry.coupons : []; +} + +function newOperationId(): string | undefined { + const api = globalThis.crypto; + if (api && typeof api.randomUUID === "function") return api.randomUUID(); + if (api && typeof api.getRandomValues === "function") { + const bytes = api.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join(""); + return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)].join("-"); + } + // Without an id the journal cannot recognise a repeat, so the dialog refuses + // rather than letting the route mint a fresh id per attempt. + return undefined; +} + +const FAILURE_KEYS: Record = { + auth_failed: "grokCoupon.authFailed", + no_account: "grokCoupon.noAccount", + no_coupons_available: "grokCoupon.noneAvailable", + operation_id_owned_by_another_account: "grokCoupon.identityMismatch", + capacity: "grokCoupon.capacity", + unavailable: "grokCoupon.capacity", + network: "grokCoupon.networkError", + redeem_failed: "grokCoupon.redeemFailed", +}; + +/** Ticket badge on an xAI account row. Muted at zero, amber when redeemable. */ +export function GrokCouponBadge({ entry, onClick, t }: { + entry: GrokCouponEntry | undefined; + onClick: () => void; + t: TFn; +}) { + if (entry === undefined || entry.status === "loading") { + // Reserve the width so the row does not shift when the count lands. Same + // aria-hidden placeholder the Codex ticket badge uses. + return ( + + ); + } + const count = entry.status === "ready" ? entry.coupons.length : null; + const label = count === null + ? t("grokCoupon.badgeErrorAria") + : t("grokCoupon.badgeAria", { count: String(count) }); + return ( + + ); +} + +function GrokCouponItem({ coupon, index, isNext, locale, t }: { + coupon: GrokResetCoupon; + index: number; + isNext: boolean; + locale: Locale; + t: TFn; +}) { + const days = coupon.validityEnd ? daysUntil(coupon.validityEnd) : null; + return ( +
+
+ + + {isNext ? t("grokCoupon.couponNext") : t("grokCoupon.couponLabel", { n: String(index + 1) })} + + {isNext && ( + + {t("grokCoupon.couponNextBadge")} + + )} +
+
+ {coupon.validityStart && {t("grokCoupon.validFrom", { date: formatCreditDate(coupon.validityStart, locale) })}} + {days !== null && ( + + {t("grokCoupon.expires", { date: formatCreditDateTime(coupon.validityEnd, locale), days: String(days) })} + + )} +
+
+ ); +} + +type Outcome = { tone: "ok" | "warn"; key: TKey }; + +export function GrokResetCouponModal({ accountId, accountLabel, entry, controller, onClose }: { + accountId: string; + accountLabel: string; + entry: GrokCouponEntry | undefined; + controller: GrokResetCouponController; + onClose: () => void; +}) { + const { locale, t } = useI18n(); + const dialogRef = useRef(null); + const redeemRef = useRef(null); + const [confirming, setConfirming] = useState(false); + const [redeeming, setRedeeming] = useState(false); + const [checking, setChecking] = useState(false); + /** Set by an aborted redemption; while it holds, the dialog posts nothing. */ + const [unknown, setUnknown] = useState<{ tokenId: string } | null>(null); + const [outcome, setOutcome] = useState(null); + const operationIdRef = useRef(undefined); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + + useEffect(() => { + if (confirming) redeemRef.current?.focus(); + }, [confirming]); + + const handleCancel = useCallback((event: React.SyntheticEvent) => { + event.preventDefault(); + onClose(); + }, [onClose]); + + const coupons = couponsOf(entry); + const next = coupons[0]; + + const startConfirm = () => { + if (unknown) return; + const id = newOperationId(); + if (!id) { + setOutcome({ tone: "warn", key: "grokCoupon.noOperationId" }); + return; + } + operationIdRef.current = id; + setOutcome(null); + setConfirming(true); + }; + + const redeem = async () => { + if (redeeming || unknown) return; + const operationId = operationIdRef.current; + if (!next?.tokenId) { + setOutcome({ tone: "warn", key: "grokCoupon.noneAvailable" }); + return; + } + if (!operationId) { + setOutcome({ tone: "warn", key: "grokCoupon.noOperationId" }); + return; + } + setRedeeming(true); + const result = await controller.redeem(accountId, { tokenId: next.tokenId, operationId }); + setRedeeming(false); + if (result.ok) { + operationIdRef.current = undefined; + setConfirming(false); + setOutcome({ tone: "ok", key: result.replayed ? "grokCoupon.redeemReplayed" : "grokCoupon.redeemSuccess" }); + return; + } + if (result.code === "aborted") { + // Outcome unknown: hold the id, stop posting, and let the user re-read. + setUnknown({ tokenId: next.tokenId }); + setOutcome(null); + void controller.refresh(accountId); + return; + } + if (result.code === "operation_id_owned_by_another_account") operationIdRef.current = undefined; + setOutcome({ tone: "warn", key: FAILURE_KEYS[result.code] ?? "grokCoupon.redeemFailed" }); + }; + + const recheck = async () => { + if (!unknown || checking) return; + setChecking(true); + await controller.refresh(accountId); + setChecking(false); + }; + + const unresolvedToken = unknown + ? couponsOf(entry).some(coupon => coupon.tokenId === unknown.tokenId) + : false; + const remaining = String(coupons.length); + + const message = (result: Outcome) => ( +

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

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

{t("grokCoupon.title")}

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

{t("common.loading")}

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

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

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

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

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

{t("grokCoupon.fifoNote")}

+ + ) : ( + <> +

{t("grokCoupon.none")}

+

{t("grokCoupon.desc")}

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

{t("grokCoupon.confirmTitle")}

+

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

+ {next?.validityEnd && ( +

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

+ )} +

{t("grokCoupon.irreversible")}

+ {outcome && message(outcome)} +
+
+ + +
+ + )} + +
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index fecf40ac2b..6ad65475f4 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -3,7 +3,7 @@ * embedding for the workspace Settings tab (WP091). Consumes WP040+WP060 * handlers via props-down; no internal auth machinery. */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import { IconLock, IconRefresh, IconTrash } from "../../icons"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; @@ -21,7 +21,9 @@ import AnthropicAccountPoolSettings from "./AnthropicAccountPoolSettings"; import { LoginHint as LoginHintView } from "../login-url-block"; import { OpenBrowserPrefToggle } from "../open-browser-pref-toggle"; import ProviderAccountQuota from "./ProviderAccountQuota"; +import { GrokCouponBadge, GrokResetCouponModal } from "./GrokResetCoupons"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; +import { useGrokResetCoupons } from "../../hooks/useGrokResetCoupons"; import { Switch } from "../../ui"; import type { AccountLoadState, @@ -37,6 +39,15 @@ const COCKPIT_IMPORT_MAX_BYTES = 256 * 1024; const EMPTY_OAUTH_ACCOUNTS: OAuthAccountRow[] = []; const EMPTY_API_KEYS: ApiKeyRow[] = []; +/** + * One predicate for "this row cannot spend a coupon right now". The read set and + * the badge must agree: a row fetched here and hidden there is a billing RPC + * spent on a 401. + */ +function accountShowsReauth(account: OAuthAccountRow): boolean { + return Boolean(account.needsReauth) || oauthHealthShowsReauth(account.health?.status); +} + function XaiChatOptInControl({ initialState, onUpdateProvider, @@ -207,6 +218,22 @@ export default function ProviderAuthPanel({ }, [connectionIdentity]); const onRefreshQuota = authHandlers?.onRefreshQuota; + const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); + const isOauth = surface === "oauth-accounts"; + const isKeyAuth = surface === "api-keys"; + // Grok reset coupons live behind a billing RPC rather than the quota payload, + // so the xAI rows read them once per roster instead of riding the quota probe. + // The gate names the OAuth surface here rather than relying on the roster + // loader three files away to leave `accounts` empty for key-auth xAI. + const grokCouponsEnabled = isOauth && item.name === "xai" && accounts.length > 0; + const grokAccountIds = useMemo( + () => (grokCouponsEnabled + ? accounts.filter(account => !accountShowsReauth(account)).map(account => account.id) + : []), + [grokCouponsEnabled, accounts], + ); + const grokCoupons = useGrokResetCoupons({ apiBase, accountIds: grokAccountIds, enabled: grokCouponsEnabled }); + const [couponAccount, setCouponAccount] = useState(null); const refreshQuota = async () => { if (!onRefreshQuota || refreshingQuota) return; const generation = ++quotaRefreshGeneration.current; @@ -222,10 +249,6 @@ export default function ProviderAuthPanel({ } }; - const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); - const isOauth = surface === "oauth-accounts"; - const isKeyAuth = surface === "api-keys"; - if (surface === "codex-accounts") { return (
@@ -495,7 +518,7 @@ export default function ProviderAuthPanel({ const label = oauthAccountDisplayLabel(accounts, account, t); const switching = switchingAccountId === account.id; const healthStatus = account.health?.status; - const showReauth = Boolean(account.needsReauth) || oauthHealthShowsReauth(healthStatus); + const showReauth = accountShowsReauth(account); const inCooldown = oauthHealthIsCooldown(healthStatus); const maskedId = displayAccountId(account.id); const healthLabel = formatOAuthHealthLabel(t, account.health); @@ -536,6 +559,13 @@ export default function ProviderAuthPanel({ {t("pws.reauthenticate")} )} + {grokCouponsEnabled && !showReauth && ( + setCouponAccount(account)} + /> + )}