Files
openswarm/backend/apps/agents/disconnect_subscription.py

112 lines
4.6 KiB
Python

"""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)