From 89bd0fba903bbfd78b0d99ce6f194863dd01b7e1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:23:19 -0700 Subject: [PATCH] feat(codex): redeem banked usage-limit resets via /usage reset (#64280) OpenAI lets ChatGPT-plan Codex users bank rate-limit reset credits, but until now they could only be redeemed from the Codex CLI/app or the website. This wires the same backend API into Hermes: - /usage on the openai-codex provider now shows "You have N resets banked - use /usage reset to activate" (parsed from the rate_limit_reset_credits field the /usage endpoint already returns). - New /usage reset subcommand (CLI + gateway) redeems one banked credit via POST .../rate-limit-reset-credits/consume with a UUID idempotency key, mirroring codex-rs backend-client semantics (PathStyle /wham vs /api/codex, ChatGPT-Account-Id header, reset/nothing_to_reset/no_credit/already_redeemed outcomes). - Guard: redemption is refused while no rate-limit window is fully exhausted, since a banked reset restores the FULL 5h + weekly allowance and spending it early wastes it. /usage reset --force overrides. Zero banked credits and non-codex providers are refused with clear messages; nothing_to_reset reports the credit was NOT spent. - i18n: new gateway.usage.unknown_subcommand / reset_wrong_provider keys across all 16 locales; docs updated (cli.md, messaging index). Tested with unit tests plus a real-socket E2E against a local fake Codex backend exercising redeem/guard/force and the /usage hint. --- agent/account_usage.py | 202 ++++++++++++++++++++- cli.py | 49 ++++- gateway/slash_commands.py | 24 +++ hermes_cli/commands.py | 3 +- locales/af.yaml | 2 + locales/de.yaml | 2 + locales/en.yaml | 2 + locales/es.yaml | 2 + locales/fr.yaml | 2 + locales/ga.yaml | 2 + locales/hu.yaml | 2 + locales/it.yaml | 2 + locales/ja.yaml | 2 + locales/ko.yaml | 2 + locales/pt.yaml | 2 + locales/ru.yaml | 2 + locales/tr.yaml | 2 + locales/uk.yaml | 2 + locales/zh-hant.yaml | 2 + locales/zh.yaml | 2 + tests/agent/test_account_usage.py | 201 ++++++++++++++++++++ tests/gateway/test_usage_command.py | 71 ++++++++ website/docs/user-guide/cli.md | 2 + website/docs/user-guide/messaging/index.md | 2 +- 24 files changed, 579 insertions(+), 7 deletions(-) diff --git a/agent/account_usage.py b/agent/account_usage.py index 712e57feda4a..9e48b0aac0cb 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -425,15 +425,28 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred ) -def _resolve_codex_usage_url(base_url: str) -> str: +def _codex_backend_urls(base_url: str) -> tuple[str, str, str]: + """Resolve the Codex backend endpoints (usage, reset-credits list, consume). + + Mirrors the Codex CLI's PathStyle split (codex-rs backend-client): base URLs + containing ``/backend-api`` use the ChatGPT ``/wham/...`` paths; everything + else uses ``/api/codex/...``. + """ normalized = (base_url or "").strip().rstrip("/") if not normalized: normalized = "https://chatgpt.com/backend-api/codex" if normalized.endswith("/codex"): normalized = normalized[: -len("/codex")] - if "/backend-api" in normalized: - return normalized + "/wham/usage" - return normalized + "/api/codex/usage" + prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex") + return ( + prefix + "/usage", + prefix + "/rate-limit-reset-credits", + prefix + "/rate-limit-reset-credits/consume", + ) + + +def _resolve_codex_usage_url(base_url: str) -> str: + return _codex_backend_urls(base_url)[0] def _resolve_codex_usage_credentials( @@ -525,6 +538,14 @@ def _fetch_codex_account_usage( ) ) details: list[str] = [] + reset_credits = payload.get("rate_limit_reset_credits") or {} + banked = reset_credits.get("available_count") + if isinstance(banked, (int, float)) and int(banked) > 0: + count = int(banked) + plural = "s" if count != 1 else "" + details.append( + f"You have {count} reset{plural} banked - use /usage reset to activate" + ) credits = payload.get("credits") or {} if credits.get("has_credits"): balance = credits.get("balance") @@ -542,6 +563,179 @@ def _fetch_codex_account_usage( ) +@dataclass(frozen=True) +class CodexResetRedeemResult: + """Outcome of a `/usage reset` attempt against the Codex backend.""" + + status: str # reset | nothing_to_reset | no_credit | already_redeemed | + # not_exhausted | no_credits_banked | unavailable + message: str + available_count: int = 0 + windows_reset: int = 0 + + @property + def redeemed(self) -> bool: + return self.status == "reset" + + +# Client-side guard threshold: a rate-limit window only counts as exhausted +# when it is fully used. Below this, redeeming a banked reset wastes most of +# its value, so we block and point at --force instead. +_CODEX_WINDOW_EXHAUSTED_PERCENT = 100.0 + + +def redeem_codex_reset_credit( + *, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + force: bool = False, +) -> CodexResetRedeemResult: + """Redeem one banked Codex rate-limit reset credit (`/usage reset`). + + Flow (mirrors the Codex CLI's reset-credits picker, codex-rs + ``backend-client``): + + 1. ``GET .../usage`` — read the current windows + banked credit count. + 2. Guard: zero banked credits → refuse. No window fully used and not + ``force`` → refuse with a warning (a banked reset restores the WHOLE + 5h + weekly allowance; burning it early wastes it). The backend has + the same protection (``nothing_to_reset`` doesn't consume the + credit), but failing fast client-side gives a clearer message. + 3. ``POST .../rate-limit-reset-credits/consume`` with a fresh UUID + idempotency key (``redeem_request_id``). No ``credit_id`` — the + backend picks the next available credit, exactly like the CLI's + default "Full reset" option. + + Never raises: every failure mode returns a ``CodexResetRedeemResult`` + with a user-renderable message. + """ + import uuid + + try: + token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key) + except Exception: + return CodexResetRedeemResult( + status="unavailable", + message="No Codex credentials available. Run `hermes auth` to sign in with your ChatGPT account.", + ) + usage_url, _credits_url, consume_url = _codex_backend_urls(resolved_base_url) + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "User-Agent": "codex-cli", + } + if account_id: + headers["ChatGPT-Account-Id"] = account_id + + try: + with httpx.Client(timeout=15.0) as client: + usage_resp = client.get(usage_url, headers=headers) + usage_resp.raise_for_status() + payload = usage_resp.json() or {} + + reset_credits = payload.get("rate_limit_reset_credits") or {} + raw_count = reset_credits.get("available_count") + available = int(raw_count) if isinstance(raw_count, (int, float)) else 0 + if available <= 0: + return CodexResetRedeemResult( + status="no_credits_banked", + message="No banked reset credits on this account — nothing to redeem.", + ) + + rate_limit = payload.get("rate_limit") or {} + worst_used: Optional[float] = None + for key in ("primary_window", "secondary_window"): + used = (rate_limit.get(key) or {}).get("used_percent") + if isinstance(used, (int, float)): + worst_used = max(worst_used or 0.0, float(used)) + exhausted = worst_used is not None and worst_used >= _CODEX_WINDOW_EXHAUSTED_PERCENT + if not exhausted and not force: + usage_note = ( + f"your busiest window is only {worst_used:.0f}% used" + if worst_used is not None + else "your current usage could not be confirmed as exhausted" + ) + plural = "s" if available != 1 else "" + return CodexResetRedeemResult( + status="not_exhausted", + message=( + f"⚠️ Not redeeming: {usage_note}. A banked reset restores your FULL " + f"5h + weekly limits, so spending it now would waste most of it. " + f"You have {available} reset{plural} banked. " + f"Use `/usage reset --force` to redeem anyway." + ), + available_count=available, + ) + + consume_resp = client.post( + consume_url, + headers={**headers, "Content-Type": "application/json"}, + json={"redeem_request_id": str(uuid.uuid4())}, + ) + consume_resp.raise_for_status() + body = consume_resp.json() or {} + except httpx.HTTPStatusError as exc: + code = exc.response.status_code + if code in (401, 403): + return CodexResetRedeemResult( + status="unavailable", + message=( + "Codex backend rejected the request (HTTP " + f"{code}). Reset credits require ChatGPT-account (OAuth) auth — " + "run `hermes auth` and sign in with your ChatGPT account." + ), + ) + return CodexResetRedeemResult( + status="unavailable", + message=f"Codex backend error (HTTP {code}) — try again shortly.", + ) + except Exception as exc: + return CodexResetRedeemResult( + status="unavailable", + message=f"Could not reach the Codex backend: {exc}", + ) + + code = str(body.get("code", "") or "").strip().lower() + windows_reset = body.get("windows_reset") + windows_reset = int(windows_reset) if isinstance(windows_reset, (int, float)) else 0 + remaining = max(0, available - 1) + plural = "s" if remaining != 1 else "" + if code == "reset": + return CodexResetRedeemResult( + status="reset", + message=( + f"✅ Reset redeemed — your usage limits have been reset. " + f"{remaining} banked reset{plural} remaining." + ), + available_count=remaining, + windows_reset=windows_reset, + ) + if code == "nothing_to_reset": + return CodexResetRedeemResult( + status="nothing_to_reset", + message=( + "Backend reports nothing to reset — your limits aren't exhausted. " + "The credit was NOT spent." + ), + available_count=available, + ) + if code == "no_credit": + return CodexResetRedeemResult( + status="no_credit", + message="Backend reports no available reset credit on this account.", + ) + if code == "already_redeemed": + return CodexResetRedeemResult( + status="already_redeemed", + message="This redemption was already processed — no additional credit was spent.", + available_count=remaining, + ) + return CodexResetRedeemResult( + status="unavailable", + message=f"Unexpected response from the Codex backend: {body!r}", + ) + + def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]: token = (resolve_anthropic_token() or "").strip() if not token: diff --git a/cli.py b/cli.py index 9887bb029780..796f38cb4c67 100644 --- a/cli.py +++ b/cli.py @@ -8722,7 +8722,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): elif canonical == "compress": self._manual_compress(cmd_original) elif canonical == "usage": - self._show_usage() + self._handle_usage_command(cmd_original) elif canonical == "credits": self._show_credits() elif canonical == "billing": @@ -9623,6 +9623,53 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): + def _handle_usage_command(self, cmd_original: str): + """Dispatch `/usage [reset [--force]]`. + + Bare `/usage` keeps the classic display. `/usage reset` redeems one + banked Codex rate-limit reset credit (guarded: refuses when limits + aren't exhausted unless --force). + """ + parts = cmd_original.split() + args = [p.lower() for p in parts[1:]] + if args and args[0] == "reset": + self._usage_reset(force="--force" in args[1:]) + return + if args: + print(f" Unknown /usage subcommand: {' '.join(parts[1:])}. Try /usage or /usage reset [--force].") + return + self._show_usage() + + def _usage_reset(self, force: bool = False): + """`/usage reset [--force]` — redeem one banked Codex reset credit.""" + provider = ( + (getattr(self.agent, "provider", None) if self.agent else None) + or getattr(self, "provider", None) + ) + normalized = str(provider or "").strip().lower() + if normalized != "openai-codex": + print(" Banked usage resets are only available on the openai-codex provider.") + print(" Switch with `/model` or `hermes auth` first.") + return + base_url = (getattr(self.agent, "base_url", None) if self.agent else None) or getattr(self, "base_url", None) + api_key = (getattr(self.agent, "api_key", None) if self.agent else None) or getattr(self, "api_key", None) + + from agent.account_usage import redeem_codex_reset_credit + + print(" ⏳ Checking banked reset credits...") + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _pool: + try: + result = _pool.submit( + redeem_codex_reset_credit, + base_url=base_url, + api_key=api_key, + force=force, + ).result(timeout=45.0) + except concurrent.futures.TimeoutError: + print(" ❌ Timed out talking to the Codex backend — try again shortly.") + return + print(f" {result.message}") + def _show_usage(self): """Rate limits + session token usage (when a live agent exists) + Nous credits. diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 38ab051c8e47..765369f1c5e6 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3951,6 +3951,15 @@ class GatewaySlashCommandsMixin: source = event.source session_key = self._session_key_for_source(source) + # `/usage reset [--force]` — redeem one banked Codex rate-limit reset + # credit. Parsed before the display path so it never mixes with the + # stats rendering below. + raw_args = event.get_command_args().strip() + args = [a.lower() for a in raw_args.split()] if raw_args else [] + wants_reset = bool(args) and args[0] == "reset" + if args and not wants_reset: + return t("gateway.usage.unknown_subcommand", args=raw_args) + # Try running agent first (mid-turn), then cached agent (between turns) agent = self._running_agents.get(session_key) if not agent or agent is _AGENT_PENDING_SENTINEL: @@ -3978,6 +3987,21 @@ class GatewaySlashCommandsMixin: provider = provider or persisted.get("billing_provider") base_url = base_url or persisted.get("billing_base_url") + if wants_reset: + normalized_provider = str(provider or "").strip().lower() + if normalized_provider != "openai-codex": + return t("gateway.usage.reset_wrong_provider") + force = "--force" in args[1:] + from agent.account_usage import redeem_codex_reset_credit + + result = await asyncio.to_thread( + redeem_codex_reset_credit, + base_url=base_url, + api_key=api_key, + force=force, + ) + return result.message + # Fetch account usage off the event loop so slow provider APIs don't # block the gateway. Failures are non-fatal -- account_lines stays []. account_lines: list[str] = [] diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 4f32f7315cbd..10f8fbf046b8 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -228,7 +228,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("help", "Show available commands", "Info"), CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session", gateway_only=True), - CommandDef("usage", "Show token usage and rate limits for the current session", "Info"), + CommandDef("usage", "Show token usage and rate limits; `reset` redeems a banked Codex limit reset", "Info", + args_hint="[reset [--force]]"), CommandDef("credits", "Show Nous credit balance and top up", "Info"), CommandDef("billing", "Manage Nous terminal billing — buy credits, auto-reload, limits", "Info", cli_only=True), diff --git a/locales/af.yaml b/locales/af.yaml index eecc3bdcf8c8..cc9a45060c93 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "Geskatte konteks: ~{count} tokens" detailed_after_first: "_(Gedetailleerde gebruik beskikbaar na die eerste agent-antwoord)_" no_data: "Geen gebruiksdata beskikbaar vir hierdie sessie nie." + unknown_subcommand: "Onbekende /usage subopdrag: `{args}`. Probeer `/usage` of `/usage reset [--force]`." + reset_wrong_provider: "Gebankte gebruikslimiet-terugstellings is slegs beskikbaar op die openai-codex verskaffer. Skakel eers oor met `/model`." credits: not_logged_in: "Nie by Nous Portal aangemeld nie. Meld aan om jou kredietsaldo te sien en op te laai." diff --git a/locales/de.yaml b/locales/de.yaml index 0d8539778554..093ee68be96f 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "Geschätzter Kontext: ~{count} Tokens" detailed_after_first: "_(Detaillierte Nutzung nach der ersten Agentenantwort verfügbar)_" no_data: "Keine Nutzungsdaten für diese Sitzung verfügbar." + unknown_subcommand: "Unbekannter /usage-Unterbefehl: `{args}`. Versuche `/usage` oder `/usage reset [--force]`." + reset_wrong_provider: "Gespeicherte Limit-Resets sind nur mit dem openai-codex-Anbieter verfügbar. Wechsle zuerst mit `/model`." credits: not_logged_in: "Nicht bei Nous Portal angemeldet. Melde dich an, um dein Guthaben zu sehen und aufzuladen." diff --git a/locales/en.yaml b/locales/en.yaml index 852ce88b4113..c47f875b0212 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -376,6 +376,8 @@ gateway: label_estimated_context: "Estimated context: ~{count} tokens" detailed_after_first: "_(Detailed usage available after the first agent response)_" no_data: "No usage data available for this session." + unknown_subcommand: "Unknown /usage subcommand: `{args}`. Try `/usage` or `/usage reset [--force]`." + reset_wrong_provider: "Banked usage resets are only available on the openai-codex provider. Switch with `/model` first." credits: not_logged_in: "Not logged into Nous Portal. Log in to see your credit balance and top up." diff --git a/locales/es.yaml b/locales/es.yaml index aaf0f1e14a83..c2ed50017c1a 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -361,6 +361,8 @@ gateway: label_estimated_context: "Contexto estimado: ~{count} tokens" detailed_after_first: "_(Uso detallado disponible tras la primera respuesta del agente)_" no_data: "No hay datos de uso disponibles para esta sesión." + unknown_subcommand: "Subcomando /usage desconocido: `{args}`. Prueba `/usage` o `/usage reset [--force]`." + reset_wrong_provider: "Los reinicios de límite acumulados solo están disponibles con el proveedor openai-codex. Cambia primero con `/model`." credits: not_logged_in: "No has iniciado sesión en Nous Portal. Inicia sesión para ver tu saldo de créditos y recargar." diff --git a/locales/fr.yaml b/locales/fr.yaml index 6173e684a1b5..da3bcaedd3ec 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "Contexte estimé : ~{count} jetons" detailed_after_first: "_(Utilisation détaillée disponible après la première réponse de l'agent)_" no_data: "Aucune donnée d'utilisation disponible pour cette session." + unknown_subcommand: "Sous-commande /usage inconnue : `{args}`. Essayez `/usage` ou `/usage reset [--force]`." + reset_wrong_provider: "Les réinitialisations de limite en réserve ne sont disponibles qu'avec le fournisseur openai-codex. Changez d'abord avec `/model`." credits: not_logged_in: "Non connecté à Nous Portal. Connecte-toi pour voir ton solde de crédits et recharger." diff --git a/locales/ga.yaml b/locales/ga.yaml index 7317c2eba171..8e56b693d590 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -368,6 +368,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "Comhthéacs measta: ~{count} comhartha" detailed_after_first: "_(Úsáid mhionsonraithe ar fáil tar éis chéad fhreagra an ghníomhaire)_" no_data: "Níl aon sonraí úsáide ar fáil don seisiún seo." + unknown_subcommand: "Fo-ordú /usage anaithnid: `{args}`. Bain triail as `/usage` nó `/usage reset [--force]`." + reset_wrong_provider: "Níl athshocruithe teorann bainc ar fáil ach ar an soláthraí openai-codex. Athraigh le `/model` ar dtús." credits: not_logged_in: "Níl tú logáilte isteach i Nous Portal. Logáil isteach chun d'iarmhéid creidmheasa a fheiceáil agus breis a chur leis." diff --git a/locales/hu.yaml b/locales/hu.yaml index 547aa8767624..5bcd86f8c578 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "Becsült kontextus: ~{count} token" detailed_after_first: "_(A részletes használat az első ügynökválasz után érhető el)_" no_data: "Ehhez a munkamenethez nincsenek elérhető használati adatok." + unknown_subcommand: "Ismeretlen /usage alparancs: `{args}`. Próbáld: `/usage` vagy `/usage reset [--force]`." + reset_wrong_provider: "A félretett limit-visszaállítások csak az openai-codex szolgáltatónál érhetők el. Válts először a `/model` paranccsal." credits: not_logged_in: "Nincs bejelentkezve a Nous Portalra. Jelentkezz be a kreditegyenleg megtekintéséhez és feltöltéséhez." diff --git a/locales/it.yaml b/locales/it.yaml index 0cbdcf68fa4f..3cd822282702 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "Contesto stimato: ~{count} token" detailed_after_first: "_(L'uso dettagliato sarà disponibile dopo la prima risposta dell'agente)_" no_data: "Nessun dato di utilizzo disponibile per questa sessione." + unknown_subcommand: "Sottocomando /usage sconosciuto: `{args}`. Prova `/usage` o `/usage reset [--force]`." + reset_wrong_provider: "I reset dei limiti accumulati sono disponibili solo con il provider openai-codex. Cambia prima con `/model`." credits: not_logged_in: "Non hai effettuato l'accesso a Nous Portal. Accedi per vedere il saldo dei crediti e ricaricare." diff --git a/locales/ja.yaml b/locales/ja.yaml index 38f8822b71bb..75e92df462f2 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "推定コンテキスト: ~{count} トークン" detailed_after_first: "_(詳細な使用状況は最初のエージェント応答後に利用可能)_" no_data: "このセッションの使用データはありません。" + unknown_subcommand: "不明な /usage サブコマンド: `{args}`。`/usage` または `/usage reset [--force]` をお試しください。" + reset_wrong_provider: "バンクされた使用制限リセットは openai-codex プロバイダーでのみ利用できます。まず `/model` で切り替えてください。" credits: not_logged_in: "Nous Portal にログインしていません。ログインすると残高の確認とチャージができます。" diff --git a/locales/ko.yaml b/locales/ko.yaml index 752012b0fd4f..d5a368f74c98 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "예상 컨텍스트: 약 {count} 토큰" detailed_after_first: "_(자세한 사용량은 첫 에이전트 응답 이후 확인할 수 있습니다)_" no_data: "이 세션에 사용 가능한 사용량 데이터가 없습니다." + unknown_subcommand: "알 수 없는 /usage 하위 명령: `{args}`. `/usage` 또는 `/usage reset [--force]`를 사용해 보세요." + reset_wrong_provider: "적립된 사용량 한도 초기화는 openai-codex 공급자에서만 사용할 수 있습니다. 먼저 `/model`로 전환하세요." credits: not_logged_in: "Nous Portal에 로그인되어 있지 않습니다. 로그인하면 크레딧 잔액 확인 및 충전을 할 수 있습니다." diff --git a/locales/pt.yaml b/locales/pt.yaml index 12d53e995dc1..2a2fd3068d18 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "Contexto estimado: ~{count} tokens" detailed_after_first: "_(Utilização detalhada disponível após a primeira resposta do agente)_" no_data: "Não há dados de utilização disponíveis para esta sessão." + unknown_subcommand: "Subcomando /usage desconhecido: `{args}`. Tente `/usage` ou `/usage reset [--force]`." + reset_wrong_provider: "Os resets de limite acumulados só estão disponíveis no provedor openai-codex. Troque primeiro com `/model`." credits: not_logged_in: "Você não está conectado ao Nous Portal. Faça login para ver seu saldo de créditos e recarregar." diff --git a/locales/ru.yaml b/locales/ru.yaml index c19536afba8a..3ebf4c94b94f 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "Ориентировочный контекст: ~{count} токенов" detailed_after_first: "_(Подробное использование доступно после первого ответа агента)_" no_data: "Данные об использовании для этого сеанса отсутствуют." + unknown_subcommand: "Неизвестная подкоманда /usage: `{args}`. Попробуйте `/usage` или `/usage reset [--force]`." + reset_wrong_provider: "Накопленные сбросы лимитов доступны только с провайдером openai-codex. Сначала переключитесь через `/model`." credits: not_logged_in: "Вы не вошли в Nous Portal. Войдите, чтобы увидеть баланс кредитов и пополнить его." diff --git a/locales/tr.yaml b/locales/tr.yaml index 9a576624bc77..c62d98b57f81 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "Tahmini bağlam: ~{count} token" detailed_after_first: "_(Ayrıntılı kullanım, ilk ajan yanıtından sonra kullanılabilir)_" no_data: "Bu oturum için kullanım verisi yok." + unknown_subcommand: "Bilinmeyen /usage alt komutu: `{args}`. `/usage` veya `/usage reset [--force]` deneyin." + reset_wrong_provider: "Biriktirilen limit sıfırlamaları yalnızca openai-codex sağlayıcısında kullanılabilir. Önce `/model` ile geçiş yapın." credits: not_logged_in: "Nous Portal'a giriş yapılmadı. Bakiyenizi görmek ve yükleme yapmak için giriş yapın." diff --git a/locales/uk.yaml b/locales/uk.yaml index e78d47653d44..51f8d6c4708f 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "Орієнтовний контекст: ~{count} токенів" detailed_after_first: "_(Детальне використання доступне після першої відповіді агента)_" no_data: "Дані про використання для цього сеансу відсутні." + unknown_subcommand: "Невідома підкоманда /usage: `{args}`. Спробуйте `/usage` або `/usage reset [--force]`." + reset_wrong_provider: "Накопичені скидання лімітів доступні лише з провайдером openai-codex. Спочатку перемкніться через `/model`." credits: not_logged_in: "Ви не ввійшли в Nous Portal. Увійдіть, щоб переглянути баланс кредитів і поповнити його." diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 6602231812f1..537492311724 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "預估上下文:~{count} 個 token" detailed_after_first: "_(首次代理回應後可檢視詳細使用情況)_" no_data: "此工作階段沒有可用的使用資料。" + unknown_subcommand: "未知的 /usage 子命令:`{args}`。請嘗試 `/usage` 或 `/usage reset [--force]`。" + reset_wrong_provider: "儲存的用量重設僅在 openai-codex 提供者上可用。請先使用 `/model` 切換。" credits: not_logged_in: "未登入 Nous Portal。登入後即可查看額度餘額並儲值。" diff --git a/locales/zh.yaml b/locales/zh.yaml index 2e307e4d60ab..d43a804ad4fc 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another label_estimated_context: "估计上下文:~{count} 个令牌" detailed_after_first: "_(首次代理响应后可查看详细使用情况)_" no_data: "此会话暂无使用数据。" + unknown_subcommand: "未知的 /usage 子命令:`{args}`。请尝试 `/usage` 或 `/usage reset [--force]`。" + reset_wrong_provider: "存储的用量重置仅在 openai-codex 提供商上可用。请先使用 `/model` 切换。" credits: not_logged_in: "未登录 Nous Portal。登录后即可查看额度余额并充值。" diff --git a/tests/agent/test_account_usage.py b/tests/agent/test_account_usage.py index 41950c70a12c..86a88d4d8230 100644 --- a/tests/agent/test_account_usage.py +++ b/tests/agent/test_account_usage.py @@ -235,3 +235,204 @@ def test_codex_usage_treats_wham_used_percent_as_used_not_remaining(monkeypatch) assert "14% used" in rendered assert "15% used" not in rendered assert "86% used" not in rendered + + +# ── Banked rate-limit reset credits (`/usage reset`) ───────────────────────── + + +class _FakeResetClient: + """GET returns the usage payload; POST returns the consume payload.""" + + def __init__(self, calls, usage_payload, consume_payload=None): + self.calls = calls + self.usage_payload = usage_payload + self.consume_payload = consume_payload or {} + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def get(self, url, headers): + self.calls.append({"method": "GET", "url": url, "headers": headers}) + return _FakeResponse(self.usage_payload) + + def post(self, url, headers=None, json=None): + self.calls.append({"method": "POST", "url": url, "headers": headers, "json": json}) + return _FakeResponse(self.consume_payload) + + +def _usage_payload_with_resets(primary_used, secondary_used, banked): + return { + "plan_type": "plus", + "rate_limit": { + "primary_window": {"used_percent": primary_used, "reset_at": 1779846359}, + "secondary_window": {"used_percent": secondary_used, "reset_at": 1780230796}, + }, + "rate_limit_reset_credits": {"available_count": banked}, + "credits": {"has_credits": False}, + } + + +def test_usage_snapshot_shows_banked_resets_hint(monkeypatch): + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeResetClient(calls, _usage_payload_with_resets(21, 4, 2)), + ) + + snapshot = account_usage.fetch_account_usage( + "openai-codex", + base_url="https://chatgpt.com/backend-api/codex", + api_key="live-agent-token", + ) + + assert snapshot is not None + rendered = "\n".join(account_usage.render_account_usage_lines(snapshot)) + assert "You have 2 resets banked - use /usage reset to activate" in rendered + + +def test_usage_snapshot_hides_reset_hint_when_none_banked(monkeypatch, codex_usage_payload): + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeResetClient(calls, codex_usage_payload), + ) + + snapshot = account_usage.fetch_account_usage( + "openai-codex", + base_url="https://chatgpt.com/backend-api/codex", + api_key="live-agent-token", + ) + + assert snapshot is not None + rendered = "\n".join(account_usage.render_account_usage_lines(snapshot)) + assert "banked" not in rendered + + +def test_redeem_blocked_when_limits_not_exhausted(monkeypatch): + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeResetClient(calls, _usage_payload_with_resets(60, 30, 2)), + ) + + result = account_usage.redeem_codex_reset_credit( + base_url="https://chatgpt.com/backend-api/codex", + api_key="live-agent-token", + ) + + assert result.status == "not_exhausted" + assert not result.redeemed + assert "--force" in result.message + assert "60% used" in result.message + assert result.available_count == 2 + # The consume endpoint must never be hit — the credit is protected. + assert [c["method"] for c in calls] == ["GET"] + + +def test_redeem_force_bypasses_exhaustion_guard(monkeypatch): + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeResetClient( + calls, + _usage_payload_with_resets(60, 30, 2), + consume_payload={"code": "reset", "windows_reset": 2}, + ), + ) + + result = account_usage.redeem_codex_reset_credit( + base_url="https://chatgpt.com/backend-api/codex", + api_key="live-agent-token", + force=True, + ) + + assert result.redeemed + assert result.windows_reset == 2 + assert result.available_count == 1 # 2 banked - 1 spent + assert "1 banked reset remaining" in result.message + post = [c for c in calls if c["method"] == "POST"][0] + assert post["url"] == "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume" + assert post["json"]["redeem_request_id"] # idempotency key present + assert "credit_id" not in post["json"] + + +def test_redeem_allowed_without_force_when_window_exhausted(monkeypatch): + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeResetClient( + calls, + _usage_payload_with_resets(100, 42, 1), + consume_payload={"code": "reset", "windows_reset": 2}, + ), + ) + + result = account_usage.redeem_codex_reset_credit( + base_url="https://chatgpt.com/backend-api/codex", + api_key="live-agent-token", + ) + + assert result.redeemed + assert result.available_count == 0 + assert "0 banked resets remaining" in result.message + + +def test_redeem_refuses_when_no_credits_banked(monkeypatch): + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeResetClient(calls, _usage_payload_with_resets(100, 100, 0)), + ) + + result = account_usage.redeem_codex_reset_credit( + base_url="https://chatgpt.com/backend-api/codex", + api_key="live-agent-token", + ) + + assert result.status == "no_credits_banked" + assert [c["method"] for c in calls] == ["GET"] + + +def test_redeem_nothing_to_reset_reports_credit_not_spent(monkeypatch): + calls = [] + monkeypatch.setattr( + account_usage.httpx, + "Client", + lambda timeout: _FakeResetClient( + calls, + _usage_payload_with_resets(100, 100, 3), + consume_payload={"code": "nothing_to_reset"}, + ), + ) + + result = account_usage.redeem_codex_reset_credit( + base_url="https://chatgpt.com/backend-api/codex", + api_key="live-agent-token", + ) + + assert result.status == "nothing_to_reset" + assert not result.redeemed + assert "NOT spent" in result.message + assert result.available_count == 3 + + +def test_redeem_missing_credentials_reports_unavailable(monkeypatch): + monkeypatch.setattr( + account_usage, + "_resolve_codex_usage_credentials", + lambda base_url, api_key: (_ for _ in ()).throw(RuntimeError("no creds")), + ) + + result = account_usage.redeem_codex_reset_credit() + + assert result.status == "unavailable" + assert "hermes auth" in result.message diff --git a/tests/gateway/test_usage_command.py b/tests/gateway/test_usage_command.py index e2a207cfbc70..a93ebe5b759b 100644 --- a/tests/gateway/test_usage_command.py +++ b/tests/gateway/test_usage_command.py @@ -245,6 +245,77 @@ class TestUsageAccountSection: assert "📈 **Account limits**" in result +class TestUsageReset: + """`/usage reset [--force]` — banked Codex reset redemption via the gateway.""" + + def _event(self, args): + event = MagicMock() + event.get_command_args.return_value = args + return event + + @pytest.mark.asyncio + async def test_reset_dispatches_redeem_for_codex_agent(self, monkeypatch): + agent = _make_mock_agent(provider="openai-codex", + base_url="https://chatgpt.com/backend-api/codex", + api_key="tok") + runner = _make_runner(SK, cached_agent=agent) + + seen = {} + + def fake_redeem(*, base_url=None, api_key=None, force=False): + seen.update(base_url=base_url, api_key=api_key, force=force) + from agent.account_usage import CodexResetRedeemResult + return CodexResetRedeemResult(status="reset", message="✅ redeemed", available_count=1) + + monkeypatch.setattr("agent.account_usage.redeem_codex_reset_credit", fake_redeem) + + result = await runner._handle_usage_command(self._event("reset")) + + assert result == "✅ redeemed" + assert seen["force"] is False + assert seen["api_key"] == "tok" + + @pytest.mark.asyncio + async def test_reset_force_flag_propagates(self, monkeypatch): + agent = _make_mock_agent(provider="openai-codex", api_key="tok") + runner = _make_runner(SK, cached_agent=agent) + + seen = {} + + def fake_redeem(*, base_url=None, api_key=None, force=False): + seen["force"] = force + from agent.account_usage import CodexResetRedeemResult + return CodexResetRedeemResult(status="reset", message="ok") + + monkeypatch.setattr("agent.account_usage.redeem_codex_reset_credit", fake_redeem) + + await runner._handle_usage_command(self._event("reset --force")) + + assert seen["force"] is True + + @pytest.mark.asyncio + async def test_reset_rejected_on_non_codex_provider(self, monkeypatch): + agent = _make_mock_agent(provider="openrouter") + runner = _make_runner(SK, cached_agent=agent) + monkeypatch.setattr( + "agent.account_usage.redeem_codex_reset_credit", + lambda **kw: (_ for _ in ()).throw(AssertionError("must not redeem")), + ) + + result = await runner._handle_usage_command(self._event("reset")) + + assert "openai-codex" in result + + @pytest.mark.asyncio + async def test_unknown_subcommand_rejected(self): + agent = _make_mock_agent(provider="openai-codex") + runner = _make_runner(SK, cached_agent=agent) + + result = await runner._handle_usage_command(self._event("bogus")) + + assert "Unknown /usage subcommand" in result + + class TestUsageContextBreakdown: """The /usage output includes the per-category context breakdown.""" diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 7bbc1fe6ec56..f786e5a42f91 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -90,6 +90,8 @@ The bar adapts to terminal width — full layout at ≥ 76 columns, compact at 5 Use `/usage` for a detailed breakdown including per-category costs (input vs output tokens). +On the `openai-codex` provider, `/usage` also shows any banked usage-limit resets on your ChatGPT account ("You have N resets banked - use /usage reset to activate"). `/usage reset` redeems one banked reset, fully restoring your 5-hour and weekly limits. Hermes refuses to redeem while your limits aren't exhausted (a banked reset restores the full allowance, so spending it early wastes it) — pass `/usage reset --force` to redeem anyway. + ### Session Resume Display When resuming a previous session (`hermes -c` or `hermes --resume `), a "Previous Conversation" panel appears between the banner and the input prompt, showing a compact recap of the conversation history. See [Sessions — Conversation Recap on Resume](sessions.md#conversation-recap-on-resume) for details and configuration. diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 8011191c89ff..ac69b9ffd048 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -172,7 +172,7 @@ hermes gateway status --system # Linux only: inspect the system service | `/compress` | Manually compress conversation context | | `/title [name]` | Set or show the session title | | `/resume [name]` | Resume a previously named session | -| `/usage` | Show token usage for this session | +| `/usage` | Show token usage for this session (`/usage reset [--force]` redeems a banked Codex limit reset) | | `/insights [days]` | Show usage insights and analytics | | `/reasoning [level\|show\|hide]` | Change reasoning effort or toggle reasoning display | | `/voice [on\|off\|tts\|join\|leave\|status]` | Control messaging voice replies and Discord voice-channel behavior |