[eric] providers: boot-time login-health pill (dead sub token -> reconnect toast, no more silent codex deaths)

This commit is contained in:
ciregenz
2026-07-08 20:34:37 -07:00
parent e6c781a380
commit 8ed9149c7e
7 changed files with 323 additions and 1 deletions
+18
View File
@@ -488,6 +488,24 @@ async def subscriptions_exchange(body: dict):
raise HTTPException(status_code=500, detail=str(e))
@agents.router.get("/subscriptions/health")
async def subscriptions_health():
"""Boot-time login-health check: 1-token probe per ACTIVE subscription lane, reporting only
definitive auth-death (the silent credential-rot class). `skipped` when the router isn't up yet
so the frontend can retry once instead of reading 'all healthy' off a cold boot."""
from backend.apps.nine_router import is_running, get_providers
from backend.apps.nine_router.subscription_health import probe_subscription_health
if not is_running():
return {"dead": [], "skipped": True}
try:
connections = await get_providers()
dead = await probe_subscription_health(connections)
return {"dead": dead, "skipped": False}
except Exception as e:
logger.debug(f"subscription health probe failed: {e}")
return {"dead": [], "skipped": True}
@agents.router.get("/subscriptions/models")
async def subscriptions_models():
"""List all models available through connected subscriptions."""
@@ -0,0 +1,114 @@
"""Boot-time subscription health probe: catches a provider login that died while the app was
closed (refresh-token rotation, the "Breaking codex" class) so the UI can offer reconnect BEFORE
the user burns a failed turn discovering it. Probes SUBSCRIPTION lanes only (1 token of sub quota,
never a billable API key), and only a definitive auth-shaped 401/403 counts as dead: transient
429/5xx/timeouts stay silent so the pill can never cry wolf. Kill switch: OPENSWARM_BOOT_HEALTH=0."""
import asyncio
import logging
import os
import time
from typing import Dict, List, Optional
import httpx
from typeguard import typechecked
from backend.apps.nine_router.process import NINE_ROUTER_URL, is_running
logger = logging.getLogger(__name__)
PREFIX_BY_PROVIDER: Dict[str, str] = {
"claude": "cc/",
"codex": "cx/",
"gemini-cli": "gemini/",
"antigravity": "ag/",
}
LABEL_BY_PROVIDER: Dict[str, str] = {
"claude": "Claude",
"codex": "ChatGPT",
"gemini-cli": "Gemini",
"antigravity": "Gemini (Antigravity)",
}
P_AUTH_DEAD_MARKERS = ("authentication", "expired", "sign in", "signing in", "invalid_grant", "unauthorized", "invalid authentication")
P_PROBE_TIMEOUT_S = 25.0
P_CACHE_TTL_S = 300.0
p_probe_lock = asyncio.Lock()
p_cached_result: Optional[List[Dict[str, str]]] = None
p_cached_at: float = 0.0
@typechecked
def health_probe_enabled() -> bool:
return os.environ.get("OPENSWARM_BOOT_HEALTH", "1") != "0"
@typechecked
def classify_auth_dead(status_code: int, body_text: str) -> bool:
"""Dead ONLY on a definitive auth failure; anything ambiguous reads healthy (silence beats a false reconnect prompt)."""
if status_code not in (401, 403):
return False
low = body_text.lower()
return any(m in low for m in P_AUTH_DEAD_MARKERS)
@typechecked
async def p_pick_probe_model(client: httpx.AsyncClient, prefix: str) -> Optional[str]:
try:
r = await client.get(f"{NINE_ROUTER_URL}/v1/models")
if r.status_code != 200:
return None
ids = [m.get("id") for m in (r.json().get("data") or []) if isinstance(m, dict)]
for i in ids:
if isinstance(i, str) and i.startswith(prefix):
return i
except Exception:
return None
return None
@typechecked
async def p_probe_one(client: httpx.AsyncClient, model: str) -> Optional[bool]:
"""True = auth dead, False = healthy, None = inconclusive (never reported)."""
try:
r = await client.post(
f"{NINE_ROUTER_URL}/v1/messages",
json={"model": model, "max_tokens": 1, "messages": [{"role": "user", "content": "hi"}]},
headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"},
)
if r.status_code < 400:
return False
return True if classify_auth_dead(r.status_code, r.text or "") else None
except Exception:
return None
@typechecked
async def probe_subscription_health(connections: List[Dict]) -> List[Dict[str, str]]:
"""Probe each active subscription connection with a 1-token turn; returns [{provider, label}]
for the definitively auth-dead ones. Cached for 5 minutes; concurrent callers share one run."""
global p_cached_result, p_cached_at
if not health_probe_enabled() or not is_running():
return []
async with p_probe_lock:
if p_cached_result is not None and time.monotonic() - p_cached_at < P_CACHE_TTL_S:
return p_cached_result
subs = [
c for c in connections
if isinstance(c, dict) and c.get("provider") in PREFIX_BY_PROVIDER and c.get("isActive")
]
dead: List[Dict[str, str]] = []
if subs:
async with httpx.AsyncClient(timeout=P_PROBE_TIMEOUT_S) as client:
for c in subs:
provider = str(c.get("provider"))
model = await p_pick_probe_model(client, PREFIX_BY_PROVIDER[provider])
if not model:
continue
verdict = await p_probe_one(client, model)
if verdict is True:
dead.append({"provider": provider, "label": LABEL_BY_PROVIDER[provider]})
logger.info(f"[sub-health] {provider}: auth dead (reconnect needed)")
p_cached_result = dead
p_cached_at = time.monotonic()
return dead
+88
View File
@@ -0,0 +1,88 @@
"""Boot-time subscription health probe: only a definitive auth-shaped 401/403 reads as dead
(transients stay silent so the reconnect pill can't cry wolf), probes cover only active
subscription lanes, and results are cached so a double boot-fetch can't double-spend probes."""
import asyncio
import json
from typing import Dict, List, Optional
import backend.apps.nine_router.subscription_health as sh
def test_classify_auth_dead_is_conservative():
assert sh.classify_auth_dead(401, '{"error": "authentication token is expired"}')
assert sh.classify_auth_dead(403, "Unauthorized access")
assert sh.classify_auth_dead(401, "Invalid authentication credentials")
assert not sh.classify_auth_dead(401, "weird opaque body") # 401 without an auth marker stays silent
assert not sh.classify_auth_dead(429, "rate limit expired") # non-auth status never fires
assert not sh.classify_auth_dead(500, "authentication service down")
assert not sh.classify_auth_dead(200, "expired")
class FakeResponse:
def __init__(self, status_code: int, payload: Dict):
self.status_code = status_code
self.text = json.dumps(payload)
self.p_payload = payload
def json(self) -> Dict:
return self.p_payload
class FakeClient:
"""cc/ lane healthy, cx/ lane auth-dead, gemini/ lane rate-limited (must stay silent)."""
def __init__(self, counter: List[int], **kwargs):
self.counter = counter
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *exc) -> None:
return None
async def get(self, url: str, **kw) -> FakeResponse:
return FakeResponse(200, {"data": [
{"id": "cc/claude-haiku"}, {"id": "cx/gpt-5.4"}, {"id": "gemini/gemini-2.5-flash"},
]})
async def post(self, url: str, **kw) -> FakeResponse:
self.counter[0] += 1
model = kw.get("json", {}).get("model", "")
if model.startswith("cx/"):
return FakeResponse(401, {"error": {"message": "authentication token is expired, try signing in again"}})
if model.startswith("gemini/"):
return FakeResponse(429, {"error": {"message": "rate limited"}})
return FakeResponse(200, {"content": []})
CONNS = [
{"provider": "claude", "isActive": True},
{"provider": "codex", "isActive": True},
{"provider": "gemini-cli", "isActive": True},
{"provider": "openrouter", "isActive": True}, # not a sub lane; never probed
{"provider": "codex", "isActive": False}, # inactive; never probed
]
def test_probe_reports_only_definitive_death_and_caches(monkeypatch):
counter = [0]
monkeypatch.setattr(sh, "is_running", lambda: True)
monkeypatch.setattr(sh.httpx, "AsyncClient", lambda **kw: FakeClient(counter, **kw))
monkeypatch.setattr(sh, "p_cached_result", None)
monkeypatch.setattr(sh, "p_cached_at", 0.0)
dead = asyncio.run(sh.probe_subscription_health(CONNS))
assert dead == [{"provider": "codex", "label": "ChatGPT"}]
assert counter[0] == 3 # claude + codex + gemini-cli probed; openrouter/inactive skipped
# Second call within the TTL serves the cache; no new probe spend.
dead2 = asyncio.run(sh.probe_subscription_health(CONNS))
assert dead2 == dead
assert counter[0] == 3
def test_probe_disabled_or_router_down(monkeypatch):
monkeypatch.setattr(sh, "is_running", lambda: False)
assert asyncio.run(sh.probe_subscription_health(CONNS)) == []
monkeypatch.setattr(sh, "is_running", lambda: True)
monkeypatch.setenv("OPENSWARM_BOOT_HEALTH", "0")
assert asyncio.run(sh.probe_subscription_health(CONNS)) == []
@@ -0,0 +1,63 @@
// Bottom-left nudge shown at launch when a subscription login died while the app was closed (silent token rotation): names the provider(s) and jumps straight to Settings -> Models to reconnect. Stays put until the user acts; the X dismisses it.
import React from 'react';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { hideProviderHealthToast } from '@/shared/state/subscriptionsSlice';
import { openSettingsModal } from '@/shared/state/settingsSlice';
export default function ProviderHealthToast() {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const open = useAppSelector((s) => s.subscriptions.healthToastOpen);
const dead = useAppSelector((s) => s.subscriptions.healthDead);
const onReconnect = React.useCallback(() => {
dispatch(openSettingsModal('models'));
dispatch(hideProviderHealthToast());
}, [dispatch]);
const labels = dead.map((d) => d.label).join(' and ');
return (
<Snackbar
open={open && dead.length > 0}
autoHideDuration={null}
onClose={() => dispatch(hideProviderHealthToast())}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert
icon={false}
severity="warning"
sx={{
bgcolor: c.bg.surface,
color: c.text.primary,
border: `1px solid ${c.border.medium}`,
'& .MuiAlert-action': { alignItems: 'center', pt: 0 },
}}
action={
<>
<Button size="small" onClick={onReconnect} sx={{ color: c.accent.primary, fontWeight: 700 }}>
Reconnect
</Button>
<IconButton
size="small"
aria-label="Dismiss"
onClick={() => dispatch(hideProviderHealthToast())}
sx={{ color: c.text.muted, ml: 0.25, '&:hover': { color: c.text.primary } }}
>
<CloseIcon fontSize="small" />
</IconButton>
</>
}
>
Your {labels} login{dead.length > 1 ? 's have' : ' has'} expired; chats on {dead.length > 1 ? 'them' : 'it'} will fail until you reconnect.
</Alert>
</Snackbar>
);
}
@@ -6,6 +6,7 @@ import CardSearchPalette from '../controls/CardSearchPalette';
import DirectionHints from '../controls/DirectionHints';
import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast';
import MissedRunsToast from '@/app/pages/Workflows/MissedRunsToast';
import ProviderHealthToast from '@/app/components/overlays/ProviderHealthToast';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -152,6 +153,9 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
{/* Launch nudge when scheduled runs elapsed while the app was closed */}
<MissedRunsToast />
{/* Launch nudge when a subscription login died while the app was closed */}
<ProviderHealthToast />
</>
);
};
@@ -25,6 +25,7 @@ import { fetchOutputs, type Output } from '@/shared/state/outputsSlice';
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
import { fetchWorkflows, fetchAllRuns, fetchActiveRuns } from '@/shared/state/workflowsSlice';
import { fetchMissedRuns } from '@/shared/state/missedRunsSlice';
import { fetchProviderHealth } from '@/shared/state/subscriptionsSlice';
import { dashboardWs } from '@/shared/ws/WebSocketManager';
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
import { getKeepAliveBrowserIds } from '@/shared/browserFocus';
@@ -81,6 +82,14 @@ export function useDashboardLifecycle({
if (!isActive || missedRunsCheckedThisSession) return;
missedRunsCheckedThisSession = true;
dispatch(fetchMissedRuns());
// Login-health check rides the same once-per-launch gate; delayed so the lazy router has time to boot, with ONE retry when the probe reports it wasn't up yet.
const t = setTimeout(async () => {
try {
const res = await dispatch(fetchProviderHealth()).unwrap();
if (res.skipped) setTimeout(() => { dispatch(fetchProviderHealth()); }, 45_000);
} catch { /* probe is best-effort; silence on failure */ }
}, 12_000);
return () => clearTimeout(t);
}, [isActive, dispatch]);
// Track dashboard engagement time
@@ -17,8 +17,15 @@ export interface SubscriptionStatus {
[key: string]: any;
}
export interface DeadProvider {
provider: string;
label: string;
}
export interface SubscriptionsState {
status: SubscriptionStatus | null;
healthDead: DeadProvider[];
healthToastOpen: boolean;
}
// Minimal slice shape for selectors; avoids circular type import from store.ts.
@@ -26,6 +33,8 @@ type WithSubscriptions = { subscriptions: SubscriptionsState };
const initialState: SubscriptionsState = {
status: null,
healthDead: [],
healthToastOpen: false,
};
/** Mirror /agents/subscriptions/status into Redux; preserveTransient debounces is_running() false negatives. */
@@ -44,6 +53,15 @@ export const fetchSubscriptionStatus = createAsyncThunk(
},
);
/** Boot-time login-health check; `skipped` means the router wasn't up yet, caller may retry once. */
export const fetchProviderHealth = createAsyncThunk(
'subscriptions/fetchHealth',
async (): Promise<{ dead: DeadProvider[]; skipped: boolean }> => {
const r = await fetch(`${API_BASE}/agents/subscriptions/health`);
return (await r.json()) as { dead: DeadProvider[]; skipped: boolean };
},
);
const subscriptionsSlice = createSlice({
name: 'subscriptions',
initialState,
@@ -72,15 +90,23 @@ const subscriptionsSlice = createSlice({
state.status.providers = { connections: conns };
}
},
hideProviderHealthToast(state) {
state.healthToastOpen = false;
},
},
extraReducers: (builder) => {
builder.addCase(fetchSubscriptionStatus.fulfilled, (state, action) => {
state.status = action.payload;
});
builder.addCase(fetchProviderHealth.fulfilled, (state, action) => {
if (action.payload.skipped) return;
state.healthDead = action.payload.dead ?? [];
state.healthToastOpen = state.healthDead.length > 0;
});
},
});
export const { setSubscriptionStatus, markSubscriptionConnected } = subscriptionsSlice.actions;
export const { setSubscriptionStatus, markSubscriptionConnected, hideProviderHealthToast } = subscriptionsSlice.actions;
// Stable empty ref so the selector doesn't hand back a fresh [] each call (forces needless rerenders).
const EMPTY_CONNECTIONS: SubscriptionConnection[] = [];