[eric] settings: disconnect a subscription provider from the UI, verified against 9router not assumed

This commit is contained in:
ciregenz
2026-07-31 00:00:43 -07:00
parent acfc6eb8e7
commit b5e205160d
6 changed files with 344 additions and 70 deletions
+7 -41
View File
@@ -438,10 +438,11 @@ async def subscriptions_connect(body: dict):
raise HTTPException(status_code=503, detail="9Router not available. Please install Node.js.")
# Reconnecting gemini-cli must wipe antigravity; registry prefers AG and a stale AG token would 400 after gemini-cli refreshes.
cascade = P_PROVIDER_CASCADE_REMOVES.get(provider, [])
from backend.apps.agents.disconnect_subscription import PROVIDER_CASCADE_REMOVES, delete_provider_connections
cascade = PROVIDER_CASCADE_REMOVES.get(provider, [])
if cascade:
try:
await p_delete_provider_connections(cascade)
await delete_provider_connections(cascade)
except Exception:
pass
@@ -892,47 +893,12 @@ async def list_models():
return {"models": result, "notes": notes}
# gemini-cli and antigravity are two Google OAuth lanes; registry prefers AG, so we cascade-wipe AG when reconnecting gemini-cli to avoid stale-AG 400s. One-directional: AG operations MUST NOT cascade back.
P_PROVIDER_CASCADE_REMOVES: dict[str, list[str]] = {
"gemini-cli": ["antigravity"],
}
async def p_delete_provider_connections(providers: list[str]) -> int:
"""Delete 9Router connections in `providers`; returns count removed, silent on 9Router unreachable."""
import httpx
from backend.apps.nine_router import NINE_ROUTER_API, get_providers
try:
connections = await get_providers()
except Exception:
return 0
targets = [c for c in connections if c.get("provider") in providers and c.get("id")]
removed = 0
async with httpx.AsyncClient(timeout=10.0) as client:
for c in targets:
try:
await client.delete(f"{NINE_ROUTER_API}/providers/{c['id']}")
removed += 1
except Exception:
pass
return removed
@agents.router.post("/subscriptions/disconnect")
async def subscriptions_disconnect(body: dict):
"""Disconnect a subscription provider via 9Router; cascades-wipe Google's paired lanes."""
"""Disconnect a subscription provider's 9Router lane; reports ok only once the lane is verifiably gone."""
from backend.apps.agents.disconnect_subscription import disconnect_subscription
provider = body.get("provider", "")
if not provider:
raise HTTPException(status_code=400, detail="provider required")
try:
to_remove = [provider, *P_PROVIDER_CASCADE_REMOVES.get(provider, [])]
removed = await p_delete_provider_connections(to_remove)
if removed:
from backend.apps.service.client import sync as p_sync
from backend.apps.settings.settings import load_settings
p_sync(load_settings().model_dump())
return {"ok": True}
return {"ok": False, "error": "Connection not found"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
result = await disconnect_subscription(provider)
return result.model_dump()
@@ -0,0 +1,111 @@
"""Drop a subscription provider's 9Router lane and report only what actually happened.
Deleting is the easy half; honesty is the hard half. 9Router answers a bad id with a 404 and a JSON
error body, so a caller that ignores status codes reports "disconnected" for a lane that is still
live, and the user reconnects on top of a stale row. So success here is verified, never inferred:
`ok` means the provider has no connection row left, confirmed by re-reading 9Router.
"""
import logging
from typing import Dict, List, Optional
import httpx
from pydantic import BaseModel, ConfigDict
from typeguard import typechecked
from backend.apps.nine_router.process import NINE_ROUTER_API, cli_auth_headers, is_running
from backend.apps.nine_router.subscription_health import invalidate_health_cache
logger = logging.getLogger(__name__)
# gemini-cli and antigravity are two Google OAuth lanes; the registry prefers AG, so dropping gemini-cli must drop the stale AG row too or it 400s. One-directional: AG operations MUST NOT cascade back.
PROVIDER_CASCADE_REMOVES: Dict[str, List[str]] = {
"gemini-cli": ["antigravity"],
}
P_TIMEOUT_S = 10.0
class SubscriptionDisconnectResult(BaseModel):
"""What the user may be told: `ok` only once the lane is verifiably empty."""
model_config = ConfigDict(validate_assignment=True)
ok: bool
removed: int = 0
error: str = ""
@typechecked
def sync_settings_state() -> None:
"""Push the settings snapshot to the cloud state sync, exactly as connecting does. Imported late: service.client reaches back into this package."""
from backend.apps.service.client import sync
from backend.apps.settings.settings import load_settings
sync(load_settings().model_dump())
@typechecked
async def p_list_connections() -> Optional[List[Dict]]:
"""Every 9Router connection row, or None when the router can't be read.
Deliberately not `nine_router.get_providers`, which folds an unreachable router into an empty
list: here "I couldn't look" and "nothing is connected" must not be the same answer.
"""
try:
async with httpx.AsyncClient(timeout=P_TIMEOUT_S, headers=cli_auth_headers()) as client:
r = await client.get(f"{NINE_ROUTER_API}/providers")
if r.status_code != 200:
return None
data = r.json()
if not isinstance(data, dict):
return None
return data.get("connections") or []
except Exception as e:
logger.debug(f"9Router provider list failed: {e}")
return None
@typechecked
async def delete_provider_connections(providers: List[str]) -> int:
"""Delete every 9Router connection belonging to `providers`; returns how many really went."""
connections = await p_list_connections()
if connections is None:
return 0
removed = 0
async with httpx.AsyncClient(timeout=P_TIMEOUT_S, headers=cli_auth_headers()) as client:
for c in connections:
if c.get("provider") not in providers or not c.get("id"):
continue
try:
r = await client.delete(f"{NINE_ROUTER_API}/providers/{c['id']}")
if r.status_code < 400:
removed += 1
else:
logger.warning(f"9Router refused to drop the {c.get('provider')} connection: HTTP {r.status_code}")
except Exception as e:
logger.warning(f"9Router delete failed for {c.get('provider')}: {e}")
return removed
@typechecked
async def disconnect_subscription(provider: str) -> SubscriptionDisconnectResult:
"""Clear `provider` (and its cascade lanes) from 9Router, confirming the lane is gone."""
if not is_running():
return SubscriptionDisconnectResult(ok=False, error="The subscription service isn't running.")
targets = [provider, *PROVIDER_CASCADE_REMOVES.get(provider, [])]
removed = await delete_provider_connections(targets)
remaining = await p_list_connections()
if remaining is None:
return SubscriptionDisconnectResult(
ok=False, removed=removed, error="Couldn't confirm the disconnect. Please try again.",
)
if any(c.get("provider") in targets for c in remaining):
return SubscriptionDisconnectResult(
ok=False, removed=removed, error="The subscription service kept the connection. Please try again.",
)
sync_settings_state()
# A deliberate disconnect makes the cached boot verdict a lie: it would nag "reconnect" about the lane the user just dropped.
invalidate_health_cache()
return SubscriptionDisconnectResult(ok=True, removed=removed)
@@ -43,6 +43,14 @@ def health_probe_enabled() -> bool:
return os.environ.get("OPENSWARM_BOOT_HEALTH", "1") != "0"
@typechecked
def invalidate_health_cache() -> None:
"""Drop the cached verdict after a deliberate connect/disconnect, which makes it stale by definition."""
global p_cached_result, p_cached_at
p_cached_result = None
p_cached_at = 0.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)."""
@@ -0,0 +1,127 @@
"""Disconnecting a subscription reports the VERIFIED end state, never the attempt.
The shipped bug this pins: 9Router answers a bad id with a 404 and a JSON error body, and the old
code counted every DELETE it managed to send as a removal, so the UI said "disconnected" for a lane
that was still live and the next connect stacked on a stale row.
"""
import asyncio
from typing import Dict, List, Optional
import backend.apps.agents.disconnect_subscription as ds
class FakeResponse:
def __init__(self, status_code: int, payload: Optional[Dict] = None):
self.status_code = status_code
self.p_payload = payload or {}
def json(self) -> Dict:
return self.p_payload
class FakeRouter:
"""A 9Router whose rows only disappear when `deletable` allows the delete to succeed."""
def __init__(self, rows: List[Dict], deletable: bool = True, readable: bool = True):
self.rows = list(rows)
self.deletable = deletable
self.readable = readable
self.deletes: List[str] = []
def client(self, **kwargs) -> "FakeClient":
return FakeClient(self)
class FakeClient:
def __init__(self, router: FakeRouter):
self.router = router
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *exc) -> None:
return None
async def get(self, url: str, **kw) -> FakeResponse:
if not self.router.readable:
return FakeResponse(503, {"error": "router down"})
return FakeResponse(200, {"connections": self.router.rows})
async def delete(self, url: str, **kw) -> FakeResponse:
conn_id = url.rsplit("/", 1)[-1]
self.router.deletes.append(conn_id)
if not self.router.deletable:
return FakeResponse(404, {"error": "Connection not found"})
self.router.rows = [r for r in self.router.rows if r["id"] != conn_id]
return FakeResponse(200, {"message": "Connection deleted successfully"})
CLAUDE_AND_CODEX = [
{"id": "c1", "provider": "claude", "name": "Account 1"},
{"id": "x1", "provider": "codex", "name": "Account 1"},
{"id": "x2", "provider": "codex", "name": "Account 2"},
]
def p_install(monkeypatch, router: FakeRouter) -> None:
monkeypatch.setattr(ds, "is_running", lambda: True)
monkeypatch.setattr(ds.httpx, "AsyncClient", lambda **kw: router.client(**kw))
monkeypatch.setattr(ds, "invalidate_health_cache", lambda: None)
monkeypatch.setattr(ds, "sync_settings_state", lambda: None)
def test_disconnect_clears_every_row_of_the_lane(monkeypatch):
router = FakeRouter(CLAUDE_AND_CODEX)
p_install(monkeypatch, router)
result = asyncio.run(ds.disconnect_subscription("codex"))
assert result.ok and result.removed == 2 and result.error == ""
assert [r["provider"] for r in router.rows] == ["claude"]
def test_a_refused_delete_is_never_reported_as_success(monkeypatch):
router = FakeRouter(CLAUDE_AND_CODEX, deletable=False)
p_install(monkeypatch, router)
result = asyncio.run(ds.disconnect_subscription("claude"))
assert not result.ok
assert result.removed == 0
assert result.error
assert len(router.rows) == 3 # the lane really is still there
def test_an_unreadable_router_cannot_confirm(monkeypatch):
router = FakeRouter(CLAUDE_AND_CODEX, readable=False)
p_install(monkeypatch, router)
result = asyncio.run(ds.disconnect_subscription("claude"))
assert not result.ok and result.error
def test_disconnect_is_idempotent_when_the_lane_is_already_clear(monkeypatch):
router = FakeRouter([{"id": "c1", "provider": "claude"}])
p_install(monkeypatch, router)
result = asyncio.run(ds.disconnect_subscription("codex"))
assert result.ok and result.removed == 0
assert router.deletes == []
def test_google_lanes_cascade_together(monkeypatch):
router = FakeRouter([
{"id": "g1", "provider": "gemini-cli"},
{"id": "a1", "provider": "antigravity"},
{"id": "c1", "provider": "claude"},
])
p_install(monkeypatch, router)
result = asyncio.run(ds.disconnect_subscription("gemini-cli"))
assert result.ok and result.removed == 2
assert [r["provider"] for r in router.rows] == ["claude"]
# One-directional: dropping antigravity must NOT take gemini-cli with it.
assert ds.PROVIDER_CASCADE_REMOVES.get("antigravity") is None
def test_router_down_fails_closed(monkeypatch):
router = FakeRouter(CLAUDE_AND_CODEX)
p_install(monkeypatch, router)
monkeypatch.setattr(ds, "is_running", lambda: False)
result = asyncio.run(ds.disconnect_subscription("claude"))
assert not result.ok and result.error
assert router.deletes == []
@@ -6,25 +6,42 @@ import CircularProgress from '@mui/material/CircularProgress';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { SubscriptionProvider } from './subscriptionProviders';
const SubscriptionCard: React.FC<{ provider: SubscriptionProvider; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string; disconnecting?: boolean }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => {
interface Props {
provider: SubscriptionProvider;
connected: boolean;
connecting: boolean;
confirmingDisconnect: boolean;
disconnecting: boolean;
error?: string;
userCode?: string;
onConnect: () => void;
onRequestDisconnect: () => void;
onCancelDisconnect: () => void;
onDisconnect: () => void;
}
const SubscriptionCard: React.FC<Props> = ({
provider, connected, connecting, confirmingDisconnect, disconnecting, error, userCode,
onConnect, onRequestDisconnect, onCancelDisconnect, onDisconnect,
}) => {
const c = useClaudeTokens();
const isPreview = (provider as any).preview;
const dotColor = connected ? c.status.success : connecting ? c.accent.primary : c.border.medium;
const linkButton = {
border: 'none', background: 'transparent', p: 0, cursor: 'pointer',
fontFamily: 'inherit', fontSize: '0.6875rem', transition: 'color 0.15s ease',
} as const;
return (
<Box sx={{
p: 1.5, borderRadius: `${c.radius.md}px`,
border: `1px solid ${connected ? c.status.success + '30' : connecting ? c.accent.primary + '30' : c.border.subtle}`,
bgcolor: connected ? `${c.status.success}06` : connecting ? `${c.accent.primary}06` : 'transparent',
opacity: isPreview ? 0.5 : 1,
opacity: provider.preview ? 0.5 : 1,
transition: c.transition,
'&:hover': isPreview ? {} : {
'&:hover': provider.preview ? {} : {
borderColor: connected ? c.status.success + '4d' : c.border.medium,
boxShadow: c.shadow.sm,
},
// affirm "Connected" at rest, reveal "Disconnect" on hover so the undo never shouts
'&:hover .sub-rest': { opacity: 0 },
'&:hover .sub-undo': { opacity: 1, pointerEvents: 'auto' },
}}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
@@ -44,21 +61,32 @@ const SubscriptionCard: React.FC<{ provider: SubscriptionProvider; connected: bo
</Box>
</Box>
{isPreview ? (
{provider.preview ? (
<Typography sx={{ fontSize: '0.625rem', color: c.text.ghost, fontStyle: 'italic', flexShrink: 0 }}>
Coming soon
</Typography>
) : connected ? (
disconnecting ? (
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
) : (
<Box sx={{ position: 'relative', flexShrink: 0, minWidth: 72, height: 16 }}>
<Typography className="sub-rest" sx={{ position: 'absolute', right: 0, top: 0, fontSize: '0.6875rem', fontWeight: 500, color: c.status.success, transition: 'opacity 0.18s ease' }}>
Connected
</Typography>
<Typography className="sub-undo" onClick={onDisconnect} sx={{ position: 'absolute', right: 0, top: 0, fontSize: '0.6875rem', color: c.text.tertiary, cursor: 'pointer', opacity: 0, pointerEvents: 'none', transition: 'opacity 0.18s ease', '&:hover': { color: c.status.error } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8, flexShrink: 0 }}>
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
<Typography sx={{ fontSize: '0.6875rem', color: c.text.muted }}>Disconnecting...</Typography>
</Box>
) : confirmingDisconnect ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, flexShrink: 0 }}>
<Typography sx={{ fontSize: '0.6875rem', color: c.text.secondary }}>Disconnect?</Typography>
<Box component="button" type="button" onClick={onCancelDisconnect} sx={{ ...linkButton, color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
Cancel
</Box>
<Box component="button" type="button" onClick={onDisconnect} sx={{ ...linkButton, color: c.status.error, fontWeight: 600, '&:hover': { opacity: 0.75 } }}>
Disconnect
</Typography>
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, flexShrink: 0 }}>
<Typography sx={{ fontSize: '0.6875rem', fontWeight: 500, color: c.status.success }}>Connected</Typography>
<Box component="button" type="button" onClick={onRequestDisconnect} sx={{ ...linkButton, color: c.text.tertiary, '&:hover': { color: c.status.error } }}>
Disconnect
</Box>
</Box>
)
) : connecting && userCode ? (
@@ -77,6 +105,12 @@ const SubscriptionCard: React.FC<{ provider: SubscriptionProvider; connected: bo
</Button>
)}
</Box>
{error && (
<Typography sx={{ mt: 0.75, fontSize: '0.625rem', color: c.status.error, lineHeight: 1.4 }}>
{error}
</Typography>
)}
</Box>
);
};
@@ -11,12 +11,24 @@ import {
setSubscriptionStatus,
markSubscriptionConnected,
selectSubscriptionConnections,
type SubscriptionConnection,
} from '@/shared/state/subscriptionsSlice';
import { API_BASE } from '@/shared/config';
import { SUBSCRIPTION_PROVIDERS } from './subscriptionProviders';
import SubscriptionCard from './SubscriptionCard';
import { runConnectFlow } from './subscriptionConnect';
/** What POST /agents/subscriptions/disconnect answers; `ok` is the backend's verified end state, never a guess. */
interface DisconnectResponse {
ok?: boolean;
removed?: number;
error?: string;
}
function isProviderActive(connections: SubscriptionConnection[], providerId: string): boolean {
return connections.some((p) => p.provider === providerId && (p.isActive || p.testStatus === 'active'));
}
function friendlyConnectError(detail: string): string {
const d = (detail || '').trim();
const lower = d.toLowerCase();
@@ -36,6 +48,8 @@ const SubscriptionCards: React.FC = () => {
const connections = useAppSelector(selectSubscriptionConnections);
const [connecting, setConnecting] = useState<string | null>(null);
const [disconnecting, setDisconnecting] = useState<string | null>(null);
const [confirmingDisconnect, setConfirmingDisconnect] = useState<string | null>(null);
const [disconnectError, setDisconnectError] = useState<{ provider: string; message: string } | null>(null);
const [userCode, setUserCode] = useState('');
const [pollTimer, setPollTimer] = useState<any>(null);
const [connectError, setConnectError] = useState<string | null>(null);
@@ -70,15 +84,18 @@ const SubscriptionCards: React.FC = () => {
return () => { cancelled = true; clearInterval(interval); };
}, [fetchStatus]);
const isConnected = (providerId: string) =>
connections.some(
(p: any) =>
p.provider === providerId && (p.isActive || p.testStatus === 'active'),
);
const isConnected = (providerId: string) => isProviderActive(connections, providerId);
// A pending confirm on a lane that died some other way (reconnect, cascade wipe) would pop back on reconnect.
useEffect(() => {
if (confirmingDisconnect && !isProviderActive(connections, confirmingDisconnect)) setConfirmingDisconnect(null);
}, [confirmingDisconnect, connections]);
const handleConnect = async (providerId: string) => {
if (pollTimer) { clearInterval(pollTimer); setPollTimer(null); }
setConnectError(null);
setConfirmingDisconnect(null);
setDisconnectError(null);
setConnecting(providerId);
setUserCode('');
@@ -105,20 +122,27 @@ const SubscriptionCards: React.FC = () => {
};
const handleDisconnect = async (providerId: string) => {
if (disconnecting) return;
setConfirmingDisconnect(null);
setDisconnectError(null);
setDisconnecting(providerId);
// No settle delay: the backend only answers ok after re-reading 9Router, so the lane is already gone.
try {
await fetch(`${API_BASE}/agents/subscriptions/disconnect`, {
const r = await fetch(`${API_BASE}/agents/subscriptions/disconnect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: providerId }),
});
} catch {}
// Wait briefly for 9Router to process, then refresh subscription status + model picker.
setTimeout(() => {
fetchStatus();
refreshPickerModels();
setDisconnecting(null);
}, 500);
const data = (await r.json().catch(() => ({}))) as DisconnectResponse;
if (!r.ok || !data.ok) {
setDisconnectError({ provider: providerId, message: data.error || 'Could not disconnect. Please try again.' });
}
} catch {
setDisconnectError({ provider: providerId, message: 'Could not reach OpenSwarm. Please try again.' });
}
await fetchStatus();
refreshPickerModels();
setDisconnecting(null);
};
// 4s safety-net poller while connecting; clears Connecting state whenever 9Router reports the provider isActive (handles Windows postMessage failures).
@@ -206,9 +230,13 @@ const SubscriptionCards: React.FC = () => {
provider={p}
connected={isConnected(p.id)}
onConnect={() => handleConnect(p.id)}
onRequestDisconnect={() => { setDisconnectError(null); setConfirmingDisconnect(p.id); }}
onCancelDisconnect={() => setConfirmingDisconnect(null)}
onDisconnect={() => handleDisconnect(p.id)}
connecting={connecting === p.id}
confirmingDisconnect={confirmingDisconnect === p.id}
disconnecting={disconnecting === p.id}
error={disconnectError?.provider === p.id ? disconnectError.message : undefined}
userCode={connecting === p.id ? userCode : undefined}
/>
))}