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