diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 71c343907d..0582b745dc 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4868,6 +4868,49 @@ def _(rid, params: dict) -> dict: return _err(rid, 5034, str(e)) +@method("model.disconnect") +def _(rid, params: dict) -> dict: + """Remove credentials for a provider. + + Params: + slug: provider slug (e.g. "deepseek", "xai") + + Returns success status and the provider's slug. + """ + try: + from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth + from hermes_cli.config import remove_env_value + + slug = (params.get("slug") or "").strip() + if not slug: + return _err(rid, 4001, "slug is required") + + pconfig = PROVIDER_REGISTRY.get(slug) + cleared_env = False + cleared_auth = False + + # Remove API key env vars from .env and process + if pconfig and pconfig.api_key_env_vars: + for ev in pconfig.api_key_env_vars: + if remove_env_value(ev): + cleared_env = True + + # Clear OAuth / credential pool state + cleared_auth = clear_provider_auth(slug) + + if not cleared_env and not cleared_auth: + return _err(rid, 4005, f"no credentials found for {slug}") + + provider_name = pconfig.name if pconfig else slug + return _ok(rid, { + "slug": slug, + "name": provider_name, + "disconnected": True, + }) + except Exception as e: + return _err(rid, 5035, str(e)) + + # ── Methods: slash.exec ────────────────────────────────────────────── diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 1e1386132f..ea999e55e2 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -14,7 +14,7 @@ const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 -type Stage = 'provider' | 'key' | 'model' +type Stage = 'provider' | 'key' | 'model' | 'disconnect' export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) { const [providers, setProviders] = useState([]) @@ -73,7 +73,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const names = useMemo(() => providerDisplayNames(providers), [providers]) const back = () => { - if (stage === 'model' || stage === 'key') { + if (stage === 'model' || stage === 'key' || stage === 'disconnect') { setStage('provider') setModelIdx(0) setKeyInput('') @@ -153,6 +153,53 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return } + // Disconnect confirmation stage + if (stage === 'disconnect') { + if (ch.toLowerCase() === 'y' || key.return) { + if (!provider) { + setStage('provider') + + return + } + + setKeySaving(true) + gw.request<{ disconnected?: boolean }>('model.disconnect', { + slug: provider.slug, + ...(sessionId ? { session_id: sessionId } : {}), + }) + .then(raw => { + const r = asRpcResult<{ disconnected?: boolean }>(raw) + + if (r?.disconnected) { + // Mark provider as unauthenticated in local state + setProviders(prev => + prev.map(p => p.slug === provider.slug + ? { ...p, authenticated: false, models: [], total_models: 0, warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure' } + : p + ) + ) + } + + setKeySaving(false) + setStage('provider') + }) + .catch(() => { + setKeySaving(false) + setStage('provider') + }) + + return + } + + if (ch.toLowerCase() === 'n' || key.escape) { + setStage('provider') + + return + } + + return + } + const count = stage === 'provider' ? providers.length : models.length const sel = stage === 'provider' ? providerIdx : modelIdx const setSel = stage === 'provider' ? setProviderIdx : setModelIdx @@ -210,6 +257,13 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return } + // Disconnect: only in provider stage, only for authenticated providers + if (ch.toLowerCase() === 'd' && stage === 'provider' && provider?.authenticated !== false) { + setStage('disconnect') + + return + } + const n = ch === '0' ? 10 : parseInt(ch, 10) if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) { @@ -294,6 +348,35 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke ) } + // ── Disconnect confirmation stage ───────────────────────────────────── + if (stage === 'disconnect' && provider) { + return ( + + + Disconnect {provider.name}? + + + + + + This removes saved credentials for {provider.name}. + + + + You can re-authenticate later by selecting it again. + + + + + {keySaving ? ( + disconnecting… + ) : ( + y/Enter confirm · n/Esc cancel + )} + + ) + } + // ── Provider selection stage ───────────────────────────────────────── if (stage === 'provider') { const rows = providers.map( @@ -359,7 +442,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke persist: {persistGlobal ? 'global' : 'session'} · g toggle - ↑/↓ select · Enter choose · Esc/q cancel + ↑/↓ select · Enter choose · d disconnect · Esc/q cancel ) }