Watch
1
0
Fork
You've already forked souveraine
0

provider-balance: read logins and every agent, not three roles

An OAuth login reports present while dead: both Claude access tokens
expired 11h ago and only the refresh token, good 27 more days, keeps
them working. Presence was the check; expiry is the answer.
Widening to per-agent models found Hal and TestAgent naming models
absent from [models.*] — they would fail at first use, not at config.
This commit is contained in:
Fimeg 2026-08-13 10:27:33 -04:00
commit c66343f105

View file

@ -41,15 +41,24 @@ Usage
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import base64
import json import json
import subprocess import subprocess
import sys import sys
import time
import tomllib import tomllib
from pathlib import Path from pathlib import Path
CONFIG = Path.home() / ".souveraine" / "config.toml" CONFIG = Path.home() / ".souveraine" / "config.toml"
TIMEOUT = 45 TIMEOUT = 45
# Logins that live outside config.toml. They back no Souveraine role yet, so a
# dead one never fails the run -- but a refresh token quietly reaching its end
# is a browser trip, and that is worth seeing before the morning it bites.
EXTERNAL_LOGINS = {
"chatgpt-oauth": Path.home() / ".codex" / "auth.json",
}
OK = "OK" OK = "OK"
NO_BALANCE = "NO_BALANCE" NO_BALANCE = "NO_BALANCE"
RATE_LIMITED = "RATE_LIMITED" RATE_LIMITED = "RATE_LIMITED"
@ -161,6 +170,78 @@ def native_balance(name: str, base_url: str, key: str) -> str | None:
return None return None
def _jwt_exp(token: str) -> int | None:
"""Seconds-epoch `exp` from a JWT payload. No signature check — we are
reading our own stored token to see when it dies, not trusting it."""
try:
part = token.split(".")[1]
part += "=" * (-len(part) % 4)
return json.loads(base64.urlsafe_b64decode(part)).get("exp")
except Exception:
return None
def _when(epoch_s: float | None) -> str:
if not epoch_s:
return "unknown"
delta = epoch_s - time.time()
if delta < 0:
return f"expired {_dur(-delta)} ago"
return f"{_dur(delta)} left"
def _dur(seconds: float) -> str:
m = seconds / 60
if m < 90:
return f"{m:.0f}m"
h = m / 60
return f"{h:.0f}h" if h < 48 else f"{h / 24:.1f}d"
def probe_oauth_file(name: str, path: Path) -> dict:
"""An OAuth login is not a key: the access token expires constantly and the
refresh token is what actually keeps the login alive. Reporting only
presence would call a dead login healthy the same 'availability is not an
answer' trap this whole script exists to close."""
if not path.exists():
return {"provider": name, "status": AUTH, "model": "",
"detail": f"no login at {path.name}"}
try:
data = json.loads(path.read_text())
except Exception as exc:
return {"provider": name, "status": AUTH, "model": "",
"detail": f"unreadable: {exc}"}
# Two shapes in the wild: Claude writes epoch-millis fields, Codex stores
# JWTs whose own payload carries `exp`.
oauth = data.get("claudeAiOauth") or {}
if oauth:
access = oauth.get("expiresAt")
access = access / 1000 if access else None
refresh = oauth.get("refreshTokenExpiresAt")
refresh = refresh / 1000 if refresh else None
plan = oauth.get("subscriptionType", "?")
else:
tokens = data.get("tokens") or {}
access = _jwt_exp(tokens.get("access_token", ""))
refresh = None
plan = data.get("auth_mode", "?")
if not tokens.get("refresh_token"):
return {"provider": name, "status": AUTH, "model": plan,
"detail": "no refresh token — re-login required"}
# An expired access token is normal and self-healing. An expired *refresh*
# token is the one that needs a human at a browser.
if refresh and refresh < time.time():
return {"provider": name, "status": AUTH, "model": plan,
"detail": f"refresh token {_when(refresh)} — re-login required"}
detail = f"{plan}; access {_when(access)}"
if refresh:
detail += f", refresh {_when(refresh)}"
return {"provider": name, "status": OK, "model": plan, "detail": detail}
def probe_provider(name: str, cfg: dict) -> dict: def probe_provider(name: str, cfg: dict) -> dict:
ptype = cfg.get("type", "") ptype = cfg.get("type", "")
key = cfg.get("api_key", "") key = cfg.get("api_key", "")
@ -169,10 +250,17 @@ def probe_provider(name: str, cfg: dict) -> dict:
if ptype == "claude-subscription": if ptype == "claude-subscription":
files = cfg.get("credential_files") or [cfg.get("credential_file")] files = cfg.get("credential_files") or [cfg.get("credential_file")]
present = [f for f in files if f and Path(f).expanduser().exists()] files = [f for f in files if f]
status = OK if present else AUTH results = [probe_oauth_file(name, Path(f).expanduser()) for f in files]
return {"provider": name, "status": status, "model": model, alive = [r for r in results if r["status"] == OK]
"detail": f"{len(present)}/{len(files)} credential files present"} # Any one live login carries the provider; name how many so a slow
# drift from 2/2 to 1/2 is visible before it reaches 0.
best = alive[0] if alive else (results[0] if results else None)
if not best:
return {"provider": name, "status": AUTH, "model": model,
"detail": "no credential files configured"}
return {"provider": name, "status": best["status"], "model": model,
"detail": f"{len(alive)}/{len(files)} logins live; {best['detail']}"}
if not key: if not key:
return {"provider": name, "status": SKIPPED, "model": model, return {"provider": name, "status": SKIPPED, "model": model,
@ -197,7 +285,13 @@ def probe_provider(name: str, cfg: dict) -> dict:
def roles(cfg: dict) -> dict[str, str]: def roles(cfg: dict) -> dict[str, str]:
"""Which model each configured role depends on.""" """Which model each configured role depends on.
Includes every agent's own primary, not just the global roles: yesterday a
provider died and the blast radius had to be worked out by hand from seven
agent.json files. An agent whose primary *and* subconscious share one
provider has no degraded mode, and that is only visible if both are listed.
"""
out = {} out = {}
if m := cfg.get("subconscious", {}).get("model"): if m := cfg.get("subconscious", {}).get("model"):
out["subconscious"] = m out["subconscious"] = m
@ -205,6 +299,19 @@ def roles(cfg: dict) -> dict[str, str]:
out["reflection"] = m out["reflection"] = m
if m := cfg.get("archivist", {}).get("compression_model"): if m := cfg.get("archivist", {}).get("compression_model"):
out["archivist"] = m out["archivist"] = m
agents = Path.home() / ".souveraine" / "server" / "agents"
for path in sorted(agents.glob("*/agent.json")):
try:
data = json.loads(path.read_text())
except Exception:
continue
name = data.get("name") or path.parent.name[:8]
# The top-level `model` key is always null; the live value is nested.
if m := (data.get("llm_config") or {}).get("model"):
out[f"{name}:primary"] = m
if m := (data.get("_souveraine") or {}).get("subconscious_model"):
out[f"{name}:sub"] = m
return out return out
@ -258,10 +365,58 @@ def selftest() -> int:
failed += not ok failed += not ok
print(f" {'PASS' if ok else 'FAIL'} {label:<42} -> {got}" print(f" {'PASS' if ok else 'FAIL'} {label:<42} -> {got}"
+ ("" if ok else f" (expected {expected})")) + ("" if ok else f" (expected {expected})"))
print(f"\n{len(cases) - failed}/{len(cases)} passed")
# --- OAuth expiry: presence is not liveness -------------------------
import tempfile
hour = 3600
now = time.time()
oauth_cases = [
# An expired access token with a live refresh token is the *normal*
# steady state — both real Claude logins on this box look like this.
("claude: access expired, refresh live", OK,
{"claudeAiOauth": {"expiresAt": int((now - 11 * hour) * 1000),
"refreshTokenExpiresAt": int((now + 27 * 24 * hour) * 1000),
"subscriptionType": "pro"}}),
("claude: refresh expired needs a human", AUTH,
{"claudeAiOauth": {"expiresAt": int((now - 99 * hour) * 1000),
"refreshTokenExpiresAt": int((now - hour) * 1000),
"subscriptionType": "pro"}}),
("codex: jwt with refresh token is fine", OK,
{"auth_mode": "chatgpt",
"tokens": {"access_token": _fake_jwt(now + 10 * 24 * hour),
"refresh_token": "rt.1.xyz"}}),
("codex: no refresh token needs re-login", AUTH,
{"auth_mode": "chatgpt",
"tokens": {"access_token": _fake_jwt(now + hour)}}),
]
for label, expected, blob in oauth_cases:
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
json.dump(blob, fh)
tmp = Path(fh.name)
got = probe_oauth_file("t", tmp)["status"]
tmp.unlink()
ok = got == expected
failed += not ok
print(f" {'PASS' if ok else 'FAIL'} {label:<42} -> {got}"
+ ("" if ok else f" (expected {expected})"))
missing = probe_oauth_file("t", Path("/nonexistent/auth.json"))["status"]
ok = missing == AUTH
failed += not ok
print(f" {'PASS' if ok else 'FAIL'} {'absent login is AUTH, not OK':<42} -> {missing}")
total = len(cases) + len(oauth_cases) + 1
print(f"\n{total - failed}/{total} passed")
return 1 if failed else 0 return 1 if failed else 0
def _fake_jwt(exp: float) -> str:
"""Only the payload segment is ever read, so a signature is unnecessary."""
payload = base64.urlsafe_b64encode(
json.dumps({"exp": int(exp)}).encode()).decode().rstrip("=")
return f"header.{payload}.sig"
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--all", action="store_true", ap.add_argument("--all", action="store_true",
@ -300,6 +455,13 @@ def main() -> int:
for r in results: for r in results:
r["roles"] = needed.get(r["provider"], []) r["roles"] = needed.get(r["provider"], [])
# External logins are reported, never fatal — nothing routes through them.
for name, path in EXTERNAL_LOGINS.items():
if args.all or path.exists():
ext = probe_oauth_file(name, path)
ext["roles"] = []
results.append(ext)
unregistered = [k for k in needed if k.startswith("<unregistered:")] unregistered = [k for k in needed if k.startswith("<unregistered:")]
failing = [r for r in results if r["roles"] and r["status"] != OK] failing = [r for r in results if r["roles"] and r["status"] != OK]