Skip to content

Commit 8a11b0a

Browse files
kshitijk4poorteknium1
authored andcommitted
feat(account-usage): add per-provider account limits module
Ports agent/account_usage.py and its tests from the original PR #2486 branch. Defines AccountUsageSnapshot / AccountUsageWindow dataclasses, a shared renderer, and provider-specific fetchers for OpenAI Codex (wham/usage), Anthropic OAuth (oauth/usage), and OpenRouter (/credits and /key). Wiring into /usage lands in a follow-up salvage commit. Authored-by: kshitijk4poor <[email protected]>
1 parent 2c69b3e commit 8a11b0a

2 files changed

Lines changed: 529 additions & 0 deletions

File tree

agent/account_usage.py

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from datetime import datetime, timezone
5+
from typing import Any, Optional
6+
7+
import httpx
8+
9+
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
10+
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
11+
from hermes_cli.runtime_provider import resolve_runtime_provider
12+
13+
14+
def _utc_now() -> datetime:
15+
return datetime.now(timezone.utc)
16+
17+
18+
@dataclass(frozen=True)
19+
class AccountUsageWindow:
20+
label: str
21+
used_percent: Optional[float] = None
22+
reset_at: Optional[datetime] = None
23+
detail: Optional[str] = None
24+
25+
26+
@dataclass(frozen=True)
27+
class AccountUsageSnapshot:
28+
provider: str
29+
source: str
30+
fetched_at: datetime
31+
title: str = "Account limits"
32+
plan: Optional[str] = None
33+
windows: tuple[AccountUsageWindow, ...] = ()
34+
details: tuple[str, ...] = ()
35+
unavailable_reason: Optional[str] = None
36+
37+
@property
38+
def available(self) -> bool:
39+
return bool(self.windows or self.details) and not self.unavailable_reason
40+
41+
42+
def _title_case_slug(value: Optional[str]) -> Optional[str]:
43+
cleaned = str(value or "").strip()
44+
if not cleaned:
45+
return None
46+
return cleaned.replace("_", " ").replace("-", " ").title()
47+
48+
49+
def _parse_dt(value: Any) -> Optional[datetime]:
50+
if value in (None, ""):
51+
return None
52+
if isinstance(value, (int, float)):
53+
return datetime.fromtimestamp(float(value), tz=timezone.utc)
54+
if isinstance(value, str):
55+
text = value.strip()
56+
if not text:
57+
return None
58+
if text.endswith("Z"):
59+
text = text[:-1] + "+00:00"
60+
try:
61+
dt = datetime.fromisoformat(text)
62+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
63+
except ValueError:
64+
return None
65+
return None
66+
67+
68+
def _format_reset(dt: Optional[datetime]) -> str:
69+
if not dt:
70+
return "unknown"
71+
local_dt = dt.astimezone()
72+
delta = dt - _utc_now()
73+
total_seconds = int(delta.total_seconds())
74+
if total_seconds <= 0:
75+
return f"now ({local_dt.strftime('%Y-%m-%d %H:%M %Z')})"
76+
hours, rem = divmod(total_seconds, 3600)
77+
minutes = rem // 60
78+
if hours >= 24:
79+
days, hours = divmod(hours, 24)
80+
rel = f"in {days}d {hours}h"
81+
elif hours > 0:
82+
rel = f"in {hours}h {minutes}m"
83+
else:
84+
rel = f"in {minutes}m"
85+
return f"{rel} ({local_dt.strftime('%Y-%m-%d %H:%M %Z')})"
86+
87+
88+
def render_account_usage_lines(snapshot: Optional[AccountUsageSnapshot], *, markdown: bool = False) -> list[str]:
89+
if not snapshot:
90+
return []
91+
header = f"📈 {'**' if markdown else ''}{snapshot.title}{'**' if markdown else ''}"
92+
lines = [header]
93+
if snapshot.plan:
94+
lines.append(f"Provider: {snapshot.provider} ({snapshot.plan})")
95+
else:
96+
lines.append(f"Provider: {snapshot.provider}")
97+
for window in snapshot.windows:
98+
if window.used_percent is None:
99+
base = f"{window.label}: unavailable"
100+
else:
101+
remaining = max(0, round(100 - float(window.used_percent)))
102+
used = max(0, round(float(window.used_percent)))
103+
base = f"{window.label}: {remaining}% remaining ({used}% used)"
104+
if window.reset_at:
105+
base += f" • resets {_format_reset(window.reset_at)}"
106+
elif window.detail:
107+
base += f" • {window.detail}"
108+
lines.append(base)
109+
for detail in snapshot.details:
110+
lines.append(detail)
111+
if snapshot.unavailable_reason:
112+
lines.append(f"Unavailable: {snapshot.unavailable_reason}")
113+
return lines
114+
115+
116+
def _resolve_codex_usage_url(base_url: str) -> str:
117+
normalized = (base_url or "").strip().rstrip("/")
118+
if not normalized:
119+
normalized = "https://chatgpt.com/backend-api/codex"
120+
if normalized.endswith("/codex"):
121+
normalized = normalized[: -len("/codex")]
122+
if "/backend-api" in normalized:
123+
return normalized + "/wham/usage"
124+
return normalized + "/api/codex/usage"
125+
126+
127+
def _fetch_codex_account_usage() -> Optional[AccountUsageSnapshot]:
128+
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
129+
token_data = _read_codex_tokens()
130+
tokens = token_data.get("tokens") or {}
131+
account_id = str(tokens.get("account_id", "") or "").strip() or None
132+
headers = {
133+
"Authorization": f"Bearer {creds['api_key']}",
134+
"Accept": "application/json",
135+
"User-Agent": "codex-cli",
136+
}
137+
if account_id:
138+
headers["ChatGPT-Account-Id"] = account_id
139+
with httpx.Client(timeout=15.0) as client:
140+
response = client.get(_resolve_codex_usage_url(creds.get("base_url", "")), headers=headers)
141+
response.raise_for_status()
142+
payload = response.json() or {}
143+
rate_limit = payload.get("rate_limit") or {}
144+
windows: list[AccountUsageWindow] = []
145+
for key, label in (("primary_window", "Session"), ("secondary_window", "Weekly")):
146+
window = rate_limit.get(key) or {}
147+
used = window.get("used_percent")
148+
if used is None:
149+
continue
150+
windows.append(
151+
AccountUsageWindow(
152+
label=label,
153+
used_percent=float(used),
154+
reset_at=_parse_dt(window.get("reset_at")),
155+
)
156+
)
157+
details: list[str] = []
158+
credits = payload.get("credits") or {}
159+
if credits.get("has_credits"):
160+
balance = credits.get("balance")
161+
if isinstance(balance, (int, float)):
162+
details.append(f"Credits balance: ${float(balance):.2f}")
163+
elif credits.get("unlimited"):
164+
details.append("Credits balance: unlimited")
165+
return AccountUsageSnapshot(
166+
provider="openai-codex",
167+
source="usage_api",
168+
fetched_at=_utc_now(),
169+
plan=_title_case_slug(payload.get("plan_type")),
170+
windows=tuple(windows),
171+
details=tuple(details),
172+
)
173+
174+
175+
def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]:
176+
token = (resolve_anthropic_token() or "").strip()
177+
if not token:
178+
return None
179+
if not _is_oauth_token(token):
180+
return AccountUsageSnapshot(
181+
provider="anthropic",
182+
source="oauth_usage_api",
183+
fetched_at=_utc_now(),
184+
unavailable_reason="Anthropic account limits are only available for OAuth-backed Claude accounts.",
185+
)
186+
headers = {
187+
"Authorization": f"Bearer {token}",
188+
"Accept": "application/json",
189+
"Content-Type": "application/json",
190+
"anthropic-beta": "oauth-2025-04-20",
191+
"User-Agent": "claude-code/2.1.0",
192+
}
193+
with httpx.Client(timeout=15.0) as client:
194+
response = client.get("https://api.anthropic.com/api/oauth/usage", headers=headers)
195+
response.raise_for_status()
196+
payload = response.json() or {}
197+
windows: list[AccountUsageWindow] = []
198+
mapping = (
199+
("five_hour", "Current session"),
200+
("seven_day", "Current week"),
201+
("seven_day_opus", "Opus week"),
202+
("seven_day_sonnet", "Sonnet week"),
203+
)
204+
for key, label in mapping:
205+
window = payload.get(key) or {}
206+
util = window.get("utilization")
207+
if util is None:
208+
continue
209+
used = float(util) * 100 if float(util) <= 1 else float(util)
210+
windows.append(
211+
AccountUsageWindow(
212+
label=label,
213+
used_percent=used,
214+
reset_at=_parse_dt(window.get("resets_at")),
215+
)
216+
)
217+
details: list[str] = []
218+
extra = payload.get("extra_usage") or {}
219+
if extra.get("is_enabled"):
220+
used_credits = extra.get("used_credits")
221+
monthly_limit = extra.get("monthly_limit")
222+
currency = extra.get("currency") or "USD"
223+
if isinstance(used_credits, (int, float)) and isinstance(monthly_limit, (int, float)):
224+
details.append(
225+
f"Extra usage: {used_credits:.2f} / {monthly_limit:.2f} {currency}"
226+
)
227+
return AccountUsageSnapshot(
228+
provider="anthropic",
229+
source="oauth_usage_api",
230+
fetched_at=_utc_now(),
231+
windows=tuple(windows),
232+
details=tuple(details),
233+
)
234+
235+
236+
def _fetch_openrouter_account_usage(base_url: Optional[str], api_key: Optional[str]) -> Optional[AccountUsageSnapshot]:
237+
runtime = resolve_runtime_provider(
238+
requested="openrouter",
239+
explicit_base_url=base_url,
240+
explicit_api_key=api_key,
241+
)
242+
token = str(runtime.get("api_key", "") or "").strip()
243+
if not token:
244+
return None
245+
normalized = str(runtime.get("base_url", "") or "").rstrip("/")
246+
credits_url = f"{normalized}/credits"
247+
key_url = f"{normalized}/key"
248+
headers = {
249+
"Authorization": f"Bearer {token}",
250+
"Accept": "application/json",
251+
}
252+
with httpx.Client(timeout=10.0) as client:
253+
credits_resp = client.get(credits_url, headers=headers)
254+
credits_resp.raise_for_status()
255+
credits = (credits_resp.json() or {}).get("data") or {}
256+
try:
257+
key_resp = client.get(key_url, headers=headers)
258+
key_resp.raise_for_status()
259+
key_data = (key_resp.json() or {}).get("data") or {}
260+
except Exception:
261+
key_data = {}
262+
total_credits = float(credits.get("total_credits") or 0.0)
263+
total_usage = float(credits.get("total_usage") or 0.0)
264+
details = [f"Credits balance: ${max(0.0, total_credits - total_usage):.2f}"]
265+
windows: list[AccountUsageWindow] = []
266+
limit = key_data.get("limit")
267+
limit_remaining = key_data.get("limit_remaining")
268+
limit_reset = str(key_data.get("limit_reset") or "").strip()
269+
usage = key_data.get("usage")
270+
if (
271+
isinstance(limit, (int, float))
272+
and float(limit) > 0
273+
and isinstance(limit_remaining, (int, float))
274+
and 0 <= float(limit_remaining) <= float(limit)
275+
):
276+
limit_value = float(limit)
277+
remaining_value = float(limit_remaining)
278+
used_percent = ((limit_value - remaining_value) / limit_value) * 100
279+
detail_parts = [f"${remaining_value:.2f} of ${limit_value:.2f} remaining"]
280+
if limit_reset:
281+
detail_parts.append(f"resets {limit_reset}")
282+
windows.append(
283+
AccountUsageWindow(
284+
label="API key quota",
285+
used_percent=used_percent,
286+
detail=" • ".join(detail_parts),
287+
)
288+
)
289+
if isinstance(usage, (int, float)):
290+
usage_parts = [f"API key usage: ${float(usage):.2f} total"]
291+
for value, label in (
292+
(key_data.get("usage_daily"), "today"),
293+
(key_data.get("usage_weekly"), "this week"),
294+
(key_data.get("usage_monthly"), "this month"),
295+
):
296+
if isinstance(value, (int, float)) and float(value) > 0:
297+
usage_parts.append(f"${float(value):.2f} {label}")
298+
details.append(" • ".join(usage_parts))
299+
return AccountUsageSnapshot(
300+
provider="openrouter",
301+
source="credits_api",
302+
fetched_at=_utc_now(),
303+
windows=tuple(windows),
304+
details=tuple(details),
305+
)
306+
307+
308+
def fetch_account_usage(
309+
provider: Optional[str],
310+
*,
311+
base_url: Optional[str] = None,
312+
api_key: Optional[str] = None,
313+
) -> Optional[AccountUsageSnapshot]:
314+
normalized = str(provider or "").strip().lower()
315+
if normalized in {"", "auto", "custom"}:
316+
return None
317+
try:
318+
if normalized == "openai-codex":
319+
return _fetch_codex_account_usage()
320+
if normalized == "anthropic":
321+
return _fetch_anthropic_account_usage()
322+
if normalized == "openrouter":
323+
return _fetch_openrouter_account_usage(base_url, api_key)
324+
except Exception:
325+
return None
326+
return None

0 commit comments

Comments
 (0)