diff --git a/devlog/_fin/261112_anthropic_reset_grant_picker/010_plan.md b/devlog/_fin/261112_anthropic_reset_grant_picker/010_plan.md new file mode 100644 index 00000000000..9de4dd3cda6 --- /dev/null +++ b/devlog/_fin/261112_anthropic_reset_grant_picker/010_plan.md @@ -0,0 +1,158 @@ +# 010 — Claude reset grant picker + +Owner request: "클로드 리셋은 골라서 쓸 수 있고 만료도 다른데 클릭되게 만들어줘". +Class: C3 (GUI component + hook helper + i18n across 11 locales; touches the spend path of a +user-consent action, so it gets an independent and a security review). Base: `origin/dev` a159cdde84. + +## Current behavior (origin/dev) + +- `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` `AnthropicResetGrantModal` lists grants + (`GrantItem`) but "Use 1 reset" always confirms `spendableGrant(snapshot)` + (`gui/src/hooks/useAnthropicResetGrants.ts:135`): the first grant in upstream order that passes the gate. +- `controller.spend(accountId, {grantId, operationId})` already posts any grantId. +- Server `src/server/management/anthropic-reset-grant-routes.ts` `handleConsume`: a new operationId re-reads + upstream and calls `anthropicResetGrantBlocker(status, grantId)` (`src/providers/anthropic-reset-grants.ts:195`) + for the exact grantId → 409 `grant_not_usable` with reason ineligible|unknown_grant|paused|not_usable|exhausted|not_limited. + A same-id retry of an open journal record skips the gate; the ledger then refuses a grant/account change + (`operation_identity_mismatch`) and a second open op on the same (account, grant) (`unresolved_prior_operation`). + So server validation is already per grant and independent of client choice; no server code change is planned. + +## Default selection decision + +Default = the gate-passing grant with the **earliest `endsAt`** (grants with no end date last; ties keep +upstream order). Reasons: +1. Every grant spends exactly one reset; a grant unused at `endsAt` is lost. Earliest-expiry first maximizes + resets the user actually gets (same rule the Grok coupon dialog already applies, `byExpiry` in + `useGrokResetCoupons.ts`). +2. Upstream order carries no documented meaning; the GUI never consumed `next_grant_id`. +3. The cost of a wrong default is low because the choice is now visible and changeable, and the confirm step + names the windows the grant clears before anything is sent. +Rejected: "first in upstream order" (arbitrary, can burn a long-lived grant while a short one expires); +"prefer the grant that clears the most windows" (guesses intent; the user can pick it explicitly). + +## Grok coupons + +Out of scope for this PR. Grok coupons are interchangeable (same effect, differ only in validity end); the +dialog already spends earliest-expiry first and says so (`grokCoupon.fifoNote`). A picker adds choice without +value there. No change. + +## Diff-level plan + +1. `gui/src/hooks/useAnthropicResetGrants.ts` + - add `export type AnthropicGrantBlocker = "paused" | "not_usable" | "exhausted" | "not_limited"` and + `grantBlocker(snapshot, grant)` mirroring the server gate order (paused → usableNow → resetsLeft → limit). + - `spendableGrant(snapshot)` returns the earliest-expiry grant with no blocker (snapshot-level + eligible/journalAvailable unchanged). Badge semantics unchanged (exists ⇔ any spendable). +2. `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` + - grant list becomes `role="radiogroup"` (`aria-label` = new key); each grant `role="radio"`, + `aria-checked`, `aria-disabled` when blocked, roving `tabIndex` (selected 0, others -1), + click / Space / Enter selects, Arrow keys and Home/End move to and select the next enabled grant. + - state `selectedId`; effective selection = `selectedId` if it still has no blocker in the current snapshot, + else `spendableGrant(snapshot)`. Selection handler is a no-op while `unresolved`, `busy`, or `confirming`. + - blocked grants keep their note; new note for exhausted (`resetsLeft === 0`). + - confirm step shows grant label, expiry date/time, and cleared windows (names only), posts that grantId. + Confirm button is disabled if the confirmed grant is no longer selectable in the current snapshot. + - unknown-outcome / pending screen additionally shows which grant the held attempt uses (read-only); + no radios render there, so the held grant cannot be swapped; retry still posts the same {grantId, operationId}. + - journal unavailable: show the existing `anthropicGrant.journalBusy` note above the list. +3. `gui/src/styles/anthropic-reset-grants.css` — selectable item styles: pointer, radio dot, checked + border (reuse amber), disabled opacity, `:focus-visible` outline. +4. i18n, all 11 catalogs (`en de fr ko zh zh-TW ru ja tr vi pt`): `anthropicGrant.chooseLabel`, `.pickHint`, + `.exhausted`, `.confirmExpires`, `.confirmClears`, `.clearsFiveHour`, `.clearsWeekly`, `.clearsOverage`. +5. Tests (written first, must fail on origin/dev): + - extend `gui/tests/anthropic-reset-grants.test.tsx` (existing happy-dom harness with the real hook and + mocked fetch; gui/tests is outside the root test-layout registry): default = earliest expiry; clicking + another usable grant then confirming posts that grantId with a UUIDv4; paused / not usable / exhausted / + requires-limit grants are `aria-disabled` with their note and clicks do not select them; ArrowDown/Space + selection skips disabled; unknown outcome and a server-held pending op render no radios and retry posts + the held grantId/operationId; confirm step shows label, expiry and cleared windows. + - `tests/server/management-anthropic-reset-grants.test.ts`: two-grant block — spending the second grant + sends that grant_id upstream; a paused second grant is refused `grant_not_usable`/`paused` with no claim. + (This passes on dev; it locks the server contract the picker now relies on.) + +## Verification + +- `cd gui && bun test tests/anthropic-reset-grants.test.tsx`; `bun test tests/server/management-anthropic-reset-grants.test.ts` +- `cd gui && bun run lint:i18n`, `bun run build`, and the GUI suite `bun test tests` before review +- `bun run typecheck`, `bun run lint:gui`, `bun run test:changed`, `bun run privacy:scan` +- Browser screenshot of the picker and the confirm step (GUI dev server with a mocked grants response), + uploaded to `pr-assets` and linked by commit SHA. + +## Risks + +- Selecting a grant that upstream refuses: server gate refuses before any claim; GUI refreshes on refusal. +- Swapping grant during an unknown outcome: blocked in UI (no radios) and in ledger (identity mismatch / + unresolved prior). + + +## Audit (A) — system24/deepseek-v4.1-flash, NEAR-PASS + +Model trail: combo/deepseek-flash-pool stalled (cancelled after 8 min without output); +hyds/deepseek-v4.1-flash-1m and hyds/deepseek-v4.1-flash failed with provider 403; system24/deepseek-v4.1-flash answered. + +1. Roving tabindex must move DOM focus — accepted: refs per grant, `.focus()` on the newly selected radio; + arrow targets come from the focused radio's index. +2. `unresolved_prior_operation` not resumed — rebutted: `post()` refreshes after every refusal, and the + refreshed snapshot's `pendingOperation` (newest open op for the account) drives the same-id retry screen. + Covered by a test. +3. Confirmed grant vanishes/blocks on refresh — accepted: the dialog leaves the confirm step and shows + `anthropicGrant.notUsableNow`; the unposted operationId is discarded (never sent, so safe). +4. Malformed `endsAt` — accepted: `expiryRank` returns Infinity for null/NaN, stable sort keeps upstream order. +5. `startConfirm` source — accepted: uses the effective selection and re-checks it has no blocker. +6. Snapshot-level blockers — accepted: `!journalAvailable` makes every radio aria-disabled and shows + `anthropicGrant.journalBusy`; `!eligible` keeps the existing ineligible screen (no list). +7. Ratchet/layout — touched files are not in `file-size-baseline.json` (locale files are exempt); + `gui/tests` is outside the root registry; the server test file is already registered. `bun run build:gui` added. +8. A pending op on any grant blocks spending another grant — intended: one unresolved claim per account at a time, + and the retry screen names the held grant. + + +## Security review (C) — combo/deepseek-flash-pool, head afd5acdb04, REQUEST_CHANGES + +1. Stale snapshot could offer a picker while an attempt is open, and the ledger refused only a same-grant + prior, so a different-grant claim could start — accepted, fixed on both sides: the ledger now allows at + most one unresolved operation per account and org (any grant) inside the ten-minute retry window (an older + open record no longer blocks, matching the same-id retry limit), and the dialog re-reads the account when it + opens. Fail-first tests: ledger (`tests/adapters/anthropic/anthropic-reset-grants.test.ts`), route + (`tests/server/management-anthropic-reset-grants.test.ts`), dialog (`gui/tests/anthropic-reset-grants.test.tsx`). +2. One frame with a live picker between `setHeld(null)` and the refresh — rebutted: `setHeld`, `setOutcome` and + the refresh's synchronous `setEntry(loading)` run in the same task after the awaited spend and are batched + into one render; the unresolved-prior dialog test covers the path. +3. `credit-next` now marks the selection — nit, kept (styling only). +4. `CLEARS_KEYS` lookup on an unknown window — nit, unreachable: the hook parser filters `clears` to the three windows. + +CodeRabbit (5 minor) — accepted: per-grant limit wording in `anthropicGrant.desc` (11 locales) and docs (8 locales); +unparsable dates hidden; empty `clears` shows `anthropicGrant.confirmClearsUnknown`. + + +## Final review — system24/deepseek-v4.1-flash, head 7f90636f41, APPROVE with should-fix + +1. No tab stop when nothing is selectable — accepted: the first grant is the group's tab stop when none is selected. +2. Re-read on open blanked the list and badge — accepted: `refresh(accountId, { keepSnapshot: true })` keeps a ready entry + during the read and the dialog disables "Use 1 reset" until it lands. A failed re-read still turns the entry into + an error (fail closed on a spend path) — that sub-point is rebutted. +3. Hint with no real choice — accepted: shown only with two or more selectable grants. +4. Missing test for a pick surviving a re-read — accepted (test added; it passes on the previous head too, as a guard). +5. German du/Sie — accepted. 6. Turkish docs wording — accepted. + +Security review round 2 (combo/deepseek-flash-pool, head 6f93946d70): APPROVE; its nits (account wording in +`anthropicGrant.unresolvedPrior`, structure scope per pool journal, newest open record named by the ledger) were folded in 7f90636f41. +Model trail for reviews: hyds/deepseek-v4.1-flash-1m and hyds/deepseek-v4.1-flash returned 403 again; combo stalled on two final-review briefs. + + +## Final review — system24/deepseek-v4.1-flash, head bf87ad512f, APPROVE + +1. pickHint false after a pick (should-fix) — accepted: shown only while the default grant is selected; test extended (fails on bf87ad512f). +2. Silent fallback when a picked grant becomes unusable (nit) — rebutted: the confirm step names the grant before any spend. +3. GET pending filter lacks orgDigest (nit) — rebutted for this PR: adding it needs a profile read on every GET; an org change mid-window fails closed (`operation_identity_mismatch` releases the hold, nothing is spent). +4. Missing `.catch` on the open re-read (nit) — accepted. +5. Tests for newest prior / StrictMode (nit) — rebutted: two open records only exist in pre-change journals; the guarded render update is exercised by the stale-confirm test. + + +## Outcome (D) + +Delivered in https://github.com/lidge-jun/opencodex/pull/8595 (squash merge into `dev`). +What did not change: Grok coupons keep their earliest-expiry-first dialog with no picker; the GET's pending +operation still does not filter by organization (an org change mid-window fails closed without a spend). +What would refute the default: evidence that upstream `next_grant_id` encodes a preference the user should follow +(for example a grant that clears the window the account is actually limited on); then prefer it among selectable grants. 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 e155797773e..5dc6a6212b0 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -95,7 +95,7 @@ Un échange non confirmé renvoie HTTP 502 avec `error.code: "attempt_unresolved Dans **Providers > xAI Grok > Accounts**, le badge de ticket de chaque compte affiche les coupons restants et leurs fenêtres de validité. Le dialogue envoie un `operationId` créé par le client. Si la livraison ou le résultat est incertain, le tableau de bord conserve cette tentative pendant la session de l’onglet du navigateur, même après fermeture puis réouverture du dialogue, changement de page ou rechargement. Cette retenue est enregistrée avant l’envoi de la requête et n’est levée que par une réponse définitive pour le même `operationId`. L’actualisation utilise uniquement GET et n’autorise aucun autre POST d’échange. `ocx account grok-reset-coupons` est l’équivalent en terminal. -Les réinitialisations de l’utilisation de Claude fonctionnent de la même manière depuis **Providers > Anthropic > Accounts**. Chaque ligne de compte connecté porte un badge de ticket indiquant le nombre de réinitialisations restantes, et la boîte de dialogue en utilise une après une seconde confirmation. Une réinitialisation recharge les limites sur 5 heures et sur une semaine sans déplacer le jour de réinitialisation hebdomadaire. Si une demande ne reçoit pas de réponse, la boîte de dialogue conserve son `operationId` et propose de réessayer avec le même identifiant pendant dix minutes, comme le fait le client Claude Code pour reprendre une demande ; toute nouvelle opération pour la même attribution est refusée jusque-là. L’utilisation est réservée au tableau de bord : le jeton administrateur seul reçoit `403 session_required`. +Les réinitialisations de l’utilisation de Claude fonctionnent de la même manière depuis **Providers > Anthropic > Accounts**. Chaque ligne de compte connecté porte un badge de ticket indiquant le nombre de réinitialisations restantes, et la boîte de dialogue en utilise une après une seconde confirmation. Chaque attribution a sa propre échéance et ses propres limites réinitialisées ; la boîte de dialogue permet donc de choisir celle à utiliser : l’attribution utilisable qui expire en premier est présélectionnée, celles qui ne sont pas utilisables pour l’instant apparaissent désactivées avec leur motif, et la confirmation indique le nom, l’échéance et les limites réinitialisées de l’attribution choisie avant d’envoyer ce `grantId`. Le serveur revérifie l’attribution choisie et refuse une attribution inutilisable avec `409 grant_not_usable`. Une réinitialisation recharge les limites que son attribution réinitialise (en général celles sur 5 heures et sur une semaine) sans déplacer le jour de réinitialisation hebdomadaire. Si une demande ne reçoit pas de réponse, la boîte de dialogue conserve son `operationId` et propose de réessayer avec le même identifiant pendant dix minutes, comme le fait le client Claude Code pour reprendre une demande ; toute nouvelle opération sur ce compte, quelle que soit l’attribution, est refusée jusque-là. L’utilisation est réservée au tableau de bord : le jeton administrateur seul reçoit `403 session_required`. 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 d3e4013024a..f71ccfb2d99 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -81,7 +81,7 @@ Authorization: Bearer **Providers > xAI Grok > Accounts** の各アカウントのチケットバッジから、残りのクーポンと有効期間を確認できます。ダイアログはクライアントで生成した `operationId` を送信します。送信や結果が不確定な場合、ダッシュボードはブラウザタブのセッション中は同じ試行を保持し、ダイアログを閉じて開き直しても、ページを移動または再読み込みしても維持します。保持は要求を送信する前に記録され、同じ `operationId` に対する確定応答でのみ解除されます。更新は GET のみで、追加の使用 POST を許可しません。`ocx account grok-reset-coupons` は対応するターミナルコマンドです。 -Claude の使用量リセットも **Providers > Anthropic > Accounts** から同様に操作できます。サインイン済みの各アカウント行には残りのリセット回数を示すチケットバッジがあり、ダイアログで再度確認した後に 1 回分を使用します。リセットすると、週次枠のリセット曜日を変えずに 5 時間枠と週次枠が補充されます。請求に応答がない場合、ダイアログは `operationId` を保持し、10 分間は同じ ID で再試行できます。Claude Code クライアント自体もこの方法で復旧し、その間は同じ付与に対する新しい操作が拒否されます。使用できるのはダッシュボードからのみで、管理者トークンだけでは `403 session_required` が返されます。 +Claude の使用量リセットも **Providers > Anthropic > Accounts** から同様に操作できます。サインイン済みの各アカウント行には残りのリセット回数を示すチケットバッジがあり、ダイアログで再度確認した後に 1 回分を使用します。付与ごとに有効期限とリセットされる枠が異なるため、ダイアログでは使用する付与を選べます。いま使える付与のうち失効が最も近いものが最初に選択され、いま使えない付与は理由とともに無効表示になります。確認画面では選んだ付与の名前、有効期限、リセットされる枠を示してから、その `grantId` を送信します。サーバーは選ばれた付与を改めて確認し、使えない場合は `409 grant_not_usable` で拒否します。リセットすると、週次枠のリセット曜日を変えずに、その付与がリセットする枠(通常は 5 時間枠と週次枠)が補充されます。請求に応答がない場合、ダイアログは `operationId` を保持し、10 分間は同じ ID で再試行できます。Claude Code クライアント自体もこの方法で復旧し、その間はどの付与であっても、そのアカウントに対する新しい操作が拒否されます。使用できるのはダッシュボードからのみで、管理者トークンだけでは `403 session_required` が返されます。 モデルロスターと暗号化されたワーカータスクの動作の背後にある概念については、「[サブエージェントサーフェス](/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 256415a0526..95ad687b796 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -83,10 +83,10 @@ Authorization: Bearer Claude 사용량 리셋도 **Providers > Anthropic > Accounts**에서 같은 방식으로 씁니다. 로그인한 계정 행마다 남은 리셋 수를 보여 주는 티켓 배지가 붙고, 대화상자에서 한 번 더 확인하면 리셋 -하나를 사용합니다. 리셋은 5시간 한도와 주간 한도를 다시 채우며 주간 리셋 요일은 바꾸지 +하나를 사용합니다. grant마다 만료일과 초기화하는 한도가 달라서 대화상자는 쓸 grant를 고를 수 있게 보여 줍니다. 지금 쓸 수 있는 grant 중 만료가 가장 가까운 것이 먼저 선택되고, 지금 쓸 수 없는 grant는 이유와 함께 비활성으로 표시되며, 확인 단계에서 고른 grant의 이름, 만료일, 초기화하는 한도를 보여 준 뒤 그 `grantId`를 보냅니다. 서버는 고른 grant를 다시 확인하고 쓸 수 없으면 `409 grant_not_usable`로 거부합니다. 리셋은 그 grant가 초기화하는 한도(보통 5시간 한도와 주간 한도)를 다시 채우며 주간 리셋 요일은 바꾸지 않습니다. 요청이 응답하지 않으면 대화상자는 `operationId`를 그대로 들고 있다가 10분 동안 같은 -ID로 다시 보내기를 제안합니다. Claude Code 클라이언트도 이렇게 복구하며, 그동안 같은 grant에 -대한 새 작업은 거부됩니다. 사용은 대시보드에서만 가능하며 관리자 토큰만으로는 +ID로 다시 보내기를 제안합니다. Claude Code 클라이언트도 이렇게 복구하며, 그동안 그 계정의 새 작업은 +어느 grant든 거부됩니다. 사용은 대시보드에서만 가능하며 관리자 토큰만으로는 `403 session_required`가 돌아옵니다. 모델 목록과 암호화된 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 13c0c360946..6cf35cce113 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -107,10 +107,10 @@ The dashboard at **Providers > xAI Grok > Accounts** lists remaining coupons and Claude usage resets work the same way from **Providers > Anthropic > Accounts**. Each signed-in account row carries a ticket badge with its remaining resets, and the dialog -spends one after a second confirmation. A reset refills the 5-hour and weekly limits +spends one after a second confirmation. Grants differ in expiry and in the limits they clear, so the dialog lists them as choices: it preselects the usable grant that expires first, shows grants that cannot be used now as disabled with the reason, and the confirmation names the chosen grant, its expiry, and the limits it clears before that `grantId` is sent. The server checks the chosen grant again and refuses one that is not usable with `409 grant_not_usable`. A reset refills the limits its grant clears (usually the 5-hour and weekly limits) without moving the weekly reset day. When a claim does not answer, the dialog keeps its `operationId` and offers a retry with that same ID for ten minutes, which is how the -Claude Code client itself recovers; a new operation for the same grant is refused +Claude Code client itself recovers; a new operation on that account, for any grant, is refused until then. Spending is dashboard-only: the admin token alone gets `403 session_required`. For the concepts behind the model roster and encrypted worker-task behavior, see 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 0267e6e1580..53562100a0f 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -96,7 +96,7 @@ GUI-сессия в стиле loopback не выпускается. В **Providers > xAI Grok > Accounts** значок-билет каждого аккаунта показывает оставшиеся купоны и сроки действия. Диалог отправляет созданный клиентом `operationId`. При неопределенной доставке или результате панель сохраняет ту же попытку на время сеанса вкладки браузера, включая закрытие и повторное открытие диалога, переход между страницами и перезагрузку. Запись сохраняется до отправки запроса и снимается только окончательным ответом для того же `operationId`. Обновление использует только GET и не разрешает новый POST обмена. Терминальный эквивалент — `ocx account grok-reset-coupons`. -Сбросы лимитов Claude аналогично доступны в **Providers > Anthropic > Accounts**. В строке каждого вошедшего аккаунта значок-билет показывает число оставшихся сбросов, а диалог использует один сброс после дополнительного подтверждения. Сброс восстанавливает 5-часовой и недельный лимиты, не меняя день недельного сброса. Если ответ на запрос не приходит, диалог сохраняет `operationId` и в течение десяти минут предлагает повторить попытку с тем же идентификатором — так восстанавливает запрос и сам клиент Claude Code. До этого момента новая операция с тем же грантом отклоняется. Использовать сброс можно только через дашборд: одного токена администратора недостаточно — ответом будет `403 session_required`. +Сбросы лимитов Claude аналогично доступны в **Providers > Anthropic > Accounts**. В строке каждого вошедшего аккаунта значок-билет показывает число оставшихся сбросов, а диалог использует один сброс после дополнительного подтверждения. У каждого гранта свой срок действия и свой набор сбрасываемых лимитов, поэтому диалог позволяет выбрать грант: по умолчанию выбран доступный грант, который истекает раньше всех, недоступные сейчас гранты показаны неактивными с указанием причины, а шаг подтверждения называет выбранный грант, его срок действия и сбрасываемые лимиты перед отправкой этого `grantId`. Сервер повторно проверяет выбранный грант и отклоняет недоступный ответом `409 grant_not_usable`. Сброс восстанавливает лимиты, которые сбрасывает этот грант (обычно 5-часовой и недельный), не меняя день недельного сброса. Если ответ на запрос не приходит, диалог сохраняет `operationId` и в течение десяти минут предлагает повторить попытку с тем же идентификатором — так восстанавливает запрос и сам клиент Claude Code. До этого момента любая новая операция для этого аккаунта, с любым грантом, отклоняется. Использовать сброс можно только через дашборд: одного токена администратора недостаточно — ответом будет `403 session_required`. О принципах 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 ed4f081649e..95d8270c1b3 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -103,11 +103,11 @@ Doğrulanamayan kullanım HTTP 502, `error.code: "attempt_unresolved"` ve geçer Claude kullanım sıfırlamaları **Providers > Anthropic > Accounts** üzerinden aynı şekilde çalışır. Oturum açmış her hesap satırında kalan sıfırlamaları gösteren bir bilet rozeti bulunur; iletişim kutusu ikinci bir onaydan sonra bir sıfırlama -kullanır. Sıfırlama, haftalık sıfırlama gününü değiştirmeden 5 saatlik ve -haftalık limitleri yeniler. Bir talep zamanında yanıt vermezse iletişim kutusu +kullanır. Her sıfırlama hakkının kendi son kullanma tarihi ve sıfırladığı limitler olduğundan iletişim kutusu hangisinin kullanılacağını seçtirir: şu anda kullanılabilen ve ilk sona eren hak önceden seçilir, şu anda kullanılamayan haklar gerekçeleriyle birlikte devre dışı gösterilir ve onay adımı o `grantId` gönderilmeden önce seçilen hakkın adını, son kullanma tarihini ve sıfırladığı limitleri gösterir. Sunucu seçilen hakkı yeniden denetler ve kullanılamayan bir hakkı `409 grant_not_usable` ile reddeder. Sıfırlama, haftalık sıfırlama gününü değiştirmeden o hakkın sıfırladığı limitleri (genellikle 5 saatlik ve +haftalık limitleri) yeniler. Bir talep zamanında yanıt vermezse iletişim kutusu `operationId` değerini korur ve on dakika boyunca aynı kimlikle yeniden deneme olanağı sunar; Claude Code istemcisi de bu şekilde toparlanır. Bu süre içinde -aynı sıfırlama hakkı için yeni bir işlem reddedilir. Sıfırlama yalnızca kontrol +o hesapta hangi sıfırlama hakkı için olursa olsun yeni bir işlem reddedilir. Sıfırlama yalnızca kontrol panelinden kullanılabilir: tek başına yönetici belirteci `403 session_required` yanıtını alır. 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 7ea7f22e196..06a7a28d384 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 @@ -81,7 +81,7 @@ Authorization: Bearer 在 **Providers > xAI Grok > Accounts** 中,每个账号的票据徽章可查看剩余优惠券及有效期。对话框发送客户端生成的 `operationId`。请求送达或结果不确定时,仪表板会在浏览器标签页会话期间保留同一次尝试,关闭并重新打开对话框、切换页面或重新加载后也会保留。该记录在发送请求之前写入,只有收到同一 `operationId` 的确定响应时才会清除。刷新只发送 GET,不允许额外的兑换 POST。对应的终端命令是 `ocx account grok-reset-coupons`。 -Claude 用量重置同样可以从 **Providers > Anthropic > Accounts** 操作。每个已登录账号行都带有显示剩余重置次数的票据徽章,对话框会在再次确认后使用一次重置机会。重置会恢复 5 小时和每周用量额度,但不会改变每周额度的重置日期。如果请求未及时返回结果,对话框会保留其 `operationId`,并在十分钟内提供使用同一 ID 重试的选项;Claude Code 客户端也以此方式恢复。在此期间,同一重置机会的新操作会被拒绝。重置机会只能通过仪表板使用:仅凭管理员令牌会收到 `403 session_required`。 +Claude 用量重置同样可以从 **Providers > Anthropic > Accounts** 操作。每个已登录账号行都带有显示剩余重置次数的票据徽章,对话框会在再次确认后使用一次重置机会。每个重置机会的过期时间和可重置的额度各不相同,因此对话框允许选择要使用的重置机会:默认选中当前可用且最先过期的一个,当前不可用的会显示为禁用并注明原因;确认步骤会显示所选重置机会的名称、过期时间和可重置的额度,然后发送对应的 `grantId`。服务器会再次检查所选重置机会,不可用时以 `409 grant_not_usable` 拒绝。重置会恢复该重置机会可重置的额度(通常是 5 小时和每周用量额度),但不会改变每周额度的重置日期。如果请求未及时返回结果,对话框会保留其 `operationId`,并在十分钟内提供使用同一 ID 重试的选项;Claude Code 客户端也以此方式恢复。在此期间,该账号的任何新操作(无论哪个重置机会)都会被拒绝。重置机会只能通过仪表板使用:仅凭管理员令牌会收到 `403 session_required`。 关于模型名录和加密工作任务行为的概念,请参见 [子代理界面](/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 2dbd25046ba..8f668df783d 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 @@ -81,7 +81,7 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 在 **Providers > xAI Grok > Accounts** 中,每個帳號的票券徽章可查看剩餘優惠券及有效期間。對話框送出客戶端產生的 `operationId`。請求送達或結果不確定時,儀表板會在瀏覽器分頁工作階段期間保留同一次嘗試,關閉並重新開啟對話框、切換頁面或重新載入後也會保留。該記錄在送出請求之前寫入,只有收到同一 `operationId` 的確定回應時才會清除。重新整理只送出 GET,不允許額外的兌換 POST。對應的終端指令是 `ocx account grok-reset-coupons`。 -Claude 用量重設也可從 **Providers > Anthropic > Accounts** 以相同方式操作。每個已登入帳號列都有票券徽章,顯示剩餘重設次數;對話框會在第二次確認後使用一次重設機會。重設會補滿 5 小時與每週額度,但不會改變每週重設日。如果使用請求未收到回應,對話框會保留其 `operationId`,並在十分鐘內提供使用相同 ID 重試的選項;Claude Code 用戶端也以此方式復原。在此期間,系統會拒絕對同一重設機會發起新操作。重設機會只能透過儀表板使用:僅持有管理員權杖會收到 `403 session_required`。 +Claude 用量重設也可從 **Providers > Anthropic > Accounts** 以相同方式操作。每個已登入帳號列都有票券徽章,顯示剩餘重設次數;對話框會在第二次確認後使用一次重設機會。每個重設機會的過期時間與可重設的額度各不相同,因此對話框可選擇要使用的重設機會:預設選取目前可用且最先過期的一個,目前無法使用的會顯示為停用並註明原因;確認步驟會顯示所選重設機會的名稱、過期時間與可重設的額度,再送出對應的 `grantId`。伺服器會再次檢查所選重設機會,無法使用時以 `409 grant_not_usable` 拒絕。重設會補滿該重設機會可重設的額度(通常是 5 小時與每週額度),但不會改變每週重設日。如果使用請求未收到回應,對話框會保留其 `operationId`,並在十分鐘內提供使用相同 ID 重試的選項;Claude Code 用戶端也以此方式復原。在此期間,系統會拒絕對該帳號發起任何新操作(無論哪個重設機會)。重設機會只能透過儀表板使用:僅持有管理員權杖會收到 `403 session_required`。 關於模型名冊與加密 worker-task 行為背後的概念,請見[子代理介面](/zh-tw/guides/sub-agent-surface/)。 diff --git a/gui/src/components/provider-workspace/AnthropicResetGrants.tsx b/gui/src/components/provider-workspace/AnthropicResetGrants.tsx index 0e7f375ee3e..2754dfd7dfc 100644 --- a/gui/src/components/provider-workspace/AnthropicResetGrants.tsx +++ b/gui/src/components/provider-workspace/AnthropicResetGrants.tsx @@ -14,8 +14,11 @@ import { useI18n, type TFn, type TKey } from "../../i18n/shared"; import { IconAlert, IconTicket } from "../../icons"; import { daysUntil, formatCreditDate, formatCreditDateTime } from "../codex-account-pool-utils"; import { + grantBlocker, + grantSelectable, spendableGrant, unspentResets, + type AnthropicGrantBlocker, type AnthropicGrantEntry, type AnthropicResetGrant, type AnthropicResetGrantController, @@ -98,32 +101,80 @@ const WINDOW_KEYS = { seven_day_overage_included: "anthropicGrant.windowOverage", } as const satisfies Record; -function GrantItem({ grant, snapshot, isNext, locale, t }: { +const CLEARS_KEYS = { + five_hour: "anthropicGrant.clearsFiveHour", + seven_day: "anthropicGrant.clearsWeekly", + seven_day_overage_included: "anthropicGrant.clearsOverage", +} as const satisfies Record; + +const BLOCKER_KEYS = { + paused: "anthropicGrant.paused", + not_usable: "anthropicGrant.notUsable", + exhausted: "anthropicGrant.exhausted", + not_limited: "anthropicGrant.requiresLimit", +} as const satisfies Record; + +/** An ISO time only when it parses; anything else is shown as absent rather than "Invalid Date". */ +function validTime(iso: string | null): string | null { + return iso !== null && !Number.isNaN(Date.parse(iso)) ? iso : null; +} + +function grantName(grant: AnthropicResetGrant): string { + return grant.label || grant.id; +} + +function clearedWindows(grant: AnthropicResetGrant, locale: string, t: TFn): string { + const names = grant.clears.map(window => t(CLEARS_KEYS[window])); + try { + return new Intl.ListFormat(locale, { type: "conjunction" }).format(names); + } catch { + return names.join(", "); + } +} + +/** One grant as a radio option. Unusable grants stay visible, disabled, with their reason. */ +function GrantOption({ grant, snapshot, checked, tabStop, selectable, optionRef, onSelect, onKeyDown, locale, t }: { grant: AnthropicResetGrant; snapshot: AnthropicResetGrantSnapshot; - isNext: boolean; + checked: boolean; + tabStop: boolean; + selectable: boolean; + optionRef: (element: HTMLDivElement | null) => void; + onSelect: () => void; + onKeyDown: (event: React.KeyboardEvent) => void; locale: string; t: TFn; }) { - const days = grant.endsAt ? daysUntil(grant.endsAt) : null; - const note = grant.paused ? t("anthropicGrant.paused") - : !grant.usableNow ? t("anthropicGrant.notUsable") - : grant.useRequiresLimit && !snapshot.atLimit ? t("anthropicGrant.requiresLimit") - : null; + const startsAt = validTime(grant.startsAt); + const endsAt = validTime(grant.endsAt); + const days = endsAt ? daysUntil(endsAt) : null; + const blocker = grantBlocker(snapshot, grant); + const note = blocker ? t(BLOCKER_KEYS[blocker]) : null; return ( -
+
+
- {grant.startsAt && {t("anthropicGrant.validFrom", { date: formatCreditDate(grant.startsAt, locale) })}} - {grant.endsAt && days !== null && ( + {startsAt && {t("anthropicGrant.validFrom", { date: formatCreditDate(startsAt, locale) })}} + {endsAt && days !== null && ( - {t("anthropicGrant.expires", { date: formatCreditDateTime(grant.endsAt, locale), days: String(days) })} + {t("anthropicGrant.expires", { date: formatCreditDateTime(endsAt, locale), days: String(days) })} )}
@@ -143,6 +194,8 @@ function GrantItem({ grant, snapshot, isNext, locale, t }: { type Outcome = { tone: "ok" | "warn"; key: TKey; count?: string }; type Held = { grantId: string; operationId: string }; +/** The confirm step keeps the grant it was opened for, so a reload cannot swap what it names. */ +type Confirming = Held & { grant: AnthropicResetGrant }; function outcomeOf(result: AnthropicSpendOutcome): Outcome { if (result.kind === "settled") { @@ -175,7 +228,10 @@ export function AnthropicResetGrantModal({ accountId, accountLabel, entry, contr const { locale, t } = useI18n(); const dialogRef = useRef(null); const primaryRef = useRef(null); - const [confirming, setConfirming] = useState(null); + const [confirming, setConfirming] = useState(null); + /** The grant the user picked; null follows the default. Ignored once it stops being selectable. */ + const [selectedId, setSelectedId] = useState(null); + const optionRefs = useRef(new Map()); const [busy, setBusy] = useState(false); const [checking, setChecking] = useState(false); /** Set by an unknown outcome; while it holds, the only post is a same-id retry. */ @@ -187,6 +243,17 @@ export function AnthropicResetGrantModal({ accountId, accountLabel, entry, contr if (dialog && !dialog.open) dialog.showModal(); }, []); + // The row's badge may have been read long ago. Re-read on open so an attempt another + // tab or process left open shows its retry screen instead of a picker for a new one. + // The list stays on screen meanwhile, but nothing can be spent until the read lands. + const { refresh } = controller; + const [opening, setOpening] = useState(true); + useEffect(() => { + let live = true; + void refresh(accountId, { keepSnapshot: true }).catch(() => undefined).finally(() => { if (live) setOpening(false); }); + return () => { live = false; }; + }, [accountId, refresh]); + useEffect(() => { if (confirming) primaryRef.current?.focus(); }, [confirming]); @@ -203,17 +270,79 @@ export function AnthropicResetGrantModal({ accountId, accountLabel, entry, contr // their retry window, so no clock check is needed here. const unresolved: Held | null = held ?? (pending ? { grantId: pending.grantId, operationId: pending.operationId } : null); - const next = snapshot ? spendableGrant(snapshot) : undefined; + const picked = snapshot && selectedId !== null + ? snapshot.grants.find(grant => grant.id === selectedId && grantSelectable(snapshot, grant)) + : undefined; + const selected = picked ?? (snapshot ? spendableGrant(snapshot) : undefined); + // Nothing may change which grant an attempt uses once one is open or being confirmed. + const locked = unresolved !== null || busy || confirming !== null; + + // A reload can pause, exhaust or drop the grant being confirmed. Its id was never + // posted, so leave the confirm step and make the user choose again. + const confirmLive = confirming && snapshot ? snapshot.grants.find(grant => grant.id === confirming.grantId) : undefined; + const confirmStale = confirming !== null && snapshot !== null && (!confirmLive || !grantSelectable(snapshot, confirmLive)); + if (confirmStale && !busy) { + // Adjusting state during render (guarded, so it settles in one pass) rather than in an effect. + setConfirming(null); + setSelectedId(null); + setOutcome({ tone: "warn", key: "anthropicGrant.notUsableNow" }); + } + + const select = (grant: AnthropicResetGrant, focus: boolean) => { + if (locked || !snapshot || !grantSelectable(snapshot, grant)) return; + setSelectedId(grant.id); + if (focus) optionRefs.current.get(grant.id)?.focus(); + }; + + const onOptionKey = (event: React.KeyboardEvent, grant: AnthropicResetGrant) => { + if (!snapshot) return; + const grants = snapshot.grants; + const usable = grants.filter(candidate => grantSelectable(snapshot, candidate)); + const from = grants.findIndex(candidate => candidate.id === grant.id); + const step = (direction: 1 | -1) => { + for (let offset = 1; offset <= grants.length; offset += 1) { + const candidate = grants[(((from + direction * offset) % grants.length) + grants.length) % grants.length]; + if (candidate && grantSelectable(snapshot, candidate)) return candidate; + } + return undefined; + }; + let target: AnthropicResetGrant | undefined; + switch (event.key) { + case " ": + case "Enter": + event.preventDefault(); + select(grant, false); + return; + case "ArrowDown": + case "ArrowRight": + target = step(1); + break; + case "ArrowUp": + case "ArrowLeft": + target = step(-1); + break; + case "Home": + target = usable[0]; + break; + case "End": + target = usable[usable.length - 1]; + break; + default: + return; + } + event.preventDefault(); + if (target) select(target, true); + }; const startConfirm = () => { - if (!next || unresolved) return; + if (opening || !selected || unresolved || !snapshot || !grantSelectable(snapshot, selected)) return; const operationId = newOperationId(); if (!operationId) { setOutcome({ tone: "warn", key: "anthropicGrant.noOperationId" }); return; } setOutcome(null); - setConfirming({ grantId: next.id, operationId }); + setConfirming({ grantId: selected.id, operationId, grant: selected }); }; const post = async (request: Held) => { @@ -250,7 +379,13 @@ export function AnthropicResetGrantModal({ accountId, accountLabel, entry, contr

); - const confirmGrant = confirming && snapshot ? snapshot.grants.find(grant => grant.id === confirming.grantId) : undefined; + const confirmEndsAt = confirming ? validTime(confirming.grant.endsAt) : null; + // One tab stop for the group even when nothing can be chosen, so the reasons stay reachable. + const tabStopId = selected?.id ?? snapshot?.grants[0]?.id; + const selectableCount = snapshot ? snapshot.grants.filter(grant => grantSelectable(snapshot, grant)).length : 0; + // The hint describes the default, so it shows only while the default is what is selected. + const showPickHint = selectableCount > 1 && selected !== undefined && selected.id === (snapshot ? spendableGrant(snapshot)?.id : undefined); + const heldGrant = unresolved && snapshot ? snapshot.grants.find(grant => grant.id === unresolved.grantId) : undefined; const total = snapshot ? unspentResets(snapshot) : 0; return ( @@ -263,6 +398,11 @@ export function AnthropicResetGrantModal({ accountId, accountLabel, entry, contr

{t("anthropicGrant.title")}

{t("anthropicGrant.unknownOutcome")}

+ {unresolved && ( +

+ {t("anthropicGrant.confirmWhich", { label: heldGrant ? grantName(heldGrant) : unresolved.grantId })} +

+ )} {outcome && message(outcome)}
@@ -282,9 +422,19 @@ export function AnthropicResetGrantModal({ accountId, accountLabel, entry, contr

{t("anthropicGrant.confirmTitle")}

{t("anthropicGrant.confirmDesc")}

- {confirmGrant && ( -

{t("anthropicGrant.confirmWhich", { label: confirmGrant.label || confirmGrant.id })}

+

+ {t("anthropicGrant.confirmWhich", { label: grantName(confirming.grant) })} +

+ {confirmEndsAt && ( +

+ {t("anthropicGrant.confirmExpires", { date: formatCreditDateTime(confirmEndsAt, locale) })} +

)} +

+ {confirming.grant.clears.length > 0 + ? t("anthropicGrant.confirmClears", { windows: clearedWindows(confirming.grant, locale, t) }) + : t("anthropicGrant.confirmClearsUnknown")} +

{t("anthropicGrant.irreversible")}

@@ -292,7 +442,8 @@ export function AnthropicResetGrantModal({ accountId, accountLabel, entry, contr {t("common.cancel")}
@@ -319,14 +470,29 @@ export function AnthropicResetGrantModal({ accountId, accountLabel, entry, contr ) : entry.snapshot.grants.length > 0 ? ( <>

{t("anthropicGrant.available", { count: String(total) })}

-
+ {!entry.snapshot.journalAvailable && ( +

{t("anthropicGrant.journalBusy")}

+ )} +
{entry.snapshot.grants.map(grant => ( - + { + if (element) optionRefs.current.set(grant.id, element); + else optionRefs.current.delete(grant.id); + }} + onSelect={() => select(grant, true)} + onKeyDown={event => onOptionKey(event, grant)} + locale={locale} t={t} /> ))}
+ {showPickHint && ( +

{t("anthropicGrant.pickHint")}

+ )}

{t("anthropicGrant.desc")}

diff --git a/gui/src/hooks/useAnthropicResetGrants.ts b/gui/src/hooks/useAnthropicResetGrants.ts index 7152e6cbcb8..ead3b8eb52a 100644 --- a/gui/src/hooks/useAnthropicResetGrants.ts +++ b/gui/src/hooks/useAnthropicResetGrants.ts @@ -58,7 +58,8 @@ export type AnthropicSpendOutcome = export interface AnthropicResetGrantController { entries: Record; - refresh: (accountId: string) => Promise; + /** `keepSnapshot` re-reads without blanking a ready entry (and the row badge) while the read runs. */ + refresh: (accountId: string, options?: { keepSnapshot?: boolean }) => Promise; spend: (accountId: string, request: { grantId: string; operationId: string }) => Promise; } @@ -131,11 +132,42 @@ export function unspentResets(snapshot: AnthropicResetGrantSnapshot): number { return snapshot.grants.reduce((total, grant) => total + grant.resetsLeft, 0); } -/** The grant a spend would use: the first one the server's gate would accept now. */ +/** Why one grant cannot be spent now; mirrors the server gate's per-grant order. */ +export type AnthropicGrantBlocker = "paused" | "not_usable" | "exhausted" | "not_limited"; + +export function grantBlocker(snapshot: AnthropicResetGrantSnapshot, grant: AnthropicResetGrant): AnthropicGrantBlocker | null { + if (grant.paused) return "paused"; + if (!grant.usableNow) return "not_usable"; + if (grant.resetsLeft <= 0) return "exhausted"; + if (grant.useRequiresLimit && !snapshot.atLimit) return "not_limited"; + return null; +} + +/** Whether the dialog may offer this grant: the snapshot allows spending and the grant has no blocker. */ +export function grantSelectable(snapshot: AnthropicResetGrantSnapshot, grant: AnthropicResetGrant): boolean { + return snapshot.eligible && snapshot.journalAvailable && grantBlocker(snapshot, grant) === null; +} + +function expiryRank(grant: AnthropicResetGrant): number { + if (!grant.endsAt) return Number.POSITIVE_INFINITY; + const parsed = Date.parse(grant.endsAt); + // An unparsable end date sorts last so it can never pose as the nearest expiry. + return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; +} + +/** + * The grant a spend uses unless the user picks another: of the grants the server's + * gate would accept now, the one that expires first (ties and undated grants keep + * upstream order). Every grant spends one reset and an unused grant is lost at its + * end date, so spending the soonest-expiring one first wastes the fewest. + */ export function spendableGrant(snapshot: AnthropicResetGrantSnapshot): AnthropicResetGrant | undefined { - if (!snapshot.eligible || !snapshot.journalAvailable) return undefined; - return snapshot.grants.find(grant => !grant.paused && grant.usableNow && grant.resetsLeft > 0 - && (!grant.useRequiresLimit || snapshot.atLimit)); + let best: AnthropicResetGrant | undefined; + for (const grant of snapshot.grants) { + if (!grantSelectable(snapshot, grant)) continue; + if (!best || expiryRank(grant) < expiryRank(best)) best = grant; + } + return best; } function errorCode(value: unknown): string { @@ -223,8 +255,8 @@ export function useAnthropicResetGrants({ apiBase, provider = "anthropic", accou return () => { epoch.current += 1; }; }, [enabled, identity, read]); - const refresh = useCallback(async (accountId: string) => { - setEntry(accountId, { status: "loading" }); + const refresh = useCallback(async (accountId: string, options: { keepSnapshot?: boolean } = {}) => { + if (!options.keepSnapshot) setEntry(accountId, { status: "loading" }); await read(accountId, epoch.current); }, [read, setEntry]); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c87f62fd8d5..ae8451283ed 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2474,7 +2474,7 @@ export const de: Record = { "anthropicGrant.badgeErrorAria": "Claude-Nutzungslimit-Rücksetzungen konnten nicht gelesen werden", "anthropicGrant.title": "Claude-Nutzungslimit-Rücksetzungen", "anthropicGrant.available": "Für dieses Konto sind {count} Nutzungslimit-Rücksetzungen verfügbar.", - "anthropicGrant.desc": "Eine Rücksetzung füllt die 5-Stunden- und Wochenlimits dieses Claude-Kontos sofort wieder auf. Der Tag der wöchentlichen Rücksetzung bleibt gleich.", + "anthropicGrant.desc": "Eine Rücksetzung füllt sofort die Claude-Limits auf, die beim jeweiligen Eintrag angegeben sind. Der Tag der wöchentlichen Rücksetzung bleibt gleich.", "anthropicGrant.none": "Für dieses Konto sind keine Nutzungslimit-Rücksetzungen verfügbar.", "anthropicGrant.ineligible": "Dieses Konto ist derzeit nicht für Nutzungslimit-Rücksetzungen berechtigt.", "anthropicGrant.loadFailed": "Die Nutzungslimit-Rücksetzungen für dieses Konto konnten nicht gelesen werden.", @@ -2489,6 +2489,15 @@ export const de: Record = { "anthropicGrant.paused": "Pausiert", "anthropicGrant.notUsable": "Noch nicht verwendbar", "anthropicGrant.requiresLimit": "Verwendbar, sobald dieses Konto ein Limit erreicht", + "anthropicGrant.chooseLabel": "Zu verwendende Rücksetzung wählen", + "anthropicGrant.pickHint": "Die zuerst ablaufende Rücksetzung ist ausgewählt. Wähle eine andere, um stattdessen diese zu verwenden.", + "anthropicGrant.exhausted": "Keine Rücksetzungen mehr", + "anthropicGrant.confirmExpires": "Läuft am {date} ab", + "anthropicGrant.confirmClears": "Setzt zurück: {windows}", + "anthropicGrant.confirmClearsUnknown": "Welche Limits diese Rücksetzung zurücksetzt, wurde nicht angegeben.", + "anthropicGrant.clearsFiveHour": "5-Stunden-Limit", + "anthropicGrant.clearsWeekly": "Wochenlimit", + "anthropicGrant.clearsOverage": "Zusätzliche Nutzung", "anthropicGrant.useOne": "1 Rücksetzung verwenden", "anthropicGrant.confirmTitle": "Nutzungslimits jetzt zurücksetzen?", "anthropicGrant.confirmDesc": "Damit werden die Claude-Nutzungslimits dieses Kontos jetzt aufgefüllt und eine Rücksetzung verbraucht.", @@ -2507,7 +2516,7 @@ export const de: Record = { "anthropicGrant.replayed": "Diese Rücksetzung wurde bereits zuvor abgeschlossen. Es wurde nicht erneut etwas verbraucht.", "anthropicGrant.notUsableNow": "Diese Rücksetzung kann gerade nicht verwendet werden. Lies das Konto erneut.", "anthropicGrant.inFlight": "Diese Rücksetzung wird noch verarbeitet. Warte einen Moment und lies das Konto dann erneut.", - "anthropicGrant.unresolvedPrior": "Ein früherer Versuch mit dieser Rücksetzung ist noch nicht bestätigt. Wiederhole diesen Versuch, statt einen neuen zu starten.", + "anthropicGrant.unresolvedPrior": "Ein früherer Rücksetzungsversuch für dieses Konto ist noch nicht bestätigt. Wiederhole diesen Versuch, statt einen neuen zu starten.", "anthropicGrant.expired": "Der frühere Versuch konnte nicht rechtzeitig bestätigt werden. Lies das Konto erneut, bevor du eine Rücksetzung verwendest.", "anthropicGrant.identityMismatch": "Diese Rücksetzung gehört zu einem anderen Konto. Schließe diesen Dialog und starte erneut.", "anthropicGrant.sessionRequired": "Öffne das Dashboard über seine eigene Anmeldung, um Rücksetzungen zu verwenden.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 12c979b8541..0409bc82a1f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3133,7 +3133,7 @@ export const en = { "anthropicGrant.badgeErrorAria": "Claude usage resets could not be read", "anthropicGrant.title": "Claude usage resets", "anthropicGrant.available": "This account has {count} usage reset(s).", - "anthropicGrant.desc": "A reset refills this account's 5-hour and weekly Claude limits right away. The weekly reset day stays the same.", + "anthropicGrant.desc": "A reset refills the Claude limits shown on its grant right away. The weekly reset day stays the same.", "anthropicGrant.none": "This account has no usage resets.", "anthropicGrant.ineligible": "This account is not eligible for usage resets right now.", "anthropicGrant.loadFailed": "Could not read usage resets for this account.", @@ -3148,6 +3148,15 @@ export const en = { "anthropicGrant.paused": "Paused", "anthropicGrant.notUsable": "Not usable yet", "anthropicGrant.requiresLimit": "Usable once this account hits a limit", + "anthropicGrant.chooseLabel": "Choose a reset to use", + "anthropicGrant.pickHint": "The reset that expires first is selected. Pick another to use it instead.", + "anthropicGrant.exhausted": "No resets left", + "anthropicGrant.confirmExpires": "Expires {date}", + "anthropicGrant.confirmClears": "Clears: {windows}", + "anthropicGrant.confirmClearsUnknown": "The limits this reset clears were not reported.", + "anthropicGrant.clearsFiveHour": "5-hour limit", + "anthropicGrant.clearsWeekly": "Weekly limit", + "anthropicGrant.clearsOverage": "Extra usage", "anthropicGrant.useOne": "Use 1 reset", "anthropicGrant.confirmTitle": "Reset usage limits now?", "anthropicGrant.confirmDesc": "This refills this account's Claude usage limits now and uses one reset.", @@ -3166,7 +3175,7 @@ export const en = { "anthropicGrant.replayed": "This reset already settled earlier. Nothing was used again.", "anthropicGrant.notUsableNow": "This reset cannot be used right now. Re-read the account.", "anthropicGrant.inFlight": "This reset is still being processed. Wait a moment, then re-read the account.", - "anthropicGrant.unresolvedPrior": "An earlier attempt on this reset is not confirmed yet. Retry that attempt instead of starting a new one.", + "anthropicGrant.unresolvedPrior": "An earlier reset attempt on this account is not confirmed yet. Retry that attempt instead of starting a new one.", "anthropicGrant.expired": "The earlier attempt could not be confirmed in time. Re-read the account before using a reset.", "anthropicGrant.identityMismatch": "That reset belongs to another account. Close this dialog and start again.", "anthropicGrant.sessionRequired": "Open the dashboard from its own login to use resets.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2775baf47a6..2605725a95a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3050,7 +3050,7 @@ export const fr: Record = { "anthropicGrant.badgeErrorAria": "Impossible de lire les réinitialisations de l’utilisation de Claude", "anthropicGrant.title": "Réinitialisations de l’utilisation de Claude", "anthropicGrant.available": "Ce compte dispose de {count} réinitialisation(s) de l’utilisation.", - "anthropicGrant.desc": "Une réinitialisation recharge immédiatement les limites Claude de ce compte sur 5 heures et sur une semaine. Le jour de réinitialisation hebdomadaire reste inchangé.", + "anthropicGrant.desc": "Une réinitialisation recharge immédiatement les limites Claude indiquées sur son attribution. Le jour de réinitialisation hebdomadaire reste inchangé.", "anthropicGrant.none": "Ce compte ne dispose d’aucune réinitialisation de l’utilisation.", "anthropicGrant.ineligible": "Ce compte ne peut pas bénéficier de réinitialisations de l’utilisation pour le moment.", "anthropicGrant.loadFailed": "Impossible de lire les réinitialisations de l’utilisation de ce compte.", @@ -3065,6 +3065,15 @@ export const fr: Record = { "anthropicGrant.paused": "En pause", "anthropicGrant.notUsable": "Pas encore utilisable", "anthropicGrant.requiresLimit": "Utilisable dès que ce compte atteint une limite", + "anthropicGrant.chooseLabel": "Choisir la réinitialisation à utiliser", + "anthropicGrant.pickHint": "La réinitialisation qui expire en premier est sélectionnée. Choisissez-en une autre pour l’utiliser à la place.", + "anthropicGrant.exhausted": "Plus aucune réinitialisation", + "anthropicGrant.confirmExpires": "Expire le {date}", + "anthropicGrant.confirmClears": "Réinitialise : {windows}", + "anthropicGrant.confirmClearsUnknown": "Les limites réinitialisées par cette attribution n’ont pas été indiquées.", + "anthropicGrant.clearsFiveHour": "Limite sur 5 heures", + "anthropicGrant.clearsWeekly": "Limite hebdomadaire", + "anthropicGrant.clearsOverage": "Usage supplémentaire", "anthropicGrant.useOne": "Utiliser 1 réinitialisation", "anthropicGrant.confirmTitle": "Réinitialiser les limites d’utilisation maintenant ?", "anthropicGrant.confirmDesc": "Les limites d’utilisation de Claude de ce compte seront rechargées immédiatement, au prix d’une réinitialisation.", @@ -3083,7 +3092,7 @@ export const fr: Record = { "anthropicGrant.replayed": "Cette réinitialisation a déjà été traitée. Rien n’a été utilisé de nouveau.", "anthropicGrant.notUsableNow": "Cette réinitialisation ne peut pas être utilisée pour le moment. Relisez le compte.", "anthropicGrant.inFlight": "Cette réinitialisation est encore en cours. Patientez un instant, puis relisez le compte.", - "anthropicGrant.unresolvedPrior": "Une tentative précédente sur cette réinitialisation n’est pas encore confirmée. Réessayez cette tentative au lieu d’en lancer une nouvelle.", + "anthropicGrant.unresolvedPrior": "Une tentative de réinitialisation précédente sur ce compte n’est pas encore confirmée. Réessayez cette tentative au lieu d’en lancer une nouvelle.", "anthropicGrant.expired": "La tentative précédente n’a pas pu être confirmée à temps. Relisez le compte avant d’utiliser une réinitialisation.", "anthropicGrant.identityMismatch": "Cette réinitialisation appartient à un autre compte. Fermez cette boîte de dialogue et recommencez.", "anthropicGrant.sessionRequired": "Ouvrez le tableau de bord depuis sa propre page de connexion pour utiliser les réinitialisations.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2d0ec1318d1..3cb50b43155 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2949,7 +2949,7 @@ export const ja: Record = { "anthropicGrant.badgeErrorAria": "Claude の使用量リセットを読み取れませんでした", "anthropicGrant.title": "Claude の使用量リセット", "anthropicGrant.available": "このアカウントには使用量リセットが {count} 回分あります。", - "anthropicGrant.desc": "リセットすると、このアカウントの Claude の 5 時間枠と週次枠がすぐに補充されます。週次枠のリセット曜日は変わりません。", + "anthropicGrant.desc": "リセットすると、そのリセットに表示されている Claude の枠がすぐに補充されます。週次枠のリセット曜日は変わりません。", "anthropicGrant.none": "このアカウントには使用量リセットがありません。", "anthropicGrant.ineligible": "このアカウントは現在、使用量リセットの対象ではありません。", "anthropicGrant.loadFailed": "このアカウントの使用量リセットを読み取れませんでした。", @@ -2964,6 +2964,15 @@ export const ja: Record = { "anthropicGrant.paused": "一時停止中", "anthropicGrant.notUsable": "まだ使用できません", "anthropicGrant.requiresLimit": "このアカウントが使用量の上限に達すると使用できます", + "anthropicGrant.chooseLabel": "使用するリセットを選択", + "anthropicGrant.pickHint": "失効が最も近いリセットが選択されています。別のリセットを使う場合は選び直してください。", + "anthropicGrant.exhausted": "残りのリセットはありません", + "anthropicGrant.confirmExpires": "失効 {date}", + "anthropicGrant.confirmClears": "リセット対象: {windows}", + "anthropicGrant.confirmClearsUnknown": "このリセットでリセットされる枠は通知されていません。", + "anthropicGrant.clearsFiveHour": "5 時間枠", + "anthropicGrant.clearsWeekly": "週次枠", + "anthropicGrant.clearsOverage": "追加枠", "anthropicGrant.useOne": "リセットを 1 回使用", "anthropicGrant.confirmTitle": "今すぐ使用量の上限をリセットしますか?", "anthropicGrant.confirmDesc": "このアカウントの Claude 使用量枠を今すぐ補充し、リセットを 1 回分使用します。", @@ -2982,7 +2991,7 @@ export const ja: Record = { "anthropicGrant.replayed": "このリセットは以前に処理済みです。再度使用されてはいません。", "anthropicGrant.notUsableNow": "現在、このリセットは使用できません。アカウントを再読み込みしてください。", "anthropicGrant.inFlight": "このリセットは処理中です。少し待ってからアカウントを再読み込みしてください。", - "anthropicGrant.unresolvedPrior": "このリセットに対する前回の試行はまだ結果が確認できていません。新しい試行を始めず、前回の試行を再試行してください。", + "anthropicGrant.unresolvedPrior": "このアカウントでの前回のリセット試行はまだ結果が確認できていません。新しい試行を始めず、前回の試行を再試行してください。", "anthropicGrant.expired": "前回の試行は期限内に結果を確認できませんでした。リセットを使用する前にアカウントを再読み込みしてください。", "anthropicGrant.identityMismatch": "このリセットは別のアカウントのものです。このダイアログを閉じてやり直してください。", "anthropicGrant.sessionRequired": "リセットを使用するには、ダッシュボードに直接ログインして開いてください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ecd00b603b3..0296f8d595c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2519,7 +2519,7 @@ export const ko: Record = { "anthropicGrant.badgeErrorAria": "Claude 사용량 리셋을 읽지 못했습니다", "anthropicGrant.title": "Claude 사용량 리셋", "anthropicGrant.available": "이 계정에 사용량 리셋이 {count}개 있습니다.", - "anthropicGrant.desc": "리셋 1개로 이 계정의 Claude 5시간·주간 한도를 바로 다시 채웁니다. 주간 리셋 요일은 바뀌지 않습니다.", + "anthropicGrant.desc": "리셋은 각 리셋에 표시된 Claude 한도를 바로 다시 채웁니다. 주간 리셋 요일은 바뀌지 않습니다.", "anthropicGrant.none": "이 계정에는 사용량 리셋이 없습니다.", "anthropicGrant.ineligible": "지금은 이 계정에서 사용량 리셋을 쓸 수 없습니다.", "anthropicGrant.loadFailed": "이 계정의 사용량 리셋을 읽지 못했습니다.", @@ -2534,6 +2534,15 @@ export const ko: Record = { "anthropicGrant.paused": "일시 중지됨", "anthropicGrant.notUsable": "아직 사용할 수 없음", "anthropicGrant.requiresLimit": "한도에 도달하면 사용할 수 있음", + "anthropicGrant.chooseLabel": "사용할 리셋 선택", + "anthropicGrant.pickHint": "만료가 가장 가까운 리셋이 선택되어 있습니다. 다른 리셋을 쓰려면 직접 고르세요.", + "anthropicGrant.exhausted": "남은 리셋 없음", + "anthropicGrant.confirmExpires": "만료 {date}", + "anthropicGrant.confirmClears": "초기화 대상: {windows}", + "anthropicGrant.confirmClearsUnknown": "이 리셋이 초기화하는 한도는 알려지지 않았습니다.", + "anthropicGrant.clearsFiveHour": "5시간 한도", + "anthropicGrant.clearsWeekly": "주간 한도", + "anthropicGrant.clearsOverage": "추가 사용량", "anthropicGrant.useOne": "리셋 1개 사용", "anthropicGrant.confirmTitle": "지금 사용량 한도를 리셋할까요?", "anthropicGrant.confirmDesc": "이 계정의 Claude 사용량 한도를 지금 다시 채우고 리셋 1개를 사용합니다.", @@ -2552,7 +2561,7 @@ export const ko: Record = { "anthropicGrant.replayed": "이 리셋은 이전에 이미 처리되었습니다. 다시 사용되지 않았습니다.", "anthropicGrant.notUsableNow": "지금은 이 리셋을 쓸 수 없습니다. 계정을 다시 읽어 보세요.", "anthropicGrant.inFlight": "이 리셋은 아직 처리 중입니다. 잠시 기다린 뒤 계정을 다시 읽으세요.", - "anthropicGrant.unresolvedPrior": "이 리셋의 이전 시도가 아직 확인되지 않았습니다. 새로 시작하지 말고 그 시도를 다시 보내세요.", + "anthropicGrant.unresolvedPrior": "이 계정의 이전 리셋 시도가 아직 확인되지 않았습니다. 새로 시작하지 말고 그 시도를 다시 보내세요.", "anthropicGrant.expired": "이전 시도를 제때 확인하지 못했습니다. 리셋을 쓰기 전에 계정을 다시 읽으세요.", "anthropicGrant.identityMismatch": "이 리셋은 다른 계정에 속합니다. 이 대화상자를 닫고 다시 시작하세요.", "anthropicGrant.sessionRequired": "리셋은 대시보드에 직접 로그인한 세션에서만 사용할 수 있습니다.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 62610ba850e..a2adcb40d08 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3092,7 +3092,7 @@ export const pt: Record = { "anthropicGrant.badgeErrorAria": "Não foi possível ler os resets de uso do Claude", "anthropicGrant.title": "Resets de uso do Claude", "anthropicGrant.available": "Esta conta tem {count} reset(s) de uso.", - "anthropicGrant.desc": "Um reset recompõe imediatamente os limites de 5 horas e semanais do Claude desta conta. O dia do reset semanal permanece o mesmo.", + "anthropicGrant.desc": "Um reset recompõe imediatamente os limites do Claude indicados nele. O dia do reset semanal permanece o mesmo.", "anthropicGrant.none": "Esta conta não tem resets de uso.", "anthropicGrant.ineligible": "Esta conta não é elegível para resets de uso no momento.", "anthropicGrant.loadFailed": "Não foi possível ler os resets de uso desta conta.", @@ -3107,6 +3107,15 @@ export const pt: Record = { "anthropicGrant.paused": "Pausado", "anthropicGrant.notUsable": "Ainda não utilizável", "anthropicGrant.requiresLimit": "Utilizável quando esta conta atingir um limite", + "anthropicGrant.chooseLabel": "Escolha o reset a usar", + "anthropicGrant.pickHint": "O reset que expira primeiro fica selecionado. Escolha outro para usá-lo no lugar.", + "anthropicGrant.exhausted": "Nenhum reset restante", + "anthropicGrant.confirmExpires": "Expira em {date}", + "anthropicGrant.confirmClears": "Redefine: {windows}", + "anthropicGrant.confirmClearsUnknown": "Os limites que este reset redefine não foram informados.", + "anthropicGrant.clearsFiveHour": "Limite de 5 horas", + "anthropicGrant.clearsWeekly": "Limite semanal", + "anthropicGrant.clearsOverage": "Uso extra", "anthropicGrant.useOne": "Usar 1 reset", "anthropicGrant.confirmTitle": "Zerar os limites de uso agora?", "anthropicGrant.confirmDesc": "Isso recompõe agora os limites de uso do Claude desta conta e consome um reset.", @@ -3125,7 +3134,7 @@ export const pt: Record = { "anthropicGrant.replayed": "Este reset já foi concluído antes. Nada foi usado novamente.", "anthropicGrant.notUsableNow": "Este reset não pode ser usado no momento. Releia a conta.", "anthropicGrant.inFlight": "Este reset ainda está sendo processado. Aguarde um instante e releia a conta.", - "anthropicGrant.unresolvedPrior": "Uma tentativa anterior deste reset ainda não foi confirmada. Repita essa tentativa em vez de iniciar uma nova.", + "anthropicGrant.unresolvedPrior": "Uma tentativa anterior de reset nesta conta ainda não foi confirmada. Repita essa tentativa em vez de iniciar uma nova.", "anthropicGrant.expired": "A tentativa anterior não pôde ser confirmada a tempo. Releia a conta antes de usar um reset.", "anthropicGrant.identityMismatch": "Esse reset pertence a outra conta. Feche este diálogo e comece de novo.", "anthropicGrant.sessionRequired": "Abra o painel pelo login próprio dele para usar os resets.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f2b1b18a805..8ade1611a77 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3040,7 +3040,7 @@ export const ru: Record = { "anthropicGrant.badgeErrorAria": "Не удалось прочитать сбросы лимитов Claude", "anthropicGrant.title": "Сбросы лимитов Claude", "anthropicGrant.available": "У этого аккаунта доступно сбросов лимитов: {count}.", - "anthropicGrant.desc": "Сброс сразу восстанавливает 5-часовой и недельный лимиты Claude для этого аккаунта. День недельного сброса не меняется.", + "anthropicGrant.desc": "Сброс сразу восстанавливает лимиты Claude, указанные у выбранного гранта. День недельного сброса не меняется.", "anthropicGrant.none": "У этого аккаунта нет доступных сбросов лимитов.", "anthropicGrant.ineligible": "Сейчас этому аккаунту недоступны сбросы лимитов.", "anthropicGrant.loadFailed": "Не удалось прочитать сбросы лимитов для этого аккаунта.", @@ -3055,6 +3055,15 @@ export const ru: Record = { "anthropicGrant.paused": "Приостановлено", "anthropicGrant.notUsable": "Пока нельзя использовать", "anthropicGrant.requiresLimit": "Можно использовать, когда этот аккаунт достигнет лимита", + "anthropicGrant.chooseLabel": "Выберите сброс", + "anthropicGrant.pickHint": "Выбран сброс, который истекает раньше всех. Выберите другой, чтобы использовать его.", + "anthropicGrant.exhausted": "Сбросов не осталось", + "anthropicGrant.confirmExpires": "Истекает {date}", + "anthropicGrant.confirmClears": "Сбрасывает: {windows}", + "anthropicGrant.confirmClearsUnknown": "Какие лимиты сбрасывает этот грант, не сообщается.", + "anthropicGrant.clearsFiveHour": "5-часовой лимит", + "anthropicGrant.clearsWeekly": "Недельный лимит", + "anthropicGrant.clearsOverage": "Дополнительное использование", "anthropicGrant.useOne": "Использовать 1 сброс", "anthropicGrant.confirmTitle": "Сбросить лимиты сейчас?", "anthropicGrant.confirmDesc": "Лимиты использования Claude для этого аккаунта будут восстановлены сейчас. Будет использован один сброс.", @@ -3073,7 +3082,7 @@ export const ru: Record = { "anthropicGrant.replayed": "Этот сброс уже был обработан ранее. Повторно ничего не использовано.", "anthropicGrant.notUsableNow": "Сейчас этот сброс использовать нельзя. Перечитайте данные аккаунта.", "anthropicGrant.inFlight": "Этот сброс ещё обрабатывается. Подождите немного и перечитайте данные аккаунта.", - "anthropicGrant.unresolvedPrior": "Результат предыдущей попытки использовать этот сброс ещё не подтверждён. Повторите ту же попытку вместо новой.", + "anthropicGrant.unresolvedPrior": "Результат предыдущей попытки сброса для этого аккаунта ещё не подтверждён. Повторите ту же попытку вместо новой.", "anthropicGrant.expired": "Не удалось вовремя подтвердить результат предыдущей попытки. Перечитайте данные аккаунта перед использованием сброса.", "anthropicGrant.identityMismatch": "Этот сброс относится к другому аккаунту. Закройте это окно и начните заново.", "anthropicGrant.sessionRequired": "Чтобы использовать сбросы, откройте дашборд через его собственный вход.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f2fa58deb77..0e31b0ce2e6 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3068,7 +3068,7 @@ export const tr: Record = { "anthropicGrant.badgeErrorAria": "Claude kullanım sıfırlamaları okunamadı", "anthropicGrant.title": "Claude Kullanım Sıfırlamaları", "anthropicGrant.available": "Bu hesapta {count} kullanım sıfırlaması var.", - "anthropicGrant.desc": "Bir sıfırlama, bu hesabın 5 saatlik ve haftalık Claude limitlerini hemen yeniler. Haftalık sıfırlama günü değişmez.", + "anthropicGrant.desc": "Bir sıfırlama, üzerinde gösterilen Claude limitlerini hemen yeniler. Haftalık sıfırlama günü değişmez.", "anthropicGrant.none": "Bu hesapta kullanım sıfırlaması yok.", "anthropicGrant.ineligible": "Bu hesap şu anda kullanım sıfırlamalarından yararlanamıyor.", "anthropicGrant.loadFailed": "Bu hesabın kullanım sıfırlamaları okunamadı.", @@ -3083,6 +3083,15 @@ export const tr: Record = { "anthropicGrant.paused": "Duraklatıldı", "anthropicGrant.notUsable": "Henüz kullanılamaz", "anthropicGrant.requiresLimit": "Bu hesap bir limite ulaştığında kullanılabilir", + "anthropicGrant.chooseLabel": "Kullanılacak sıfırlamayı seçin", + "anthropicGrant.pickHint": "İlk sona eren sıfırlama seçili. Başka birini kullanmak için onu seçin.", + "anthropicGrant.exhausted": "Sıfırlama kalmadı", + "anthropicGrant.confirmExpires": "Son kullanma {date}", + "anthropicGrant.confirmClears": "Sıfırlar: {windows}", + "anthropicGrant.confirmClearsUnknown": "Bu sıfırlamanın hangi limitleri sıfırladığı bildirilmedi.", + "anthropicGrant.clearsFiveHour": "5 saatlik limit", + "anthropicGrant.clearsWeekly": "Haftalık limit", + "anthropicGrant.clearsOverage": "Ek kullanım", "anthropicGrant.useOne": "1 Sıfırlama Kullan", "anthropicGrant.confirmTitle": "Kullanım Limitleri Şimdi Sıfırlansın mı?", "anthropicGrant.confirmDesc": "Bu işlem, bu hesabın Claude kullanım limitlerini şimdi yeniler ve bir sıfırlama kullanır.", @@ -3101,7 +3110,7 @@ export const tr: Record = { "anthropicGrant.replayed": "Bu sıfırlama daha önce sonuçlandı. Yeniden bir sıfırlama kullanılmadı.", "anthropicGrant.notUsableNow": "Bu sıfırlama şu anda kullanılamaz. Hesabı yeniden okuyun.", "anthropicGrant.inFlight": "Bu sıfırlama hâlâ işleniyor. Biraz bekleyip hesabı yeniden okuyun.", - "anthropicGrant.unresolvedPrior": "Bu sıfırlamayla ilgili önceki girişim henüz doğrulanmadı. Yeni bir işlem başlatmak yerine o girişimi yeniden deneyin.", + "anthropicGrant.unresolvedPrior": "Bu hesaptaki önceki sıfırlama girişimi henüz doğrulanmadı. Yeni bir işlem başlatmak yerine o girişimi yeniden deneyin.", "anthropicGrant.expired": "Önceki girişim zamanında doğrulanamadı. Bir sıfırlama kullanmadan önce hesabı yeniden okuyun.", "anthropicGrant.identityMismatch": "Bu sıfırlama başka bir hesaba ait. Bu iletişim kutusunu kapatıp yeniden başlayın.", "anthropicGrant.sessionRequired": "Sıfırlamaları kullanmak için kontrol panelini kendi oturum açma sayfasından açın.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index c10739222b7..cb3c9273088 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3062,7 +3062,7 @@ export const vi: Record = { "anthropicGrant.badgeErrorAria": "Không thể đọc các lần đặt lại mức sử dụng Claude", "anthropicGrant.title": "Đặt lại mức sử dụng Claude", "anthropicGrant.available": "Tài khoản này có {count} lần đặt lại mức sử dụng.", - "anthropicGrant.desc": "Một lần đặt lại sẽ khôi phục ngay giới hạn sử dụng Claude trong 5 giờ và hàng tuần của tài khoản này. Ngày đặt lại hàng tuần vẫn giữ nguyên.", + "anthropicGrant.desc": "Một lần đặt lại sẽ khôi phục ngay các giới hạn Claude được ghi trên lần đặt lại đó. Ngày đặt lại hàng tuần vẫn giữ nguyên.", "anthropicGrant.none": "Tài khoản này không có lần đặt lại mức sử dụng nào.", "anthropicGrant.ineligible": "Tài khoản này hiện không đủ điều kiện để đặt lại mức sử dụng.", "anthropicGrant.loadFailed": "Không thể đọc các lần đặt lại mức sử dụng của tài khoản này.", @@ -3077,6 +3077,15 @@ export const vi: Record = { "anthropicGrant.paused": "Đã tạm dừng", "anthropicGrant.notUsable": "Chưa thể sử dụng", "anthropicGrant.requiresLimit": "Có thể sử dụng khi tài khoản này chạm giới hạn", + "anthropicGrant.chooseLabel": "Chọn lần đặt lại để dùng", + "anthropicGrant.pickHint": "Lần đặt lại hết hạn sớm nhất đang được chọn. Hãy chọn lần khác nếu muốn dùng lần đó.", + "anthropicGrant.exhausted": "Không còn lần đặt lại nào", + "anthropicGrant.confirmExpires": "Hết hạn {date}", + "anthropicGrant.confirmClears": "Đặt lại: {windows}", + "anthropicGrant.confirmClearsUnknown": "Không có thông tin về các giới hạn mà lần đặt lại này khôi phục.", + "anthropicGrant.clearsFiveHour": "Giới hạn 5 giờ", + "anthropicGrant.clearsWeekly": "Giới hạn hàng tuần", + "anthropicGrant.clearsOverage": "Mức dùng bổ sung", "anthropicGrant.useOne": "Dùng 1 lần đặt lại", "anthropicGrant.confirmTitle": "Đặt lại giới hạn sử dụng ngay?", "anthropicGrant.confirmDesc": "Việc này sẽ khôi phục ngay giới hạn sử dụng Claude của tài khoản này và dùng một lần đặt lại.", @@ -3095,7 +3104,7 @@ export const vi: Record = { "anthropicGrant.replayed": "Lần đặt lại này đã hoàn tất trước đó. Không có lần nào được sử dụng thêm.", "anthropicGrant.notUsableNow": "Hiện không thể sử dụng lần đặt lại này. Hãy đọc lại tài khoản.", "anthropicGrant.inFlight": "Lần đặt lại này vẫn đang được xử lý. Hãy đợi giây lát rồi đọc lại tài khoản.", - "anthropicGrant.unresolvedPrior": "Một lần thử trước đó với lần đặt lại này vẫn chưa được xác nhận. Hãy thử lại lần đó thay vì bắt đầu lần mới.", + "anthropicGrant.unresolvedPrior": "Một lần thử đặt lại trước đó trên tài khoản này vẫn chưa được xác nhận. Hãy thử lại lần đó thay vì bắt đầu lần mới.", "anthropicGrant.expired": "Không thể xác nhận lần thử trước đó kịp thời. Hãy đọc lại tài khoản trước khi dùng một lần đặt lại.", "anthropicGrant.identityMismatch": "Lần đặt lại đó thuộc tài khoản khác. Hãy đóng hộp thoại này và bắt đầu lại.", "anthropicGrant.sessionRequired": "Hãy mở dashboard từ phiên đăng nhập của chính nó để dùng các lần đặt lại.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 0fa2d6deb4b..48e204cabaa 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2331,7 +2331,7 @@ export const zhTW: Record = { "anthropicGrant.badgeErrorAria": "無法讀取 Claude 用量重設次數", "anthropicGrant.title": "Claude 用量重設", "anthropicGrant.available": "此帳號有 {count} 次用量重設機會。", - "anthropicGrant.desc": "每次重設都會立即補滿此帳號的 Claude 5 小時與每週額度。每週重設日維持不變。", + "anthropicGrant.desc": "重設會立即補滿該重設機會上標示的 Claude 額度。每週重設日維持不變。", "anthropicGrant.none": "此帳號沒有用量重設機會。", "anthropicGrant.ineligible": "此帳號目前不符合用量重設資格。", "anthropicGrant.loadFailed": "無法讀取此帳號的用量重設資訊。", @@ -2346,6 +2346,15 @@ export const zhTW: Record = { "anthropicGrant.paused": "已暫停", "anthropicGrant.notUsable": "尚無法使用", "anthropicGrant.requiresLimit": "此帳號達到額度上限後才能使用", + "anthropicGrant.chooseLabel": "選擇要使用的重設", + "anthropicGrant.pickHint": "預設選取最先過期的重設。若要改用其他重設,請另行選擇。", + "anthropicGrant.exhausted": "沒有剩餘重設次數", + "anthropicGrant.confirmExpires": "過期 {date}", + "anthropicGrant.confirmClears": "重設範圍:{windows}", + "anthropicGrant.confirmClearsUnknown": "未提供此重設機會可重設的額度。", + "anthropicGrant.clearsFiveHour": "5 小時額度上限", + "anthropicGrant.clearsWeekly": "每週額度上限", + "anthropicGrant.clearsOverage": "額外用量", "anthropicGrant.useOne": "使用 1 次重設", "anthropicGrant.confirmTitle": "立即重設用量額度?", "anthropicGrant.confirmDesc": "這將立即補滿此帳號的 Claude 用量額度,並使用一次重設機會。", @@ -2364,7 +2373,7 @@ export const zhTW: Record = { "anthropicGrant.replayed": "此重設請求稍早已完成,未再次使用重設機會。", "anthropicGrant.notUsableNow": "目前無法使用此重設機會。請重新讀取帳號。", "anthropicGrant.inFlight": "此重設仍在處理中。請稍候再重新讀取帳號。", - "anthropicGrant.unresolvedPrior": "此重設機會先前的使用嘗試尚未確認。請重試該次請求,不要開始新的請求。", + "anthropicGrant.unresolvedPrior": "此帳號先前的重設嘗試尚未確認。請重試該次請求,不要開始新的請求。", "anthropicGrant.expired": "先前的使用嘗試未能及時確認。使用重設機會前,請重新讀取帳號。", "anthropicGrant.identityMismatch": "該重設機會屬於其他帳號。請關閉此對話框後重新開始。", "anthropicGrant.sessionRequired": "請從儀表板本身的登入入口開啟儀表板,才能使用重設機會。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cb2d592b088..c3b5e882342 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2492,7 +2492,7 @@ export const zh: Record = { "anthropicGrant.badgeErrorAria": "无法读取 Claude 用量重置次数", "anthropicGrant.title": "Claude 用量重置", "anthropicGrant.available": "此账号有 {count} 次用量重置机会。", - "anthropicGrant.desc": "使用一次重置机会可立即恢复此账号的 Claude 5 小时和每周用量额度。每周额度的重置日期不变。", + "anthropicGrant.desc": "重置会立即恢复该重置机会上标明的 Claude 用量额度。每周额度的重置日期不变。", "anthropicGrant.none": "此账号没有用量重置机会。", "anthropicGrant.ineligible": "此账号目前不符合用量重置条件。", "anthropicGrant.loadFailed": "无法读取此账号的用量重置机会。", @@ -2507,6 +2507,15 @@ export const zh: Record = { "anthropicGrant.paused": "已暂停", "anthropicGrant.notUsable": "尚不可用", "anthropicGrant.requiresLimit": "此账号达到用量上限后可用", + "anthropicGrant.chooseLabel": "选择要使用的重置", + "anthropicGrant.pickHint": "默认选中最先过期的重置。如需使用其他重置,请另行选择。", + "anthropicGrant.exhausted": "没有剩余重置次数", + "anthropicGrant.confirmExpires": "过期 {date}", + "anthropicGrant.confirmClears": "重置范围:{windows}", + "anthropicGrant.confirmClearsUnknown": "未提供此重置机会可重置的额度。", + "anthropicGrant.clearsFiveHour": "5 小时用量上限", + "anthropicGrant.clearsWeekly": "每周用量上限", + "anthropicGrant.clearsOverage": "额外用量", "anthropicGrant.useOne": "使用 1 次重置机会", "anthropicGrant.confirmTitle": "现在重置用量额度?", "anthropicGrant.confirmDesc": "这将立即恢复此账号的 Claude 用量额度,并使用一次重置机会。", @@ -2525,7 +2534,7 @@ export const zh: Record = { "anthropicGrant.replayed": "此次重置此前已完成,未再次使用重置机会。", "anthropicGrant.notUsableNow": "此次重置目前不可用。请重新读取账号。", "anthropicGrant.inFlight": "此次重置仍在处理中。请稍等片刻,再重新读取账号。", - "anthropicGrant.unresolvedPrior": "此重置机会的较早一次尝试尚未确认。请重试该次请求,不要发起新请求。", + "anthropicGrant.unresolvedPrior": "此账号较早的一次重置尝试尚未确认。请重试该次请求,不要发起新请求。", "anthropicGrant.expired": "较早一次尝试未能及时确认。使用重置机会前请重新读取账号。", "anthropicGrant.identityMismatch": "该重置机会属于其他账号。请关闭此对话框后重新开始。", "anthropicGrant.sessionRequired": "请从仪表板自身的登录入口打开仪表板,才能使用重置机会。", diff --git a/gui/src/styles/anthropic-reset-grants.css b/gui/src/styles/anthropic-reset-grants.css index 6a86df34e61..18292a00442 100644 --- a/gui/src/styles/anthropic-reset-grants.css +++ b/gui/src/styles/anthropic-reset-grants.css @@ -11,3 +11,20 @@ color: var(--muted); } .anthropic-grant-note { margin-top: 4px; } + +/* Grant picker: each grant is a radio option (role="radio"). */ +.anthropic-grant-option { cursor: pointer; } +.anthropic-grant-option:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; } +.anthropic-grant-option[aria-disabled="true"] { cursor: default; opacity: 0.6; } +.anthropic-grant-radio { + width: 12px; + height: 12px; + border: 1px solid var(--border); + border-radius: 50%; + flex-shrink: 0; + box-sizing: border-box; +} +.anthropic-grant-option[aria-checked="true"] .anthropic-grant-radio { + border: 4px solid var(--amber); + background: var(--raised); +} diff --git a/gui/tests/anthropic-reset-grants.test.tsx b/gui/tests/anthropic-reset-grants.test.tsx index cda6ffaeb38..20ffbd55608 100644 --- a/gui/tests/anthropic-reset-grants.test.tsx +++ b/gui/tests/anthropic-reset-grants.test.tsx @@ -5,7 +5,7 @@ import { act, useLayoutEffect, useState } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; import { useT } from "../src/i18n/shared"; -import { useAnthropicResetGrants } from "../src/hooks/useAnthropicResetGrants"; +import { spendableGrant, useAnthropicResetGrants, type AnthropicResetGrantSnapshot } from "../src/hooks/useAnthropicResetGrants"; import { AnthropicGrantBadge, AnthropicResetGrantModal } from "../src/components/provider-workspace/AnthropicResetGrants"; // The dialog's one irreversible action: after a claim with an unknown outcome it @@ -117,9 +117,12 @@ async function tick(): Promise { }); } +let harnessController: ReturnType | undefined; + function Harness() { const t = useT(); const controller = useAnthropicResetGrants({ apiBase: "", accountIds: ["acct-1"], enabled: true }); + harnessController = controller; const [open, setOpen] = useState(false); return ( <> @@ -252,3 +255,332 @@ test("a replayed refusal reads as that refusal, not as a success", async () => { expect(container.querySelector(".pws-status-warn")).not.toBeNull(); await act(async () => { root.unmount(); }); }); + +// ---- Grant picker: the user chooses which grant a reset spends. ---- + +function grant(id: string, overrides: Record = {}) { + return { + id, label: id + " reset", resetsTotal: 1, resetsLeft: 1, + startsAt: null, endsAt: "2099-12-01T00:00:00+00:00", clears: ["five_hour"], + paused: false, usableNow: true, useRequiresLimit: false, percentUsed: {}, + ...overrides, + }; +} + +// Upstream order deliberately puts the later-expiring grant first. +const PICKER = { + ...SNAPSHOT, + grants: [ + grant("late", { endsAt: "2099-12-01T00:00:00+00:00", clears: ["five_hour"] }), + grant("soon", { endsAt: "2099-11-01T00:00:00+00:00", clears: ["seven_day", "seven_day_overage_included"] }), + grant("paused", { endsAt: "2099-10-01T00:00:00+00:00", paused: true }), + grant("notyet", { endsAt: "2099-10-02T00:00:00+00:00", usableNow: false }), + grant("empty", { endsAt: "2099-10-03T00:00:00+00:00", resetsLeft: 0 }), + grant("limit", { endsAt: "2099-10-04T00:00:00+00:00", useRequiresLimit: true }), + grant("nodate", { endsAt: null }), + ], + nextGrantId: "late", +}; + +const PICKER_ID = "11111111-2222-4333-8444-555555555555"; + +function radio(container: HTMLElement, id: string): HTMLElement { + const element = container.querySelector(`[role="radio"][data-anthropic-grant="${id}"]`) as HTMLElement | null; + expect(element).not.toBeNull(); + return element!; +} + +function checkedId(container: HTMLElement): string | null { + const checked = container.querySelectorAll('[role="radio"][aria-checked="true"]'); + expect(checked.length).toBeLessThanOrEqual(1); + return checked[0]?.getAttribute("data-anthropic-grant") ?? null; +} + +async function press(target: HTMLElement, key: string): Promise { + await act(async () => { + target.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }) as unknown as Event); + }); + await tick(); +} + +function pickerFetch(posts: Array>, options: { + read?: (reads: number) => unknown; + post?: (body: Record, count: number) => Response | Promise; +} = {}): typeof fetch { + let reads = 0; + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.startsWith("/api/anthropic/reset-grants?")) { + reads += 1; + return Response.json(options.read?.(reads) ?? PICKER); + } + if (url === "/api/anthropic/reset-grants/consume") { + const body = JSON.parse(String(init?.body)) as Record; + posts.push(body); + return options.post?.(body, posts.length) ?? Response.json({ code: "reset", replayed: false, resetsLeft: 0 }); + } + throw new Error("unexpected fetch " + url); + }) as typeof fetch; +} + +test("the default grant is the usable one that expires first, not upstream order", () => { + const snapshot = { ...PICKER, pendingOperation: null } as unknown as AnthropicResetGrantSnapshot; + expect(spendableGrant(snapshot)?.id).toBe("soon"); + // A malformed or missing end date never sorts ahead of a real one. + const odd = { ...snapshot, grants: [grant("bad", { endsAt: "not-a-date" }), grant("none", { endsAt: null }), grant("real")] } as unknown as AnthropicResetGrantSnapshot; + expect(spendableGrant(odd)?.id).toBe("real"); + expect(spendableGrant({ ...snapshot, journalAvailable: false })).toBeUndefined(); +}); + +test("grants render as a radio group; unusable grants are disabled with their reason", async () => { + globalThis.fetch = pickerFetch([]); + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + expect(container.querySelector('[role="radiogroup"]')?.getAttribute("aria-label")).toBe("Choose a reset to use"); + expect(checkedId(container)).toBe("soon"); + expect(radio(container, "soon").tabIndex).toBe(0); + expect(radio(container, "late").tabIndex).toBe(-1); + const notes: Record = { + paused: "Paused", notyet: "Not usable yet", empty: "No resets left", limit: "Usable once this account hits a limit", + }; + for (const [id, note] of Object.entries(notes)) { + const option = radio(container, id); + expect(option.getAttribute("aria-disabled")).toBe("true"); + expect(option.tabIndex).toBe(-1); + expect(option.textContent).toContain(note); + await act(async () => { option.click(); }); + expect(checkedId(container)).toBe("soon"); + } + for (const id of ["late", "soon", "nodate"]) expect(radio(container, id).getAttribute("aria-disabled")).not.toBe("true"); + await act(async () => { root.unmount(); }); +}); + +test("a clicked grant is confirmed by name, expiry and windows, and its id is what gets spent", async () => { + const posts: Array> = []; + globalThis.fetch = pickerFetch(posts); + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + expect(container.textContent).toContain("The reset that expires first is selected"); + await act(async () => { radio(container, "late").click(); }); + expect(checkedId(container)).toBe("late"); + // The default hint would now be false: the chosen grant is not the one expiring first. + expect(container.textContent).not.toContain("The reset that expires first is selected"); + await click(container, "[data-anthropic-grant-use]"); + const confirm = container.textContent ?? ""; + expect(confirm).toContain("late reset"); + expect(confirm).toContain("Expires"); + expect(confirm).toContain("2099"); + expect(confirm).toContain("Clears: 5-hour limit"); + expect(posts).toHaveLength(0); + await click(container, "[data-anthropic-grant-confirm]"); + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ accountId: "acct-1", grantId: "late" }); + expect(String(posts[0].operationId)).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + await act(async () => { root.unmount(); }); +}); + +test("arrow keys move focus and selection across usable grants only; Space selects", async () => { + const posts: Array> = []; + globalThis.fetch = pickerFetch(posts); + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + await act(async () => { radio(container, "soon").focus(); }); + await press(radio(container, "soon"), "ArrowDown"); + expect(checkedId(container)).toBe("nodate"); + expect(testWindow.document.activeElement?.getAttribute("data-anthropic-grant")).toBe("nodate"); + await press(radio(container, "nodate"), "ArrowDown"); + expect(checkedId(container)).toBe("late"); + await press(radio(container, "late"), "ArrowUp"); + expect(checkedId(container)).toBe("nodate"); + await press(radio(container, "nodate"), "Home"); + expect(checkedId(container)).toBe("late"); + await press(radio(container, "late"), "End"); + expect(checkedId(container)).toBe("nodate"); + await press(radio(container, "paused"), " "); + expect(checkedId(container)).toBe("nodate"); + await click(container, "[data-anthropic-grant-use]"); + await click(container, "[data-anthropic-grant-confirm]"); + expect(posts[0]).toMatchObject({ grantId: "nodate" }); + await act(async () => { root.unmount(); }); +}); + +test("after an unknown outcome the held grant is locked: no picker, retry posts the same grant and id", async () => { + const posts: Array> = []; + globalThis.fetch = pickerFetch(posts, { + post: (_body, count) => { + if (count === 1) throw new TypeError("connection reset"); + return Response.json({ code: "reset", replayed: false, resetsLeft: 0 }); + }, + }); + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + await act(async () => { radio(container, "late").click(); }); + await click(container, "[data-anthropic-grant-use]"); + await click(container, "[data-anthropic-grant-confirm]"); + expect(container.querySelector('[role="radio"]')).toBeNull(); + expect(container.textContent).toContain("late reset"); + await click(container, "[data-anthropic-grant-retry]"); + expect(posts).toHaveLength(2); + expect(posts[1]).toEqual(posts[0]); + expect(posts[0]).toMatchObject({ grantId: "late" }); + await act(async () => { root.unmount(); }); +}); + +test("a server-held attempt on another grant locks the picker and retries that grant", async () => { + const posts: Array> = []; + globalThis.fetch = pickerFetch(posts, { + read: () => ({ ...PICKER, pendingOperation: { operationId: PICKER_ID, grantId: "late", retryableUntil: Date.now() + 60_000 } }), + }); + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + expect(container.querySelector('[role="radio"]')).toBeNull(); + expect(container.querySelector("[data-anthropic-grant-use]")).toBeNull(); + expect(container.textContent).toContain("late reset"); + await click(container, "[data-anthropic-grant-retry]"); + expect(posts).toEqual([{ accountId: "acct-1", grantId: "late", operationId: PICKER_ID }]); + await act(async () => { root.unmount(); }); +}); + +test("an unresolved prior attempt on the chosen grant is resumed after the refusal re-reads the account", async () => { + const posts: Array> = []; + let refused = false; + globalThis.fetch = pickerFetch(posts, { + read: () => refused + ? { ...PICKER, pendingOperation: { operationId: PICKER_ID, grantId: "late", retryableUntil: Date.now() + 60_000 } } + : PICKER, + post: (body, count) => { + if (count === 1) { + refused = true; + return Response.json({ error: { code: "unresolved_prior_operation", message: "x", pendingOperationId: PICKER_ID } }, { status: 409 }); + } + return Response.json({ code: "already_used", replayed: false, resetsLeft: 0, operationId: body.operationId }); + }, + }); + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + await act(async () => { radio(container, "late").click(); }); + await click(container, "[data-anthropic-grant-use]"); + await click(container, "[data-anthropic-grant-confirm]"); + expect(container.querySelector('[role="radio"]')).toBeNull(); + await click(container, "[data-anthropic-grant-retry]"); + expect(posts[1]).toEqual({ accountId: "acct-1", grantId: "late", operationId: PICKER_ID }); + await act(async () => { root.unmount(); }); +}); + +test("a confirmed grant that stops being usable sends the dialog back to the picker without posting", async () => { + const posts: Array> = []; + let pauseLate = false; + globalThis.fetch = pickerFetch(posts, { + read: () => pauseLate ? { ...PICKER, grants: PICKER.grants.map(item => item.id === "late" ? { ...item, paused: true } : item) } : PICKER, + }); + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + await act(async () => { radio(container, "late").click(); }); + await click(container, "[data-anthropic-grant-use]"); + expect(container.querySelector("[data-anthropic-grant-confirm]")).not.toBeNull(); + pauseLate = true; + await act(async () => { await harnessController!.refresh("acct-1"); }); + await tick(); + expect(container.querySelector("[data-anthropic-grant-confirm]")).toBeNull(); + expect(checkedId(container)).toBe("soon"); + expect(container.textContent).toContain("This reset cannot be used right now"); + expect(posts).toHaveLength(0); + await act(async () => { root.unmount(); }); +}); + +test("an unavailable journal disables every grant and says why", async () => { + globalThis.fetch = pickerFetch([], { read: () => ({ ...PICKER, journalAvailable: false }) }); + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + for (const option of container.querySelectorAll('[role="radio"]')) expect(option.getAttribute("aria-disabled")).toBe("true"); + expect(checkedId(container)).toBeNull(); + expect((container.querySelector("[data-anthropic-grant-use]") as HTMLButtonElement).disabled).toBe(true); + expect(container.textContent).toContain("The reset journal is busy or unavailable"); + await act(async () => { root.unmount(); }); +}); + + +test("a grant with an unparseable end date and no reported windows confirms without inventing either", async () => { + const posts: Array> = []; + globalThis.fetch = pickerFetch(posts, { + read: () => ({ ...PICKER, grants: [grant("odd", { endsAt: "not-a-date", startsAt: "also-bad", clears: [] })] }), + }); + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + expect(checkedId(container)).toBe("odd"); + expect(container.textContent).not.toContain("Invalid Date"); + expect(container.textContent).not.toContain("NaN"); + await click(container, "[data-anthropic-grant-use]"); + expect(container.textContent).not.toContain("Expires"); + expect(container.textContent).toContain("The limits this reset clears were not reported."); + await click(container, "[data-anthropic-grant-confirm]"); + expect(posts[0]).toMatchObject({ grantId: "odd" }); + await act(async () => { root.unmount(); }); +}); + + +test("opening the dialog re-reads the account, so an attempt started elsewhere locks the picker", async () => { + const posts: Array> = []; + let startedElsewhere = false; + globalThis.fetch = pickerFetch(posts, { + read: () => startedElsewhere + ? { ...PICKER, pendingOperation: { operationId: PICKER_ID, grantId: "soon", retryableUntil: Date.now() + 60_000 } } + : PICKER, + }); + const { container, root } = await mount(); + startedElsewhere = true; + await click(container, "[data-anthropic-grant-badge]"); + expect(container.querySelector('[role="radio"]')).toBeNull(); + expect(container.querySelector("[data-anthropic-grant-use]")).toBeNull(); + await click(container, "[data-anthropic-grant-retry]"); + expect(posts).toEqual([{ accountId: "acct-1", grantId: "soon", operationId: PICKER_ID }]); + await act(async () => { root.unmount(); }); +}); + + +test("with nothing selectable the radio group still has exactly one tab stop", async () => { + globalThis.fetch = pickerFetch([], { read: () => ({ ...PICKER, journalAvailable: false }) }); + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + const stops = [...container.querySelectorAll('[role="radio"]')].filter(option => (option as HTMLElement).tabIndex === 0); + expect(stops.map(option => option.getAttribute("data-anthropic-grant"))).toEqual(["late"]); + // Two grants but no choice: the "expires first is selected" hint would be false. + expect(container.textContent).not.toContain("The reset that expires first is selected"); + await act(async () => { root.unmount(); }); +}); + +test("the re-read on open keeps the list and badge, and holds the spend button until it lands", async () => { + let reads = 0; + let release: (() => void) | undefined; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (!url.startsWith("/api/anthropic/reset-grants?")) throw new Error("unexpected fetch " + url); + reads += 1; + if (reads === 1) return Response.json(PICKER); + await new Promise(resolve => { release = resolve; }); + return Response.json(PICKER); + }) as typeof fetch; + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + expect(reads).toBe(2); + expect(container.querySelector("[data-anthropic-grant-badge]")?.getAttribute("data-anthropic-grant-badge")).toBe("6"); + expect(checkedId(container)).toBe("soon"); + expect((container.querySelector("[data-anthropic-grant-use]") as HTMLButtonElement).disabled).toBe(true); + await act(async () => { release!(); }); + await tick(); + await tick(); + expect((container.querySelector("[data-anthropic-grant-use]") as HTMLButtonElement).disabled).toBe(false); + await act(async () => { root.unmount(); }); +}); + +test("a picked grant stays picked across a re-read while it is still usable", async () => { + globalThis.fetch = pickerFetch([]); + const { container, root } = await mount(); + await click(container, "[data-anthropic-grant-badge]"); + await act(async () => { radio(container, "late").click(); }); + await act(async () => { await harnessController!.refresh("acct-1"); }); + await tick(); + expect(checkedId(container)).toBe("late"); + await act(async () => { root.unmount(); }); +}); + diff --git a/src/providers/anthropic-reset-grant-ledger.ts b/src/providers/anthropic-reset-grant-ledger.ts index d57aa3c8a74..1cd776ea87e 100644 --- a/src/providers/anthropic-reset-grant-ledger.ts +++ b/src/providers/anthropic-reset-grant-ledger.ts @@ -214,12 +214,18 @@ export function beginAnthropicResetOperation( writeJournal(filePath, journal, now); return { kind: "execute", attempt: existing.attempts }; } + // One unresolved claim per account, whatever grant it names: the dashboard lets the + // user pick a grant, and a stale view must not open a second claim while the first + // one's outcome is unknown. Name the newest open one, as pendingAnthropicResetOperation does. + let prior: { operationId: string; createdAt: number } | null = null; for (const [operationId, record] of Object.entries(journal.operations)) { - if (record.status === "open" && record.accountId === identity.accountId && record.grantId === identity.grantId - && record.orgDigest === identity.orgDigest && now - record.createdAt < ANTHROPIC_RESET_RETRY_WINDOW_MS) { - return { kind: "unresolved-prior", operationId }; + if (record.status === "open" && record.accountId === identity.accountId + && record.orgDigest === identity.orgDigest && now - record.createdAt < ANTHROPIC_RESET_RETRY_WINDOW_MS + && (!prior || record.createdAt > prior.createdAt)) { + prior = { operationId, createdAt: record.createdAt }; } } + if (prior) return { kind: "unresolved-prior", operationId: prior.operationId }; if (Object.keys(journal.operations).length >= MAX_OPERATIONS) return { kind: "capacity" }; journal.operations[identity.operationId] = { accountId: identity.accountId, diff --git a/src/server/management/anthropic-reset-grant-routes.ts b/src/server/management/anthropic-reset-grant-routes.ts index b1c38752db7..9a369039e19 100644 --- a/src/server/management/anthropic-reset-grant-routes.ts +++ b/src/server/management/anthropic-reset-grant-routes.ts @@ -56,7 +56,7 @@ const MESSAGES = { grant_not_usable: "This reset grant cannot be used right now.", operation_identity_mismatch: "This operation belongs to another account or grant.", in_flight: "This reset is still being processed.", - unresolved_prior_operation: "An earlier reset attempt for this grant is not confirmed yet.", + unresolved_prior_operation: "An earlier reset attempt on this account is not confirmed yet.", unknown_outcome_expired: "This attempt can no longer be retried; re-read the account.", unknown_outcome: "The reset did not answer; its outcome is unknown.", journal_write_failed: "The reset answered but could not be recorded; its outcome is unknown here.", diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 60e69bb43f1..4d9b1dacf75 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -310,7 +310,7 @@ after the save, and a non-retryable skip (no managed catalog) is a clean save. C | File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). The route returns `ledger_unavailable` before dispatch when the shared ledger cannot be read or locked; stale uncertain attempts are inspected without another redemption or inferred success. A late inspection refusal or a lost claim replays an identity-matching definitive winner; otherwise it returns `operation_state_changed` (late refusal) or `attempt_in_progress` (lost claim) and never replaces an attempted or terminal record. Unknown-outcome responses include the effective operation ID; the CLI mints it before dispatch. Uncertain and pending attempts live in a module-level hold store shared by all hook instances and mirrored to `sessionStorage` (`ocx.grok-coupon-holds.v1`), so they survive modal close/reopen, remount, and reload for the tab session; the pending hold is written before the POST, only a definitive response for the same `operationId` clears it, and only GET refreshes are allowed after uncertainty. Every response that cannot prove settlement under a supplied operation ID, including pre-open refusals, carries that `operationId`. A definitive failure releases the matching speculative pending hold. Supplied token IDs must be non-empty strings before journal admission, and omitted account/token retries recover the operation’s durable identity within retention. Legacy version-one opens are quarantined rather than dispatched. After confirmed redemption, local settlement has five attempts and four 20 ms waits only for a `ConfigMutationLockError` caused by `SQLITE_BUSY`; any other error or changed claim stops without another redemption. | -| Claude reset grants | `src/server/management/anthropic-reset-grant-routes.ts` — `GET /api/anthropic/reset-grants`, `POST /api/anthropic/reset-grants/consume` (lazy-loaded). Wire and fail-closed parsing live in `src/providers/anthropic-reset-grants.ts` (the Claude Code 2.1.278 `cedar_ember` contract, sent with `CLAUDE_CLI_USER_AGENT` from `src/providers/claude-cli-identity.ts`); the journal is `src/providers/anthropic-reset-grant-ledger.ts`: a cross-process `BEGIN IMMEDIATE` lock around every synchronous read-modify-write, a 90 s lease, the operation id reused as the upstream `request_id`, same-id retry only inside the vendor's ten-minute window, no settlement inferred from a re-read, and a fail-closed `500 journal_write_failed` when an answer cannot be recorded. Spending requires the `gui-session` principal. A journaled `reset`, including a same-id replay of that code, lifts that account cooldown only when it is a 429 quota refusal recorded before the operation was first journaled (the claim fence, `claimedAt`) and the journal's cleared windows cover every window the refusal named (both when it named none); it then drops the cached quota row and rereads usage with its own request rather than joining an older read. Model-family exclusions stay until the usage reread proves a family is no longer rejected. A failed reread leaves them in place. The HTTP result stays the journal answer if the reread fails. Other result codes do not admit. The dashboard owner is `gui/src/hooks/useAnthropicResetGrants.ts` with `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` on the Anthropic OAuth rows of `ProviderAuthPanel`; after an unknown outcome the dialog only retries the same id. Design and audit record: [`../devlog/_fin/260923_claude_reset_grants/010_plan.md`](../devlog/_fin/260923_claude_reset_grants/010_plan.md). | +| Claude reset grants | `src/server/management/anthropic-reset-grant-routes.ts` — `GET /api/anthropic/reset-grants`, `POST /api/anthropic/reset-grants/consume` (lazy-loaded). Wire and fail-closed parsing live in `src/providers/anthropic-reset-grants.ts` (the Claude Code 2.1.278 `cedar_ember` contract, sent with `CLAUDE_CLI_USER_AGENT` from `src/providers/claude-cli-identity.ts`); the journal is `src/providers/anthropic-reset-grant-ledger.ts`: a cross-process `BEGIN IMMEDIATE` lock around every synchronous read-modify-write, a 90 s lease, the operation id reused as the upstream `request_id`, same-id retry only inside the vendor's ten-minute window, at most one unresolved operation per account and organization within each pool's journal and that ten-minute window, whatever grant it names (a new id gets `409 unresolved_prior_operation`), no settlement inferred from a re-read, and a fail-closed `500 journal_write_failed` when an answer cannot be recorded. Spending requires the `gui-session` principal. A journaled `reset`, including a same-id replay of that code, lifts that account cooldown only when it is a 429 quota refusal recorded before the operation was first journaled (the claim fence, `claimedAt`) and the journal's cleared windows cover every window the refusal named (both when it named none); it then drops the cached quota row and rereads usage with its own request rather than joining an older read. Model-family exclusions stay until the usage reread proves a family is no longer rejected. A failed reread leaves them in place. The HTTP result stays the journal answer if the reread fails. Other result codes do not admit. The dashboard owner is `gui/src/hooks/useAnthropicResetGrants.ts` with `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` on the Anthropic OAuth rows of `ProviderAuthPanel`; the dialog re-reads the account when it opens, lets the user choose the grant (default: the selectable grant that expires first; the server re-gates the chosen `grantId`), and after an unknown outcome only retries the same id with no picker shown. Design and audit record: [`../devlog/_fin/260923_claude_reset_grants/010_plan.md`](../devlog/_fin/260923_claude_reset_grants/010_plan.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. `PUT` keeps a stored field the body omits (`cooldownMs`, `waitForCooldownMs`, `defaultEffortMode`, `reasoningEffortMode`, `imageInput`, `cooldownWaitPolicy`, per-target `lastResort`, JEV `decisionQuotaSignals`/`decisionQuotaTiers`/`jevEffortMode`/`sessionPin` on updates/rename); quota false/null clears sparsely and switching away removes quota policy under [the owning contract](providers/jev-decision.md#loaded-advisory-quota). Explicit values replace it and defaults are stored sparse. A combo validation 400 is `{ error, field? }`, where `field` is the top-level combo field of the first issue. | | Protocol paths | `src/server/management/protocol-routes.ts` (lazy-loaded) — `GET /api/protocols` returns the contract version, the resolved API surfaces and protocol settings, the policy revision and the feature vocabulary; with `?provider=` (one non-empty name of at most 200 characters without control characters, else 400 `invalid_provider`; an unconfigured name is 404 `unknown_provider`, neither echoing the name) it adds a `provider` block from `src/protocols/provider-summary.ts` ([Protocol Paths](data-planes/protocol-paths.md#provider-wire-summary)); `POST /api/protocols/plan` takes `{ model, inbound, features? }` (model at most 200 characters, at most 24 features, any other key refused with 400) and returns a `ProtocolPlanV1` with `basis: "preview"` from `src/protocols/plan-snapshot.ts`. Both are read-only, never log their input, and send nothing upstream. `PATCH /api/protocols/settings` takes `{ messagesEnabled?, unrepresentable?, rollout? }` (strict: unknown keys and wrong types are 400), validates and applies through `src/server/management/protocol-settings-patch.ts`, persists with `saveConfigPreservingClaudeCode`, restores the live config if the save fails (409 on lock contention, 500 otherwise), and answers with the fresh `GET /api/protocols` body; closing Messages also writes `claudeCode.enabled = false` through `commitClaudeCodeBlock` ([Protocol Paths](data-planes/protocol-paths.md#settings)). The CLI drives all three through `ocx api protocols`, `ocx api explain` and `ocx api policy` ([Protocol Paths](data-planes/protocol-paths.md#cli)); none carries a route-registry exemption. | | Workflow budget | `src/server/management/workflow-budget-routes.ts` — `GET /api/workflow-budget` reads the tracked roots or one root, and `POST /api/workflow-budget/clear` clears exactly one. The clear moves the windowed send ring and the child map and nothing else: `active` belongs to turns still in flight, the spend ledger is a token budget an operator did not ask to forgive, and the lifetime send total survives so a clear cannot launder the record. A refusal event carries `spendScope` and `spendLimit` when a token ceiling fired, so the reason is readable without the config open beside it; no scope id is ever attached, because root ids are client thread headers and identity ids are credentials. Both are `deferred-verb` in the route registry — they are owed CLI verbs, and because the ledger is process memory there is no local projection the CLI could read instead. `GET`/`PUT /api/workflow-budget/settings` read and write the per-install count ceilings (`workflowBudget` in config: concurrent turns, sends and distinct children per window); the live config is bound in `src/lib/workflow-budget-config.ts` and read per decision by HTTP admission and the pre-dispatch send ceiling, so a write applies to the next request and never resets a root's usage. The window, interactive reserve and tracked-root bound stay fixed because a root's ring geometry is fixed when it first appears. The GET is `local-transport` (same block via `ocx config get`), the PUT `deferred-verb` with the other budget verbs. See [`../devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md`](../devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md). | diff --git a/tests/adapters/anthropic/anthropic-reset-grants.test.ts b/tests/adapters/anthropic/anthropic-reset-grants.test.ts index 08e60d1b3ef..92fa8431096 100644 --- a/tests/adapters/anthropic/anthropic-reset-grants.test.ts +++ b/tests/adapters/anthropic/anthropic-reset-grants.test.ts @@ -278,6 +278,17 @@ describe("reset-grant journal", () => { expect(pendingAnthropicResetOperation("acct-1", { journalPath, now: T0 + ANTHROPIC_RESET_RETRY_WINDOW_MS + 1 })?.operationId).toBe(OP2); }); + test("a new id for another grant on the same account is refused while an attempt is unresolved", () => { + // The dashboard lets the user pick a grant, so a stale view must not open a second + // claim on the account while the first one's outcome is still unknown. + beginAnthropicResetOperation(id(OP), { journalPath, now: T0 }); + releaseAnthropicResetLease(OP, { journalPath, now: T0 + 1 }); + expect(beginAnthropicResetOperation(id(OP2, { grantId: "grant-b" }), { journalPath, now: T0 + 60_000 })) + .toEqual({ kind: "unresolved-prior", operationId: OP }); + expect(beginAnthropicResetOperation(id(OP2, { accountId: "acct-2", grantId: "grant-b" }), { journalPath, now: T0 + 60_000 })) + .toEqual({ kind: "execute", attempt: 1 }); + }); + test("an operation id bound to another account, grant, or organization is refused", () => { beginAnthropicResetOperation(id(OP), { journalPath, now: T0 }); for (const other of [{ accountId: "acct-2" }, { grantId: "grant-b" }, { org: "cbbef438-0000-4000-8000-000000000002" }]) { diff --git a/tests/server/management-anthropic-reset-grants.test.ts b/tests/server/management-anthropic-reset-grants.test.ts index 1f6cbb3775e..1d9a745a63b 100644 --- a/tests/server/management-anthropic-reset-grants.test.ts +++ b/tests/server/management-anthropic-reset-grants.test.ts @@ -185,6 +185,38 @@ describe("POST /api/anthropic/reset-grants/consume", () => { expect(up.claims).toEqual([]); }); + test("the dashboard's chosen grant is gated and claimed by its own id, not the first grant", async () => { + // The picker can send any listed grant; the server re-reads and gates that exact id. + const SECOND = "spring-bonus-20261101"; + const second = { ...grantBlock().grants[0], id: SECOND, label: "Spring bonus", ends_at: "2026-10-01T16:00:00+00:00" }; + const up = upstream(); + up.status = () => Response.json({ cedar_ember: grantBlock({ grants: [grantBlock().grants[0], { ...second, paused: true }] }) }); + expect(await consume(deps(up), { accountId: "acct-1", grantId: SECOND, operationId: OP })) + .toMatchObject({ status: 409, json: { error: { code: "grant_not_usable", reason: "paused" } } }); + up.status = () => Response.json({ cedar_ember: grantBlock({ grants: [grantBlock().grants[0], { ...second, resets_left: 0 }] }) }); + expect((await consume(deps(up), { accountId: "acct-1", grantId: SECOND, operationId: OP })).json.error.reason).toBe("exhausted"); + up.status = () => Response.json({ cedar_ember: grantBlock({ grants: [grantBlock().grants[0], { ...second, usable_now: false }] }) }); + expect((await consume(deps(up), { accountId: "acct-1", grantId: SECOND, operationId: OP })).json.error.reason).toBe("not_usable"); + expect((await consume(deps(up), { accountId: "acct-1", grantId: "not-listed", operationId: OP })).json.error.reason).toBe("unknown_grant"); + expect(up.claims).toEqual([]); + up.status = () => Response.json({ cedar_ember: grantBlock({ grants: [grantBlock().grants[0], second] }) }); + const spent = await consume(deps(up), { accountId: "acct-1", grantId: SECOND, operationId: OP }); + expect(spent).toMatchObject({ status: 200, json: { code: "reset", grantId: SECOND } }); + expect(up.claims.map(claim => claim.body.grant_id)).toEqual([SECOND]); + }); + + test("after an unknown outcome on one grant, a new spend on another grant of the account is refused", async () => { + const SECOND = "spring-bonus-20261101"; + const up = upstream(); + up.status = () => Response.json({ cedar_ember: grantBlock({ grants: [grantBlock().grants[0], { ...grantBlock().grants[0], id: SECOND }] }) }); + up.claim = () => { throw new TypeError("connection reset"); }; + expect((await consume(deps(up))).json.error.code).toBe("unknown_outcome"); + up.claim = () => Response.json({ result: "reset", resets_left: 0, cleared: ["five_hour", "seven_day"] }); + expect(await consume(deps(up), { accountId: "acct-1", grantId: SECOND, operationId: OP2 })) + .toMatchObject({ status: 409, json: { error: { code: "unresolved_prior_operation", pendingOperationId: OP } } }); + expect(up.claims.map(claim => claim.body.grant_id)).toEqual([GRANT]); + }); + test("a spend posts once with the operation id as request id and then replays", async () => { const up = upstream(); const first = await consume(deps(up));