atlas-iac/services/hermes/scripts/ai_usage_claude.py

396 lines
14 KiB
Python
Raw Normal View History

2026-08-23 00:42:39 -03:00
#!/usr/bin/env python3
"""Read Claude subscription quotas without exposing account credentials."""
2026-08-23 00:42:39 -03:00
from __future__ import annotations
import json
import os
import stat
import time
import uuid
2026-08-23 00:42:39 -03:00
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
2026-08-23 00:42:39 -03:00
from urllib.request import Request, urlopen
OAUTH_TOKEN_FILE = Path(
os.environ.get(
"ATLAS_AI_CLAUDE_OAUTH_TOKEN_FILE",
"/claude-oauth-access/token",
)
)
CREDENTIALS_FILE = Path(
os.environ.get(
"ATLAS_AI_CLAUDE_CREDENTIALS",
"/runtime-access/claude/.credentials.json",
)
)
USAGE_URL = os.environ.get(
"ATLAS_AI_CLAUDE_USAGE_URL",
"https://api.anthropic.com/api/oauth/usage",
)
TOKEN_URL = os.environ.get(
"ATLAS_AI_CLAUDE_TOKEN_URL",
"https://platform.claude.com/v1/oauth/token",
)
2026-08-23 00:42:39 -03:00
MESSAGES_URL = os.environ.get(
"ATLAS_AI_CLAUDE_MESSAGES_URL",
"https://api.anthropic.com/v1/messages",
)
OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
2026-08-23 00:42:39 -03:00
QUOTA_MODEL = os.environ.get(
"ATLAS_AI_CLAUDE_QUOTA_MODEL",
"claude-haiku-4-5-20251001",
)
REQUEST_TIMEOUT_SECONDS = max(
5.0,
float(os.environ.get("ATLAS_AI_CLAUDE_QUERY_TIMEOUT_SECONDS", "30")),
)
WINDOW_HEADERS = {
"five_hour": "5h",
"seven_day": "7d",
}
SCOPED_WINDOWS = (
"five_hour",
"seven_day",
"seven_day_opus",
"seven_day_sonnet",
)
FABLE_DISPLAY_NAMES = frozenset(("fable", "fable 5"))
DEFAULT_SCOPES = (
"user:profile",
"user:inference",
"user:sessions:claude_code",
"user:mcp_servers",
"user:file_upload",
)
MAX_DOCUMENT_BYTES = 1 << 20
REFRESH_SKEW_MILLISECONDS = 5 * 60 * 1000
2026-08-23 00:42:39 -03:00
class QuotaNotExposed(RuntimeError):
"""The active provider credential cannot expose account quota."""
def _setup_token() -> str:
2026-08-23 00:42:39 -03:00
"""Read the Vault-rendered setup token from its memory-backed file."""
try:
token = OAUTH_TOKEN_FILE.read_text(encoding="utf-8").strip()
except (OSError, UnicodeError) as error:
raise QuotaNotExposed("Claude quota token is unavailable") from error
if not token:
raise QuotaNotExposed("Claude quota token is unavailable")
return token
def _percentage(value: str | None) -> float | None:
"""Convert Anthropic's fractional utilization header to a percentage."""
try:
utilization = float(value) if value is not None else None
except ValueError:
return None
if utilization is None or not 0 <= utilization <= 1:
return None
return utilization * 100
def _used_percentage(value: object) -> float | None:
"""Validate one percentage returned by the scoped usage endpoint."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
percentage = float(value)
if not 0 <= percentage <= 100:
return None
return percentage
2026-08-23 00:42:39 -03:00
def _timestamp(value: str | None) -> float | None:
"""Validate one Unix reset timestamp from a response header."""
try:
timestamp = float(value) if value is not None else None
except ValueError:
return None
if timestamp is None or timestamp <= 0:
return None
return timestamp
def _header_payload(headers: Any) -> dict[str, Any]:
"""Translate bounded unified-rate-limit headers into a usage document."""
2026-08-23 00:42:39 -03:00
payload: dict[str, Any] = {}
for window, abbreviation in WINDOW_HEADERS.items():
prefix = f"anthropic-ratelimit-unified-{abbreviation}"
utilization = _percentage(headers.get(f"{prefix}-utilization"))
if utilization is None:
continue
result: dict[str, Any] = {"utilization": utilization}
reset = _timestamp(headers.get(f"{prefix}-reset"))
if reset is not None:
result["resets_at"] = reset
payload[window] = result
if not payload:
raise QuotaNotExposed("Claude response omitted subscription quota headers")
return payload
def _bounded_window(value: object) -> dict[str, Any] | None:
"""Copy only the numeric utilization and bounded reset field."""
if not isinstance(value, dict):
return None
utilization = _used_percentage(value.get("utilization"))
if utilization is None:
return None
result: dict[str, Any] = {"utilization": utilization}
reset = value.get("resets_at")
if isinstance(reset, (int, float, str)) and not isinstance(reset, bool):
result["resets_at"] = reset
return result
def _scoped_payload(document: object) -> dict[str, Any]:
"""Normalize overall and exact Fable weekly limits from Claude Code usage."""
if not isinstance(document, dict):
raise QuotaNotExposed("Claude usage response is not an object")
payload: dict[str, Any] = {}
for window in SCOPED_WINDOWS:
bounded = _bounded_window(document.get(window))
if bounded is not None:
payload[window] = bounded
limits = document.get("limits")
if isinstance(limits, list):
for limit in limits[:100]:
if not isinstance(limit, dict) or limit.get("kind") != "weekly_scoped":
continue
scope = limit.get("scope")
model = scope.get("model") if isinstance(scope, dict) else None
display_name = model.get("display_name") if isinstance(model, dict) else None
if not isinstance(display_name, str):
continue
if display_name.strip().casefold() not in FABLE_DISPLAY_NAMES:
continue
utilization = _used_percentage(limit.get("percent"))
if utilization is None:
continue
fable: dict[str, Any] = {"utilization": utilization}
reset = limit.get("resets_at")
if isinstance(reset, (int, float, str)) and not isinstance(reset, bool):
fable["resets_at"] = reset
payload["seven_day_fable"] = fable
break
if not payload:
raise QuotaNotExposed("Claude usage response omitted supported quota windows")
return payload
def _read_credentials() -> tuple[dict[str, Any], dict[str, Any]]:
"""Read one private Claude OAuth document without following a final symlink."""
try:
descriptor = os.open(
CREDENTIALS_FILE,
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
)
except OSError as error:
raise QuotaNotExposed("Claude scoped quota credential is unavailable") from error
try:
info = os.fstat(descriptor)
if (
not stat.S_ISREG(info.st_mode)
or info.st_mode & 0o077
or not 0 < info.st_size <= MAX_DOCUMENT_BYTES
):
raise QuotaNotExposed("Claude scoped quota credential is unsafe")
raw = os.read(descriptor, info.st_size + 1)
finally:
os.close(descriptor)
try:
document = json.loads(raw)
except (UnicodeError, json.JSONDecodeError) as error:
raise QuotaNotExposed("Claude scoped quota credential is invalid") from error
credentials = document.get("claudeAiOauth") if isinstance(document, dict) else None
if not isinstance(credentials, dict):
raise QuotaNotExposed("Claude scoped quota credential is invalid")
return document, credentials
def _write_credentials(document: dict[str, Any]) -> None:
"""Atomically persist a provider-rotated OAuth document for Vault sync."""
encoded = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8")
if len(encoded) > MAX_DOCUMENT_BYTES:
raise QuotaNotExposed("Claude scoped quota credential is too large")
temporary = CREDENTIALS_FILE.with_name(
f".{CREDENTIALS_FILE.name}.{uuid.uuid4().hex}.tmp"
)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(temporary, flags, 0o600)
try:
remaining = memoryview(encoded)
while remaining:
remaining = remaining[os.write(descriptor, remaining) :]
os.fsync(descriptor)
os.fchmod(descriptor, 0o600)
finally:
os.close(descriptor)
try:
os.replace(temporary, CREDENTIALS_FILE)
finally:
temporary.unlink(missing_ok=True)
def _refresh_access_token(
document: dict[str, Any], credentials: dict[str, Any]
) -> str:
"""Refresh the scoped access token and retain any refresh-token rotation."""
refresh_token = credentials.get("refreshToken")
if not isinstance(refresh_token, str) or not refresh_token:
raise QuotaNotExposed("Claude scoped quota refresh credential is unavailable")
scopes = credentials.get("scopes")
if not (
isinstance(scopes, list)
and scopes
and all(isinstance(scope, str) and scope in DEFAULT_SCOPES for scope in scopes)
):
scopes = list(DEFAULT_SCOPES)
body = json.dumps(
{
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": OAUTH_CLIENT_ID,
"scope": " ".join(scopes),
},
separators=(",", ":"),
).encode("utf-8")
request = Request(
TOKEN_URL,
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "atlas-ai-usage-exporter/3.0",
},
)
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
response_document = json.loads(response.read(MAX_DOCUMENT_BYTES))
if not isinstance(response_document, dict):
raise QuotaNotExposed("Claude token refresh response is invalid")
access_token = response_document.get("access_token")
expires_in = response_document.get("expires_in")
rotated_refresh = response_document.get("refresh_token", refresh_token)
if (
not isinstance(access_token, str)
or not access_token
or isinstance(expires_in, bool)
or not isinstance(expires_in, (int, float))
or expires_in <= 0
or not isinstance(rotated_refresh, str)
or not rotated_refresh
):
raise QuotaNotExposed("Claude token refresh response is invalid")
credentials["accessToken"] = access_token
credentials["refreshToken"] = rotated_refresh
credentials["expiresAt"] = int(time.time() * 1000 + expires_in * 1000)
response_scopes = response_document.get("scope")
if isinstance(response_scopes, str) and response_scopes:
credentials["scopes"] = response_scopes.split()
refresh_expires_in = response_document.get("refresh_token_expires_in")
if (
not isinstance(refresh_expires_in, bool)
and isinstance(refresh_expires_in, (int, float))
and refresh_expires_in > 0
):
credentials["refreshTokenExpiresAt"] = int(
time.time() * 1000 + refresh_expires_in * 1000
)
_write_credentials(document)
return access_token
def _access_token(*, force_refresh: bool = False) -> str:
"""Return a current scoped token, refreshing it before provider expiry."""
document, credentials = _read_credentials()
access_token = credentials.get("accessToken")
expires_at = credentials.get("expiresAt")
current = time.time() * 1000
if (
not force_refresh
and isinstance(access_token, str)
and access_token
and not isinstance(expires_at, bool)
and isinstance(expires_at, (int, float))
and expires_at - current > REFRESH_SKEW_MILLISECONDS
):
return access_token
return _refresh_access_token(document, credentials)
def _query_scoped_usage() -> dict[str, Any]:
"""Fetch Claude Code's authenticated overall and model-scoped quotas."""
token = _access_token()
for attempt in range(2):
request = Request(
USAGE_URL,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"anthropic-beta": "oauth-2025-04-20",
"User-Agent": "atlas-ai-usage-exporter/3.0",
},
)
try:
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
return _scoped_payload(json.loads(response.read(MAX_DOCUMENT_BYTES)))
except HTTPError as error:
if error.code != 401 or attempt:
raise
token = _access_token(force_refresh=True)
raise QuotaNotExposed("Claude scoped quota request failed")
def _query_header_usage() -> dict[str, Any]:
"""Fetch unified quotas through the inference-scoped setup-token fallback."""
token = _setup_token()
2026-08-23 00:42:39 -03:00
body = json.dumps(
{
"model": QUOTA_MODEL,
"max_tokens": 1,
"messages": [{"role": "user", "content": "quota"}],
},
separators=(",", ":"),
).encode("utf-8")
request = Request(
MESSAGES_URL,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
"anthropic-version": "2023-06-01",
"anthropic-beta": "oauth-2025-04-20,claude-code-20250219",
"User-Agent": "atlas-ai-usage-exporter/3.0",
2026-08-23 00:42:39 -03:00
},
)
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
payload = _header_payload(response.headers)
response.read(MAX_DOCUMENT_BYTES)
2026-08-23 00:42:39 -03:00
return payload
def query_claude() -> dict[str, Any]:
"""Prefer exact scoped quotas and preserve unified metrics as a fallback."""
try:
return _query_scoped_usage()
except (
HTTPError,
URLError,
TimeoutError,
OSError,
UnicodeError,
json.JSONDecodeError,
QuotaNotExposed,
TypeError,
ValueError,
):
return _query_header_usage()