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:
parent
a2648543b2
commit
c66343f105
1 changed files with 168 additions and 6 deletions
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue