mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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.
438 lines
14 KiB
Python
438 lines
14 KiB
Python
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from agent import account_usage
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self, calls, payload):
|
|
self.calls = calls
|
|
self.payload = payload
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
def get(self, url, headers):
|
|
self.calls.append({"url": url, "headers": headers})
|
|
return _FakeResponse(self.payload)
|
|
|
|
|
|
@pytest.fixture
|
|
def codex_usage_payload():
|
|
return {
|
|
"plan_type": "plus",
|
|
"rate_limit": {
|
|
"primary_window": {
|
|
"used_percent": 21,
|
|
"reset_at": 1779846359,
|
|
},
|
|
"secondary_window": {
|
|
"used_percent": 4,
|
|
"reset_at": 1780230796,
|
|
},
|
|
},
|
|
"credits": {"has_credits": False},
|
|
}
|
|
|
|
|
|
def test_codex_usage_prefers_explicit_live_agent_credentials(monkeypatch, codex_usage_payload):
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
account_usage.httpx,
|
|
"Client",
|
|
lambda timeout: _FakeClient(calls, codex_usage_payload),
|
|
)
|
|
monkeypatch.setattr(
|
|
account_usage,
|
|
"resolve_codex_runtime_credentials",
|
|
lambda **kwargs: (_ for _ in ()).throw(AssertionError("legacy auth should not be used")),
|
|
)
|
|
|
|
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
|
|
assert snapshot.provider == "openai-codex"
|
|
assert snapshot.plan == "Plus"
|
|
assert [w.label for w in snapshot.windows] == ["Session", "Weekly"]
|
|
assert snapshot.windows[0].used_percent == 21
|
|
assert calls[0]["url"] == "https://chatgpt.com/backend-api/wham/usage"
|
|
assert calls[0]["headers"]["Authorization"] == "Bearer live-agent-token"
|
|
|
|
|
|
def test_codex_usage_falls_back_to_native_credential_pool(monkeypatch, codex_usage_payload):
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
account_usage.httpx,
|
|
"Client",
|
|
lambda timeout: _FakeClient(calls, codex_usage_payload),
|
|
)
|
|
# Pool fallback fires only on AuthError (the documented "no creds" mode of
|
|
# the resolver), NOT on arbitrary exceptions — see the transient-error guard
|
|
# test below.
|
|
monkeypatch.setattr(
|
|
account_usage,
|
|
"resolve_codex_runtime_credentials",
|
|
lambda **kwargs: (_ for _ in ()).throw(
|
|
account_usage.AuthError("no singleton auth", provider="openai-codex", code="codex_auth_missing")
|
|
),
|
|
)
|
|
|
|
pool_entry = SimpleNamespace(
|
|
runtime_api_key="pooled-token",
|
|
runtime_base_url="https://chatgpt.com/backend-api/codex",
|
|
)
|
|
pool = SimpleNamespace(select=lambda: pool_entry)
|
|
|
|
import agent.credential_pool as credential_pool
|
|
|
|
monkeypatch.setattr(credential_pool, "load_pool", lambda provider: pool)
|
|
|
|
snapshot = account_usage.fetch_account_usage("openai-codex")
|
|
|
|
assert snapshot is not None
|
|
assert snapshot.windows[0].label == "Session"
|
|
assert snapshot.windows[1].label == "Weekly"
|
|
assert calls[0]["url"] == "https://chatgpt.com/backend-api/wham/usage"
|
|
assert calls[0]["headers"]["Authorization"] == "Bearer pooled-token"
|
|
# Pool creds have no account_id concept — the ChatGPT-Account-Id header must
|
|
# be omitted rather than sent stale/wrong.
|
|
assert "ChatGPT-Account-Id" not in calls[0]["headers"]
|
|
|
|
|
|
def test_codex_usage_does_not_swap_to_pool_on_transient_resolver_error(monkeypatch, codex_usage_payload):
|
|
"""A transient refresh/network failure (non-AuthError) must NOT silently
|
|
downgrade to a possibly-different pool account. It fails open (no snapshot)
|
|
instead of reporting the wrong account's usage."""
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
account_usage.httpx,
|
|
"Client",
|
|
lambda timeout: _FakeClient(calls, codex_usage_payload),
|
|
)
|
|
monkeypatch.setattr(
|
|
account_usage,
|
|
"resolve_codex_runtime_credentials",
|
|
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("refresh endpoint 503")),
|
|
)
|
|
|
|
pool_entry = SimpleNamespace(
|
|
runtime_api_key="pooled-token-WRONG-ACCOUNT",
|
|
runtime_base_url="https://chatgpt.com/backend-api/codex",
|
|
)
|
|
pool = SimpleNamespace(select=lambda: pool_entry)
|
|
|
|
import agent.credential_pool as credential_pool
|
|
|
|
# If the guard regressed, this pool would be consulted and return a snapshot
|
|
# for the wrong account. It must NOT be.
|
|
monkeypatch.setattr(credential_pool, "load_pool", lambda provider: pool)
|
|
|
|
snapshot = account_usage.fetch_account_usage("openai-codex")
|
|
|
|
assert snapshot is None
|
|
assert calls == [] # HTTP usage endpoint never hit with a wrong-account token
|
|
|
|
|
|
def test_codex_usage_account_id_read_failure_keeps_singleton_token(monkeypatch, codex_usage_payload):
|
|
"""When the resolver succeeds but the separate account_id read raises, the
|
|
working singleton token must still be used (best-effort account_id), NOT
|
|
abandoned in favor of a header-less pool credential."""
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
account_usage.httpx,
|
|
"Client",
|
|
lambda timeout: _FakeClient(calls, codex_usage_payload),
|
|
)
|
|
monkeypatch.setattr(
|
|
account_usage,
|
|
"resolve_codex_runtime_credentials",
|
|
lambda **kwargs: {
|
|
"api_key": "singleton-token",
|
|
"base_url": "https://chatgpt.com/backend-api/codex",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
account_usage,
|
|
"_read_codex_tokens",
|
|
lambda *a, **k: (_ for _ in ()).throw(
|
|
account_usage.AuthError("partial store", provider="openai-codex", code="codex_auth_invalid_shape")
|
|
),
|
|
)
|
|
|
|
import agent.credential_pool as credential_pool
|
|
|
|
monkeypatch.setattr(
|
|
credential_pool,
|
|
"load_pool",
|
|
lambda provider: (_ for _ in ()).throw(AssertionError("pool must not be consulted")),
|
|
)
|
|
|
|
snapshot = account_usage.fetch_account_usage("openai-codex")
|
|
|
|
assert snapshot is not None
|
|
assert calls[0]["headers"]["Authorization"] == "Bearer singleton-token"
|
|
# account_id read failed → header omitted, but the singleton token is kept.
|
|
assert "ChatGPT-Account-Id" not in calls[0]["headers"]
|
|
|
|
|
|
def test_codex_usage_treats_wham_used_percent_as_used_not_remaining(monkeypatch):
|
|
"""ChatGPT UI says "left"; /wham/usage.used_percent is already used."""
|
|
payload = {
|
|
"plan_type": "plus",
|
|
"rate_limit": {
|
|
"primary_window": {
|
|
"used_percent": 85,
|
|
"reset_at": 1779846359,
|
|
},
|
|
"secondary_window": {
|
|
"used_percent": 14,
|
|
"reset_at": 1780230796,
|
|
},
|
|
},
|
|
"credits": {"has_credits": False},
|
|
}
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
account_usage.httpx,
|
|
"Client",
|
|
lambda timeout: _FakeClient(calls, payload),
|
|
)
|
|
monkeypatch.setattr(
|
|
account_usage,
|
|
"resolve_codex_runtime_credentials",
|
|
lambda **kwargs: (_ for _ in ()).throw(AssertionError("explicit auth should be used")),
|
|
)
|
|
|
|
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
|
|
assert [window.used_percent for window in snapshot.windows] == [85, 14]
|
|
rendered = "\n".join(account_usage.render_account_usage_lines(snapshot, markdown=True))
|
|
assert "85% used" in rendered
|
|
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
|