hermes: expose Claude remaining quota

This commit is contained in:
jenkins 2026-08-23 00:42:39 -03:00
parent d036062519
commit 81b3e6b992
7 changed files with 208 additions and 81 deletions

View File

@ -25,7 +25,7 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
ai.bstein.dev/config-rev: "20260823-ai-quota-stability"
ai.bstein.dev/config-rev: "20260823-claude-quota-headers"
prometheus.io/scrape: "true"
prometheus.io/path: /metrics
prometheus.io/port: "9010"
@ -1034,7 +1034,9 @@ spec:
- {name: CODEX_HOME, value: /runtime-access/codex}
- {name: ATLAS_AI_CODEX_BIN, value: /opt/data/tools/bin/codex}
- {name: ATLAS_AI_CODEX_QUERY_TIMEOUT_SECONDS, value: "45"}
- {name: ATLAS_AI_CLAUDE_CREDENTIALS, value: /runtime-access/claude/.credentials.json}
- {name: ATLAS_AI_CLAUDE_OAUTH_TOKEN_FILE, value: /claude-oauth-access/token}
- {name: ATLAS_AI_CLAUDE_QUERY_TIMEOUT_SECONDS, value: "30"}
- {name: ATLAS_AI_CLAUDE_QUOTA_MODEL, value: claude-haiku-4-5-20251001}
- {name: ATLAS_AI_PROVIDER_HEALTH_ROOT, value: /provider-health}
- {name: ATLAS_AI_USAGE_INTERVAL_SECONDS, value: "300"}
- {name: ATLAS_AI_USAGE_PORT, value: "9010"}
@ -1057,6 +1059,7 @@ spec:
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: claude-oauth-access, mountPath: /claude-oauth-access, readOnly: true}
- {name: home, mountPath: /opt/data/tools, subPath: tools, readOnly: true}
- {name: home, mountPath: /provider-health, subPath: provider-health, readOnly: true}
- {name: runtime-access, mountPath: /runtime-access/claude, subPath: claude}

View File

@ -102,6 +102,7 @@ configMapGenerator:
- name: hermes-coordinator
namespace: hermes
files:
- ai_usage_claude.py=scripts/ai_usage_claude.py
- ai_usage_codex.py=scripts/ai_usage_codex.py
- ai_usage_exporter.py=scripts/ai_usage_exporter.py
- ai_usage_http.py=scripts/ai_usage_http.py

View File

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Read Claude subscription quotas from first-party response headers."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from urllib.request import Request, urlopen
OAUTH_TOKEN_FILE = Path(
os.environ.get(
"ATLAS_AI_CLAUDE_OAUTH_TOKEN_FILE",
"/claude-oauth-access/token",
)
)
MESSAGES_URL = os.environ.get(
"ATLAS_AI_CLAUDE_MESSAGES_URL",
"https://api.anthropic.com/v1/messages",
)
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",
}
class QuotaNotExposed(RuntimeError):
"""The active provider credential cannot expose account quota."""
def _token() -> str:
"""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 _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 _payload(headers: Any) -> dict[str, Any]:
"""Translate bounded unified-rate-limit headers into the usage document."""
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 query_claude() -> dict[str, Any]:
"""Make one minimal subscription request and return exact quota windows."""
token = _token()
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/2.0",
},
)
with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
payload = _payload(response.headers)
response.read(1 << 20)
return payload

View File

@ -11,26 +11,16 @@ from dataclasses import dataclass, field
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from typing import Any
from urllib.request import Request, urlopen
import ai_usage_claude as claude_query
import ai_usage_codex as codex_query
import ai_usage_http as http_engine
import ai_usage_polling as polling_engine
CLAUDE_CREDENTIALS = Path(
os.environ.get(
"ATLAS_AI_CLAUDE_CREDENTIALS",
"/runtime-access/claude/.credentials.json",
)
)
PROVIDER_HEALTH_ROOT = Path(
os.environ.get("ATLAS_AI_PROVIDER_HEALTH_ROOT", "/provider-health")
)
CLAUDE_USAGE_URL = os.environ.get(
"ATLAS_AI_CLAUDE_USAGE_URL",
"https://api.anthropic.com/api/oauth/usage",
)
CLAUDE_WINDOWS = (
"five_hour",
"seven_day",
@ -51,8 +41,8 @@ METRIC_HELP = {
"atlas_ai_quota_used_percent": "Used percentage in a first-party coding CLI quota window.",
"atlas_ai_quota_window_duration_seconds": "Nominal duration of a coding CLI quota window.",
}
class QuotaNotExposed(RuntimeError):
"""The active provider credential cannot expose account quota."""
QuotaNotExposed = claude_query.QuotaNotExposed
@dataclass(frozen=True)
@ -258,6 +248,7 @@ def parse_claude_payload(payload: dict[str, Any]) -> list[Sample]:
query_codex = codex_query.query_codex
query_claude = claude_query.query_claude
def _provider_authenticated(provider: str) -> bool:
@ -272,38 +263,6 @@ def _provider_authenticated(provider: str) -> bool:
return isinstance(document, dict) and document.get("authenticated") is True
def query_claude() -> dict[str, Any]:
"""Read Claude account quota with the runtime OAuth token held only in memory."""
try:
document = json.loads(CLAUDE_CREDENTIALS.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise QuotaNotExposed("Claude runtime credentials are incomplete") from error
oauth = document.get("claudeAiOauth", {})
token = oauth.get("accessToken") if isinstance(oauth, dict) else None
if not isinstance(token, str) or not token:
raise QuotaNotExposed("Claude runtime credentials are incomplete")
expires_at = _number(oauth.get("expiresAt"))
if expires_at is not None:
if expires_at > 10_000_000_000:
expires_at /= 1000
if expires_at <= time.time():
raise QuotaNotExposed("Claude quota credential has expired")
request = Request(
CLAUDE_USAGE_URL,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"anthropic-version": "2023-06-01",
"User-Agent": "atlas-ai-usage-exporter/1.0",
},
)
with urlopen(request, timeout=15) as response:
payload = json.loads(response.read(1 << 20))
if not isinstance(payload, dict):
raise RuntimeError("Claude usage response is not an object")
return payload
def _escape_label(value: str) -> str:
"""Escape one Prometheus label value."""
return value.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')

View File

@ -262,11 +262,21 @@ def test_manifest_rolls_out_the_bounded_codex_deadline_and_poller_module():
exporter = next(item for item in containers if item["name"] == "ai-usage-exporter")
environment = {item["name"]: item["value"] for item in exporter["env"]}
assert annotations["ai.bstein.dev/config-rev"] == "20260823-ai-quota-stability"
assert annotations["ai.bstein.dev/config-rev"] == (
"20260823-claude-quota-headers"
)
assert environment["ATLAS_AI_CODEX_QUERY_TIMEOUT_SECONDS"] == "45"
assert "ai_usage_polling.py=scripts/ai_usage_polling.py" in (
HERMES / "kustomization.yaml"
).read_text()
assert environment["ATLAS_AI_CLAUDE_OAUTH_TOKEN_FILE"] == (
"/claude-oauth-access/token"
)
assert environment["ATLAS_AI_CLAUDE_QUOTA_MODEL"] == (
"claude-haiku-4-5-20251001"
)
mounts = {item["name"]: item for item in exporter["volumeMounts"]}
assert mounts["claude-oauth-access"]["readOnly"] is True
kustomization = (HERMES / "kustomization.yaml").read_text()
assert "ai_usage_claude.py=scripts/ai_usage_claude.py" in kustomization
assert "ai_usage_polling.py=scripts/ai_usage_polling.py" in kustomization
def test_health_endpoint_uses_poller_state_and_ignores_provider_failure():

View File

@ -90,25 +90,57 @@ def test_claude_parser_ignores_unbounded_fields_and_invalid_extra_usage():
assert mod.parse_claude_payload({"extra_usage": "invalid"}) == []
def test_claude_header_probe_rejects_malformed_optional_values(
tmp_path, monkeypatch
):
mod = load_module()
missing = tmp_path / "missing-token"
monkeypatch.setattr(mod.claude_query, "OAUTH_TOKEN_FILE", missing)
with pytest.raises(RuntimeError, match="token is unavailable"):
mod.claude_query._token()
assert mod.claude_query._percentage("invalid") is None
assert mod.claude_query._percentage(None) is None
assert mod.claude_query._percentage("-0.1") is None
assert mod.claude_query._percentage("1.1") is None
assert mod.claude_query._timestamp("invalid") is None
assert mod.claude_query._timestamp(None) is None
assert mod.claude_query._timestamp("0") is None
assert mod.claude_query._payload(
{"anthropic-ratelimit-unified-5h-utilization": "0.5"}
) == {"five_hour": {"utilization": 50}}
def test_provider_queries_validate_credentials_and_response_shape(
tmp_path, monkeypatch
):
mod = load_module()
credentials = tmp_path / "credentials.json"
credentials.write_text("{}", encoding="utf-8")
monkeypatch.setattr(mod, "CLAUDE_CREDENTIALS", credentials)
token_file = tmp_path / "claude-oauth-token"
token_file.write_text("", encoding="utf-8")
monkeypatch.setattr(mod.claude_query, "OAUTH_TOKEN_FILE", token_file)
with pytest.raises(RuntimeError, match="credentials are incomplete"):
with pytest.raises(RuntimeError, match="token is unavailable"):
mod.query_claude()
credentials.write_text(
json.dumps({"claudeAiOauth": {"accessToken": "runtime-only-token"}}),
encoding="utf-8",
token_file.write_text("runtime-only-token\n", encoding="utf-8")
response_headers = iter(
[
{
"anthropic-ratelimit-unified-5h-utilization": "0.16",
"anthropic-ratelimit-unified-5h-reset": "1800000000",
"anthropic-ratelimit-unified-7d-utilization": "0.26",
"anthropic-ratelimit-unified-7d-reset": "1800100000",
},
{},
]
)
bodies = iter([{"five_hour": {}}, []])
requests = []
class Response:
def __init__(self):
self.headers = next(response_headers)
def __enter__(self):
return self
@ -117,44 +149,46 @@ def test_provider_queries_validate_credentials_and_response_shape(
def read(self, maximum):
assert maximum == 1 << 20
return json.dumps(next(bodies)).encode()
return b"{}"
def open_request(request, timeout):
requests.append(request)
assert timeout == 15
assert timeout == 30
return Response()
monkeypatch.setattr(mod, "urlopen", open_request)
assert mod.query_claude() == {"five_hour": {}}
monkeypatch.setattr(mod.claude_query, "urlopen", open_request)
assert mod.query_claude() == {
"five_hour": {"utilization": 16, "resets_at": 1_800_000_000},
"seven_day": {"utilization": 26, "resets_at": 1_800_100_000},
}
assert requests[0].get_header("Authorization") == "Bearer runtime-only-token"
with pytest.raises(RuntimeError, match="response is not an object"):
assert requests[0].get_header("Anthropic-beta") == (
"oauth-2025-04-20,claude-code-20250219"
)
assert json.loads(requests[0].data) == {
"model": "claude-haiku-4-5-20251001",
"max_tokens": 1,
"messages": [{"role": "user", "content": "quota"}],
}
with pytest.raises(RuntimeError, match="omitted subscription quota headers"):
mod.query_claude()
assert mod.query_codex is mod.codex_query.query_codex
def test_expired_claude_quota_credential_is_not_sent_or_logged(
def test_claude_quota_failure_does_not_log_the_vault_token(
tmp_path, monkeypatch, capsys
):
mod = load_module()
credentials = tmp_path / "credentials.json"
credentials.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "revoked-runtime-token",
"expiresAt": 1_700_000_000_000,
}
}
),
encoding="utf-8",
)
monkeypatch.setattr(mod, "CLAUDE_CREDENTIALS", credentials)
monkeypatch.setattr(mod.time, "time", lambda: 1_800_000_000)
token_file = tmp_path / "claude-oauth-token"
token_file.write_text("revoked-runtime-token", encoding="utf-8")
monkeypatch.setattr(mod.claude_query, "OAUTH_TOKEN_FILE", token_file)
monkeypatch.setattr(
mod,
mod.claude_query,
"urlopen",
lambda *_args, **_kwargs: pytest.fail("expired credentials must not be sent"),
lambda *_args, **_kwargs: (_ for _ in ()).throw(
TimeoutError("revoked-runtime-token")
),
)
collector = mod.Collector()

View File

@ -467,6 +467,7 @@ def test_manifests_never_seed_access_material_into_persistent_env():
"cli-lane-runner",
"model-steward",
"claude-broker",
"ai-usage-exporter",
}
token_init_containers = {
item["name"]