mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
8582f35d96
commit
89bd0fba90
24 changed files with 579 additions and 7 deletions
|
|
@ -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
49
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 にログインしていません。ログインすると残高の確認とチャージができます。"
|
||||
|
|
|
|||
|
|
@ -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에 로그인되어 있지 않습니다. 로그인하면 크레딧 잔액 확인 및 충전을 할 수 있습니다."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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. Войдите, чтобы увидеть баланс кредитов и пополнить его."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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. Увійдіть, щоб переглянути баланс кредитів і поповнити його."
|
||||
|
|
|
|||
|
|
@ -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。登入後即可查看額度餘額並儲值。"
|
||||
|
|
|
|||
|
|
@ -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。登录后即可查看额度余额并充值。"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue