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.
This commit is contained in:
Teknium 2026-07-14 03:23:19 -07:00 committed by GitHub
parent 8582f35d96
commit 89bd0fba90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 579 additions and 7 deletions

View file

@ -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:

49
cli.py
View file

@ -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.

View file

@ -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] = []

View file

@ -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),

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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."

View file

@ -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 にログインしていません。ログインすると残高の確認とチャージができます。"

View file

@ -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에 로그인되어 있지 않습니다. 로그인하면 크레딧 잔액 확인 및 충전을 할 수 있습니다."

View file

@ -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."

View file

@ -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. Войдите, чтобы увидеть баланс кредитов и пополнить его."

View file

@ -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."

View file

@ -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. Увійдіть, щоб переглянути баланс кредитів і поповнити його."

View file

@ -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。登入後即可查看額度餘額並儲值。"

View file

@ -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。登录后即可查看额度余额并充值。"

View file

@ -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

View file

@ -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."""

View file

@ -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 <id>`), 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.

View file

@ -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 |