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 1ee13d85e11..331fa027022 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -79,7 +79,7 @@ résultats propres à chaque route, sans répéter ce tableau. | `PUT /api/grok/selection` | Persister les modèles Grok exclus | 400 sélection invalide ou surdimensionnée | | `POST /api/grok/apply` | Appliquer la configuration Grok persistante par la synchronisation gérée | 409 `grok_apply_busy` ; 400/500 échec de l'application | | `GET /api/grok/reset-coupons?accountId=...` | Lire les jetons de réinitialisation de facturation Grok restants et leurs fenêtres de validité pour le compte xAI actif ou spécifié | 400 compte manquant ; 401 non authentifié ; 502 erreur gRPC-Web en amont | -| `POST /api/grok/reset-coupons/consume` | Échanger un coupon de réinitialisation éligible. Corps `{ accountId?, tokenId?, operationId? }`. L'`operationId` facultatif (UUIDv4) rend l'échange idempotent : répéter le même identifiant rejoue le résultat durable sans double échange. | 400 JSON/UUID invalide ; 401 non authentifié ; 409 `identity_mismatch` ; 502 erreur en amont ; 503 capacité du registre | +| `POST /api/grok/reset-coupons/consume` | Échanger un coupon de réinitialisation éligible. Corps `{ accountId?, tokenId?, operationId? }`. L'`operationId` facultatif (UUIDv4) rend l'échange idempotent : répéter le même identifiant rejoue le résultat durable sans double échange. | 400 JSON/UUID invalide ; 401 non authentifié ; 409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved` ; 502 erreur en amont ; 503 capacité du registre / `ledger_unavailable`; 500 `attempt_mark_failed` | | `GET /api/anthropic/reset-grants?accountId=...` | Lire les réinitialisations des limites d’utilisation de Claude pour un compte OAuth Anthropic : son admissibilité, le nombre de réinitialisations restantes pour chaque attribution, sa période de validité et les fenêtres qu’elle remet à zéro, ainsi que toute tentative non confirmée pouvant encore être relancée | 400 aucun compte correspondant ; 401 nouvelle authentification requise ; 502 service en amont indisponible | | `POST /api/anthropic/reset-grants/consume` | Utiliser une réinitialisation. Corps `{ accountId, grantId, operationId }` ; `operationId` est un UUIDv4 envoyé en amont comme identifiant de requête : le répéter relance la même demande. Nécessite une session du tableau de bord. | 400 corps invalide ; 401 nouvelle authentification requise ; 403 `session_required` ; 409 `grant_not_usable`, `in_flight`, `unresolved_prior_operation`, `unknown_outcome_expired`, `operation_identity_mismatch` ; 500 `journal_write_failed` ; 502 `unknown_outcome` ; 503 journal occupé, indisponible ou saturé | | `GET, PUT /api/claude-desktop` | Lire ou enregistrer le profil Claude Desktop routé ou natif | 400 affectation invalide ou indisponible | @@ -87,13 +87,13 @@ résultats propres à chaque route, sans répéter ce tableau. | `GET /api/claude-desktop/status` | Inspecter le profil enregistré par rapport à celui appliqué et l'état du bureau | 400 échec de lecture de l'état | | `GET, PUT /api/claude-code` | Lire ou mettre à jour les paramètres de passerelle, de mode d'authentification, de correspondance des modèles, de contexte, d'agent et de service auxiliaire | 400 champ ou structure invalide | -Le tableau de bord pilote les deux chemins de coupon depuis **Providers > xAI Grok > Accounts** : chaque -ligne de compte connecté porte un badge de ticket indiquant le nombre de coupons restants, et le -badge ouvre une boîte de dialogue qui liste les fenêtres de validité et échange le coupon le plus -proche de l'expiration. La boîte de dialogue envoie un `operationId` émis par le client, et cesse -d'envoyer après un délai d'attente au lieu de réessayer, car un échange dont l'enregistrement du -journal est encore ouvert s'exécuterait de nouveau. `ocx account grok-reset-coupons` reste l'équivalent -en terminal. +Si `tokenId` est fourni, il doit être une chaîne non vide ; sinon la requête renvoie `400 invalid_token_id` avant de lire les identifiants ou d’ouvrir le journal. Pendant les 30 jours de conservation, une reprise du même `operationId` sans `accountId` utilise le compte enregistré, même si le compte actif a changé. Une opération `open` exécutable sans `tokenId` reprend le token enregistré. Un autre compte ou token explicite reste refusé. Après un échec définitif, le tableau de bord libère le blocage provisoire correspondant pour permettre une nouvelle tentative expressément confirmée ; les résultats réellement incertains restent bloqués. + +La tentative est enregistrée durablement avant l’échange. Seule une réponse de succès confirmée par le service en amont permet d’enregistrer `redeemed` ; la disparition du coupon de la liste ne prouve pas que cette opération a réussi. Après un délai dépassé ou un résultat non enregistré, les nouvelles requêtes vérifient seulement la disponibilité, sans nouvel échange, et renvoient `attempt_unresolved`. Une tentative récente renvoie `attempt_in_progress`. Un refus issu d’une inspection tardive ne peut régler qu’une opération encore `open`. Dans la même transaction, un résultat final confirmé dont le compte et le jeton correspondent est rejoué avec HTTP 200 et son code d’origine. Un résultat non confirmé ou une identité différente renvoie `operation_state_changed` ; conservez le même identifiant pour la récupération. Un registre illisible ou verrouillé renvoie `ledger_unavailable` sans lancer d’échange. + +Un échange non confirmé renvoie HTTP 502 avec `error.code: "attempt_unresolved"` et l’`operationId` effectif. Conservez cet identifiant : un nouvel identifiant lancerait une autre opération. La CLI crée l’identifiant avant l’envoi et indique de conserver le même `--operation-id` si la livraison ou la réponse est incertaine. Les anciens enregistrements `open` du registre version un et les `redeem_failed` non confirmés sont isolés comme tentatives incertaines et n’autorisent aucun nouvel échange. + +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 contrôleur du compte conserve cette tentative tant qu’il reste monté, même après fermeture puis réouverture du dialogue. 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`. 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 49dd5cdd010..a4573c74329 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -65,7 +65,7 @@ Authorization: Bearer | `PUT /api/grok/selection` |除外された Grok モデルを永続化します。 400 個の無効な選択またはサイズが大きすぎる選択 | | `POST /api/grok/apply` |管理された同期を通じて永続的な Grok 設定を適用する | 409 `grok_apply_busy`; 400/500 適用失敗 | | `GET /api/grok/reset-coupons?accountId=...` | アクティブまたは指定された xAI アカウントの残り Grok 請求リセット トークンと有効期限ウィンドウを読む | 400 アカウントがありません; 401 未認証; 502 上流 gRPC-Web エラー | -| `POST /api/grok/reset-coupons/consume` | 対象となるリセット クーポンを換金します。本文は `{ accountId?, tokenId?, operationId? }`。任意の `operationId`(UUIDv4)により換金は冪等になります: 同じ ID を繰り返すと、二重換金せずに永続化された結果を再生します。 | 400 無効な JSON/UUID; 401 未認証; 409 `identity_mismatch`; 502 上流エラー; 503 台帳容量 | +| `POST /api/grok/reset-coupons/consume` | 対象となるリセット クーポンを換金します。本文は `{ accountId?, tokenId?, operationId? }`。任意の `operationId`(UUIDv4)により換金は冪等になります: 同じ ID を繰り返すと、二重換金せずに永続化された結果を再生します。 | 400 無効な JSON/UUID; 401 未認証; 409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved`; 502 上流エラー; 503 台帳容量 / `ledger_unavailable`; 500 `attempt_mark_failed` | | `GET /api/anthropic/reset-grants?accountId=...` | 1 つの Anthropic OAuth アカウントの Claude 使用量上限リセット付与を読み取ります。対象資格、各付与の残り回数、有効期間、リセットされる使用量枠、再試行可能な未確定の試行が含まれます。 | 400 該当するアカウントなし; 401 再認証が必要; 502 上流を利用できません | | `POST /api/anthropic/reset-grants/consume` | リセット付与を 1 回分使用します。本文は `{ accountId, grantId, operationId }`。`operationId` はリクエスト ID として上流へ送信する UUIDv4 で、同じ値を繰り返すと同じ請求を再試行します。ダッシュボードセッションが必要です。 | 400 無効な本文; 401 再認証が必要; 403 `session_required`; 409 `grant_not_usable`, `in_flight`, `unresolved_prior_operation`, `unknown_outcome_expired`, `operation_identity_mismatch`; 500 `journal_write_failed`; 502 `unknown_outcome`; 503 ジャーナルが使用中、利用不可、または満杯 | | `GET, PUT /api/claude-desktop` | Claude Desktop のルーティング/ネイティブ プロファイルを読み取るか永続化する | 400 無効または使用できない割り当て | @@ -73,7 +73,13 @@ Authorization: Bearer | `GET /api/claude-desktop/status` |保存済みプロファイルと適用済みプロファイルおよびデスクトップの健全性を検査する | 400 ステータス読み取り失敗 | | `GET, PUT /api/claude-code` |クロード コードのゲートウェイ、認証モード、モデル マップ、コンテキスト、エージェント、サイドカー設定の読み取りまたは更新 | 400 無効なフィールドまたは図形 | -ダッシュボードは **Providers > xAI Grok > Accounts** から両方のクーポン パスを操作します。サインイン済みの各アカウント行には残りのクーポン数を示すチケット バッジがあり、バッジは有効期限ウィンドウを一覧し、期限が最も近いクーポンを換金するダイアログを開きます。ダイアログはクライアントが発行した `operationId` を送り、再試行せずタイムアウト後に送信を止めます。ジャーナル記録がまだ開いている換金は再実行されてしまうためです。`ocx account grok-reset-coupons` はターミナル側の同等コマンドです。 +`tokenId` を指定する場合は空でない文字列が必要です。不正な値は認証情報の読み取りや台帳を開く前に `400 invalid_token_id` で拒否されます。30 日の保持期間内では、同じ `operationId` の再試行で `accountId` を省略すると、アクティブアカウントが変わっていても記録済みのアカウントを使います。実行可能な `open` の再試行で `tokenId` を省略すると記録済みのトークンを使います。別のアカウントやトークンを明示した場合は引き続き拒否されます。確定した失敗の後、ダッシュボードは該当する保留リクエストの暫定保留を解除し、新しい試行を明示的に確認できるようにします。本当に結果が不明な試行は保留を維持します。 + +換金前に台帳へ試行を記録し、一つのリクエストだけを実行します。上流から換金成功の応答を確認した場合だけ `redeemed` を記録します。一覧からクーポンが消えても、この操作の成功を示す証拠にはなりません。タイムアウトや結果保存の失敗後は、再換金せずに可用性だけを照会し `attempt_unresolved` を返します。最近の試行は `attempt_in_progress` を返します。遅れて完了した検査による拒否は、まだ `open` の操作だけに記録します。同じトランザクション内で、アカウントとトークンが一致する確定済みの最終結果は元のコードと HTTP 200 で再生します。結果が未確定、またはアカウントやトークンが一致しない場合は `operation_state_changed` を返し、同じ操作 ID を保持して復旧します。台帳を読めない、またはロックできない場合は換金せず `ledger_unavailable` を返します。 + +使用結果を確認できない場合は HTTP 502 と `error.code: "attempt_unresolved"`、実際の `operationId` を返します。この ID を保持してください。新しい ID は別の操作を開始します。CLI は送信前に ID を生成し、送信や応答が不確かな場合は同じ `--operation-id` を保持するよう案内します。旧バージョンの台帳の `open` 記録と結果未確認の `redeem_failed` 記録は不確定な試行として隔離され、再使用を許可しません。 + +**Providers > xAI Grok > Accounts** の各アカウントのチケットバッジから、残りのクーポンと有効期間を確認できます。ダイアログはクライアントで生成した `operationId` を送信します。送信や結果が不確定な場合、アカウントコントローラーがマウントされている間は同じ試行を保持し、ダイアログを閉じて開き直しても維持します。更新は GET のみで、追加の使用 POST を許可しません。`ocx account grok-reset-coupons` は対応するターミナルコマンドです。 Claude の使用量リセットも **Providers > Anthropic > Accounts** から同様に操作できます。サインイン済みの各アカウント行には残りのリセット回数を示すチケットバッジがあり、ダイアログで再度確認した後に 1 回分を使用します。リセットすると、週次枠のリセット曜日を変えずに 5 時間枠と週次枠が補充されます。請求に応答がない場合、ダイアログは `operationId` を保持し、10 分間は同じ ID で再試行できます。Claude Code クライアント自体もこの方法で復旧し、その間は同じ付与に対する新しい操作が拒否されます。使用できるのはダッシュボードからのみで、管理者トークンだけでは `403 session_required` が返されます。 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 46e59b87f1b..f0e79933c11 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -65,7 +65,7 @@ Authorization: Bearer | `PUT /api/grok/selection` | 제외할 Grok 모델을 영속화합니다 | 400 잘못되었거나 너무 큰 선택 | | `POST /api/grok/apply` | 관리형 동기화를 통해 영속화된 Grok 구성을 적용합니다 | 409 `grok_apply_busy`; 400/500 적용 실패 | | `GET /api/grok/reset-coupons?accountId=...` | 활성 또는 지정된 xAI 계정의 남은 Grok billing reset 토큰과 유효 기간을 읽습니다 | 400 누락된 account; 401 인증되지 않음; 502 upstream gRPC-Web 오류 | -| `POST /api/grok/reset-coupons/consume` | 사용할 수 있는 reset coupon을 교환합니다. 본문은 `{ accountId?, tokenId?, operationId? }`. 선택적 `operationId`(UUIDv4)는 교환을 멱등하게 만듭니다 — 같은 id를 반복하면 이중 교환 없이 저장된 결과를 재생합니다. | 400 잘못된 JSON/UUID; 401 인증되지 않음; 409 `identity_mismatch`; 502 upstream 오류; 503 ledger 용량 | +| `POST /api/grok/reset-coupons/consume` | 사용할 수 있는 reset coupon을 교환합니다. 본문은 `{ accountId?, tokenId?, operationId? }`. 선택적 `operationId`(UUIDv4)는 교환을 멱등하게 만듭니다 — 같은 id를 반복하면 이중 교환 없이 저장된 결과를 재생합니다. | 400 잘못된 JSON/UUID; 401 인증되지 않음; 409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved`; 502 upstream 오류; 503 ledger 용량 / `ledger_unavailable`; 500 `attempt_mark_failed` | | `GET /api/anthropic/reset-grants?accountId=...` | Anthropic OAuth 계정 하나의 Claude 사용량 리셋 grant를 읽습니다. 사용 가능 여부, grant별 남은 리셋 수와 유효 기간, 초기화하는 한도, 아직 다시 보낼 수 있는 미확인 시도를 함께 돌려줍니다 | 400 일치하는 계정 없음; 401 재로그인 필요; 502 upstream 응답 없음 | | `POST /api/anthropic/reset-grants/consume` | 리셋 grant 하나를 사용합니다. 본문은 `{ accountId, grantId, operationId }`이고, `operationId`(UUIDv4)는 upstream 요청 ID로 그대로 전송되므로 같은 값을 다시 보내면 같은 요청을 재시도합니다. 대시보드 세션이 필요합니다. | 400 잘못된 본문; 401 재로그인 필요; 403 `session_required`; 409 `grant_not_usable`, `in_flight`, `unresolved_prior_operation`, `unknown_outcome_expired`, `operation_identity_mismatch`; 500 `journal_write_failed`; 502 `unknown_outcome`; 503 저널 사용 중, 열 수 없음, 또는 가득 참 | | `GET, PUT /api/claude-desktop` | Claude Desktop 라우팅/네이티브 프로필을 읽거나 저장합니다 | 400 잘못되었거나 사용할 수 없는 할당 | @@ -73,11 +73,13 @@ Authorization: Bearer | `GET /api/claude-desktop/status` | 저장된 프로필과 적용된 프로필, Desktop 상태를 확인합니다 | 400 상태 읽기 실패 | | `GET, PUT /api/claude-code` | Claude Code gateway, auth-mode, model-map, context, agent, sidecar 설정을 읽거나 갱신합니다 | 400 잘못된 필드 또는 형태 | -대시보드는 **Providers > xAI Grok > Accounts**에서 두 coupon 경로를 모두 사용합니다. 로그인한 각 -계정 행에는 남은 coupon 개수가 표시된 티켓 배지가 있으며, 이 배지는 유효 기간을 나열하고 만료가 -가장 가까운 coupon을 교환하는 대화 상자를 엽니다. 대화 상자는 클라이언트가 생성한 `operationId`를 -보내며, 재시도하는 대신 타임아웃 후 전송을 중단합니다. 저널 기록이 아직 열린 교환이 다시 실행되기 -때문입니다. `ocx account grok-reset-coupons`는 터미널 대응 명령으로 그대로 남습니다. +`tokenId`를 보내면 비어 있지 않은 문자열이어야 합니다. 잘못된 값은 자격 증명을 읽거나 원장을 열기 전에 `400 invalid_token_id`로 거절됩니다. 30일 보존 기간 안에서 같은 `operationId` 재시도가 `accountId`를 생략하면 활성 계정이 바뀌어도 기록된 계정을 사용합니다. 실행 가능한 `open` 재시도가 `tokenId`를 생략하면 기록된 토큰을 사용합니다. 다른 계정이나 토큰을 명시하면 계속 거절됩니다. 대시보드는 확정 실패 뒤 해당 대기 요청에서 생긴 추측성 보류를 해제하여 새 작업을 명시적으로 확인할 수 있게 합니다. 실제 불확정 결과는 계속 보류합니다. + +교환 전에 ledger에 시도를 기록해 한 요청만 교환을 진행합니다. upstream의 교환 성공 응답이 확인된 경우에만 `redeemed`를 기록하며, 목록에서 쿠폰이 사라졌다는 사실만으로 이 operation의 성공을 판단하지 않습니다. 타임아웃이나 결과 저장 실패 후 재시도는 다시 교환하지 않고 가용성만 조회한 뒤 `attempt_unresolved`를 반환하며, 최근 시도는 `attempt_in_progress`를 반환합니다. 늦게 끝난 검사로 인한 거절은 아직 `open`인 operation에만 기록합니다. 같은 트랜잭션 안에서 계정과 토큰이 일치하는 확정된 최종 결과는 원래 코드와 HTTP 200으로 재생합니다. 결과가 미확정이거나 계정·토큰이 일치하지 않으면 `operation_state_changed`를 반환하며 같은 operation ID를 보존해 복구해야 합니다. ledger를 읽거나 잠글 수 없으면 교환 없이 `ledger_unavailable`을 반환합니다. + +결과를 확인하지 못한 사용 요청은 HTTP 502와 `error.code: "attempt_unresolved"`, 실제 `operationId`를 반환합니다. 이 ID를 보존하세요. 새 ID는 다른 작업을 시작합니다. CLI는 전송 전에 ID를 생성하고, 전송이나 응답이 불확실하면 같은 `--operation-id`를 보존하라는 안내를 출력합니다. 구버전 원장의 `open` 기록과 결과가 확인되지 않은 `redeem_failed` 기록은 불확정 상태로 격리되어 재사용 요청을 허용하지 않습니다. + +**Providers > xAI Grok > Accounts**의 계정별 티켓 배지에서 남은 쿠폰과 유효 기간을 확인합니다. 대화상자는 클라이언트가 생성한 `operationId`를 전송합니다. 전송이나 결과가 불확정이면 계정 컨트롤러가 마운트되어 있는 동안 같은 시도를 보존하며, 대화상자를 닫았다 다시 열어도 유지합니다. 새로고침은 GET만 보내고 추가 사용 POST를 허용하지 않습니다. `ocx account grok-reset-coupons`는 대응하는 터미널 명령입니다. Claude 사용량 리셋도 **Providers > Anthropic > Accounts**에서 같은 방식으로 씁니다. 로그인한 계정 행마다 남은 리셋 수를 보여 주는 티켓 배지가 붙고, 대화상자에서 한 번 더 확인하면 리셋 diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 66312da9530..c9fa24f0a01 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -83,7 +83,7 @@ route-specific results rather than repeating this table. | `PUT /api/grok/selection` | Persist the excluded Grok models | 400 invalid or oversized selection | | `POST /api/grok/apply` | Apply persisted Grok configuration through the managed sync | 409 `grok_apply_busy`; 400/500 apply failure | | `GET /api/grok/reset-coupons?accountId=...` | Read remaining Grok billing reset tokens and validity windows for the active or specified xAI account | 400 missing account; 401 unauthenticated; 502 upstream gRPC-Web error | -| `POST /api/grok/reset-coupons/consume` | Redeem an eligible reset coupon. Body `{ accountId?, tokenId?, operationId? }`. Optional `operationId` (UUIDv4) makes redemption idempotent: repeating the same ID replays the durable result without double-redemption. | 400 invalid JSON/UUID; 401 unauthenticated; 409 `identity_mismatch`; 502 upstream error; 503 ledger capacity | +| `POST /api/grok/reset-coupons/consume` | Redeem an eligible reset coupon. Body `{ accountId?, tokenId?, operationId? }`. Optional `operationId` (UUIDv4) makes redemption idempotent: repeating the same ID replays the durable result without double-redemption. | 400 invalid JSON/UUID; 401 unauthenticated; 409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved`; 502 upstream error; 503 ledger capacity / `ledger_unavailable`; 500 `attempt_mark_failed` | | `GET /api/anthropic/reset-grants?accountId=...` | Read the Claude usage-limit reset grants of one Anthropic OAuth account: eligibility, each grant's resets left, validity window, and the windows it clears, plus any unconfirmed attempt still retryable | 400 no matching account; 401 re-authentication needed; 502 upstream unavailable | | `POST /api/anthropic/reset-grants/consume` | Spend one reset grant. Body `{ accountId, grantId, operationId }`; `operationId` is a UUIDv4 sent upstream as the request ID, so repeating it retries the same claim. Requires a dashboard session. | 400 invalid body; 401 re-authentication needed; 403 `session_required`; 409 `grant_not_usable`, `in_flight`, `unresolved_prior_operation`, `unknown_outcome_expired`, `operation_identity_mismatch`; 500 `journal_write_failed`; 502 `unknown_outcome`; 503 journal busy, unavailable, or full | | `GET, PUT /api/claude-desktop` | Read or persist the Claude Desktop routed/native profile | 400 invalid or unavailable assignment | @@ -91,12 +91,13 @@ route-specific results rather than repeating this table. | `GET /api/claude-desktop/status` | Inspect saved-versus-applied profile and Desktop health | 400 status read failure | | `GET, PUT /api/claude-code` | Read or update Claude Code gateway, auth-mode, model-map, context, agent, and sidecar settings | 400 invalid field or shape | -The dashboard drives both coupon paths from **Providers > xAI Grok > Accounts**: each -signed-in account row carries a ticket badge with its remaining coupon count, and the -badge opens a dialog that lists validity windows and redeems the coupon closest to -expiry. The dialog sends a client-minted `operationId`, and it stops sending after a -timeout instead of retrying, because a redemption whose journal record is still open -would execute again. `ocx account grok-reset-coupons` remains the terminal equivalent. +`tokenId`, when supplied, must be a non-empty string; invalid values return `400 invalid_token_id` before credentials are read or the ledger is opened. Within the 30-day retention window, a same-`operationId` retry that omits `accountId` uses the recorded account, even if the active account changed. An executable `open` retry that omits `tokenId` uses its recorded token. An explicit different account or token is still refused. A definitive failure clears a matching speculative pending hold in the dashboard, allowing a new explicitly confirmed attempt; actual unknown outcomes remain held. + +An attempt is durably claimed before redemption. Only a confirmed upstream redemption response can record `redeemed`; a coupon missing from the remaining list does not prove this operation succeeded. After a timeout or missing settlement, retries inspect availability without redeeming again and return `attempt_unresolved`; a recent attempt returns `attempt_in_progress`. A late inspection refusal settles only an operation still `open`. In the same transaction, an identity-matching definitive terminal winner is replayed with HTTP 200 and its original code; an unresolved or identity-mismatched winner returns `operation_state_changed`, preserving same-ID recovery guidance. An unreadable or locked ledger returns `ledger_unavailable` without dispatching a redemption. + +An unconfirmed redemption returns HTTP 502 with `error.code: "attempt_unresolved"` and the effective `operationId`. Preserve that ID; a replacement ID would start a different operation. The CLI creates the ID before sending and prints `--operation-id` guidance if delivery or the response is uncertain. Legacy version-one `open` records and unconfirmed `redeem_failed` records are quarantined as uncertain; they never authorize another redemption. + +The dashboard at **Providers > xAI Grok > Accounts** lists remaining coupons and their validity windows from each account’s ticket badge. It sends a client-minted `operationId`. After uncertain delivery or an unknown-outcome response, the account controller retains that attempt while it remains mounted, including across dialog close/reopen. Availability refreshes issue GET only and never authorize another consume POST. `ocx account grok-reset-coupons` is the terminal equivalent. 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 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 c4d46c323ee..cab7eb7ceb3 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -80,7 +80,7 @@ GUI-сессия в стиле loopback не выпускается. | `PUT /api/grok/selection` | Сохранить список исключённых моделей Grok | 400 invalid or oversized selection | | `POST /api/grok/apply` | Применить сохранённую конфигурацию Grok через managed sync | 409 `grok_apply_busy`; 400/500 apply failure | | `GET /api/grok/reset-coupons?accountId=...` | Прочитать оставшиеся токены сброса биллинга Grok и окна их действия для активного или указанного аккаунта xAI | 400 отсутствует аккаунт; 401 без аутентификации; 502 ошибка upstream gRPC-Web | -| `POST /api/grok/reset-coupons/consume` | Обменять подходящий купон сброса. Тело `{ accountId?, tokenId?, operationId? }`. Необязательный `operationId` (UUIDv4) делает обмен идемпотентным: повтор того же идентификатора воспроизводит сохраненный результат без повторного обмена. | 400 некорректные JSON/UUID; 401 без аутентификации; 409 `identity_mismatch`; 502 ошибка upstream; 503 емкость реестра | +| `POST /api/grok/reset-coupons/consume` | Обменять подходящий купон сброса. Тело `{ accountId?, tokenId?, operationId? }`. Необязательный `operationId` (UUIDv4) делает обмен идемпотентным: повтор того же идентификатора воспроизводит сохраненный результат без повторного обмена. | 400 некорректные JSON/UUID; 401 без аутентификации; 409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved`; 502 ошибка upstream; 503 емкость реестра / `ledger_unavailable`; 500 `attempt_mark_failed` | | `GET /api/anthropic/reset-grants?accountId=...` | Прочитать сбросы лимитов Claude для одного OAuth-аккаунта Anthropic: доступность, число оставшихся сбросов для каждого гранта, срок действия и сбрасываемые окна, а также неподтверждённую попытку, которую ещё можно повторить | 400 подходящий аккаунт не найден; 401 требуется повторный вход; 502 вышестоящий сервис недоступен | | `POST /api/anthropic/reset-grants/consume` | Использовать один сброс. Тело `{ accountId, grantId, operationId }`; `operationId` — UUIDv4, который передаётся вышестоящему сервису как идентификатор запроса, поэтому повтор с ним возобновляет ту же попытку. Требуется сессия дашборда. | 400 некорректное тело; 401 требуется повторный вход; 403 `session_required`; 409 `grant_not_usable`, `in_flight`, `unresolved_prior_operation`, `unknown_outcome_expired`, `operation_identity_mismatch`; 500 `journal_write_failed`; 502 `unknown_outcome`; 503 журнал занят, недоступен или заполнен | | `GET, PUT /api/claude-desktop` | Прочитать или сохранить routed/native-профиль Claude Desktop | 400 invalid or unavailable assignment | @@ -88,13 +88,13 @@ GUI-сессия в стиле loopback не выпускается. | `GET /api/claude-desktop/status` | Проверить согласованность saved-vs-applied profile и здоровье Desktop | 400 status read failure | | `GET, PUT /api/claude-code` | Прочитать или обновить настройки gateway, auth-mode, model-map, context, agent и sidecar для Claude Code | 400 invalid field or shape | -Дашборд управляет обоими путями купонов из **Providers > xAI Grok > Accounts**: каждая -строка вошедшего аккаунта несёт значок-билет с числом оставшихся купонов, а значок -открывает диалог, который показывает окна действия и обменивает купон, ближайший -к истечению срока. Диалог отправляет сгенерированный клиентом `operationId` и после -таймаута прекращает отправку вместо повторной попытки, потому что обмен, запись -журнала которого ещё открыта, выполнился бы снова. `ocx account grok-reset-coupons` -остаётся эквивалентом в терминале. +Если указан `tokenId`, он должен быть непустой строкой; иначе возвращается `400 invalid_token_id` до чтения учётных данных или открытия журнала. В течение 30 дней хранения повтор того же `operationId` без `accountId` использует записанный аккаунт, даже если активный аккаунт сменился. Исполняемый повтор операции `open` без `tokenId` использует записанный токен. Другой явно указанный аккаунт или токен по-прежнему отклоняется. После окончательной ошибки панель снимает соответствующую предварительную блокировку ожидающего запроса и разрешает новую попытку с явным подтверждением; действительно неопределённые результаты остаются заблокированными. + +Попытка фиксируется в реестре до обмена. Код `redeemed` записывается только после подтвержденного ответа об успешном обмене от upstream; исчезновение купона из списка не доказывает успех этой операции. После тайм-аута или ошибки сохранения результата повторный запрос только проверяет доступность, не выполняет обмен снова и возвращает `attempt_unresolved`. Недавняя попытка возвращает `attempt_in_progress`. Отказ по результату запоздалой проверки записывается только для операции в состоянии `open`. В той же транзакции подтвержденный окончательный результат с совпадающими учетной записью и токеном воспроизводится с HTTP 200 и исходным кодом. Если результат не подтвержден или идентификаторы не совпадают, возвращается `operation_state_changed`; для восстановления сохраните тот же идентификатор операции. Недоступный или заблокированный реестр возвращает `ledger_unavailable` без обмена. + +При неподтвержденном обмене возвращаются HTTP 502, `error.code: "attempt_unresolved"` и фактический `operationId`. Сохраните этот идентификатор: новый ID начнет другую операцию. CLI создает ID до отправки и при неопределенной доставке или ответе предлагает сохранить тот же `--operation-id`. Старые записи `open` реестра версии один и неподтвержденные `redeem_failed` изолируются как неопределенные попытки и не разрешают повторный обмен. + +В **Providers > xAI Grok > Accounts** значок-билет каждого аккаунта показывает оставшиеся купоны и сроки действия. Диалог отправляет созданный клиентом `operationId`. При неопределенной доставке или результате контроллер аккаунта сохраняет ту же попытку, пока остается смонтированным, включая закрытие и повторное открытие диалога. Обновление использует только GET и не разрешает новый POST обмена. Терминальный эквивалент — `ocx account grok-reset-coupons`. Сбросы лимитов Claude аналогично доступны в **Providers > Anthropic > Accounts**. В строке каждого вошедшего аккаунта значок-билет показывает число оставшихся сбросов, а диалог использует один сброс после дополнительного подтверждения. Сброс восстанавливает 5-часовой и недельный лимиты, не меняя день недельного сброса. Если ответ на запрос не приходит, диалог сохраняет `operationId` и в течение десяти минут предлагает повторить попытку с тем же идентификатором — так восстанавливает запрос и сам клиент Claude Code. До этого момента новая операция с тем же грантом отклоняется. Использовать сброс можно только через дашборд: одного токена администратора недостаточно — ответом будет `403 session_required`. 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 5233d4ce458..a27b257a88b 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -84,7 +84,7 @@ hatalar" sütunu bu tabloyu tekrarlamak yerine rotaya özgü ek sonuçları list | `PUT /api/grok/selection` | Hariç tutulan Grok modellerini kalıcı hale getirin | 400 geçersiz veya aşırı büyük seçim | | `POST /api/grok/apply` | Kalıcı hale getirilen Grok yapılandırmasını yönetilen senkronizasyon aracılığıyla uygulayın | 409 `grok_apply_busy`; 400/500 uygulama hatası | | `GET /api/grok/reset-coupons?accountId=...` | Aktif veya belirtilen xAI hesabı için kalan Grok faturalandırma sıfırlama jetonlarını ve geçerlilik pencerelerini okuyun | 400 eksik hesap; 401 kimlik doğrulaması yok; 502 yukarı akış gRPC-Web hatası | -| `POST /api/grok/reset-coupons/consume` | Uygun bir sıfırlama kuponunu kullanın. Gövde `{ accountId?, tokenId?, operationId? }`. İsteğe bağlı `operationId` (UUIDv4) kullanımı idempotent yapar: aynı kimliği yinelemek, çift kullanım olmadan kalıcı sonucu yeniden oynatır. | 400 geçersiz JSON/UUID; 401 kimlik doğrulaması yok; 409 `identity_mismatch`; 502 yukarı akış hatası; 503 kayıt defteri kapasitesi | +| `POST /api/grok/reset-coupons/consume` | Uygun bir sıfırlama kuponunu kullanın. Gövde `{ accountId?, tokenId?, operationId? }`. İsteğe bağlı `operationId` (UUIDv4) kullanımı idempotent yapar: aynı kimliği yinelemek, çift kullanım olmadan kalıcı sonucu yeniden oynatır. | 400 geçersiz JSON/UUID; 401 kimlik doğrulaması yok; 409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved`; 502 yukarı akış hatası; 503 kayıt defteri kapasitesi / `ledger_unavailable`; 500 `attempt_mark_failed` | | `GET /api/anthropic/reset-grants?accountId=...` | Bir Anthropic OAuth hesabının Claude kullanım limiti sıfırlama haklarını okuyun: uygunluk durumu, her hakkın kalan sıfırlama sayısı, geçerlilik aralığı ve sıfırladığı pencereler ile hâlâ yeniden denenebilen doğrulanmamış girişimler | 400 eşleşen hesap yok; 401 yeniden kimlik doğrulaması gerekli; 502 yukarı akış kullanılamıyor | | `POST /api/anthropic/reset-grants/consume` | Bir sıfırlama hakkı kullanın. Gövde `{ accountId, grantId, operationId }`; `operationId`, istek kimliği olarak yukarı akışa gönderilen bir UUIDv4'tür; aynı kimliği yinelemek aynı talebi yeniden dener. Kontrol paneli oturumu gerektirir. | 400 geçersiz gövde; 401 yeniden kimlik doğrulaması gerekli; 403 `session_required`; 409 `grant_not_usable`, `in_flight`, `unresolved_prior_operation`, `unknown_outcome_expired`, `operation_identity_mismatch`; 500 `journal_write_failed`; 502 `unknown_outcome`; 503 günlük meşgul, kullanılamıyor veya dolu | | `GET, PUT /api/claude-desktop` | Claude Desktop yönlendirilen/yerel profilini okuyun veya kalıcı hale getirin | 400 geçersiz veya kullanılamaz atama | @@ -92,14 +92,13 @@ hatalar" sütunu bu tabloyu tekrarlamak yerine rotaya özgü ek sonuçları list | `GET /api/claude-desktop/status` | Kaydedilen ve uygulanan profili ve Desktop sağlığını inceleyin | 400 durum okuma hatası | | `GET, PUT /api/claude-code` | Claude Code ağ geçidi, kimlik doğrulama modu, model haritası, bağlam, ajan ve sidecar ayarlarını okuyun veya güncelleyin | 400 geçersiz alan veya şekil | -Kontrol paneli her iki kupon yolunu da **Providers > xAI Grok > Accounts** -üzerinden yürütür: oturum açmış her hesap satırı, kalan kupon sayısını gösteren -bir bilet rozeti taşır ve rozet, geçerlilik pencerelerini listeleyen ve süresi -dolmaya en yakın kuponu kullanan bir iletişim kutusu açar. İletişim kutusu -istemci tarafından üretilen bir `operationId` gönderir ve yeniden denemek yerine -zaman aşımından sonra göndermeyi durdurur; çünkü günlük kaydı hâlâ açık olan -bir kullanım yeniden yürütülür. `ocx account grok-reset-coupons` uçbirim -eşdeğeri olarak kalır. +`tokenId` gönderilirse boş olmayan bir dize olmalıdır; geçersiz değerler kimlik bilgileri okunmadan veya günlük açılmadan `400 invalid_token_id` ile reddedilir. 30 günlük saklama süresinde aynı `operationId` ile `accountId` olmadan yeniden deneme, etkin hesap değişse bile kayıtlı hesabı kullanır. Çalıştırılabilir bir `open` işlemi `tokenId` olmadan yeniden denenirse kayıtlı token kullanılır. Açıkça belirtilen farklı hesap veya token yine reddedilir. Kesin bir hatadan sonra pano, bekleyen isteğe ait geçici engeli kaldırarak yeni bir denemenin açıkça onaylanmasına izin verir; sonucu gerçekten belirsiz olan denemeler engelli kalır. + +Deneme, kullanımdan önce deftere kalıcı olarak kaydedilir. Yalnızca üst hizmetten doğrulanmış başarılı kullanım yanıtı alınırsa `redeemed` kaydedilir; kuponun listeden kaybolması bu işlemin başarılı olduğunu kanıtlamaz. Zaman aşımı veya sonuç kaydı hatasından sonra yeniden deneme yalnızca kullanılabilirliği sorgular, kuponu tekrar kullanmaz ve `attempt_unresolved` döndürür. Yakın tarihli deneme `attempt_in_progress` döndürür. Geç tamamlanan incelemenin reddi yalnızca hâlâ `open` olan bir işleme yazılır. Aynı işlem içinde, hesap ve kupon kimliği eşleşen kesin nihai sonuç HTTP 200 ve özgün koduyla yeniden oynatılır. Sonuç belirsizse veya kimlikler eşleşmiyorsa `operation_state_changed` döner; kurtarma için aynı işlem kimliğini koruyun. Okunamayan veya kilitlenemeyen defter, kullanım yapmadan `ledger_unavailable` döndürür. + +Doğrulanamayan kullanım HTTP 502, `error.code: "attempt_unresolved"` ve geçerli `operationId` döndürür. Bu kimliği koruyun; yeni kimlik farklı bir işlem başlatır. CLI kimliği göndermeden önce üretir ve teslimat veya yanıt belirsizse aynı `--operation-id` değerini koruma yönergesi verir. Birinci sürüm defterdeki eski `open` kayıtları ve doğrulanmamış `redeem_failed` kayıtları belirsiz denemeler olarak karantinaya alınır; yeniden kullanıma izin vermezler. + +**Providers > xAI Grok > Accounts** bölümünde her hesabın bilet rozeti kalan kuponları ve geçerlilik aralıklarını gösterir. İletişim kutusu istemcinin ürettiği bir `operationId` gönderir. Teslimat veya sonuç belirsizse hesap denetleyicisi bağlı kaldığı sürece, iletişim kutusu kapatılıp yeniden açılsa bile aynı denemeyi korur. Yenileme yalnızca GET gönderir ve başka bir kullanım POST isteğine izin vermez. Uçbirim karşılığı `ocx account grok-reset-coupons` komutudur. 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 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 5add376205e..11003504724 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 @@ -65,7 +65,7 @@ Authorization: Bearer | `PUT /api/grok/selection` | 持久化被排除的 Grok 模型 | 400 选择无效或超出大小限制 | | `POST /api/grok/apply` | 通过托管同步应用已持久化的 Grok 配置 | 409 `grok_apply_busy`;400/500 应用失败 | | `GET /api/grok/reset-coupons?accountId=...` | 读取活跃或指定 xAI 账号剩余的 Grok 计费重置 token 及有效期窗口 | 400 缺少账号;401 未认证;502 上游 gRPC-Web 错误 | -| `POST /api/grok/reset-coupons/consume` | 兑换一个符合条件的重置优惠券。请求体为 `{ accountId?, tokenId?, operationId? }`。可选的 `operationId`(UUIDv4)让兑换具备幂等性:重复相同 id 会重放持久化结果,而不会重复兑换。 | 400 无效的 JSON/UUID;401 未认证;409 `identity_mismatch`;502 上游错误;503 ledger 容量 | +| `POST /api/grok/reset-coupons/consume` | 兑换一个符合条件的重置优惠券。请求体为 `{ accountId?, tokenId?, operationId? }`。可选的 `operationId`(UUIDv4)让兑换具备幂等性:重复相同 id 会重放持久化结果,而不会重复兑换。 | 400 无效的 JSON/UUID;401 未认证;409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved`;502 上游错误;503 ledger 容量 / `ledger_unavailable`; 500 `attempt_mark_failed` | | `GET /api/anthropic/reset-grants?accountId=...` | 读取一个 Anthropic OAuth 账号的 Claude 用量额度重置机会:是否符合条件、每项重置机会的剩余次数、有效期及可恢复的用量窗口,以及仍可重试的未确认请求 | 400 无匹配账号;401 需要重新认证;502 上游不可用 | | `POST /api/anthropic/reset-grants/consume` | 使用一次重置机会。请求体为 `{ accountId, grantId, operationId }`;`operationId` 是作为请求 ID 发送给上游的 UUIDv4,重复发送会重试同一次请求。需要仪表板会话。 | 400 请求体无效;401 需要重新认证;403 `session_required`;409 `grant_not_usable`、`in_flight`、`unresolved_prior_operation`、`unknown_outcome_expired`、`operation_identity_mismatch`;500 `journal_write_failed`;502 `unknown_outcome`;503 日志正忙、不可用或已满 | | `GET, PUT /api/claude-desktop` | 读取或持久化 Claude Desktop 的路由/原生配置文件 | 400 分配无效或不可用 | @@ -73,11 +73,13 @@ Authorization: Bearer | `GET /api/claude-desktop/status` | 检查已保存与已应用的配置文件以及 Desktop 健康状态 | 400 状态读取失败 | | `GET, PUT /api/claude-code` | 读取或更新 Claude Code 的网关、认证模式、模型映射、上下文、代理和 sidecar 设置 | 400 字段或结构无效 | -仪表板从 **Providers > xAI Grok > Accounts** 驱动这两条优惠券路径:每个已登录账号行 -都带有显示剩余优惠券数量的票据徽章,该徽章会打开一个对话框,列出有效期窗口并兑换 -最接近到期的优惠券。该对话框会发送客户端生成的 `operationId`,并在超时后停止发送而不是 -重试,因为 journal 记录仍处于打开状态的兑换会再次执行。`ocx account grok-reset-coupons` -仍然是对应的终端命令。 +如果提供 `tokenId`,它必须是非空字符串;无效值会在读取凭据或打开账本前返回 `400 invalid_token_id`。在 30 天保留期内,使用相同 `operationId` 重试且省略 `accountId` 时,即使当前账号已更改,也会使用记录的账号。可执行的 `open` 操作重试且省略 `tokenId` 时,会使用记录的令牌。明确指定不同账号或令牌仍会被拒绝。收到确定的失败后,仪表盘会清除该待处理请求对应的临时保留状态,允许明确确认新的尝试;真正未知的结果仍保持保留。 + +兑换前会将尝试持久记录在账本中。只有收到上游确认兑换成功的响应,才会记录 `redeemed`;券从列表中消失不能证明本次操作成功。超时或结果保存失败后,重试只查询可用性,不会再次兑换,并返回 `attempt_unresolved`;近期尝试返回 `attempt_in_progress`。迟到的检查拒绝只能写入仍处于 `open` 的操作。在同一事务中,账号与券标识符相符的已确认最终结果会以 HTTP 200 和原始代码重放。结果未确认或身份不匹配时返回 `operation_state_changed`,请保留相同操作 ID 以进行恢复。账本无法读取或锁定时返回 `ledger_unavailable`,不执行兑换。 + +无法确认兑换结果时,返回 HTTP 502、`error.code: "attempt_unresolved"` 和实际的 `operationId`。请保留该 ID;新 ID 会启动另一项操作。CLI 在发送前生成 ID,并在请求送达或响应不确定时提示保留同一个 `--operation-id`。第一版账本的旧 `open` 记录和结果未确认的 `redeem_failed` 记录会被隔离为不确定尝试,不允许再次兑换。 + +在 **Providers > xAI Grok > Accounts** 中,每个账号的票据徽章可查看剩余优惠券及有效期。对话框发送客户端生成的 `operationId`。请求送达或结果不确定时,账号控制器在保持挂载期间保留同一次尝试,关闭并重新打开对话框也会保留。刷新只发送 GET,不允许额外的兑换 POST。对应的终端命令是 `ocx account grok-reset-coupons`。 Claude 用量重置同样可以从 **Providers > Anthropic > Accounts** 操作。每个已登录账号行都带有显示剩余重置次数的票据徽章,对话框会在再次确认后使用一次重置机会。重置会恢复 5 小时和每周用量额度,但不会改变每周额度的重置日期。如果请求未及时返回结果,对话框会保留其 `operationId`,并在十分钟内提供使用同一 ID 重试的选项;Claude Code 客户端也以此方式恢复。在此期间,同一重置机会的新操作会被拒绝。重置机会只能通过仪表板使用:仅凭管理员令牌会收到 `403 session_required`。 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 a67def0e48a..eb63c0ec998 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 @@ -65,7 +65,7 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `PUT /api/grok/selection` | 持久化排除的 Grok 模型 | 400 無效或過大選擇 | | `POST /api/grok/apply` | 透過受管同步套用持久化的 Grok 設定 | 409 `grok_apply_busy`;400/500 套用失敗 | | `GET /api/grok/reset-coupons?accountId=...` | 讀取活躍或指定 xAI 帳號剩餘的 Grok 計費重置 token 與有效期間 | 400 缺失帳號;401 未認證;502 上游 gRPC-Web 錯誤 | -| `POST /api/grok/reset-coupons/consume` | 兌換一個合格的 reset coupon。請求主體為 `{ accountId?, tokenId?, operationId? }`。選用的 `operationId`(UUIDv4)可讓兌換具備冪等性:重複相同 id 會重播持久化結果,而不會重複兌換。 | 400 無效的 JSON/UUID;401 未認證;409 `identity_mismatch`;502 上游錯誤;503 ledger 容量 | +| `POST /api/grok/reset-coupons/consume` | 兌換一個合格的 reset coupon。請求主體為 `{ accountId?, tokenId?, operationId? }`。選用的 `operationId`(UUIDv4)可讓兌換具備冪等性:重複相同 id 會重播持久化結果,而不會重複兌換。 | 400 無效的 JSON/UUID;401 未認證;409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved`;502 上游錯誤;503 ledger 容量 / `ledger_unavailable`; 500 `attempt_mark_failed` | | `GET /api/anthropic/reset-grants?accountId=...` | 讀取單一 Anthropic OAuth 帳號的 Claude 用量額度重設機會:資格、每次重設的剩餘次數、有效期間及可清除的用量視窗,以及任何尚未確認但仍可重試的使用嘗試 | 400 找不到相符帳號;401 需要重新認證;502 上游無法使用 | | `POST /api/anthropic/reset-grants/consume` | 使用一次重設機會。請求主體為 `{ accountId, grantId, operationId }`;`operationId` 是傳送至上游作為請求 ID 的 UUIDv4,重複傳送即可重試同一次使用請求。需要儀表板工作階段。 | 400 無效的請求主體;401 需要重新認證;403 `session_required`;409 `grant_not_usable`、`in_flight`、`unresolved_prior_operation`、`unknown_outcome_expired`、`operation_identity_mismatch`;500 `journal_write_failed`;502 `unknown_outcome`;503 日誌忙碌、無法使用或已滿 | | `GET, PUT /api/claude-desktop` | 讀取或持久化 Claude Desktop 路由/原生設定檔 | 400 無效或不可用指派 | @@ -73,7 +73,13 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `GET /api/claude-desktop/status` | 檢查已儲存 vs 已套用設定檔與 Desktop 健康 | 400 狀態讀取失敗 | | `GET, PUT /api/claude-code` | 讀取或更新 Claude Code 閘道、auth-mode、model-map、context、agent 與 sidecar 設定 | 400 無效欄位或結構 | -儀表板從 **Providers > xAI Grok > Accounts** 驅動這兩條 coupon 路徑:每個已登入帳號列都帶有票券徽章,顯示剩餘的 reset coupon 數量,徽章會開啟對話框,列出有效期間並兌換最接近到期的 reset coupon。該對話框會送出由客戶端鑄造的 `operationId`,並在逾時後停止送出而不重試,因為日誌記錄仍為開啟的兌換會再次執行。`ocx account grok-reset-coupons` 仍是終端機等價指令。 +若提供 `tokenId`,它必須是非空字串;無效值會在讀取憑證或開啟帳本前回傳 `400 invalid_token_id`。在 30 天保留期內,以相同 `operationId` 重試且省略 `accountId` 時,即使目前帳號已變更,也會使用記錄的帳號。可執行的 `open` 操作重試且省略 `tokenId` 時,會使用記錄的權杖。明確指定不同帳號或權杖仍會被拒絕。收到確定的失敗後,儀表板會清除該待處理請求對應的暫時保留狀態,允許明確確認新的嘗試;真正未知的結果仍維持保留。 + +兌換前會將嘗試持久記錄在帳本中。只有收到上游確認兌換成功的回應,才會記錄 `redeemed`;券從清單中消失不能證明本次操作成功。逾時或結果儲存失敗後,重試只查詢可用性,不會再次兌換,並回傳 `attempt_unresolved`;近期嘗試回傳 `attempt_in_progress`。遲到的檢查拒絕只能寫入仍處於 `open` 的操作。在同一交易中,帳號與券識別碼相符的已確定最終結果會以 HTTP 200 和原始代碼重播。結果未確定或識別資訊不符時回傳 `operation_state_changed`,請保留相同操作 ID 以進行復原。帳本無法讀取或鎖定時回傳 `ledger_unavailable`,不執行兌換。 + +無法確認兌換結果時,回傳 HTTP 502、`error.code: "attempt_unresolved"` 和實際的 `operationId`。請保留此 ID;新 ID 會啟動另一項操作。CLI 在送出前產生 ID,並在請求送達或回應不確定時提示保留相同的 `--operation-id`。第一版帳本的舊 `open` 記錄與結果未確認的 `redeem_failed` 記錄會隔離為不確定嘗試,不允許再次兌換。 + +在 **Providers > xAI Grok > Accounts** 中,每個帳號的票券徽章可查看剩餘優惠券及有效期間。對話框送出客戶端產生的 `operationId`。請求送達或結果不確定時,帳號控制器在保持掛載期間保留同一次嘗試,關閉並重新開啟對話框也會保留。重新整理只送出 GET,不允許額外的兌換 POST。對應的終端指令是 `ocx account grok-reset-coupons`。 Claude 用量重設也可從 **Providers > Anthropic > Accounts** 以相同方式操作。每個已登入帳號列都有票券徽章,顯示剩餘重設次數;對話框會在第二次確認後使用一次重設機會。重設會補滿 5 小時與每週額度,但不會改變每週重設日。如果使用請求未收到回應,對話框會保留其 `operationId`,並在十分鐘內提供使用相同 ID 重試的選項;Claude Code 用戶端也以此方式復原。在此期間,系統會拒絕對同一重設機會發起新操作。重設機會只能透過儀表板使用:僅持有管理員權杖會收到 `403 session_required`。 diff --git a/gui/src/components/provider-workspace/GrokResetCoupons.tsx b/gui/src/components/provider-workspace/GrokResetCoupons.tsx index d0deca4fd0e..833a2e6d746 100644 --- a/gui/src/components/provider-workspace/GrokResetCoupons.tsx +++ b/gui/src/components/provider-workspace/GrokResetCoupons.tsx @@ -3,9 +3,8 @@ * * The dialog is deliberately conservative about the one irreversible thing it * does. It always names the coupon it is spending, it holds one client-minted - * operation id per confirmation, and when a redemption aborts it stops posting - * entirely: the route re-executes a redemption whose journal record is still - * open, so a retry after a timeout can spend a second coupon. + * operation id per confirmation. Unconfirmed delivery stops posting and keeps + * that attempt in the account controller across modal close/reopen. */ import { useCallback, useEffect, useRef, useState } from "react"; import { useI18n, type Locale, type TFn, type TKey } from "../../i18n/shared"; @@ -125,8 +124,8 @@ export function GrokResetCouponModal({ accountId, accountLabel, entry, controlle const [confirming, setConfirming] = useState(false); const [redeeming, setRedeeming] = useState(false); const [checking, setChecking] = useState(false); - /** Set by an aborted redemption; while it holds, the dialog posts nothing. */ - const [unknown, setUnknown] = useState<{ tokenId: string } | null>(null); + /** The controller clears this hold when the original request definitively completes. */ + const unknown = controller.uncertain[accountId]; const [outcome, setOutcome] = useState(null); const operationIdRef = useRef(undefined); @@ -179,9 +178,10 @@ export function GrokResetCouponModal({ accountId, accountLabel, entry, controlle setOutcome({ tone: "ok", key: result.replayed ? "grokCoupon.redeemReplayed" : "grokCoupon.redeemSuccess" }); return; } - if (result.code === "aborted") { + if (result.uncertain || result.code === "aborted") { + if (result.operationId) operationIdRef.current = result.operationId; // Outcome unknown: hold the id, stop posting, and let the user re-read. - setUnknown({ tokenId: next.tokenId }); + setConfirming(false); setOutcome(null); void controller.refresh(accountId); return; diff --git a/gui/src/hooks/useGrokResetCoupons.ts b/gui/src/hooks/useGrokResetCoupons.ts index 8bf4e86679a..9f88806b4a9 100644 --- a/gui/src/hooks/useGrokResetCoupons.ts +++ b/gui/src/hooks/useGrokResetCoupons.ts @@ -37,11 +37,17 @@ export interface GrokRedeemOutcome { /** Settled ledger code, or `aborted` when delivery may have succeeded. */ code: string; replayed: boolean; + operationId?: string; + uncertain?: boolean; } +export interface GrokCouponAttempt { tokenId: string; operationId: string } + export interface GrokResetCouponController { /** Absent id means "not read yet"; consumers render that as loading. */ entries: Record; + /** Held in the account controller so closing the modal cannot mint a new spend. */ + uncertain: Record; refresh: (accountId: string) => Promise; redeem: (accountId: string, request: { tokenId: string; operationId: string }) => Promise; } @@ -81,12 +87,12 @@ function errorCode(value: unknown): string { return "redeem_failed"; } -function settledCode(value: unknown): string { +function settledCode(value: unknown): string | null { if (value && typeof value === "object") { const code = (value as { code?: unknown }).code; if (typeof code === "string" && code !== "") return code; } - return "redeemed"; + return null; } function expiryRank(coupon: GrokResetCoupon): number { @@ -107,6 +113,9 @@ export function useGrokResetCoupons({ apiBase, accountIds, enabled }: { enabled: boolean; }): GrokResetCouponController { const [entries, setEntries] = useState>({}); + const [uncertain, setUncertain] = useState>({}); + const uncertainRef = useRef(new Map()); + const activeRedeems = useRef(new Map()); /** Roster epoch: bumped only by the effect and its cleanup. */ const epoch = useRef(0); /** Per-account request token, so one row's retry cannot cancel another row's read. */ @@ -175,6 +184,25 @@ export function useGrokResetCoupons({ apiBase, accountIds, enabled }: { accountId: string, request: { tokenId: string; operationId: string }, ): Promise => { + const hold = (attempt: GrokCouponAttempt, code = "attempt_unresolved"): GrokRedeemOutcome => { + uncertainRef.current.set(accountId, attempt); + setUncertain(current => ({ ...current, [accountId]: attempt })); + return { ok: false, code, replayed: false, operationId: attempt.operationId, uncertain: true }; + }; + const held = uncertainRef.current.get(accountId) ?? activeRedeems.current.get(accountId); + if (held) return hold(held); + activeRedeems.current.set(accountId, request); + const clearMatchingHold = () => { + // Once definitive, a follow-up GET must not make this attempt look active. + if (activeRedeems.current.get(accountId) === request) activeRedeems.current.delete(accountId); + if (uncertainRef.current.get(accountId)?.operationId === request.operationId) uncertainRef.current.delete(accountId); + setUncertain(current => { + if (current[accountId]?.operationId !== request.operationId) return current; + const next = { ...current }; + delete next[accountId]; + return next; + }); + }; const bounded = createBoundedFetch(REDEEM_TIMEOUT_MS); try { const response = await fetch(`${apiBase}/api/grok/reset-coupons/consume`, { @@ -184,21 +212,27 @@ export function useGrokResetCoupons({ apiBase, accountIds, enabled }: { signal: bounded.signal, }); if (!response.ok) { - return { ok: false, code: errorCode(await response.json().catch(() => null)), replayed: false }; + const code = errorCode(await response.json().catch(() => null)); + if (["attempt_unresolved", "attempt_in_progress", "attempt_reconcile_failed", "operation_state_changed", "redeem_failed"].includes(code)) return hold(request, code); + clearMatchingHold(); + return { ok: false, code, replayed: false }; } const data = await response.json().catch(() => null) as unknown; const replayed = Boolean(data && typeof data === "object" && (data as { replayed?: unknown }).replayed === true); const code = settledCode(data); + if (code === null) return hold(request); + clearMatchingHold(); await read(accountId, epoch.current); return { ok: code === "redeemed", code, replayed }; } catch { // Any transport rejection after dispatch has an unknown outcome: the route // may still be executing it. The caller must stop posting, not retry. - return { ok: false, code: "aborted", replayed: false }; + return hold(request, "aborted"); } finally { bounded.clear(); + if (activeRedeems.current.get(accountId) === request) activeRedeems.current.delete(accountId); } }, [apiBase, read]); - return { entries, refresh, redeem }; + return { entries, uncertain, refresh, redeem }; } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8ad26c35ab3..ead730e0520 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2113,11 +2113,11 @@ export const de: Record = { "grokCoupon.networkError": "Der Proxy war nicht erreichbar.", "grokCoupon.capacity": "Das Einlösungsjournal ist gerade voll. Versuche es in einem Moment erneut.", "grokCoupon.noOperationId": "Dieser Browser kann keine Einlösungs-ID erzeugen, daher ist das Einlösen hier deaktiviert.", - "grokCoupon.unknownOutcome": "Die Einlösung hat nicht rechtzeitig geantwortet, daher ist ihr Ergebnis unbekannt. Lies das Konto erneut, um zu sehen, ob der Coupon verbraucht wurde.", + "grokCoupon.unknownOutcome": "Das Ergebnis der Einlösung ist unbekannt. Lies das Konto erneut, um verfügbare Gutscheine zu prüfen; das bestätigt den ursprünglichen Versuch nicht.", "grokCoupon.checkResult": "Konto erneut lesen", "grokCoupon.checking": "Wird erneut gelesen…", - "grokCoupon.consumedElsewhere": "Der Coupon ist in diesem Konto nicht mehr vorhanden, die Einlösung ist also durchgegangen.", - "grokCoupon.unresolved": "Der Coupon ist noch aufgeführt. Die Einlösung läuft möglicherweise noch upstream — lies in einer Minute erneut, bevor du einen weiteren Coupon einlöst.", + "grokCoupon.consumedElsewhere": "Der Gutschein wird nicht mehr aufgeführt. Die ursprüngliche Einlösung bleibt unbestätigt; es wird keine weitere Einlösung gesendet.", + "grokCoupon.unresolved": "Der Gutschein wird noch aufgeführt. Die ursprüngliche Einlösung kann noch abgeschlossen werden; es wird keine weitere Einlösung gesendet.", "anthropicGrant.badgeAria": "{count} Claude-Nutzungslimit-Rücksetzungen", "anthropicGrant.badgeErrorAria": "Claude-Nutzungslimit-Rücksetzungen konnten nicht gelesen werden", "anthropicGrant.title": "Claude-Nutzungslimit-Rücksetzungen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ce81fed2972..c5a58528762 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2733,11 +2733,11 @@ export const en = { "grokCoupon.networkError": "The proxy could not be reached.", "grokCoupon.capacity": "The redemption journal is full right now. Try again in a moment.", "grokCoupon.noOperationId": "This browser cannot generate a redemption ID, so redeeming is disabled here.", - "grokCoupon.unknownOutcome": "The redemption did not answer in time, so its outcome is unknown. Re-read the account to see whether the coupon was spent.", + "grokCoupon.unknownOutcome": "The redemption outcome is unknown. Re-read the account to inspect coupon availability; this cannot confirm the original attempt.", "grokCoupon.checkResult": "Re-read account", "grokCoupon.checking": "Re-reading…", - "grokCoupon.consumedElsewhere": "The coupon is gone from this account, so the redemption went through.", - "grokCoupon.unresolved": "The coupon is still listed. The redemption may still be running upstream — re-read in a minute before spending another coupon.", + "grokCoupon.consumedElsewhere": "The coupon is no longer listed. The original redemption remains unconfirmed; no further redemption will be sent.", + "grokCoupon.unresolved": "The coupon is still listed. The original redemption may still complete; no further redemption will be sent.", "anthropicGrant.badgeAria": "{count} Claude usage reset(s)", "anthropicGrant.badgeErrorAria": "Claude usage resets could not be read", "anthropicGrant.title": "Claude usage resets", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a63d3c768fd..ed97f240e49 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2649,11 +2649,11 @@ export const fr: Record = { "grokCoupon.networkError": "Impossible de joindre le proxy.", "grokCoupon.capacity": "Le journal d’utilisation est saturé pour le moment. Réessayez dans un instant.", "grokCoupon.noOperationId": "Ce navigateur ne peut pas générer d’ID d’utilisation, l’utilisation est donc désactivée ici.", - "grokCoupon.unknownOutcome": "L’utilisation n’a pas répondu à temps, son résultat est donc inconnu. Relisez le compte pour voir si le coupon a été dépensé.", + "grokCoupon.unknownOutcome": "Le résultat de l’échange est inconnu. Relisez le compte pour consulter les coupons disponibles ; cela ne confirme pas la tentative initiale.", "grokCoupon.checkResult": "Relire le compte", "grokCoupon.checking": "Relecture…", - "grokCoupon.consumedElsewhere": "Le coupon a disparu de ce compte, l’utilisation a donc abouti.", - "grokCoupon.unresolved": "Le coupon figure encore dans la liste. L’utilisation est peut-être encore en cours en amont — relisez dans une minute avant d’utiliser un autre coupon.", + "grokCoupon.consumedElsewhere": "Le coupon ne figure plus dans la liste. L’échange initial reste non confirmé ; aucun nouvel échange ne sera envoyé.", + "grokCoupon.unresolved": "Le coupon figure encore dans la liste. L’échange initial peut encore aboutir ; aucun nouvel échange ne sera envoyé.", "anthropicGrant.badgeAria": "{count} réinitialisation(s) de l’utilisation de Claude", "anthropicGrant.badgeErrorAria": "Impossible de lire les réinitialisations de l’utilisation de Claude", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 1777cc26017..bf30b4bdb20 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2549,11 +2549,11 @@ export const ja: Record = { "grokCoupon.networkError": "プロキシに到達できませんでした。", "grokCoupon.capacity": "引き換えジャーナルが現在満杯です。しばらくしてから再試行してください。", "grokCoupon.noOperationId": "このブラウザでは引き換え ID を生成できないため、ここでは引き換えできません。", - "grokCoupon.unknownOutcome": "引き換えの応答が間に合わなかったため、結果は不明です。アカウントを再読み込みして、クーポンが消費されたかどうかを確認してください。", + "grokCoupon.unknownOutcome": "使用結果は不明です。アカウントを再読み込みしてクーポンの有無を確認できますが、元の試行の結果は確定できません。", "grokCoupon.checkResult": "アカウントを再読み込み", "grokCoupon.checking": "再読み込み中…", - "grokCoupon.consumedElsewhere": "このアカウントからクーポンがなくなっているため、引き換えは完了しています。", - "grokCoupon.unresolved": "クーポンはまだ一覧にあります。上流で引き換えが進行中の可能性があります。別のクーポンを使う前に、しばらくしてから再読み込みしてください。", + "grokCoupon.consumedElsewhere": "クーポンは一覧にありません。元の使用結果は未確認のままで、追加の使用要求は送信しません。", + "grokCoupon.unresolved": "クーポンはまだ一覧にあります。元の使用要求が後から完了する可能性があるため、追加の使用要求は送信しません。", "anthropicGrant.badgeAria": "Claude の使用量リセット {count} 回分", "anthropicGrant.badgeErrorAria": "Claude の使用量リセットを読み取れませんでした", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7e295a542c9..24604d54a51 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2158,11 +2158,11 @@ export const ko: Record = { "grokCoupon.networkError": "프록시에 연결할 수 없습니다.", "grokCoupon.capacity": "지금은 사용 저널이 가득 찼습니다. 잠시 후 다시 시도하세요.", "grokCoupon.noOperationId": "이 브라우저는 사용 ID를 생성할 수 없어 여기서는 쿠폰을 사용할 수 없습니다.", - "grokCoupon.unknownOutcome": "사용 요청이 제때 응답하지 않아 결과를 알 수 없습니다. 계정을 다시 읽어 쿠폰이 사용되었는지 확인하세요.", + "grokCoupon.unknownOutcome": "사용 결과를 알 수 없습니다. 계정을 다시 읽어 쿠폰 목록을 확인할 수 있지만, 원래 요청의 성공 여부는 확인되지 않습니다.", "grokCoupon.checkResult": "계정 다시 읽기", "grokCoupon.checking": "다시 읽는 중…", - "grokCoupon.consumedElsewhere": "이 계정에서 쿠폰이 없어졌으므로 사용이 완료되었습니다.", - "grokCoupon.unresolved": "쿠폰이 아직 목록에 있습니다. 업스트림에서 사용이 계속 진행 중일 수 있습니다 — 다른 쿠폰을 쓰기 전에 잠시 후 다시 읽으세요.", + "grokCoupon.consumedElsewhere": "쿠폰이 목록에서 사라졌습니다. 원래 사용 요청의 결과는 여전히 확인되지 않았으며 추가 사용 요청은 보내지 않습니다.", + "grokCoupon.unresolved": "쿠폰이 아직 목록에 있습니다. 원래 사용 요청이 나중에 완료될 수 있으므로 추가 사용 요청은 보내지 않습니다.", "anthropicGrant.badgeAria": "Claude 사용량 리셋 {count}개", "anthropicGrant.badgeErrorAria": "Claude 사용량 리셋을 읽지 못했습니다", "anthropicGrant.title": "Claude 사용량 리셋", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index b76dfd4d823..e6507b00798 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2694,11 +2694,11 @@ export const pt: Record = { "grokCoupon.networkError": "Não foi possível alcançar o proxy.", "grokCoupon.capacity": "O registro de resgates está cheio no momento. Tente novamente em instantes.", "grokCoupon.noOperationId": "Este navegador não consegue gerar um ID de resgate, então o resgate está desativado aqui.", - "grokCoupon.unknownOutcome": "O resgate não respondeu a tempo, então seu resultado é desconhecido. Releia a conta para ver se o cupom foi gasto.", + "grokCoupon.unknownOutcome": "O resultado do resgate é desconhecido. Releia a conta para consultar os cupons disponíveis; isso não confirma a tentativa original.", "grokCoupon.checkResult": "Reler conta", "grokCoupon.checking": "Relendo…", - "grokCoupon.consumedElsewhere": "O cupom não está mais nesta conta, então o resgate foi concluído.", - "grokCoupon.unresolved": "O cupom ainda aparece na lista. O resgate pode ainda estar em andamento no upstream — releia em um minuto antes de gastar outro cupom.", + "grokCoupon.consumedElsewhere": "O cupom não está mais na lista. O resgate original continua sem confirmação; nenhum novo resgate será enviado.", + "grokCoupon.unresolved": "O cupom ainda está na lista. O resgate original ainda pode ser concluído; nenhum novo resgate será enviado.", "anthropicGrant.badgeAria": "{count} reset(s) de uso do Claude", "anthropicGrant.badgeErrorAria": "Não foi possível ler os resets de uso do Claude", "anthropicGrant.title": "Resets de uso do Claude", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index dbb43b8da43..336484c23b4 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2640,11 +2640,11 @@ export const ru: Record = { "grokCoupon.networkError": "Не удалось связаться с прокси.", "grokCoupon.capacity": "Журнал использования сейчас заполнен. Попробуйте ещё раз чуть позже.", "grokCoupon.noOperationId": "Этот браузер не может создать идентификатор использования, поэтому здесь использование купонов отключено.", - "grokCoupon.unknownOutcome": "Ответ по использованию не пришёл вовремя, поэтому результат неизвестен. Перечитайте аккаунт, чтобы узнать, был ли купон потрачен.", + "grokCoupon.unknownOutcome": "Результат обмена неизвестен. Перечитайте аккаунт, чтобы проверить доступные купоны; это не подтверждает исходную попытку.", "grokCoupon.checkResult": "Перечитать аккаунт", "grokCoupon.checking": "Перечитывание…", - "grokCoupon.consumedElsewhere": "Купона больше нет в этом аккаунте — значит, использование прошло успешно.", - "grokCoupon.unresolved": "Купон всё ещё в списке. Использование, возможно, ещё выполняется на стороне вышестоящего сервиса — через минуту перечитайте аккаунт, прежде чем тратить другой купон.", + "grokCoupon.consumedElsewhere": "Купон больше не отображается в списке. Исходный обмен остается неподтвержденным; новый запрос обмена отправлен не будет.", + "grokCoupon.unresolved": "Купон еще отображается в списке. Исходный обмен еще может завершиться; новый запрос обмена отправлен не будет.", "anthropicGrant.badgeAria": "Сбросы лимитов Claude: {count}", "anthropicGrant.badgeErrorAria": "Не удалось прочитать сбросы лимитов Claude", "anthropicGrant.title": "Сбросы лимитов Claude", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7544549a08e..c7409031b68 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2668,11 +2668,11 @@ export const tr: Record = { "grokCoupon.networkError": "Proxy'ye ulaşılamadı.", "grokCoupon.capacity": "Kullanım günlüğü şu anda dolu. Biraz sonra tekrar deneyin.", "grokCoupon.noOperationId": "Bu tarayıcı bir kullanım kimliği üretemiyor, bu yüzden kupon kullanma burada devre dışı.", - "grokCoupon.unknownOutcome": "Kullanım zamanında yanıt vermedi, bu yüzden sonucu bilinmiyor. Kuponun harcanıp harcanmadığını görmek için hesabı yeniden okuyun.", + "grokCoupon.unknownOutcome": "Kullanım sonucu bilinmiyor. Kullanılabilir kuponları görmek için hesabı yeniden okuyun; bu, ilk denemeyi doğrulamaz.", "grokCoupon.checkResult": "Hesabı yeniden oku", "grokCoupon.checking": "Yeniden okunuyor…", - "grokCoupon.consumedElsewhere": "Kupon bu hesapta artık yok, yani kullanım gerçekleşmiş.", - "grokCoupon.unresolved": "Kupon hâlâ listede. Kullanım üst akışta hâlâ sürüyor olabilir — başka bir kupon harcamadan önce bir dakika sonra yeniden okuyun.", + "grokCoupon.consumedElsewhere": "Kupon artık listede yok. İlk kullanım hâlâ doğrulanmadı; başka kullanım isteği gönderilmeyecek.", + "grokCoupon.unresolved": "Kupon hâlâ listede. İlk kullanım daha sonra tamamlanabilir; başka kullanım isteği gönderilmeyecek.", "anthropicGrant.badgeAria": "{count} Claude kullanım sıfırlaması", "anthropicGrant.badgeErrorAria": "Claude kullanım sıfırlamaları okunamadı", "anthropicGrant.title": "Claude Kullanım Sıfırlamaları", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 6d5e54f6576..0c4f69a023b 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2669,11 +2669,11 @@ export const vi: Record = { "grokCoupon.networkError": "Không thể kết nối tới proxy.", "grokCoupon.capacity": "Nhật ký đổi phiếu hiện đã đầy. Hãy thử lại sau giây lát.", "grokCoupon.noOperationId": "Trình duyệt này không thể tạo ID đổi phiếu, nên tính năng đổi phiếu bị tắt tại đây.", - "grokCoupon.unknownOutcome": "Lần đổi phiếu không phản hồi kịp thời nên chưa rõ kết quả. Hãy đọc lại tài khoản để xem phiếu đã được dùng hay chưa.", + "grokCoupon.unknownOutcome": "Chưa rõ kết quả đổi phiếu. Đọc lại tài khoản để xem phiếu còn khả dụng; điều này không xác nhận lần thử ban đầu.", "grokCoupon.checkResult": "Đọc lại tài khoản", "grokCoupon.checking": "Đang đọc lại…", - "grokCoupon.consumedElsewhere": "Phiếu không còn trong tài khoản này, nên việc đổi phiếu đã thành công.", - "grokCoupon.unresolved": "Phiếu vẫn còn trong danh sách. Việc đổi có thể vẫn đang chạy ở upstream — hãy đọc lại sau một phút trước khi dùng phiếu khác.", + "grokCoupon.consumedElsewhere": "Phiếu không còn trong danh sách. Lần đổi ban đầu vẫn chưa được xác nhận; sẽ không gửi thêm yêu cầu đổi.", + "grokCoupon.unresolved": "Phiếu vẫn còn trong danh sách. Lần đổi ban đầu có thể hoàn tất sau; sẽ không gửi thêm yêu cầu đổi.", "anthropicGrant.badgeAria": "{count} lần đặt lại mức sử dụng Claude", "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", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 561d41d5065..2f282506185 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1979,11 +1979,11 @@ export const zhTW: Record = { "grokCoupon.networkError": "無法連線到代理。", "grokCoupon.capacity": "兌換日誌目前已滿。請稍後再試。", "grokCoupon.noOperationId": "此瀏覽器無法產生兌換 ID,因此此處無法兌換。", - "grokCoupon.unknownOutcome": "兌換未及時回應,結果未知。請重新讀取帳號以確認券是否已被消耗。", + "grokCoupon.unknownOutcome": "兌換結果未知。重新讀取帳號可查看優惠券清單,但無法確認原始嘗試的結果。", "grokCoupon.checkResult": "重新讀取帳號", "grokCoupon.checking": "正在重新讀取…", - "grokCoupon.consumedElsewhere": "此帳號已沒有該券,表示兌換已成功。", - "grokCoupon.unresolved": "該券仍在列表中。上游兌換可能仍在進行 — 請等一分鐘後再重新讀取,再使用另一張券。", + "grokCoupon.consumedElsewhere": "優惠券已不在清單中。原始兌換結果仍未確認,不會再送出兌換請求。", + "grokCoupon.unresolved": "優惠券仍在清單中。原始兌換可能稍後完成,不會再送出兌換請求。", "anthropicGrant.badgeAria": "{count} 次 Claude 用量重設", "anthropicGrant.badgeErrorAria": "無法讀取 Claude 用量重設次數", "anthropicGrant.title": "Claude 用量重設", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 55cd5988453..5bdde069560 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2131,11 +2131,11 @@ export const zh: Record = { "grokCoupon.networkError": "无法连接到代理。", "grokCoupon.capacity": "兑换日志目前已满。请稍后再试。", "grokCoupon.noOperationId": "此浏览器无法生成兑换 ID,因此无法在此兑换。", - "grokCoupon.unknownOutcome": "兑换未及时返回结果,因此结果未知。请重新读取账号,确认该券是否已被使用。", + "grokCoupon.unknownOutcome": "兑换结果未知。重新读取账号可查看优惠券列表,但无法确认原始尝试的结果。", "grokCoupon.checkResult": "重新读取账号", "grokCoupon.checking": "正在重新读取…", - "grokCoupon.consumedElsewhere": "此账号中已没有该券,说明兑换已成功。", - "grokCoupon.unresolved": "该券仍在列表中。上游兑换可能仍在进行 — 请一分钟后再重新读取,再使用另一张券。", + "grokCoupon.consumedElsewhere": "优惠券已不在列表中。原始兑换结果仍未确认,不会再发送兑换请求。", + "grokCoupon.unresolved": "优惠券仍在列表中。原始兑换可能稍后完成,不会再发送兑换请求。", "anthropicGrant.badgeAria": "{count} 次 Claude 用量重置", "anthropicGrant.badgeErrorAria": "无法读取 Claude 用量重置次数", "anthropicGrant.title": "Claude 用量重置", diff --git a/gui/tests/grok-reset-coupons.test.tsx b/gui/tests/grok-reset-coupons.test.tsx index e8f10cff324..1a7de6d7cdf 100644 --- a/gui/tests/grok-reset-coupons.test.tsx +++ b/gui/tests/grok-reset-coupons.test.tsx @@ -276,10 +276,161 @@ test("a transport-rejected redemption stops posting, re-reads the account, and o expect(harness.reads.filter(id => id === "acct-a").length).toBe(2); expect([...host.querySelectorAll(".modal-card button")].some(b => (b.textContent ?? "").includes("Use coupon"))).toBe(false); + harness.coupons.set("acct-a", []); await act(async () => { buttonWithText(host, "Re-read account").click(); await flush(); }); expect(harness.consumes.length).toBe(1); + expect(dialogText(host)).toContain("original redemption remains unconfirmed"); + expect(dialogText(host)).not.toContain("redemption went through"); + await act(async () => { buttonWithText(host, "Close").click(); await flush(); }); + await openDialog(host); + expect(dialogText(host)).toContain("outcome is unknown"); + expect([...host.querySelectorAll(".modal-card button")].some(b => (b.textContent ?? "").includes("Use 1 coupon"))).toBe(false); +}); + +for (const [code, status] of [["attempt_unresolved", 502], ["redeem_failed", 502], ["attempt_in_progress", 409], ["attempt_reconcile_failed", 502], ["operation_state_changed", 409], ["missing_code", 200]] as const) { + test(`a returned ${code} stays unknown across dialog close and reopen`, async () => { + harness.coupons.set("acct-a", [COUPON("restok_a1", 10)]); + harness.consumeReply = async () => json(code === "missing_code" ? { success: true } : { error: { code } }, status); + const host = await mountPanel([ACCOUNT("acct-a")]); + await openDialog(host); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + expect(dialogText(host)).toContain("outcome is unknown"); + const held = harness.consumes[0].operationId; + await act(async () => { buttonWithText(host, "Close").click(); await flush(); }); + await openDialog(host); + expect(dialogText(host)).toContain("outcome is unknown"); + await act(async () => { buttonWithText(host, "Re-read account").click(); await flush(); }); + expect(harness.consumes).toHaveLength(1); + expect(harness.consumes[0].operationId).toBe(held); + expect([...host.querySelectorAll(".modal-card button")].some(b => (b.textContent ?? "").includes("Use coupon"))).toBe(false); + }); +} + +test("closing and reopening during dispatch cannot post a new operation", async () => { + harness.coupons.set("acct-a", [COUPON("restok_a1", 10)]); + const finishers: Array<(response: Response) => void> = []; + harness.consumeReply = () => new Promise(resolve => { finishers.push(resolve); }); + const host = await mountPanel([ACCOUNT("acct-a")]); + await openDialog(host); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + try { + await act(async () => { host.querySelector(".modal-backdrop-dismiss")!.click(); await flush(); }); + await openDialog(host); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + expect(harness.consumes).toHaveLength(1); + } finally { + await act(async () => { for (const finish of finishers) finish(json({ code: "redeemed" })); await flush(); }); + } }); +for (const code of ["redeemed", "coupon_unavailable"] as const) { + test(`a reopened pending modal clears uncertainty immediately after ${code}`, async () => { + harness.coupons.set("acct-a", [COUPON("restok_a1", 10), COUPON("restok_a2", 20)]); + let finish!: (response: Response) => void; + harness.consumeReply = () => new Promise(resolve => { finish = resolve; }); + const host = await mountPanel([ACCOUNT("acct-a")]); + await openDialog(host); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + await act(async () => { host.querySelector(".modal-backdrop-dismiss")!.click(); await flush(); }); + await openDialog(host); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + expect(harness.consumes).toHaveLength(1); + expect(dialogText(host)).toContain("outcome is unknown"); + try { + await act(async () => { + if (code === "redeemed") harness.coupons.set("acct-a", [COUPON("restok_a2", 20)]); + finish(code === "redeemed" ? json({ code }) : json({ error: { code } }, 409)); + await flush(); + }); + expect(dialogText(host)).not.toContain("outcome is unknown"); + expect(buttonWithText(host, "Use 1 coupon").disabled).toBe(false); + harness.consumeReply = async () => json({ code: "redeemed" }); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + expect(harness.consumes).toHaveLength(2); + expect(harness.consumes[1].operationId).not.toBe(harness.consumes[0].operationId); + if (code === "redeemed") expect(harness.consumes[1].tokenId).toBe("restok_a2"); + } finally { + await act(async () => { finish(json({ code: "redeemed" })); await flush(); }); + } + }); +} + +for (const code of ["redeemed", "coupon_unavailable"] as const) { + test(`a definitive ${code} retires the attempt before its refresh GET completes`, async () => { + harness.coupons.set("acct-a", [COUPON("restok_a1", 10), COUPON("restok_a2", 20)]); + let finish!: (response: Response) => void; + harness.consumeReply = () => new Promise(resolve => { finish = resolve; }); + const host = await mountPanel([ACCOUNT("acct-a")]); + await openDialog(host); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + await act(async () => { host.querySelector(".modal-backdrop-dismiss")!.click(); await flush(); }); + harness.holdReads = true; + try { + await act(async () => { finish(json({ code, replayed: code !== "redeemed" })); await flush(); }); + expect(harness.releaseRead).toHaveLength(1); + await openDialog(host); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + expect(dialogText(host)).not.toContain("outcome is unknown"); + expect(harness.consumes).toHaveLength(2); + expect(harness.consumes[1].operationId).not.toBe(harness.consumes[0].operationId); + await act(async () => { harness.holdReads = false; harness.releaseRead.shift()!(); await flush(); }); + // Finishing the older GET must not retire the newer pending redemption. + await act(async () => { host.querySelector(".modal-backdrop-dismiss")!.click(); await flush(); }); + await openDialog(host); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + expect(harness.consumes).toHaveLength(2); + expect(dialogText(host)).toContain("outcome is unknown"); + await act(async () => { finish(json({ code: "redeemed" })); await flush(); }); + } finally { + await act(async () => { + finish(json({ code: "redeemed" })); + harness.holdReads = false; + for (const release of harness.releaseRead.splice(0)) release(); + await flush(); + }); + } + expect(dialogText(host)).not.toContain("outcome is unknown"); + }); +} + +for (const code of ["coupon_unavailable", "auth_failed", "fetch_resets_failed"]) { + test(`a definitive ${code} releases a speculative pending hold`, async () => { + harness.coupons.set("acct-a", [COUPON("restok_a1", 10)]); + let finish!: (response: Response) => void; + harness.consumeReply = () => new Promise(resolve => { finish = resolve; }); + const host = await mountPanel([ACCOUNT("acct-a")]); + await openDialog(host); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + await act(async () => { host.querySelector(".modal-backdrop-dismiss")!.click(); await flush(); }); + await openDialog(host); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + expect(harness.consumes).toHaveLength(1); + expect(dialogText(host)).toContain("outcome is unknown"); + await act(async () => { finish(json({ error: { code } }, 409)); await flush(); }); + expect(dialogText(host)).not.toContain("outcome is unknown"); + await act(async () => { host.querySelector(".modal-backdrop-dismiss")!.click(); await flush(); }); + await openDialog(host); + expect(dialogText(host)).not.toContain("outcome is unknown"); + expect(buttonWithText(host, "Use 1 coupon").disabled).toBe(false); + harness.consumeReply = async () => json({ code: "redeemed" }); + await act(async () => { buttonWithText(host, "Use 1 coupon").click(); await flush(); }); + await act(async () => { buttonWithText(host, "Use coupon").click(); await flush(); }); + expect(harness.consumes).toHaveLength(2); + expect(harness.consumes[1].operationId).not.toBe(harness.consumes[0].operationId); + }); +} + test("one row's retry does not cancel another row's in-flight read", async () => { harness.coupons.set("acct-a", [COUPON("restok_a1", 10)]); harness.coupons.set("acct-b", [COUPON("restok_b1", 10), COUPON("restok_b2", 12)]); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f68faa9a94a..f7918d8eea3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1000,7 +1000,7 @@ "grok-models-effort-list.test.ts": "providers/xai", "grok-orphan-adoption.test.ts": "providers/xai", "grok-reset-coupon-cli.test.ts": "providers/xai", - "grok-reset-coupons.test.ts": "providers/xai", + "grok-reset-coupons.test.ts": "providers/xai", "grok-coupon-reconcile.test.ts": "providers/xai", "grok-coupon-claim.test.ts": "providers/xai", "grok-selection.test.ts": "providers/xai", "grok-session-identity.test.ts": "providers/xai", "grok-status.test.ts": "providers/xai", diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 399504d6d13..6d2a6c57fb1 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -6,6 +6,7 @@ import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh"; import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery"; import { BROWSER_LAUNCH_FAILED_NOTICE } from "../lib/browser-launch-notice"; import { + RuntimeApiError, CliUsageError, printData, readSecretLine, @@ -465,18 +466,25 @@ async function grokResetCoupons(argv: string[], deps: RuntimeApiDeps): Promise= 500) || ["attempt_unresolved", "attempt_in_progress", "attempt_reconcile_failed", "operation_state_changed", "redeem_failed"].includes(code ?? "") + )))) { + throw new RuntimeApiError(`Coupon redemption outcome is unconfirmed. Preserve --operation-id ${effectiveOperationId}; reuse it to inspect this attempt and do not create a replacement operation.`, apiError?.status ?? 503, apiError?.body ?? null); + } + throw error; + } printData(result, wantsJson); } diff --git a/src/grok/reset-coupon-ledger.ts b/src/grok/reset-coupon-ledger.ts index ac63f9fc447..4fb81924d64 100644 --- a/src/grok/reset-coupon-ledger.ts +++ b/src/grok/reset-coupon-ledger.ts @@ -1,9 +1,10 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { atomicWriteFile } from "../config/atomic-write"; +import { closeSync, fsyncSync, openSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFileStreamed } from "../config/atomic-write"; +import { withConfigMutationLockSync } from "../config/mutation-lock"; import { getConfigDir } from "../config/paths"; -export type GrokResetCouponOperationKind = "execute" | "replay" | "identity-mismatch" | "capacity"; +export type GrokResetCouponOperationKind = "execute" | "replay" | "identity-mismatch" | "token-mismatch" | "capacity"; export interface GrokResetCouponOperationIdentity { accountId: string; @@ -16,6 +17,12 @@ export interface GrokResetCouponOperationRecord { operationId: string; accountId?: string; tokenId?: string; + /** Token expiry (ms) captured at claim time as journal metadata. Remaining + * availability or expiry cannot establish this operation's outcome. */ + tokenValidityEnd?: number; + /** When the attempt was marked (ms) — set only on an "attempted" replay so + * the route can defer inspection while the attempt may still be in flight. */ + attemptedAt?: number; code?: string; settledAt?: number; } @@ -23,14 +30,18 @@ export interface GrokResetCouponOperationRecord { interface GrokResetCouponOperationState { accountId: string; tokenId?: string; - status: "open" | "settled" | "failed"; + // "attempted" sits between open and settled: the spend call may have fired, + // so the operation must never execute again. Its replay carries no code — + // the route reconciles it against upstream instead of trusting a status. + status: "open" | "attempted" | "settled" | "failed"; code?: string; + tokenValidityEnd?: number; createdAt: number; updatedAt: number; } interface GrokResetCouponLedger { - version: 1; + version: 2; operations: Record; } @@ -40,95 +51,249 @@ export function grokCouponJournalPath(customDir?: string): string { } function readGrokCouponLedger(filePath: string): GrokResetCouponLedger { - if (!existsSync(filePath)) { - return { version: 1, operations: {} }; + let raw: string; + try { raw = readFileSync(filePath, "utf-8"); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 2, operations: {} }; + throw new Error("Grok coupon ledger is unavailable; existing records were preserved."); } try { - const raw = readFileSync(filePath, "utf-8"); - const parsed = JSON.parse(raw) as GrokResetCouponLedger; - return parsed && parsed.version === 1 && parsed.operations && typeof parsed.operations === "object" - ? parsed - : { version: 1, operations: {} }; + const parsed = JSON.parse(raw) as Omit & { version: unknown }; + if (!parsed || (parsed.version !== 1 && parsed.version !== 2) || !parsed.operations + || typeof parsed.operations !== "object" || Array.isArray(parsed.operations)) throw new Error(); + for (const op of Object.values(parsed.operations)) { + if (!op || typeof op.accountId !== "string" || !op.accountId + || !["open", "attempted", "settled", "failed"].includes(op.status) + || !Number.isFinite(op.createdAt) || !Number.isFinite(op.updatedAt) + || (op.tokenId !== undefined && typeof op.tokenId !== "string") + || (op.code !== undefined && typeof op.code !== "string") + || (op.tokenValidityEnd !== undefined && !Number.isFinite(op.tokenValidityEnd))) throw new Error(); + } + if (parsed.version === 1) { + for (const op of Object.values(parsed.operations)) { + // Old opens (and transport failures recorded as failed) may already + // have dispatched. Only version-two opens prove pre-dispatch state. + if (op.status === "open" || (op.status === "failed" && op.code === "redeem_failed")) { + op.status = "attempted"; + delete op.code; + } + } + } + return { version: 2, operations: parsed.operations }; } catch { - return { version: 1, operations: {} }; + throw new Error("Grok coupon ledger is unreadable; existing records were preserved."); } } function writeGrokCouponLedger(filePath: string, ledger: GrokResetCouponLedger, now = Date.now()): void { - // Prune settled/failed operations older than 30 days to avoid unbounded growth + // Prune every record past the 30-day window — including "open" and + // "attempted". Idempotency retention is explicitly bounded to 30 days; + // operations are not retry authorities after that window. const retentionCutoff = now - 30 * 24 * 60 * 60_000; ledger.operations = Object.fromEntries( - Object.entries(ledger.operations).filter( - ([, op]) => op.status === "open" || op.updatedAt > retentionCutoff, - ), + Object.entries(ledger.operations).filter(([, op]) => op.updatedAt > retentionCutoff), ); - atomicWriteFile(filePath, JSON.stringify(ledger, null, 2)); + atomicWriteFileStreamed(filePath, descriptor => writeFileSync(descriptor, JSON.stringify(ledger, null, 2)), { + afterRename: target => { + // The shared streamed writer flushes the temp. A spend claim must also + // refuse on a POSIX directory-sync failure before dispatch, rather than + // relying on that writer's best-effort parent sync. Windows has no + // portable directory descriptor and retains the writer's platform contract. + if (process.platform === "win32") return; + const descriptor = openSync(dirname(target), "r"); + try { fsyncSync(descriptor); } + finally { closeSync(descriptor); } + }, + }); } const MAX_GROK_RESET_COUPON_OPERATION_IDS = 256; +/** Resolve an omitted-account retry from its durable identity, within retention. */ +export function getGrokResetCouponOperationAccountId( + operationId: string, + now = Date.now(), + journalPath?: string, +): string | undefined { + return withConfigMutationLockSync(() => { + const record = readGrokCouponLedger(journalPath ?? grokCouponJournalPath()).operations[operationId]; + if (!record || record.updatedAt <= now - 30 * 24 * 60 * 60_000) return undefined; + return record.accountId; + }); +} + export function openGrokResetCouponOperation( identity: GrokResetCouponOperationIdentity, now = Date.now(), journalPath?: string, ): GrokResetCouponOperationRecord { - const filePath = journalPath ?? grokCouponJournalPath(); - const ledger = readGrokCouponLedger(filePath); + // Read→decide→write under the shared config mutation lock: two concurrent + // coupon requests on the same home must not lose an operation record — the + // ledger exists precisely to make an irreversible spend replay-safe. + return withConfigMutationLockSync(() => { + const filePath = journalPath ?? grokCouponJournalPath(); + const ledger = readGrokCouponLedger(filePath); - if (Object.keys(ledger.operations).length >= MAX_GROK_RESET_COUPON_OPERATION_IDS) { - return { kind: "capacity", operationId: identity.operationId }; - } + // Prune BEFORE the capacity check: expiry is enforced on write, so a ledger + // full of stale records would otherwise reject every new operation forever + // (the prune path inside writeGrokCouponLedger is never reached when all + // openings are rejected). + const retentionCutoff = now - 30 * 24 * 60 * 60_000; + ledger.operations = Object.fromEntries( + Object.entries(ledger.operations).filter(([, op]) => op.updatedAt > retentionCutoff), + ); - const existing = ledger.operations[identity.operationId]; - if (existing) { - if (existing.accountId !== identity.accountId) { - return { kind: "identity-mismatch", operationId: identity.operationId }; - } - if (existing.status !== "open") { - // Durably settled already: replay the recorded outcome instead of - // trusting upstream idempotency for an irreversible spend. + const existing = ledger.operations[identity.operationId]; + if (existing) { + if (existing.accountId !== identity.accountId) { + return { kind: "identity-mismatch", operationId: identity.operationId }; + } + if (identity.tokenId !== undefined && existing.tokenId !== undefined && existing.tokenId !== identity.tokenId) { + return { kind: "token-mismatch", operationId: identity.operationId }; + } + if (existing.status !== "open") { + // Non-open: replay the recorded outcome. "attempted" records carry no + // code — the caller must reconcile them against upstream, never re-run. + return { + kind: "replay", + operationId: identity.operationId, + accountId: existing.accountId, + tokenId: existing.tokenId, + tokenValidityEnd: existing.tokenValidityEnd, + attemptedAt: existing.status === "attempted" ? existing.updatedAt : undefined, + code: existing.code, + settledAt: existing.updatedAt, + }; + } return { - kind: "replay", + kind: "execute", operationId: identity.operationId, accountId: existing.accountId, tokenId: existing.tokenId, - code: existing.code, - settledAt: existing.updatedAt, }; } + + if (Object.keys(ledger.operations).length >= MAX_GROK_RESET_COUPON_OPERATION_IDS) { + return { kind: "capacity", operationId: identity.operationId }; + } + + ledger.operations[identity.operationId] = { + accountId: identity.accountId, + ...(identity.tokenId === undefined ? {} : { tokenId: identity.tokenId }), + status: "open", + createdAt: now, + updatedAt: now, + }; + writeGrokCouponLedger(filePath, ledger, now); return { kind: "execute", operationId: identity.operationId, - accountId: existing.accountId, - tokenId: existing.tokenId, + accountId: identity.accountId, + tokenId: identity.tokenId, }; - } + }); +} - ledger.operations[identity.operationId] = { - accountId: identity.accountId, - ...(identity.tokenId === undefined ? {} : { tokenId: identity.tokenId }), - status: "open", - createdAt: now, - updatedAt: now, - }; - writeGrokCouponLedger(filePath, ledger, now); - return { - kind: "execute", - operationId: identity.operationId, - accountId: identity.accountId, - tokenId: identity.tokenId, - }; +/** + * Flip an open operation to `attempted` BEFORE the upstream spend call. The + * write happens while nothing is irreversible yet: if it throws, the caller + * aborts without spending; if a later settle write dies instead, the + * operation still refuses blind re-execution — the next open returns a + * code-less replay the route resolves via the upstream remaining-resets list. + */ +export function markGrokResetCouponAttempt( + operationId: string, + tokenId: string, + now = Date.now(), + journalPath?: string, + tokenValidityEnd?: number, +): boolean { + return withConfigMutationLockSync(() => { + const filePath = journalPath ?? grokCouponJournalPath(); + const ledger = readGrokCouponLedger(filePath); + const existing = ledger.operations[operationId]; + if (!existing || existing.status !== "open" + || (existing.tokenId !== undefined && existing.tokenId !== tokenId)) return false; + existing.status = "attempted"; + existing.tokenId = tokenId; + if (tokenValidityEnd !== undefined) existing.tokenValidityEnd = tokenValidityEnd; + existing.updatedAt = now; + writeGrokCouponLedger(filePath, ledger, now); + return true; + }); +} + +type GrokResetCouponSettlement = { operationId: string; accountId: string; code: string } & ( + | { tokenId?: string; status: "failed"; expectedStatus: "open" } + | { tokenId: string; status: "success"; expectedStatus: "attempted" } +); + +type GrokResetCouponPreflightResult = + | { kind: "recorded" | "changed" } + | { kind: "replay"; code: string; tokenId?: string; settledAt: number }; + +const UNRESOLVED_COUPON_CODES = new Set(["redeem_failed", "attempt_unresolved", "attempt_in_progress", "attempt_reconcile_failed", "operation_state_changed"]); + +/** The durable definitive outcome for this account and token, if the operation has one. */ +function terminalReplay( + ledger: GrokResetCouponLedger, operationId: string, accountId: string, tokenId: string | undefined, +): Extract | null { + const existing = ledger.operations[operationId]; + if (!existing || existing.accountId !== accountId) return null; + if (tokenId !== undefined && existing.tokenId !== undefined && existing.tokenId !== tokenId) return null; + if ((existing.status !== "settled" && existing.status !== "failed") || !existing.code || UNRESOLVED_COUPON_CODES.has(existing.code)) return null; + return { kind: "replay", code: existing.code, tokenId: existing.tokenId, settledAt: existing.updatedAt }; } +/** Settle a refusal or inspect its winner without releasing the mutation transaction. */ +export function settleGrokResetCouponPreflightRefusal( + settlement: Extract, + now = Date.now(), + journalPath?: string, +): GrokResetCouponPreflightResult { + return withConfigMutationLockSync(() => { + const filePath = journalPath ?? grokCouponJournalPath(); + const ledger = readGrokCouponLedger(filePath); + if (recordSettlement(ledger, filePath, settlement, now)) return { kind: "recorded" }; + return terminalReplay(ledger, settlement.operationId, settlement.accountId, settlement.tokenId) ?? { kind: "changed" }; + }); +} + +/** + * After a lost claim: the definitive winner to replay, or null while the + * operation is still unresolved. Terminal states never revert, so reading after + * the failed claim cannot replay an outcome that is later replaced. + */ +export function readGrokResetCouponTerminalReplay( + operationId: string, accountId: string, tokenId?: string, journalPath?: string, +): Extract | null { + return withConfigMutationLockSync(() => { + const filePath = journalPath ?? grokCouponJournalPath(); + return terminalReplay(readGrokCouponLedger(filePath), operationId, accountId, tokenId); + }); +} + +/** A preflight refusal cannot replace a claim or a terminal result. */ export function recordGrokResetCouponSettlement( - settlement: { operationId: string; tokenId?: string; code: string; status: "success" | "failed" }, + settlement: GrokResetCouponSettlement, now = Date.now(), journalPath?: string, -): void { - const filePath = journalPath ?? grokCouponJournalPath(); - const ledger = readGrokCouponLedger(filePath); +): boolean { + return withConfigMutationLockSync(() => { + const filePath = journalPath ?? grokCouponJournalPath(); + const ledger = readGrokCouponLedger(filePath); + return recordSettlement(ledger, filePath, settlement, now); + }); +} + +function recordSettlement( + ledger: GrokResetCouponLedger, filePath: string, settlement: GrokResetCouponSettlement, now: number, +): boolean { const existing = ledger.operations[settlement.operationId]; - if (!existing) return; + if (!existing || existing.status !== settlement.expectedStatus + || existing.accountId !== settlement.accountId + || (settlement.tokenId !== undefined && existing.tokenId !== undefined && existing.tokenId !== settlement.tokenId) + || (settlement.status === "success" && existing.tokenId !== settlement.tokenId)) return false; existing.status = settlement.status === "success" ? "settled" : "failed"; existing.code = settlement.code; @@ -136,4 +301,5 @@ export function recordGrokResetCouponSettlement( existing.updatedAt = now; writeGrokCouponLedger(filePath, ledger, now); + return true; } diff --git a/src/grok/reset-coupons.ts b/src/grok/reset-coupons.ts index 90e9229a9c2..edbee424c0d 100644 --- a/src/grok/reset-coupons.ts +++ b/src/grok/reset-coupons.ts @@ -230,6 +230,17 @@ function buildGrokHeaders(accessToken: string): Record { /** * Reads available Grok reset tokens for the authenticated xAI account. */ +function assertConfirmedCouponStatus(decoded: ReturnType): void { + const rawStatus = decoded.trailers?.metadata["grpc-status"]; + // The shared decoder defaults a missing/malformed status to zero. Coupon + // availability and an irreversible redemption need affirmative confirmation. + if (rawStatus === undefined || !/^\d+$/.test(rawStatus) || !Number.isSafeInteger(Number(rawStatus))) { + throw new Error("Grok coupon response has no confirmed gRPC status"); + } + const status = Number(rawStatus); + if (status !== 0) throw new GrpcWebError(status, decoded.statusMessage ?? "Unknown gRPC error"); +} + export async function getGrokRemainingResets(options: GetRemainingResetsOptions): Promise<{ tokens: GrokResetCoupon[] }> { const fetchImpl = options.fetchFn ?? globalThis.fetch; const endpoint = options.endpoint ?? GROK_GET_REMAINING_RESETS_ENDPOINT; @@ -251,9 +262,7 @@ export async function getGrokRemainingResets(options: GetRemainingResetsOptions) const rawBytes = new Uint8Array(await res.arrayBuffer()); const decoded = decodeGrpcWebResponse(rawBytes); - if (decoded.status !== 0) { - throw new GrpcWebError(decoded.status, decoded.statusMessage ?? "Unknown gRPC error"); - } + assertConfirmedCouponStatus(decoded); if (decoded.messages.length === 0) { return { tokens: [] }; @@ -285,9 +294,7 @@ export async function redeemGrokResetCoupon(options: RedeemResetOptions): Promis const rawBytes = new Uint8Array(await res.arrayBuffer()); const decoded = decodeGrpcWebResponse(rawBytes); - if (decoded.status !== 0) { - throw new GrpcWebError(decoded.status, decoded.statusMessage ?? "Unknown gRPC error"); - } + assertConfirmedCouponStatus(decoded); return { success: true, diff --git a/src/server/management/grok-coupon-routes.ts b/src/server/management/grok-coupon-routes.ts index ee75d7bbd64..3aa33045a01 100644 --- a/src/server/management/grok-coupon-routes.ts +++ b/src/server/management/grok-coupon-routes.ts @@ -11,6 +11,7 @@ import { jsonResponse } from "../auth-cors"; import type { ManagementContext } from "./context"; +import { ConfigMutationLockError } from "../../config/mutation-lock"; import { isCodexResetCreditOperationId } from "../../codex/reset-credit-recovery"; import { getValidAccessSnapshotForAccount } from "../../oauth"; import { listAccounts, captureOAuthAccountSelection } from "../../oauth/store"; @@ -20,8 +21,12 @@ import { type GrokResetCoupon, } from "../../grok/reset-coupons"; import { + getGrokResetCouponOperationAccountId, + markGrokResetCouponAttempt, openGrokResetCouponOperation, recordGrokResetCouponSettlement, + readGrokResetCouponTerminalReplay, + settleGrokResetCouponPreflightRefusal, type GrokResetCouponOperationRecord, } from "../../grok/reset-coupon-ledger"; @@ -41,6 +46,11 @@ export interface GrokConsumeCouponRequestBody { operationId?: string; } +// A recent attempt can still be in flight. Older attempts may be inspected, +// but a remaining token is not proof that the original request cannot land. +const GROK_COUPON_ATTEMPT_STALE_MS = 90_000; +const grokCouponInFlightAttempts = new Set(); + function resolveTargetAccountId(requestedAccountId?: string): string { if (requestedAccountId && requestedAccountId.trim() !== "") { return requestedAccountId.trim(); @@ -130,6 +140,10 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise t.tokenId === opRecord.tokenId); + return jsonResponse({ operationId: effectiveOpId, error: { + code: "attempt_unresolved", + message: listed + ? "The prior redemption may still complete; its listed coupon will not be redeemed again" + : "The coupon is no longer listed, but this operation's outcome is unconfirmed; no redemption will be repeated", + } }, 409, req, config); } + if (opRecord.kind === "token-mismatch") return jsonResponse({ error: { + code: "operation_token_mismatch", message: "Operation ID was previously registered with a different coupon", + } }, 409, req, config); + if (opRecord.kind === "identity-mismatch") { return jsonResponse( { @@ -213,16 +314,48 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise { + try { + const result = settleGrokResetCouponPreflightRefusal({ + operationId: effectiveOpId, accountId, tokenId, code, + status: "failed", expectedStatus: "open", + }); + if (result.kind === "recorded") return null; + if (result.kind === "replay") return jsonResponse({ + code: result.code, replayed: true, tokenId: result.tokenId, settledAt: result.settledAt, + }, 200, req, config); + return jsonResponse({ error: { + code: "operation_state_changed", + message: "Another request changed this operation during inspection; retry the same operationId to read its durable state", + } }, 409, req, config); + } catch { + return jsonResponse({ error: { + code: "ledger_unavailable", message: "Coupon refusal could not be recorded; no redemption was attempted", + } }, 503, req, config); + } + }; + + { + // Always consult the upstream list: it resolves the token when the + // caller omits one, and — for an explicit tokenId — proves the coupon + // still exists. Captured validity remains journal metadata, never proof + // that this operation succeeded during a later reconciliation. + let tokens: GrokResetCoupon[]; try { const remaining = await getGrokRemainingResets({ accessToken: tokenSnapshot.accessToken }); - if (!remaining.tokens || remaining.tokens.length === 0) { - recordGrokResetCouponSettlement({ - operationId: effectiveOpId, - code: "no_coupons_available", - status: "failed", - }); + tokens = remaining.tokens ?? []; + } catch (err) { + return jsonResponse( + { error: { code: "fetch_resets_failed", message: err instanceof Error ? err.message : String(err) } }, + 502, + req, + config, + ); + } + if (!resolvedTokenId) { + if (tokens.length === 0) { + const changed = settlePreflightRefusal("no_coupons_available"); + if (changed) return changed; return jsonResponse( { error: { code: "no_coupons_available", message: "No reset coupons available to redeem" } }, 400, @@ -230,29 +363,131 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise t.tokenId === resolvedTokenId); + if (!match) { + // The requested coupon is already consumed or expired upstream — + // redeeming it would only surface an upstream error. + const changed = settlePreflightRefusal("coupon_unavailable", resolvedTokenId); + if (changed) return changed; return jsonResponse( - { error: { code: "fetch_resets_failed", message: err instanceof Error ? err.message : String(err) } }, - 502, + { + error: { + code: "coupon_unavailable", + message: "The requested reset coupon is no longer available upstream", + }, + }, + 409, req, config, ); } + const parsedEnd = Date.parse(match.validityEnd); + if (!Number.isNaN(parsedEnd)) resolvedTokenValidityEnd = parsedEnd; + } + + if (resolvedTokenId === undefined) { + // Unreachable: every path above either resolves a token or returns. + return jsonResponse( + { error: { code: "token_unresolved", message: "No reset coupon token could be resolved" } }, + 500, + req, + config, + ); + } + + if (grokCouponInFlightAttempts.has(effectiveOpId)) { + // The original request holding this operation is still running in this + // process — its upstream spend has not concluded, so nothing may spend + // on this operationId again regardless of how stale the mark looks. + return jsonResponse( + { + error: { + code: "attempt_in_progress", + message: "A redemption attempt for this operation is still running; retry later", + }, + }, + 409, + req, + config, + ); } + grokCouponInFlightAttempts.add(effectiveOpId); try { - const redeemResult = await redeemGrokResetCoupon({ - accessToken: tokenSnapshot.accessToken, - tokenId: resolvedTokenId, - }); + // Record the attempt BEFORE the spend call: a crash between redemption + // and settlement must still leave the operation non-open so a retry can + // never execute it again. The atomic claim admits exactly one contender; + // a failed mark write aborts here while nothing has been spent. + try { + const claimed = markGrokResetCouponAttempt(effectiveOpId, resolvedTokenId, undefined, undefined, resolvedTokenValidityEnd); + if (!claimed) { + // A concurrent request may already have reached a definitive outcome; + // replay it rather than holding the caller on a finished operation. + // A failed read proves nothing about the winner, so it stays uncertain: + // reporting a definitive error here would let the caller open a + // replacement operation while this one may already have spent. + let winner: ReturnType = null; + try { winner = readGrokResetCouponTerminalReplay(effectiveOpId, accountId, resolvedTokenId); } catch { winner = null; } + if (winner) return jsonResponse({ + code: winner.code, replayed: true, tokenId: winner.tokenId, settledAt: winner.settledAt, + }, 200, req, config); + return jsonResponse({ error: { + code: "attempt_in_progress", message: "Another request already claimed this operation; no redemption was attempted", + } }, 409, req, config); + } + } catch (err) { + return jsonResponse( + { error: { code: "attempt_mark_failed", message: err instanceof Error ? err.message : String(err) } }, + 500, + req, + config, + ); + } - recordGrokResetCouponSettlement({ - operationId: effectiveOpId, - tokenId: resolvedTokenId, - code: "redeemed", - status: "success", - }); + try { + await redeemGrokResetCoupon({ + accessToken: tokenSnapshot.accessToken, + tokenId: resolvedTokenId, + }); + } catch (err) { + // A transport error cannot prove that the irreversible request failed. + // Keep "attempted" so a later retry can inspect without dispatching again. + return jsonResponse( + { operationId: effectiveOpId, accountId, tokenId: resolvedTokenId, error: { + code: "attempt_unresolved", + message: "Redemption delivery is unconfirmed; reuse this operationId to inspect its state and do not create a replacement attempt", + } }, + 502, + req, + config, + ); + } + + // A confirmed redemption remains successful even when settlement fails. + // The existing claim protects retries if settlement does not land. + let settlementRecorded = false; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + settlementRecorded = recordGrokResetCouponSettlement({ + operationId: effectiveOpId, + accountId, + tokenId: resolvedTokenId, + code: "redeemed", + status: "success", + expectedStatus: "attempted", + }); + break; + } catch (error) { + // Retry only transient acquisition contention, never an unsafe or + // unreadable ledger, a changed claim, or the upstream redemption. + const cause = error instanceof ConfigMutationLockError ? error.cause : undefined; + if (!cause || typeof cause !== "object" || !("code" in cause) + || cause.code !== "SQLITE_BUSY" || attempt === 4) break; + await Bun.sleep(20); + } + } return jsonResponse( { @@ -262,24 +497,14 @@ export async function handleGrokCouponRoutes(ctx: ManagementContext): Promise` (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. | diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 224c23d0311..436564b1bd3 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -162,10 +162,11 @@ byte-identical payloads pass through unchanged. ### Grok Reset Coupons (Billing API Parity) - **Upstream RPCs:** `prod_mc_billing.ConsumerUiSvc/GetRemainingResets` (inspection) and `prod_mc_billing.ConsumerUiSvc/RedeemReset` (redemption). -- **Transport:** Binary gRPC-Web over HTTP/1.1 or HTTP/2 with 5-byte frame envelope (`0x00` data / `0x80` trailers) and protobuf wire format. Plain JSON is rejected with empty responses upstream. +- **Transport:** Binary gRPC-Web over HTTP/1.1 or HTTP/2 with 5-byte frame envelope (`0x00` data / `0x80` trailers) and protobuf wire format. `src/grok/reset-coupons.ts` requires an explicit valid gRPC status in the trailer for both inspection and redemption; HTTP 200, data-only, missing or malformed status does not establish success. Plain JSON is rejected with empty responses upstream. - **Authentication:** `Authorization: Bearer ` + `X-XAI-Token-Auth: xai-grok-cli`. No cookies required. -- **Safety & Idempotency:** Managed via `src/grok/reset-coupon-ledger.ts` using UUIDv4 operation tracking before upstream dispatch to prevent duplicate consumption during network flakes. -- **Surfaces:** `ocx account grok-reset-coupons` in the terminal, and the dashboard at Providers > xAI Grok > Accounts, where each OAuth row carries a ticket badge with its remaining count and opens a redemption dialog (`gui/src/hooks/useGrokResetCoupons.ts`, `gui/src/components/provider-workspace/GrokResetCoupons.tsx`). The dashboard reads one `GET /api/grok/reset-coupons` per account with at most three in flight, always sends an explicit `tokenId` and a client-minted `operationId`, and treats redemption truth as the settled `code` rather than HTTP 200 ??a replayed *failure* returns 200 with `replayed: true`. After a request times out it issues no further consume call, because a redemption whose ledger record is still `open` re-executes. +- **Safety & Idempotency:** `src/grok/reset-coupon-ledger.ts` serializes open, claim, and settlement under the shared config mutation transaction. A durable atomic `open` → `attempted` claim admits exactly one request before upstream dispatch. The streamed atomic writer flushes the temporary file before rename; the coupon ledger additionally requires a successful POSIX parent-directory flush before admitting dispatch. A flush error refuses redemption even if the renamed claim is already visible. Windows retains mandatory file flush and the shared writer’s platform limitation for directory flush. Version-two opens prove pre-dispatch state. Version-one `open` records and `failed` records with `redeem_failed` are read as uncertain attempts; definitive terminal outcomes retain their replay. Subsequent writes use version two. Settlement checks the expected state, account and token in the same transaction: preflight refusals settle only `open`, confirmed redemption settles only `attempted`, and terminal records are immutable. A late refusal inspects the winner in the same config mutation transaction: an identity-matching definitive terminal result is replayed as HTTP 200 with its original code, token and settlement timestamp; an unresolved or identity-mismatched winner returns `operation_state_changed`. Neither path replaces the winner’s record. A lost atomic claim reads the durable winner under the config mutation transaction: a matching definitive outcome uses the same replay response; an unresolved or identity-mismatched operation, or a failed read of the winner, returns `attempt_in_progress`, without another redemption. After a confirmed redemption, only a `ConfigMutationLockError` caused by `SQLITE_BUSY` permits up to five local settlement attempts with four 20 ms waits; any other error or a changed state/account/token stops immediately. Redemption is never retried. Existing operations replay even at the 256-record capacity; records expire after the 30-day retention window. Unreadable or malformed ledgers refuse new dispatch and preserve their bytes. Supplied token IDs are validated as non-empty strings before credential reads or journal opening. Within retention, omitted-account same-ID retries recover the recorded account; executable open retries without a token reuse the recorded token. Explicit identity mismatches still refuse. +- **Uncertain attempts:** An unconfirmed transport response retains an `attempted` claim and returns HTTP 502 with `attempt_unresolved` and the effective operation ID. A failed settlement never permits dispatch again: the claim or a terminal record already made visible by rename remains the retry authority. A recent attempt returns `attempt_in_progress`; after 90 seconds, reconciliation inspects the remaining-reset list without redeeming again. Every unconfirmed attempt stays `attempt_unresolved`, whether its token is listed, missing, expired or of unknown validity. Availability cannot identify which request consumed a coupon. Only a confirmed redemption response establishes success; neither a stale timestamp nor a listed token proves the original request cannot still complete. +- **Surfaces:** `ocx account grok-reset-coupons` in the terminal, and the dashboard at Providers > xAI Grok > Accounts, where each OAuth row carries a ticket badge with its remaining count and opens a redemption dialog (`gui/src/hooks/useGrokResetCoupons.ts`, `gui/src/components/provider-workspace/GrokResetCoupons.tsx`). The dashboard reads one `GET /api/grok/reset-coupons` per account with at most three in flight, always sends an explicit `tokenId` and a client-minted `operationId`, and treats redemption truth as the settled `code` rather than HTTP 200; a replayed *failure* returns 200 with `replayed: true`. The CLI also mints its operation ID before sending and includes same-ID retry guidance on uncertain delivery, including `operation_state_changed` responses. After transport rejection, an unknown-outcome response, or a concurrent confirmation while the original POST is pending, the dashboard retains the original attempt in its account controller across modal close/reopen. An `operation_state_changed` response retains the original attempt as uncertain. The modal derives uncertainty from the controller so a confirmed success or definitive failure retires the active attempt and clears the matching hold before any refresh GET is awaited, including in a reopened modal. Completion of an older request cannot retire a newer active attempt. Refresh is GET-only and does not authorize another consume POST. A definitive failure clears only the matching speculative pending hold, allowing a new explicitly confirmed operation; real unknown outcomes remain held. This controller state lasts while the hook remains mounted; it is not browser-restart persistence. The backend preserves an uncertain attempt and never blindly dispatches the same operation again. Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../dashboard-and-usage.md#usage-accounting); readable totals are not represented as a complete ledger. Upstream API-key usage follows the [physical-attempt account attribution contract](../dashboard-and-usage.md#upstream-key-account-attribution), independently of subscription quota observations. diff --git a/tests/config/config-mutation-lock.test.ts b/tests/config/config-mutation-lock.test.ts index 728f991a2a0..278ed3d80a9 100644 --- a/tests/config/config-mutation-lock.test.ts +++ b/tests/config/config-mutation-lock.test.ts @@ -483,3 +483,51 @@ test("management API maps config mutation lock contention to retryable 503", asy } } }); + +test("own-state writers honor the shared mutation lock (grok coupon ledger)", async () => { + saveConfig(config()); + const readyPath = join(testRoot, "own-state-holder-ready"); + const releasePath = join(testRoot, "own-state-holder-release"); + const configModuleUrl = pathToFileURL(repoPath("src/config.ts")).href; + const childSource = ` + import { existsSync, writeFileSync } from "node:fs"; + import { withConfigMutationLockSync } from ${JSON.stringify(configModuleUrl)}; + withConfigMutationLockSync(() => { + writeFileSync(${JSON.stringify(readyPath)}, "ready"); + while (!existsSync(${JSON.stringify(releasePath)})) Bun.sleepSync(10); + }); + `; + const child = Bun.spawn([process.execPath, "-e", childSource], { + cwd: repoRoot(), + env: { ...process.env, OPENCODEX_HOME: testRoot }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + let childKilled = false; + try { + try { + await waitForOwnedChildReady(child, readyPath); + } catch (error) { + childKilled = true; + child.kill(); + await child.exited; + const stderr = await new Response(child.stderr).text().catch(() => ""); + throw new Error(`${(error as Error).message}\nchild stderr: ${stderr}`); + } + const { openGrokResetCouponOperation } = await import("../../src/grok/reset-coupon-ledger"); + const journalPath = join(testRoot, "grok-reset-coupon-ledger.json"); + expect(() => openGrokResetCouponOperation( + { accountId: "acct-1", operationId: crypto.randomUUID() }, + Date.now(), + journalPath, + )).toThrow(ConfigMutationLockError); + expect(existsSync(journalPath)).toBe(false); + } finally { + writeFileSync(releasePath, "release"); + if (!childKilled) { + expect(await waitForOwnedChild(child)).toBe(0); + } + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5ceec6d4942..7b23f4836e9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -632,7 +632,9 @@ "grok-effort-inject.test.ts": "providers/xai", "grok-lifecycle.test.ts": "providers/xai", "grok-management-api.test.ts": "providers/xai", "grok-models-effort-list.test.ts": "providers/xai", "grok-orphan-adoption.test.ts": "providers/xai", "grok-reset-coupon-cli.test.ts": "providers/xai", - "grok-reset-coupons.test.ts": "providers/xai", "grok-selection.test.ts": "providers/xai", + "grok-reset-coupons.test.ts": "providers/xai", + "grok-coupon-reconcile.test.ts": "providers/xai", + "grok-coupon-claim.test.ts": "providers/xai", "grok-selection.test.ts": "providers/xai", "grok-session-identity.test.ts": "providers/xai", "grok-status.test.ts": "providers/xai", "grok-sync.test.ts": "providers/xai", "grok-writer-boundary.test.ts": "providers/xai", "gui-api-error.test.ts": "gui", "html-lang-boot-script.test.ts": "gui", diff --git a/tests/providers/xai/grok-coupon-claim.test.ts b/tests/providers/xai/grok-coupon-claim.test.ts new file mode 100644 index 00000000000..90841cb9182 --- /dev/null +++ b/tests/providers/xai/grok-coupon-claim.test.ts @@ -0,0 +1,643 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import * as oauth from "../../../src/oauth"; +import * as oauthStore from "../../../src/oauth/store"; +import { handleAccountAuthCommand } from "../../../src/cli/account-auth"; +import * as coupons from "../../../src/grok/reset-coupons"; +import * as ledger from "../../../src/grok/reset-coupon-ledger"; +import { ConfigMutationLockError, readConfigGenerationInCurrentMutationTransaction } from "../../../src/config/mutation-lock"; +import { handleGrokCouponRoutes } from "../../../src/server/management/grok-coupon-routes"; +import type { ManagementContext } from "../../../src/server/management/context"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; +import { repoPath } from "../../helpers/repo-root"; +import { watchdogMs } from "../../helpers/ci-watchdog"; + +const OP = "11111111-1111-4111-8111-111111111111"; +const TOKEN = { tokenId: "fixture-coupon", validityStart: "2026-01-01T00:00:00Z", validityEnd: "2999-01-01T00:00:00Z" }; +let home: string; +let previous: string | undefined; +const spies: Array<{ mockRestore(): void }> = []; + +beforeEach(() => { + previous = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-coupon-claim-")); + process.env.OPENCODEX_HOME = home; +}); +afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore(); + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(home); +}); +function identity() { return { accountId: "fixture-account", tokenId: TOKEN.tokenId, operationId: OP }; } +function request(omitToken = false, omitOperation = false): ManagementContext { + const url = new URL("http://localhost/api/grok/reset-coupons/consume"); + const body = { ...identity(), tokenId: omitToken ? undefined : TOKEN.tokenId, + operationId: omitOperation ? undefined : OP }; + const req = new Request(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + return { req, url, config: {}, deps: {}, version: "test" } as ManagementContext; +} + +function confirmedRedemption() { + const auth = spyOn(oauth, "getValidAccessSnapshotForAccount").mockResolvedValue({ accessToken: "fixture-access-token" } as never); + const remaining = spyOn(coupons, "getGrokRemainingResets").mockResolvedValue({ tokens: [TOKEN] }); + const redeem = spyOn(coupons, "redeemGrokResetCoupon").mockResolvedValue({ success: true, status: 0 }); + spies.push(auth, remaining, redeem); + return redeem; +} +function busySettlement() { + return new ConfigMutationLockError("Config mutation already in progress", { cause: { code: "SQLITE_BUSY" } }); +} + +for (const tokenId of [null, 42, {}, [], true, "", " "]) { + test(`invalid coupon token ${JSON.stringify(tokenId)} refuses before opening the ledger`, async () => { + const redeem = confirmedRedemption(); + const ctx = request(); + const response = await handleGrokCouponRoutes({ ...ctx, req: new Request(ctx.url, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...identity(), tokenId }), + }) }); + expect(response!.status).toBe(400); + expect((await response!.json()).error.code).toBe("invalid_token_id"); + expect(fs.existsSync(ledger.grokCouponJournalPath())).toBe(false); + expect(oauth.getValidAccessSnapshotForAccount).not.toHaveBeenCalled(); + expect((await handleGrokCouponRoutes(request()))!.status).toBe(200); + expect(redeem).toHaveBeenCalledTimes(1); + }); +} + +test("an open-operation retry without a token reuses its journaled coupon", async () => { + const redeem = confirmedRedemption(); + const remaining = spyOn(coupons, "getGrokRemainingResets") + .mockRejectedValueOnce(new Error("fixture list failure")) + .mockResolvedValue({ tokens: [{ ...TOKEN, tokenId: "fixture-other-coupon" }, TOKEN] }); + spies.push(remaining); + expect((await handleGrokCouponRoutes(request()))!.status).toBe(502); + const response = await handleGrokCouponRoutes(request(true)); + expect(response!.status).toBe(200); + expect(redeem).toHaveBeenCalledTimes(1); + expect(redeem.mock.calls[0]![0].tokenId).toBe(TOKEN.tokenId); +}); + +test("an omitted-account CLI retry recovers the operation's recorded account", async () => { + let active = "fixture-original-account"; + const selection = spyOn(oauthStore, "captureOAuthAccountSelection") + .mockImplementation(() => ({ accountId: active }) as never); + const redeem = confirmedRedemption().mockRejectedValue(new Error("fixture lost response")); + spies.push(selection); + const calls: Array> = []; + const responses: Array<{ status: number; code: string }> = []; + const errors: string[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); }; + const deps = { baseUrl: "http://localhost", fetchImpl: (async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + calls.push(body); + const ctx = request(); + const response = (await handleGrokCouponRoutes({ ...ctx, req: new Request(ctx.url, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }) }))!; + responses.push({ status: response.status, code: (await response.clone().json()).error.code }); + return response; + }) as typeof fetch }; + try { + expect(await handleAccountAuthCommand("grok-reset-coupons", ["--consume", "--yes", "--operation-id", OP], deps)).toBeGreaterThan(0); + expect(errors.join("\n")).toContain(OP); + active = "fixture-new-active-account"; + expect(await handleAccountAuthCommand("grok-reset-coupons", ["--consume", "--yes", "--operation-id", OP], deps)).toBeGreaterThan(0); + } finally { console.error = originalError; } + expect(calls).toHaveLength(2); + expect(calls.every(body => body.accountId === undefined)).toBe(true); + expect(responses[0].code).toBe("attempt_unresolved"); + expect(responses[1]).toEqual({ status: 409, code: "attempt_in_progress" }); + expect(oauth.getValidAccessSnapshotForAccount).toHaveBeenLastCalledWith("xai", "fixture-original-account", { requireUsableAccount: true }); + expect(redeem).toHaveBeenCalledTimes(1); +}); + +test("the attempted claim flushes its file and parent before redemption", async () => { + const sync = fs.fsyncSync; + const flushed: string[] = []; + const flush = spyOn(fs, "fsyncSync").mockImplementation(fd => { + flushed.push(fs.fstatSync(fd).isDirectory() ? "directory" : "file"); + sync(fd); + }); + const redeem = confirmedRedemption().mockImplementation(async () => { + expect(flushed).toContain("file"); + if (process.platform !== "win32") expect(flushed).toContain("directory"); + expect(JSON.parse(readFileSync(ledger.grokCouponJournalPath(), "utf8")).operations[OP].status).toBe("attempted"); + return { success: true, status: 0 }; + }); + const remaining = spyOn(coupons, "getGrokRemainingResets").mockImplementation(async () => { + // Ignore the initial open: these flushes must belong to the attempted claim. + flushed.length = 0; + return { tokens: [TOKEN] }; + }); + spies.push(flush, remaining); + expect((await handleGrokCouponRoutes(request()))!.status).toBe(200); + expect(redeem).toHaveBeenCalledTimes(1); +}); + +for (const failure of ["file", "directory"] as const) { + test.skipIf(failure === "directory" && process.platform === "win32")(`a claim ${failure} fsync failure prevents redemption`, async () => { + const redeem = confirmedRedemption(); + const sync = fs.fsyncSync; + const remaining = spyOn(coupons, "getGrokRemainingResets").mockImplementation(async () => { + const flush = spyOn(fs, "fsyncSync").mockImplementation(fd => { + if (fs.fstatSync(fd).isDirectory() === (failure === "directory")) throw new Error("fixture flush failure"); + sync(fd); + }); + spies.push(flush); + return { tokens: [TOKEN] }; + }); + spies.push(remaining); + const response = await handleGrokCouponRoutes(request()); + expect(response!.status).toBe(500); + expect((await response!.json()).error.code).toBe("attempt_mark_failed"); + expect(redeem).not.toHaveBeenCalled(); + }); +} + +for (const tokenId of [TOKEN.tokenId, undefined]) { + test(`a legacy version-one open with ${tokenId ? "known" : "unknown"} token never dispatches`, async () => { + writeFileSync(ledger.grokCouponJournalPath(), JSON.stringify({ version: 1, operations: { + [OP]: { accountId: "fixture-account", tokenId, status: "open", createdAt: Date.now() - 120_000, updatedAt: Date.now() - 120_000 }, + } })); + const redeem = confirmedRedemption(); + const response = await handleGrokCouponRoutes(request()); + expect(response!.status).toBe(409); + expect((await response!.json()).error.code).toBe("attempt_unresolved"); + expect(redeem).not.toHaveBeenCalled(); + }); +} + +test("a legacy redemption failure with unconfirmed delivery is quarantined", async () => { + writeFileSync(ledger.grokCouponJournalPath(), JSON.stringify({ version: 1, operations: { + [OP]: { ...identity(), status: "failed", code: "redeem_failed", createdAt: Date.now() - 120_000, updatedAt: Date.now() - 120_000 }, + } })); + const redeem = confirmedRedemption(); + const response = await handleGrokCouponRoutes(request()); + expect(response!.status).toBe(409); + expect((await response!.json()).error.code).toBe("attempt_unresolved"); + expect(redeem).not.toHaveBeenCalled(); +}); + +for (const [status, code] of [["settled", "redeemed"], ["failed", "coupon_unavailable"]] as const) { + test(`a legacy definitive ${status} outcome retains its replay`, async () => { + writeFileSync(ledger.grokCouponJournalPath(), JSON.stringify({ version: 1, operations: { + [OP]: { ...identity(), status, code, createdAt: Date.now(), updatedAt: Date.now() }, + } })); + const redeem = confirmedRedemption(); + const response = await handleGrokCouponRoutes(request()); + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ code, replayed: true }); + expect(redeem).not.toHaveBeenCalled(); + }); +} + +test("new pre-dispatch opens are stored in version two and remain claimable", () => { + expect(ledger.openGrokResetCouponOperation(identity()).kind).toBe("execute"); + expect(JSON.parse(readFileSync(ledger.grokCouponJournalPath(), "utf8")).version).toBe(2); + expect(ledger.openGrokResetCouponOperation(identity()).kind).toBe("execute"); + expect(ledger.markGrokResetCouponAttempt(OP, TOKEN.tokenId)).toBe(true); + expect(ledger.openGrokResetCouponOperation(identity()).kind).toBe("replay"); +}); + +test("uncertain redemption returns the generated operation id as its retry anchor", async () => { + const redeem = confirmedRedemption().mockRejectedValue(new Error("fixture connection reset")); + const response = await handleGrokCouponRoutes(request(false, true)); + const body = await response!.json(); + expect(response!.status).toBe(502); + expect(body.operationId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(body.error.code).toBe("attempt_unresolved"); + expect(JSON.parse(readFileSync(ledger.grokCouponJournalPath(), "utf8")).operations[body.operationId].status).toBe("attempted"); + const ctx = request(); + ctx.req = new Request(ctx.url, { method: "POST", body: JSON.stringify({ ...identity(), operationId: body.operationId }) }); + expect((await handleGrokCouponRoutes(ctx))!.status).toBe(409); + expect(redeem).toHaveBeenCalledTimes(1); +}); + +test("confirmed redemption settles after a real child releases the config mutation lock", async () => { + const redeem = confirmedRedemption(); + let child: ReturnType | undefined; + let killTimer: ReturnType | undefined; + let busyFailures = 0; + const realSettlement = ledger.recordGrokResetCouponSettlement; + const settle = spyOn(ledger, "recordGrokResetCouponSettlement").mockImplementation((...args) => { + try { return realSettlement(...args); } + catch (error) { + expect(error).toBeInstanceOf(ConfigMutationLockError); + expect((error as Error).cause).toMatchObject({ code: "SQLITE_BUSY" }); + busyFailures += 1; + throw error; + } + }); + spies.push(settle); + redeem.mockImplementation(async () => { + const moduleUrl = pathToFileURL(repoPath("src/config/mutation-lock.ts")).href; + const owned = Bun.spawn([process.execPath, "-e", `import { readSync } from "node:fs"; + import { withConfigMutationLockSync } from ${JSON.stringify(moduleUrl)}; + withConfigMutationLockSync(() => { process.stdout.write("held\\n"); readSync(0, Buffer.alloc(1), 0, 1, null); });`], { + env: { ...process.env, OPENCODEX_HOME: home }, stdin: "pipe", stdout: "pipe", stderr: "pipe", + }); + child = owned; + killTimer = setTimeout(() => owned.kill(), watchdogMs(10_000)); + const reader = owned.stdout.getReader(); + try { expect(new TextDecoder().decode((await reader.read()).value)).toBe("held\n"); } + finally { reader.releaseLock(); } + // The retry wait is a barrier: release the actual SQLite owner and wait + // for its exit before allowing the next settlement acquisition. + const sleep = spyOn(Bun, "sleep").mockImplementation(async () => { + owned.stdin.write("release"); + owned.stdin.end(); + expect(await owned.exited).toBe(0); + }); + spies.push(sleep); + return { success: true, status: 0 }; + }); + try { + const response = await handleGrokCouponRoutes(request()); + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ code: "redeemed", settlementRecorded: true }); + expect(busyFailures).toBe(1); + expect(settle).toHaveBeenCalledTimes(2); + const replay = await handleGrokCouponRoutes(request()); + expect(await replay!.json()).toMatchObject({ code: "redeemed", replayed: true }); + expect(redeem).toHaveBeenCalledTimes(1); + } finally { + clearTimeout(killTimer); + child?.kill(); + if (child) await child.exited; + } +}); + +for (const [label, error] of [ + ["ordinary write failure", new Error("fixture write failure")], + ["lock error without a cause", new ConfigMutationLockError("fixture unavailable")], + ["corrupt coordination database", new ConfigMutationLockError("fixture unavailable", { cause: { code: "SQLITE_CORRUPT" } })], + ["unopenable coordination database", new ConfigMutationLockError("fixture unavailable", { cause: { code: "SQLITE_CANTOPEN" } })], + ["busy cause outside the lock error class", new Error("fixture failure", { cause: { code: "SQLITE_BUSY" } })], +] as const) { + test(`confirmed redemption stops settlement immediately on ${label}`, async () => { + const redeem = confirmedRedemption(); + const settle = spyOn(ledger, "recordGrokResetCouponSettlement").mockImplementation(() => { throw error; }); + const sleep = spyOn(Bun, "sleep").mockResolvedValue(undefined); + spies.push(settle, sleep); + const response = await handleGrokCouponRoutes(request()); + expect(await response!.json()).toMatchObject({ code: "redeemed", settlementRecorded: false }); + expect(settle).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + const attempted = readFileSync(ledger.grokCouponJournalPath(), "utf8"); + expect(JSON.parse(attempted).operations[OP].status).toBe("attempted"); + expect((await handleGrokCouponRoutes(request()))!.status).toBe(409); + expect(readFileSync(ledger.grokCouponJournalPath(), "utf8")).toBe(attempted); + expect(redeem).toHaveBeenCalledTimes(1); + }); +} + +test("confirmed redemption exhausts five local settlement attempts without another spend", async () => { + const redeem = confirmedRedemption(); + const settle = spyOn(ledger, "recordGrokResetCouponSettlement").mockImplementation(() => { throw busySettlement(); }); + const sleep = spyOn(Bun, "sleep").mockResolvedValue(undefined); + spies.push(settle, sleep); + const response = await handleGrokCouponRoutes(request()); + expect(await response!.json()).toMatchObject({ code: "redeemed", settlementRecorded: false }); + expect(settle).toHaveBeenCalledTimes(5); + expect(sleep.mock.calls).toEqual([[20], [20], [20], [20]]); + const attempted = readFileSync(ledger.grokCouponJournalPath(), "utf8"); + expect(JSON.parse(attempted).operations[OP].status).toBe("attempted"); + expect((await handleGrokCouponRoutes(request()))!.status).toBe(409); + expect(readFileSync(ledger.grokCouponJournalPath(), "utf8")).toBe(attempted); + expect(redeem).toHaveBeenCalledTimes(1); +}); + +for (const changed of ["account", "token", "status"] as const) { + test(`settlement retry stops when the ${changed} guard changes while waiting`, async () => { + const redeem = confirmedRedemption(); + const realSettlement = ledger.recordGrokResetCouponSettlement; + const settle = spyOn(ledger, "recordGrokResetCouponSettlement").mockImplementation(realSettlement) + .mockImplementationOnce(() => { throw busySettlement(); }); + let guardedBytes = ""; + const sleep = spyOn(Bun, "sleep").mockImplementation(async () => { + const state = JSON.parse(readFileSync(ledger.grokCouponJournalPath(), "utf8")); + if (changed === "account") state.operations[OP].accountId = "another-fixture-account"; + if (changed === "token") state.operations[OP].tokenId = "another-fixture-token"; + if (changed === "status") Object.assign(state.operations[OP], { status: "settled", code: "redeemed" }); + guardedBytes = JSON.stringify(state); + writeFileSync(ledger.grokCouponJournalPath(), guardedBytes); + }); + spies.push(settle, sleep); + const response = await handleGrokCouponRoutes(request()); + expect(await response!.json()).toMatchObject({ code: "redeemed", settlementRecorded: false }); + expect(settle).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + expect(readFileSync(ledger.grokCouponJournalPath(), "utf8")).toBe(guardedBytes); + expect(redeem).toHaveBeenCalledTimes(1); + }); +} + +test("only one independently opened request can claim the spend", () => { + expect(ledger.openGrokResetCouponOperation(identity()).kind).toBe("execute"); + expect(ledger.openGrokResetCouponOperation(identity()).kind).toBe("execute"); + expect(ledger.markGrokResetCouponAttempt(OP, TOKEN.tokenId)).toBe(true); + const attempted = readFileSync(ledger.grokCouponJournalPath(), "utf8"); + expect(ledger.markGrokResetCouponAttempt(OP, TOKEN.tokenId)).toBe(false); + expect(readFileSync(ledger.grokCouponJournalPath(), "utf8")).toBe(attempted); +}); + +test("settlement checks claim state and identity and never replaces a terminal result", () => { + ledger.openGrokResetCouponOperation(identity()); + const confirmed = { operationId: OP, accountId: "fixture-account", tokenId: TOKEN.tokenId, + code: "redeemed", status: "success" as const, expectedStatus: "attempted" as const }; + expect(ledger.recordGrokResetCouponSettlement(confirmed)).toBe(false); + ledger.markGrokResetCouponAttempt(OP, TOKEN.tokenId); + const attempted = readFileSync(ledger.grokCouponJournalPath(), "utf8"); + expect(ledger.recordGrokResetCouponSettlement({ ...confirmed, accountId: "another-fixture-account" })).toBe(false); + expect(ledger.recordGrokResetCouponSettlement({ ...confirmed, tokenId: "another-fixture-token" })).toBe(false); + expect(readFileSync(ledger.grokCouponJournalPath(), "utf8")).toBe(attempted); + expect(ledger.recordGrokResetCouponSettlement(confirmed)).toBe(true); + const settled = readFileSync(ledger.grokCouponJournalPath(), "utf8"); + expect(ledger.recordGrokResetCouponSettlement({ operationId: OP, accountId: "fixture-account", + code: "no_coupons_available", status: "failed", expectedStatus: "open" })).toBe(false); + expect(ledger.recordGrokResetCouponSettlement(confirmed)).toBe(false); + expect(readFileSync(ledger.grokCouponJournalPath(), "utf8")).toBe(settled); +}); + +test("a route does not spend after a competing request claims during inspection", async () => { + const auth = spyOn(oauth, "getValidAccessSnapshotForAccount").mockResolvedValue({ accessToken: "fixture-access-token" } as never); + const remaining = spyOn(coupons, "getGrokRemainingResets").mockImplementation(async () => { + // Claim the same shared ledger from a real second process while inspection waits. + const moduleUrl = pathToFileURL(repoPath("src/grok/reset-coupon-ledger.ts")).href; + const child = Bun.spawnSync([process.execPath, "-e", `import { markGrokResetCouponAttempt } from ${JSON.stringify(moduleUrl)}; + markGrokResetCouponAttempt(${JSON.stringify(OP)}, ${JSON.stringify(TOKEN.tokenId)}); console.log("marked");`], { + env: { ...process.env, OPENCODEX_HOME: home }, timeout: watchdogMs(10_000), stdout: "pipe", stderr: "pipe", + }); + expect(child.exitCode).toBe(0); + expect(child.stdout.toString().trim()).toBe("marked"); + return { tokens: [TOKEN] } as never; + }); + const redeem = spyOn(coupons, "redeemGrokResetCoupon").mockResolvedValue(undefined as never); + spies.push(auth, remaining, redeem); + const response = await handleGrokCouponRoutes(request()); + expect(response!.status).toBe(409); + expect((await response!.json()).error.code).toBe("attempt_in_progress"); + expect(redeem).not.toHaveBeenCalled(); +}); + +test("a full ledger still replays its existing settled operation", () => { + const operations = Object.fromEntries(Array.from({ length: 256 }, (_, i) => [i === 0 ? OP : `fixture-${i}`, { + accountId: "fixture-account", tokenId: TOKEN.tokenId, status: "settled", code: "redeemed", createdAt: Date.now(), updatedAt: Date.now(), + }])); + writeFileSync(ledger.grokCouponJournalPath(), JSON.stringify({ version: 1, operations })); + expect(ledger.openGrokResetCouponOperation(identity())).toMatchObject({ kind: "replay", code: "redeemed" }); +}); + +test("an unreadable ledger refuses new spends and preserves its bytes", async () => { + const path = ledger.grokCouponJournalPath(); + writeFileSync(path, "{interrupted-write"); + const auth = spyOn(oauth, "getValidAccessSnapshotForAccount").mockResolvedValue({ accessToken: "fixture-access-token" } as never); + const remaining = spyOn(coupons, "getGrokRemainingResets").mockResolvedValue({ tokens: [TOKEN] } as never); + const redeem = spyOn(coupons, "redeemGrokResetCoupon").mockResolvedValue(undefined as never); + spies.push(auth, remaining, redeem); + const response = await handleGrokCouponRoutes(request()); + expect(response!.status).toBe(503); + expect((await response!.json()).error.code).toBe("ledger_unavailable"); + expect(readFileSync(path, "utf8")).toBe("{interrupted-write"); + expect(remaining).not.toHaveBeenCalled(); + expect(redeem).not.toHaveBeenCalled(); +}); + +test("a transport failure remains attempted instead of recording a definitive failure", async () => { + const auth = spyOn(oauth, "getValidAccessSnapshotForAccount").mockResolvedValue({ accessToken: "fixture-access-token" } as never); + const remaining = spyOn(coupons, "getGrokRemainingResets").mockResolvedValue({ tokens: [TOKEN] } as never); + const redeem = spyOn(coupons, "redeemGrokResetCoupon").mockRejectedValue(new Error("fixture timeout after dispatch")); + spies.push(auth, remaining, redeem); + const response = await handleGrokCouponRoutes(request()); + expect(response!.status).toBe(502); + expect(ledger.openGrokResetCouponOperation(identity())).toMatchObject({ kind: "replay", code: undefined, tokenId: TOKEN.tokenId }); + expect(redeem).toHaveBeenCalledTimes(1); + const retry = await handleGrokCouponRoutes(request()); + expect(retry!.status).toBe(409); + expect(redeem).toHaveBeenCalledTimes(1); +}); + +test("an unconfirmed HTTP 200 redemption preserves attempted state rather than recording success", async () => { + const realRedeem = coupons.redeemGrokResetCoupon; + const auth = spyOn(oauth, "getValidAccessSnapshotForAccount").mockResolvedValue({ accessToken: "fixture-access-token" } as never); + const remaining = spyOn(coupons, "getGrokRemainingResets").mockResolvedValue({ tokens: [TOKEN] } as never); + const redeem = spyOn(coupons, "redeemGrokResetCoupon").mockImplementation(options => realRedeem({ + ...options, fetchFn: async () => new Response(null, { status: 200 }), + })); + spies.push(auth, remaining, redeem); + const response = await handleGrokCouponRoutes(request()); + expect(response!.status).toBe(502); + expect((await response!.json()).error.code).toBe("attempt_unresolved"); + expect(ledger.openGrokResetCouponOperation(identity())).toMatchObject({ kind: "replay", code: undefined }); + expect(JSON.parse(readFileSync(ledger.grokCouponJournalPath(), "utf8")).operations[OP].status).toBe("attempted"); + expect(redeem).toHaveBeenCalledTimes(1); +}); + +test("a stale attempt with a still-listed token is inspected without a second spend", async () => { + ledger.openGrokResetCouponOperation(identity()); + ledger.markGrokResetCouponAttempt(OP, TOKEN.tokenId, Date.now() - 120_000); + const auth = spyOn(oauth, "getValidAccessSnapshotForAccount").mockResolvedValue({ accessToken: "fixture-access-token" } as never); + const remaining = spyOn(coupons, "getGrokRemainingResets").mockResolvedValue({ tokens: [TOKEN] } as never); + const redeem = spyOn(coupons, "redeemGrokResetCoupon").mockResolvedValue(undefined as never); + spies.push(auth, remaining, redeem); + const response = await handleGrokCouponRoutes(request()); + // A listed token and an old local timestamp cannot rule out a delayed upstream completion. + expect(redeem).not.toHaveBeenCalled(); + expect(response!.status).toBe(409); + expect((await response!.json()).error.code).toBe("attempt_unresolved"); + expect(remaining).toHaveBeenCalledTimes(1); +}); + +for (const refusal of ["no_coupons_available", "coupon_unavailable"] as const) { + for (const winnerState of ["attempted", "settled"] as const) { + test(`late ${refusal} inspection preserves a competing ${winnerState} operation`, async () => { + const omitToken = refusal === "no_coupons_available"; + let releaseInspection!: (value: { tokens: typeof TOKEN[] }) => void; + let enteredInspection!: () => void; + const inspectionStarted = new Promise(resolve => { enteredInspection = resolve; }); + let releaseRedemption!: () => void; + let enteredRedemption!: () => void; + const redemptionStarted = new Promise(resolve => { enteredRedemption = resolve; }); + let inspectionCalls = 0; + const auth = spyOn(oauth, "getValidAccessSnapshotForAccount").mockResolvedValue({ accessToken: "fixture-access-token" } as never); + const remaining = spyOn(coupons, "getGrokRemainingResets").mockImplementation(() => { + if (++inspectionCalls === 1) { + enteredInspection(); + return new Promise(resolve => { releaseInspection = resolve; }); + } + return Promise.resolve({ tokens: [TOKEN] }); + }); + const redeem = spyOn(coupons, "redeemGrokResetCoupon").mockImplementation(() => new Promise(resolve => { + releaseRedemption = () => resolve(undefined as never); + enteredRedemption(); + })); + spies.push(auth, remaining, redeem); + const loser = handleGrokCouponRoutes(request(omitToken)); + await inspectionStarted; + const winner = handleGrokCouponRoutes(request(omitToken)); + try { + await redemptionStarted; + if (winnerState === "settled") { + releaseRedemption(); + expect((await winner)!.status).toBe(200); + } + const before = readFileSync(ledger.grokCouponJournalPath(), "utf8"); + expect(JSON.parse(before).operations[OP].status).toBe(winnerState); + releaseInspection({ tokens: [] }); + const result = (await loser)!; + expect(readFileSync(ledger.grokCouponJournalPath(), "utf8")).toBe(before); + expect(result.status).toBe(winnerState === "settled" ? 200 : 409); + const body = await result.json(); + if (winnerState === "settled") expect(body).toMatchObject({ code: "redeemed", replayed: true, tokenId: TOKEN.tokenId }); + else expect(body.error.code).toBe("operation_state_changed"); + expect(body.success).toBeUndefined(); + expect(redeem).toHaveBeenCalledTimes(1); + } finally { + releaseInspection({ tokens: [] }); + releaseRedemption(); + await Promise.all([loser, winner]); + } + expect(ledger.openGrokResetCouponOperation(identity())).toMatchObject({ kind: "replay", code: "redeemed" }); + }); + } +} + +for (const omitToken of [false, true]) { + test(`concurrent definitive refusals replay the terminal winner (omit token ${omitToken})`, async () => { + let release!: (value: { tokens: typeof TOKEN[] }) => void; + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + let calls = 0; + const auth = spyOn(oauth, "getValidAccessSnapshotForAccount").mockResolvedValue({ accessToken: "fixture-access-token" } as never); + const remaining = spyOn(coupons, "getGrokRemainingResets").mockImplementation(() => { + if (++calls === 1) { entered(); return new Promise(resolve => { release = resolve; }); } + return Promise.resolve({ tokens: [] }); + }); + const redeem = spyOn(coupons, "redeemGrokResetCoupon").mockResolvedValue({ success: true, status: 0 }); + spies.push(auth, remaining, redeem); + const loser = handleGrokCouponRoutes(request(omitToken)); + await started; + try { + const code = omitToken ? "no_coupons_available" : "coupon_unavailable"; + expect((await (await handleGrokCouponRoutes(request(omitToken)))!.json()).error.code).toBe(code); + const before = readFileSync(ledger.grokCouponJournalPath(), "utf8"); + release({ tokens: [] }); + const response = (await loser)!; + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ code, replayed: true }); + expect(readFileSync(ledger.grokCouponJournalPath(), "utf8")).toBe(before); + expect(redeem).not.toHaveBeenCalled(); + } finally { release({ tokens: [] }); await loser; } + }); +} + +for (const [status, code, accountId, tokenId, replay] of [ + ["failed", "auth_failed", "fixture-account", TOKEN.tokenId, true], + ["settled", "redeemed", "fixture-account", TOKEN.tokenId, true], + ["failed", "coupon_unavailable", "another-account", TOKEN.tokenId, false], + ["failed", "coupon_unavailable", "fixture-account", "another-token", false], + ["attempted", "redeemed", "fixture-account", TOKEN.tokenId, false], + ["failed", undefined, "fixture-account", TOKEN.tokenId, false], + ...["redeem_failed", "attempt_unresolved", "attempt_in_progress", "attempt_reconcile_failed", "operation_state_changed"] + .map(code => ["failed", code, "fixture-account", TOKEN.tokenId, false] as const), +] as const) { + test(`preflight winner replay guards ${status}/${code}/${accountId}/${tokenId}`, () => { + const path = ledger.grokCouponJournalPath(); + const before = JSON.stringify({ version: 2, operations: { [OP]: { + status, code, accountId, tokenId, createdAt: Date.now(), updatedAt: Date.now(), + } } }); + writeFileSync(path, before); + const read = fs.readFileSync; + let journalReads = 0; + const guardedRead = spyOn(fs, "readFileSync").mockImplementation((...args: Parameters) => { + if (args[0] === path) { + // The terminal decision reads one snapshot while the real mutation transaction is held. + expect(() => readConfigGenerationInCurrentMutationTransaction()).not.toThrow(); + journalReads += 1; + } + return read(...args as [never, never]); + }); + spies.push(guardedRead); + const result = ledger.settleGrokResetCouponPreflightRefusal({ + ...identity(), code: "coupon_unavailable", status: "failed", expectedStatus: "open", + }); + expect(result).toMatchObject(replay ? { kind: "replay", code, tokenId } : { kind: "changed" }); + expect(journalReads).toBe(1); + const winner = ledger.readGrokResetCouponTerminalReplay(OP, "fixture-account", TOKEN.tokenId); + expect(winner).toEqual(replay ? { kind: "replay", code, tokenId, settledAt: JSON.parse(before).operations[OP].updatedAt } : null); + expect(journalReads).toBe(2); + guardedRead.mockRestore(); + expect(readFileSync(path, "utf8")).toBe(before); + }); +} + +for (const [winnerState, replayFailure] of [ + ["failed", null], ["settled", null], ["attempted", null], ["attempted", "busy"], ["attempted", "read"], +] as const) { + test(`late positive inspection replays only a definitive ${winnerState} claim winner (read failure ${replayFailure})`, async () => { + if (replayFailure) spies.push(spyOn(ledger, "readGrokResetCouponTerminalReplay").mockImplementation(() => { + throw replayFailure === "busy" ? busySettlement() : new Error("fixture unreadable winner"); + })); + let release!: (value: { tokens: typeof TOKEN[] }) => void; + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + let reads = 0; + const assertOutsideTransaction = () => expect(() => readConfigGenerationInCurrentMutationTransaction()).toThrow(); + const auth = spyOn(oauth, "getValidAccessSnapshotForAccount").mockImplementation(async () => { + assertOutsideTransaction(); + return { accessToken: "fixture-access-token" } as never; + }); + const remaining = spyOn(coupons, "getGrokRemainingResets").mockImplementation(() => { + assertOutsideTransaction(); + if (++reads === 1) { entered(); return new Promise(resolve => { release = resolve; }); } + return Promise.resolve({ tokens: winnerState === "failed" ? [] : [TOKEN] }); + }); + const redeem = spyOn(coupons, "redeemGrokResetCoupon").mockImplementation(async () => { + assertOutsideTransaction(); + if (winnerState === "attempted") throw new Error("fixture lost delivery"); + return { success: true, status: 0 }; + }); + spies.push(auth, remaining, redeem); + const loser = handleGrokCouponRoutes(request()); + await started; + try { + const winner = (await handleGrokCouponRoutes(request()))!; + expect(winner.status).toBe(winnerState === "failed" ? 409 : winnerState === "settled" ? 200 : 502); + const before = readFileSync(ledger.grokCouponJournalPath(), "utf8"); + expect(JSON.parse(before).operations[OP].status).toBe(winnerState); + release({ tokens: [TOKEN] }); + const response = (await loser)!; + expect(readFileSync(ledger.grokCouponJournalPath(), "utf8")).toBe(before); + expect(response.status).toBe(winnerState === "attempted" ? 409 : 200); + const body = await response.json(); + if (winnerState === "attempted") { + expect(body.error.code).toBe("attempt_in_progress"); + expect(body.code).toBeUndefined(); + expect(body.replayed).toBeUndefined(); + } else { + const expectedCode = winnerState === "failed" ? "coupon_unavailable" : "redeemed"; + expect(body).toEqual({ code: expectedCode, replayed: true, tokenId: TOKEN.tokenId, + settledAt: JSON.parse(before).operations[OP].updatedAt }); + const replay = (await handleGrokCouponRoutes(request()))!; + expect(replay.status).toBe(200); + expect(await replay.json()).toEqual(body); + } + expect(redeem).toHaveBeenCalledTimes(winnerState === "failed" ? 0 : 1); + } finally { release({ tokens: [TOKEN] }); await loser; } + }); +} + +test("a missing or still-open operation has no terminal replay", () => { + expect(ledger.readGrokResetCouponTerminalReplay(OP, "fixture-account", TOKEN.tokenId)).toBeNull(); + ledger.openGrokResetCouponOperation(identity()); + const before = readFileSync(ledger.grokCouponJournalPath(), "utf8"); + expect(ledger.readGrokResetCouponTerminalReplay(OP, "fixture-account", TOKEN.tokenId)).toBeNull(); + expect(readFileSync(ledger.grokCouponJournalPath(), "utf8")).toBe(before); +}); diff --git a/tests/providers/xai/grok-coupon-reconcile.test.ts b/tests/providers/xai/grok-coupon-reconcile.test.ts new file mode 100644 index 00000000000..be24ee83e02 --- /dev/null +++ b/tests/providers/xai/grok-coupon-reconcile.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; +import * as oauthModule from "../../../src/oauth"; +import * as oauthStoreModule from "../../../src/oauth/store"; +import * as ledgerModule from "../../../src/grok/reset-coupon-ledger"; +import * as resetCouponsModule from "../../../src/grok/reset-coupons"; +import { + markGrokResetCouponAttempt, + openGrokResetCouponOperation, +} from "../../../src/grok/reset-coupon-ledger"; +import { handleGrokCouponRoutes } from "../../../src/server/management/grok-coupon-routes"; +import type { ManagementContext } from "../../../src/server/management/context"; +import type { OcxConfig } from "../../../src/types"; + +function consumeRequest(operationId: string, tokenId?: string): ManagementContext { + const url = new URL("http://localhost/api/grok/reset-coupons/consume"); + const req = new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: "acc-1", tokenId, operationId }), + }); + return { req, url, config: {} as OcxConfig, deps: {} as never, version: "test" } as ManagementContext; +} + +const TOKEN = { tokenId: "tok-live", validityStart: "2026-01-01T00:00:00Z", validityEnd: "2999-01-01T00:00:00Z" }; + +describe("grok coupon attempt reconciliation", () => { + let tempDir = ""; + let previousHome: string | undefined; + let selectionSpy: ReturnType; + let snapshotSpy: ReturnType; + let remainingSpy: ReturnType; + let redeemSpy: ReturnType; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + tempDir = mkdtempSync(join(tmpdir(), "ocx-grok-reconcile-")); + process.env.OPENCODEX_HOME = tempDir; + selectionSpy = spyOn(oauthStoreModule, "captureOAuthAccountSelection").mockReturnValue({ accountId: "acc-1" } as never); + snapshotSpy = spyOn(oauthModule, "getValidAccessSnapshotForAccount").mockResolvedValue({ accessToken: "tok" } as never); + remainingSpy = spyOn(resetCouponsModule, "getGrokRemainingResets").mockResolvedValue({ tokens: [TOKEN] } as never); + redeemSpy = spyOn(resetCouponsModule, "redeemGrokResetCoupon").mockResolvedValue(undefined as never); + }); + + afterEach(() => { + selectionSpy.mockRestore(); + snapshotSpy.mockRestore(); + remainingSpy.mockRestore(); + redeemSpy.mockRestore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(tempDir); + }); + + const STALE = 120_000; + + it("keeps an interrupted attempt unresolved when the coupon is still listed", async () => { + openGrokResetCouponOperation({ accountId: "acc-1", tokenId: "tok-live", operationId: "11111111-1111-4111-8111-111111111111" }); + markGrokResetCouponAttempt("11111111-1111-4111-8111-111111111111", "tok-live", Date.now() - STALE); + + const res = await handleGrokCouponRoutes(consumeRequest("11111111-1111-4111-8111-111111111111", "tok-live")); + expect(res).not.toBeNull(); + expect(res!.status).toBe(409); + const body = await res!.json(); + expect(body.error.code).toBe("attempt_unresolved"); + expect(redeemSpy).not.toHaveBeenCalled(); + expect(openGrokResetCouponOperation({ accountId: "acc-1", tokenId: "tok-live", operationId: "11111111-1111-4111-8111-111111111111" })).toMatchObject({ kind: "replay", code: undefined }); + }); + + it("does not infer an interrupted operation's success from a missing still-valid token", async () => { + openGrokResetCouponOperation({ accountId: "acc-1", tokenId: "tok-live", operationId: "22222222-2222-4222-8222-222222222222" }); + markGrokResetCouponAttempt("22222222-2222-4222-8222-222222222222", "tok-live", Date.now() - STALE, undefined, Date.now() + 86_400_000); + remainingSpy.mockResolvedValue({ tokens: [] } as never); + const before = readFileSync(ledgerModule.grokCouponJournalPath(), "utf8"); + + const res = await handleGrokCouponRoutes(consumeRequest("22222222-2222-4222-8222-222222222222", "tok-live")); + expect(res!.status).toBe(409); + const body = await res!.json(); + expect(body.error.code).toBe("attempt_unresolved"); + expect(body.success).toBeUndefined(); + expect(readFileSync(ledgerModule.grokCouponJournalPath(), "utf8")).toBe(before); + expect(redeemSpy).not.toHaveBeenCalled(); + }); + + it("reports attempt_unresolved when the token lapsed during an interrupted attempt", async () => { + openGrokResetCouponOperation({ accountId: "acc-1", tokenId: "tok-live", operationId: "33333333-3333-4333-8333-333333333333" }); + markGrokResetCouponAttempt("33333333-3333-4333-8333-333333333333", "tok-live", Date.now() - 3_600_000, undefined, Date.now() - 60_000); + remainingSpy.mockResolvedValue({ tokens: [] } as never); + + const res = await handleGrokCouponRoutes(consumeRequest("33333333-3333-4333-8333-333333333333", "tok-live")); + expect(res!.status).toBe(409); + const body = await res!.json(); + expect(body.error.code).toBe("attempt_unresolved"); + expect(redeemSpy).not.toHaveBeenCalled(); + }); + + it("refuses to resume an attempt that may still be in flight", async () => { + openGrokResetCouponOperation({ accountId: "acc-1", tokenId: "tok-live", operationId: "55555555-5555-4555-8555-555555555555" }); + markGrokResetCouponAttempt("55555555-5555-4555-8555-555555555555", "tok-live"); + + const res = await handleGrokCouponRoutes(consumeRequest("55555555-5555-4555-8555-555555555555", "tok-live")); + expect(res!.status).toBe(409); + const body = await res!.json(); + expect(body.error.code).toBe("attempt_in_progress"); + // No upstream calls at all — the in-flight window is checked first. + expect(remainingSpy).not.toHaveBeenCalled(); + expect(redeemSpy).not.toHaveBeenCalled(); + }); + + it("rejects a retry that names a different coupon than the recorded attempt", async () => { + openGrokResetCouponOperation({ accountId: "acc-1", tokenId: "tok-live", operationId: "66666666-6666-4666-8666-666666666666" }); + markGrokResetCouponAttempt("66666666-6666-4666-8666-666666666666", "tok-live", Date.now() - STALE); + + const res = await handleGrokCouponRoutes(consumeRequest("66666666-6666-4666-8666-666666666666", "tok-other")); + expect(res!.status).toBe(409); + const body = await res!.json(); + expect(body.error.code).toBe("operation_token_mismatch"); + expect(redeemSpy).not.toHaveBeenCalled(); + }); + + it("refuses a stale-window retry while the original transport is still running", async () => { + let releaseRedeem!: () => void; + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + redeemSpy.mockImplementation(() => new Promise(done => { releaseRedeem = done; entered(); })); + const mark = ledgerModule.markGrokResetCouponAttempt; + const markSpy = spyOn(ledgerModule, "markGrokResetCouponAttempt").mockImplementation((op, token, _now, path, end) => mark(op, token, Date.now() - STALE, path, end)); + const first = handleGrokCouponRoutes(consumeRequest("77777777-7777-4777-8777-777777777777", "tok-live")); + try { + await started; + const second = await handleGrokCouponRoutes(consumeRequest("77777777-7777-4777-8777-777777777777", "tok-live")); + expect(second!.status).toBe(409); + expect((await second!.json()).error.code).toBe("attempt_in_progress"); + expect(redeemSpy).toHaveBeenCalledTimes(1); + } finally { releaseRedeem(); markSpy.mockRestore(); } + expect((await first)!.status).toBe(200); + }); + + it("rejects a requested coupon that is no longer listed upstream", async () => { + const res = await handleGrokCouponRoutes(consumeRequest("44444444-4444-4444-8444-444444444444", "tok-stale")); + expect(res!.status).toBe(409); + const body = await res!.json(); + expect(body.error.code).toBe("coupon_unavailable"); + expect(redeemSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/providers/xai/grok-reset-coupon-cli.test.ts b/tests/providers/xai/grok-reset-coupon-cli.test.ts index 39ce3632cf9..7eaf5ae37ff 100644 --- a/tests/providers/xai/grok-reset-coupon-cli.test.ts +++ b/tests/providers/xai/grok-reset-coupon-cli.test.ts @@ -23,7 +23,8 @@ function deps(respond: (captured: Captured) => unknown, calls: Captured[]): Runt body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), }; calls.push(captured); - return new Response(JSON.stringify(respond(captured)), { status: 200 }); + const reply = respond(captured); + return reply instanceof Response ? reply : new Response(JSON.stringify(reply), { status: 200 }); }) as unknown as typeof fetch, }; } @@ -39,6 +40,51 @@ function capture(): { lines: string[]; errors: string[]; restore: () => void } { } describe("ocx account grok-reset-coupons", () => { + test("consume mints a stable operation id before sending", async () => { + const calls: Captured[] = []; + const out = capture(); + try { + expect(await handleAccountAuthCommand("grok-reset-coupons", ["main", "--consume", "--yes"], + deps(() => ({ code: "redeemed" }), calls))).toBe(0); + } finally { out.restore(); } + expect(calls).toHaveLength(1); + expect((calls[0].body as { operationId: string }).operationId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + + test("an uncertain response prints the same operation id instead of encouraging a new spend", async () => { + const calls: Captured[] = []; + const out = capture(); + try { + expect(await handleAccountAuthCommand("grok-reset-coupons", ["main", "--consume", "--yes"], deps(c => + new Response(JSON.stringify({ operationId: (c.body as { operationId: string }).operationId, + error: { code: "attempt_unresolved" } }), { status: 502 }), calls))).toBeGreaterThan(0); + } finally { out.restore(); } + expect(calls).toHaveLength(1); + expect(out.errors.join("\n")).toContain(`--operation-id ${(calls[0].body as { operationId: string }).operationId}`); + }); + + test("a changed operation preserves its client-minted recovery id without another POST", async () => { + const calls: Captured[] = []; + const out = capture(); + try { + expect(await handleAccountAuthCommand("grok-reset-coupons", ["main", "--consume", "--yes"], deps(() => + new Response(JSON.stringify({ error: { code: "operation_state_changed" } }), { status: 409 }), calls))).toBeGreaterThan(0); + } finally { out.restore(); } + expect(calls).toHaveLength(1); + expect(out.errors.join("\n")).toContain(`--operation-id ${(calls[0].body as { operationId: string }).operationId}`); + }); + + test("a lost response retains its client-minted operation id without another POST", async () => { + const calls: Captured[] = []; + const out = capture(); + try { + expect(await handleAccountAuthCommand("grok-reset-coupons", ["main", "--consume", "--yes"], + deps(() => { throw new Error("fixture connection lost after dispatch"); }, calls))).toBeGreaterThan(0); + } finally { out.restore(); } + expect(calls).toHaveLength(1); + expect(out.errors.join("\n")).toContain(`--operation-id ${(calls[0].body as { operationId: string }).operationId}`); + }); + test("--consume without --yes refuses locally, before any fetch", async () => { const calls: Captured[] = []; const out = capture(); diff --git a/tests/providers/xai/grok-reset-coupons.test.ts b/tests/providers/xai/grok-reset-coupons.test.ts index e61d5e62e88..af534c5d98a 100644 --- a/tests/providers/xai/grok-reset-coupons.test.ts +++ b/tests/providers/xai/grok-reset-coupons.test.ts @@ -20,6 +20,7 @@ import { } from "../../../src/grok/reset-coupons"; import { grokCouponJournalPath, + markGrokResetCouponAttempt, openGrokResetCouponOperation, recordGrokResetCouponSettlement, } from "../../../src/grok/reset-coupon-ledger"; @@ -273,6 +274,39 @@ describe("grok reset coupons", () => { expect(grpcErr.statusMessage).toContain("Invalid token_id"); }); + for (const rpc of ["inspection", "redemption"] as const) { + for (const response of ["empty", "data-only", "missing-status", "invalid-status"] as const) { + it(`${rpc} rejects HTTP 200 ${response} without a confirmed gRPC status`, async () => { + let bytes = new Uint8Array(0); + if (response === "data-only") bytes = encodeGrpcWebEnvelope(new Uint8Array(0)); + if (response === "missing-status" || response === "invalid-status") { + const payload = new TextEncoder().encode(response === "missing-status" + ? "grpc-message:ok\r\n" : "grpc-status:0garbage\r\n"); + bytes = new Uint8Array(5 + payload.length); + bytes[0] = 0x80; + new DataView(bytes.buffer).setUint32(1, payload.length, false); + bytes.set(payload, 5); + } + const fetchFn: typeof fetch = async () => new Response(bytes, { status: 200 }); + const result = rpc === "inspection" + ? getGrokRemainingResets({ accessToken: "fixture-token", fetchFn }) + : redeemGrokResetCoupon({ accessToken: "fixture-token", tokenId: "fixture-coupon", fetchFn }); + await expect(result).rejects.toThrow("Grok coupon response has no confirmed gRPC status"); + }); + } + } + + it("accepts a redemption with an explicit successful gRPC trailer", async () => { + const payload = new TextEncoder().encode("grpc-status:0\r\n"); + const bytes = new Uint8Array(5 + payload.length); + bytes[0] = 0x80; + new DataView(bytes.buffer).setUint32(1, payload.length, false); + bytes.set(payload, 5); + const fetchFn: typeof fetch = async () => new Response(bytes, { status: 200 }); + expect(await redeemGrokResetCoupon({ accessToken: "fixture-token", tokenId: "fixture-coupon", fetchFn })) + .toMatchObject({ success: true, status: 0 }); + }); + it("handles crash-safe ledger open and idempotent replay", () => { const ledgerPath = grokCouponJournalPath(tempDir); @@ -283,11 +317,14 @@ describe("grok reset coupons", () => { }, undefined, ledgerPath); expect(first.kind).toBe("execute"); + markGrokResetCouponAttempt("op-uuid-1", "tok-456", undefined, ledgerPath); recordGrokResetCouponSettlement({ operationId: "op-uuid-1", + accountId: "acc-123", tokenId: "tok-456", code: "redeemed", status: "success", + expectedStatus: "attempted", }, undefined, ledgerPath); // Re-opening the same settled operationId replays the durable outcome @@ -301,6 +338,60 @@ describe("grok reset coupons", () => { expect(replay.settledAt).toBeDefined(); }); + it("replays an interrupted attempt without a code and settles it later", () => { + const ledgerPath = grokCouponJournalPath(tempDir); + const identity = { accountId: "acc-123", tokenId: "tok-456", operationId: "op-uuid-2" }; + + openGrokResetCouponOperation(identity, undefined, ledgerPath); + markGrokResetCouponAttempt("op-uuid-2", "tok-456", undefined, ledgerPath); + + // An attempted-but-never-settled op replays with no code: the route must + // reconcile upstream rather than trusting the record as a success. + const interrupted = openGrokResetCouponOperation(identity, undefined, ledgerPath); + expect(interrupted.kind).toBe("replay"); + expect(interrupted.code).toBeUndefined(); + expect(interrupted.tokenId).toBe("tok-456"); + + // Once a confirmed upstream response is settled, open replays that outcome. + recordGrokResetCouponSettlement( + { operationId: "op-uuid-2", accountId: "acc-123", tokenId: "tok-456", code: "redeemed", status: "success", expectedStatus: "attempted" }, + undefined, + ledgerPath, + ); + const settled = openGrokResetCouponOperation(identity, undefined, ledgerPath); + expect(settled.kind).toBe("replay"); + expect(settled.code).toBe("redeemed"); + }); + + it("expires stranded open and attempted records past the retention window", () => { + const ledgerPath = grokCouponJournalPath(tempDir); + const t0 = 1_700_000_000_000; + const DAY = 24 * 60 * 60_000; + + openGrokResetCouponOperation( + { accountId: "acc-123", tokenId: "tok-456", operationId: "op-old" }, + t0, + ledgerPath, + ); + markGrokResetCouponAttempt("op-old", "tok-456", t0, ledgerPath); + + // A later write prunes the stranded attempt... + openGrokResetCouponOperation( + { accountId: "acc-123", tokenId: "tok-999", operationId: "op-new" }, + t0 + 31 * DAY, + ledgerPath, + ); + + // ...so the same operationId can open fresh instead of being a dead + // record occupying the 256-operation cap forever. + const reopened = openGrokResetCouponOperation( + { accountId: "acc-123", tokenId: "tok-456", operationId: "op-old" }, + t0 + 31 * DAY + 1, + ledgerPath, + ); + expect(reopened.kind).toBe("execute"); + }); + it("refreshes token on 401 when integrated with refresh provider stub", async () => { let callCount = 0; let tokenUsed = "";