360 lines
14 KiB
Python
360 lines
14 KiB
Python
"""Read-only provider-pool status derived from Switchyard and local auth metadata."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
SWITCHYARD_ROOT = os.environ.get(
|
|
"HERMES_SWITCHYARD_STATUS_URL",
|
|
"http://hermes-switchyard.hermes.svc.cluster.local:9005",
|
|
).rstrip("/")
|
|
CODEX_USAGE_URL = "https://chatgpt.com/codex/settings/usage"
|
|
CLAUDE_USAGE_URL = "https://claude.ai/settings/usage"
|
|
CODEX_AUTH_PATH = Path(
|
|
os.environ.get("CODEX_HOME", "/opt/data/home/.codex")
|
|
) / "auth.json"
|
|
CLAUDE_AUTH_PATH = Path(
|
|
os.environ.get("CLAUDE_CONFIG_DIR", "/opt/data/home/.claude")
|
|
) / ".credentials.json"
|
|
ROUTING_CATALOG_PATH = Path(
|
|
os.environ.get("HERMES_ROUTING_CATALOG_PATH", "/routing-catalog/catalog.json")
|
|
)
|
|
CLAUDE_HEALTH_PATH = Path(
|
|
os.environ.get(
|
|
"HERMES_CLAUDE_HEALTH_PATH", "/opt/data/provider-health/claude.json"
|
|
)
|
|
)
|
|
CODEX_HEALTH_PATH = Path(
|
|
os.environ.get("HERMES_CODEX_HEALTH_PATH", "/opt/data/provider-health/codex.json")
|
|
)
|
|
|
|
|
|
def _read_json(path: Path) -> dict[str, Any]:
|
|
"""Return one local JSON object without leaking parsing details."""
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, TypeError, ValueError, json.JSONDecodeError):
|
|
return {}
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _get_json(url: str, timeout: float = 3.0) -> dict[str, Any]:
|
|
"""Fetch a bounded JSON status response from the in-cluster router."""
|
|
request = Request(url, headers={"Accept": "application/json"})
|
|
try:
|
|
with urlopen(request, timeout=timeout) as response:
|
|
value = json.load(response)
|
|
except (HTTPError, URLError, OSError, TimeoutError, ValueError):
|
|
return {}
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _jwt_claims(token: Any) -> dict[str, Any]:
|
|
"""Decode trusted local token metadata for display; do not verify or return it."""
|
|
if not isinstance(token, str):
|
|
return {}
|
|
try:
|
|
encoded = token.split(".")[1]
|
|
encoded += "=" * (-len(encoded) % 4)
|
|
value = json.loads(base64.urlsafe_b64decode(encoded))
|
|
except (
|
|
IndexError,
|
|
TypeError,
|
|
ValueError,
|
|
json.JSONDecodeError,
|
|
base64.binascii.Error,
|
|
):
|
|
return {}
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _timestamp(value: Any) -> tuple[str | None, bool | None]:
|
|
"""Normalize an epoch or ISO-8601 timestamp and report whether it is live."""
|
|
if isinstance(value, str):
|
|
rendered_value = value.strip()
|
|
if rendered_value and not rendered_value.replace(".", "", 1).isdigit():
|
|
try:
|
|
parsed = datetime.fromisoformat(rendered_value.replace("Z", "+00:00"))
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
except ValueError:
|
|
return None, None
|
|
rendered = parsed.astimezone(timezone.utc).isoformat()
|
|
return rendered, parsed.timestamp() > time.time()
|
|
try:
|
|
raw = float(value)
|
|
except (TypeError, ValueError):
|
|
return None, None
|
|
if raw > 100_000_000_000:
|
|
raw /= 1000
|
|
try:
|
|
rendered = datetime.fromtimestamp(raw, tz=timezone.utc).isoformat()
|
|
except (OverflowError, OSError, ValueError):
|
|
return None, None
|
|
return rendered, raw > time.time()
|
|
|
|
|
|
def _codex_account() -> dict[str, Any]:
|
|
"""Return non-secret Codex plan and credential health metadata."""
|
|
auth = _read_json(CODEX_AUTH_PATH)
|
|
tokens = auth.get("tokens") if isinstance(auth.get("tokens"), dict) else {}
|
|
access = _jwt_claims(tokens.get("access_token"))
|
|
identity = _jwt_claims(tokens.get("id_token"))
|
|
claims = identity.get("https://api.openai.com/auth")
|
|
claims = claims if isinstance(claims, dict) else {}
|
|
expires_at, token_live = _timestamp(access.get("exp"))
|
|
subscription_until, subscription_live = _timestamp(
|
|
claims.get("chatgpt_subscription_active_until")
|
|
)
|
|
refreshable = bool(tokens.get("refresh_token"))
|
|
authenticated = (
|
|
bool(tokens.get("access_token")) and token_live is not False
|
|
) or refreshable
|
|
return {
|
|
"authenticated": authenticated,
|
|
"access_token_live": token_live,
|
|
"refreshable": refreshable,
|
|
"auth_mode": auth.get("auth_mode") or "unknown",
|
|
"plan": claims.get("chatgpt_plan_type") or "unknown",
|
|
"token_expires_at": expires_at,
|
|
"subscription_active": subscription_live,
|
|
"subscription_until": subscription_until,
|
|
"last_refresh": auth.get("last_refresh"),
|
|
"quota_reported": False,
|
|
"usage_url": CODEX_USAGE_URL,
|
|
}
|
|
|
|
|
|
def _claude_account() -> dict[str, Any]:
|
|
"""Return non-secret Claude plan and credential health metadata."""
|
|
auth = _read_json(CLAUDE_AUTH_PATH)
|
|
oauth = auth.get("claudeAiOauth")
|
|
oauth = oauth if isinstance(oauth, dict) else {}
|
|
expires_at, token_live = _timestamp(oauth.get("expiresAt"))
|
|
refresh_expires_at, refresh_live = _timestamp(oauth.get("refreshTokenExpiresAt"))
|
|
refreshable = bool(oauth.get("refreshToken")) and refresh_live is not False
|
|
authenticated = (
|
|
bool(oauth.get("accessToken")) and token_live is not False
|
|
) or refreshable
|
|
return {
|
|
"authenticated": authenticated,
|
|
"access_token_live": token_live,
|
|
"refreshable": refreshable,
|
|
"plan": oauth.get("subscriptionType") or "unknown",
|
|
"rate_limit_tier": oauth.get("rateLimitTier") or "unknown",
|
|
"token_expires_at": expires_at,
|
|
"refresh_expires_at": refresh_expires_at,
|
|
"refresh_live": refresh_live,
|
|
"quota_reported": False,
|
|
"usage_url": CLAUDE_USAGE_URL,
|
|
}
|
|
|
|
|
|
def _provider_for_model(model_id: str) -> str:
|
|
"""Map Switchyard target identifiers to their operator-facing provider."""
|
|
value = model_id.lower()
|
|
if "/codex/" in value or value.startswith("openai"):
|
|
return "codex"
|
|
if "/claude/" in value or value.startswith("anthropic"):
|
|
return "claude"
|
|
if "/local/" in value or "qwen" in value or "ollama" in value:
|
|
return "local"
|
|
return "other"
|
|
|
|
|
|
def _number(value: Any) -> int:
|
|
"""Coerce non-negative counters from Switchyard's JSON safely."""
|
|
try:
|
|
return max(0, int(value or 0))
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _provider_summary(name: str, models: dict[str, Any]) -> dict[str, Any]:
|
|
"""Aggregate successful calls, errors, tokens, and latency for one lane."""
|
|
selected: list[dict[str, Any]] = []
|
|
totals = {
|
|
"calls": 0,
|
|
"errors": 0,
|
|
"prompt_tokens": 0,
|
|
"completion_tokens": 0,
|
|
"reasoning_tokens": 0,
|
|
"total_tokens": 0,
|
|
}
|
|
latency_weight = 0.0
|
|
latency_calls = 0
|
|
for model_id, raw in sorted(models.items()):
|
|
if _provider_for_model(str(model_id)) != name or not isinstance(raw, dict):
|
|
continue
|
|
item = {"id": str(model_id)}
|
|
for key in totals:
|
|
item[key] = _number(raw.get(key))
|
|
totals[key] += item[key]
|
|
try:
|
|
item["avg_latency_ms"] = round(float(raw.get("avg_latency_ms") or 0), 1)
|
|
except (TypeError, ValueError):
|
|
item["avg_latency_ms"] = 0.0
|
|
latency_weight += item["avg_latency_ms"] * item["calls"]
|
|
latency_calls += item["calls"]
|
|
selected.append(item)
|
|
|
|
calls = totals["calls"]
|
|
errors = totals["errors"]
|
|
# Switchyard counters span the router process lifetime. One old transient
|
|
# failure must not leave an otherwise healthy provider permanently yellow.
|
|
total_boundaries = calls + errors
|
|
error_ratio = errors / total_boundaries if total_boundaries else 0.0
|
|
if calls and error_ratio >= 0.05:
|
|
state = "degraded"
|
|
elif calls:
|
|
state = "available"
|
|
elif errors:
|
|
state = "unavailable"
|
|
else:
|
|
state = "unobserved"
|
|
return {
|
|
"state": state,
|
|
**totals,
|
|
"avg_latency_ms": round(latency_weight / latency_calls, 1)
|
|
if latency_calls
|
|
else 0.0,
|
|
"models": selected,
|
|
}
|
|
|
|
|
|
def _configured_models(provider: str) -> list[str]:
|
|
"""Return the stewarded model catalog independently of observed traffic."""
|
|
catalog = _read_json(ROUTING_CATALOG_PATH)
|
|
providers = catalog.get("providers")
|
|
providers = providers if isinstance(providers, dict) else {}
|
|
record = providers.get(provider)
|
|
record = record if isinstance(record, dict) else {}
|
|
models = record.get("models")
|
|
if not isinstance(models, list):
|
|
return []
|
|
return sorted({str(model) for model in models if isinstance(model, str)})
|
|
|
|
|
|
def _fresh_health(path: Path, maximum_age: float = 86400.0) -> dict[str, Any]:
|
|
"""Read recent broker health without treating stale state as authoritative."""
|
|
value = _read_json(path)
|
|
try:
|
|
age = time.time() - path.stat().st_mtime
|
|
except OSError:
|
|
return {}
|
|
return value if age <= maximum_age else {}
|
|
|
|
|
|
def _apply_native_health(provider: dict[str, Any], health: dict[str, Any]) -> None:
|
|
"""Prefer current native transport health over lifetime router counters."""
|
|
provider["native_health"] = health
|
|
native_state = health.get("state")
|
|
if native_state == "available":
|
|
provider["state"] = "available"
|
|
elif native_state in {"capacity-limited", "degraded"}:
|
|
provider["state"] = "degraded"
|
|
elif native_state == "unavailable":
|
|
provider["state"] = "unavailable"
|
|
|
|
|
|
def provider_status_payload() -> dict[str, Any]:
|
|
"""Build the owner-safe status document shared by dashboard and TUI."""
|
|
health = _get_json(f"{SWITCHYARD_ROOT}/health")
|
|
stats = _get_json(f"{SWITCHYARD_ROOT}/v1/stats")
|
|
router_ok = health.get("status") == "ok" and bool(stats)
|
|
models = stats.get("models") if isinstance(stats.get("models"), dict) else {}
|
|
providers = {
|
|
name: _provider_summary(name, models)
|
|
for name in ("codex", "claude", "local")
|
|
}
|
|
for name, item in providers.items():
|
|
item["configured_models"] = _configured_models(name)
|
|
item["supported_efforts"] = (
|
|
["low", "medium", "high", "xhigh"]
|
|
if name in {"codex", "claude"}
|
|
else ["medium"]
|
|
)
|
|
providers["codex"]["account"] = _codex_account()
|
|
providers["claude"]["account"] = _claude_account()
|
|
_apply_native_health(providers["codex"], _fresh_health(CODEX_HEALTH_PATH))
|
|
_apply_native_health(providers["claude"], _fresh_health(CLAUDE_HEALTH_PATH))
|
|
classifier = stats.get("classifier")
|
|
classifier = classifier if isinstance(classifier, dict) else {}
|
|
fallbacks = stats.get("routing_fallbacks")
|
|
fallbacks = fallbacks if isinstance(fallbacks, dict) else {}
|
|
return {
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"window": "Since the last Switchyard restart",
|
|
"quota_note": (
|
|
"Codex uses the owner's ChatGPT Codex OAuth and Claude uses the "
|
|
"owner's native first-party Claude Code subscription; the Claude "
|
|
"lane does not use the metered Anthropic API key. Open each official "
|
|
"usage page for authoritative remaining capacity. The counters here "
|
|
"show actual work observed by Switchyard."
|
|
),
|
|
"router": {
|
|
"state": "available" if router_ok else "unavailable",
|
|
"total_requests": _number(stats.get("total_requests")),
|
|
"total_errors": _number(stats.get("total_errors")),
|
|
"total_tokens": _number(
|
|
(stats.get("total_tokens") or {}).get("total")
|
|
if isinstance(stats.get("total_tokens"), dict)
|
|
else 0
|
|
),
|
|
"fallbacks": {str(key): _number(value) for key, value in fallbacks.items()},
|
|
"classifier_calls": _number(classifier.get("total_requests")),
|
|
"classifier_errors": _number(classifier.get("total_errors")),
|
|
},
|
|
"providers": providers,
|
|
}
|
|
|
|
|
|
def provider_status_text() -> str:
|
|
"""Render the same status as a compact native Hermes command response."""
|
|
payload = provider_status_payload()
|
|
router = payload["router"]
|
|
labels = {"codex": "Codex", "claude": "Claude", "local": "Local"}
|
|
lines = [
|
|
f"Provider pool: Switchyard {router['state']}",
|
|
str(payload["window"]),
|
|
]
|
|
for key in ("codex", "claude", "local"):
|
|
item = payload["providers"][key]
|
|
account = item.get("account") or {}
|
|
plan = f" · plan {account.get('plan', 'unknown')}" if account else ""
|
|
auth = ""
|
|
if account:
|
|
if account.get("access_token_live") is False and account.get("refreshable"):
|
|
auth = " · auth refreshable"
|
|
else:
|
|
auth = (
|
|
" · auth ready"
|
|
if account.get("authenticated")
|
|
else " · auth unavailable"
|
|
)
|
|
lines.append(
|
|
f"{labels[key]}: {item['state']} · {item['calls']} completed · "
|
|
f"{item['errors']} errors · {item['total_tokens']} tokens{plan}{auth}"
|
|
)
|
|
lines.extend(
|
|
[
|
|
f"Fallbacks: {sum(router['fallbacks'].values())} · "
|
|
f"classifier: {router['classifier_calls']} calls / "
|
|
f"{router['classifier_errors']} errors",
|
|
str(payload["quota_note"]),
|
|
f"Codex usage: {CODEX_USAGE_URL}",
|
|
f"Claude usage: {CLAUDE_USAGE_URL}",
|
|
"Dashboard: Provider Status",
|
|
]
|
|
)
|
|
return "\n".join(lines)
|