From 26f7f68507576138d2e62e54013e7323763e89b2 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 30 Apr 2026 20:28:46 -0400 Subject: [PATCH] feat(tui): show all providers in /model picker with inline API key setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model.options now returns all canonical providers (not just authenticated), each with authenticated/auth_type/key_env fields - New model.save_key RPC method: saves API key to .env, sets in process, returns refreshed provider with models - Picker shows ● (authed) / ○ (no key) markers with dimmed styling - Selecting an unauthenticated api_key provider opens inline masked key input — after save, transitions directly to model selection - Non-api_key auth providers show guidance to run hermes model - Row numbers now show absolute position in list --- tui_gateway/server.py | 142 +++++++++++++++++++++++-- ui-tui/src/components/modelPicker.tsx | 147 +++++++++++++++++++++++++- ui-tui/src/gatewayTypes.ts | 3 + 3 files changed, 278 insertions(+), 14 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e3fd669837..71c343907d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4705,6 +4705,7 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: try: from hermes_cli.model_switch import list_authenticated_providers + from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS session = _sessions.get(params.get("session_id", "")) agent = session.get("agent") if session else None @@ -4718,6 +4719,116 @@ def _(rid, params: dict) -> dict: # provider_model_ids() — that bypasses curation and pulls in # non-agentic models (e.g. Nous /models returns ~400 IDs including # TTS, embeddings, rerankers, image/video generators). + user_provs = ( + cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {} + ) + custom_provs = ( + cfg.get("custom_providers") + if isinstance(cfg.get("custom_providers"), list) + else [] + ) + authenticated = list_authenticated_providers( + current_provider=current_provider, + current_base_url=current_base_url, + current_model=current_model, + user_providers=user_provs, + custom_providers=custom_provs, + max_models=50, + ) + + # Mark authenticated providers and build lookup + authed_slugs = set() + for p in authenticated: + p["authenticated"] = True + authed_slugs.add(p["slug"]) + + # Add unauthenticated canonical providers so the picker shows all + # options (matching `hermes model` behaviour). + from hermes_cli.auth import PROVIDER_REGISTRY as _auth_reg + for entry in CANONICAL_PROVIDERS: + if entry.slug in authed_slugs: + continue + pconfig = _auth_reg.get(entry.slug) + auth_type = pconfig.auth_type if pconfig else "api_key" + key_env = pconfig.api_key_env_vars[0] if (pconfig and pconfig.api_key_env_vars) else "" + if auth_type == "api_key" and key_env: + warning = f"paste {key_env} to activate" + else: + warning = f"run `hermes model` to configure ({auth_type})" + authenticated.append({ + "slug": entry.slug, + "name": _PROVIDER_LABELS.get(entry.slug, entry.label), + "is_current": False, + "is_user_defined": False, + "models": [], + "total_models": 0, + "source": "built-in", + "authenticated": False, + "auth_type": auth_type, + "key_env": key_env, + "warning": warning, + }) + + return _ok( + rid, + { + "providers": authenticated, + "model": current_model, + "provider": current_provider, + }, + ) + except Exception as e: + return _err(rid, 5033, str(e)) + + +@method("model.save_key") +def _(rid, params: dict) -> dict: + """Save an API key for a provider, then return its refreshed model list. + + Params: + slug: provider slug (e.g. "deepseek", "xai") + api_key: the key value to save + + Returns the provider dict with models populated (same shape as + model.options entries) on success. + """ + try: + from hermes_cli.auth import PROVIDER_REGISTRY + from hermes_cli.config import save_env_value + from hermes_cli.model_switch import list_authenticated_providers + + slug = (params.get("slug") or "").strip() + api_key = (params.get("api_key") or "").strip() + if not slug or not api_key: + return _err(rid, 4001, "slug and api_key are required") + + pconfig = PROVIDER_REGISTRY.get(slug) + if not pconfig: + return _err(rid, 4002, f"unknown provider: {slug}") + if pconfig.auth_type != "api_key": + return _err( + rid, 4003, + f"{pconfig.name} uses {pconfig.auth_type} auth — " + f"run `hermes model` to configure" + ) + if not pconfig.api_key_env_vars: + return _err(rid, 4004, f"no env var defined for {pconfig.name}") + + # Save the key to ~/.hermes/.env + env_var = pconfig.api_key_env_vars[0] + save_env_value(env_var, api_key) + # Also set in current process so list_authenticated_providers sees it + import os + os.environ[env_var] = api_key + + # Refresh provider data + cfg = _load_cfg() + session = _sessions.get(params.get("session_id", "")) + agent = session.get("agent") if session else None + current_provider = getattr(agent, "provider", "") or "" + current_model = getattr(agent, "model", "") or _resolve_model() + current_base_url = getattr(agent, "base_url", "") or "" + providers = list_authenticated_providers( current_provider=current_provider, current_base_url=current_base_url, @@ -4732,16 +4843,29 @@ def _(rid, params: dict) -> dict: ), max_models=50, ) - return _ok( - rid, - { - "providers": providers, - "model": current_model, - "provider": current_provider, - }, - ) + + # Find the newly-authenticated provider + provider_data = None + for p in providers: + if p["slug"] == slug: + provider_data = p + break + + if not provider_data: + # Key was saved but provider didn't appear — still return success + provider_data = { + "slug": slug, + "name": pconfig.name, + "is_current": False, + "models": [], + "total_models": 0, + "authenticated": True, + } + + provider_data["authenticated"] = True + return _ok(rid, {"provider": provider_data}) except Exception as e: - return _err(rid, 5033, str(e)) + return _err(rid, 5034, str(e)) # ── Methods: slash.exec ────────────────────────────────────────────── diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 9ae910ea2e..1e1386132f 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -14,6 +14,8 @@ const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 +type Stage = 'provider' | 'key' | 'model' + export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) { const [providers, setProviders] = useState([]) const [currentModel, setCurrentModel] = useState('') @@ -22,7 +24,10 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const [persistGlobal, setPersistGlobal] = useState(false) const [providerIdx, setProviderIdx] = useState(0) const [modelIdx, setModelIdx] = useState(0) - const [stage, setStage] = useState<'model' | 'provider'>('provider') + const [stage, setStage] = useState('provider') + const [keyInput, setKeyInput] = useState('') + const [keySaving, setKeySaving] = useState(false) + const [keyError, setKeyError] = useState('') const { stdout } = useStdout() // Pin the picker to a stable width so the FloatBox parent (which shrinks- @@ -68,9 +73,11 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const names = useMemo(() => providerDisplayNames(providers), [providers]) const back = () => { - if (stage === 'model') { + if (stage === 'model' || stage === 'key') { setStage('provider') setModelIdx(0) + setKeyInput('') + setKeyError('') return } @@ -81,6 +88,71 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke useOverlayKeys({ onBack: back, onClose: onCancel }) useInput((ch, key) => { + // Key entry stage handles its own input + if (stage === 'key') { + if (keySaving) { + return + } + + if (key.return) { + if (!keyInput.trim()) { + return + } + + setKeySaving(true) + setKeyError('') + gw.request<{ provider?: ModelOptionProvider }>('model.save_key', { + slug: provider?.slug, + api_key: keyInput.trim(), + ...(sessionId ? { session_id: sessionId } : {}), + }) + .then(raw => { + const r = asRpcResult<{ provider?: ModelOptionProvider }>(raw) + + if (!r?.provider) { + setKeyError('failed to save key') + setKeySaving(false) + + return + } + + // Update the provider in our list with fresh data + setProviders(prev => + prev.map(p => p.slug === r.provider!.slug ? r.provider! : p) + ) + setKeyInput('') + setKeySaving(false) + setStage('model') + setModelIdx(0) + }) + .catch((e: unknown) => { + setKeyError(rpcErrorMessage(e)) + setKeySaving(false) + }) + + return + } + + if (key.backspace || key.delete) { + setKeyInput(v => v.slice(0, -1)) + + return + } + + // ctrl+u clears input + if (ch === '\u0015') { + setKeyInput('') + + return + } + + if (ch && !key.ctrl && !key.meta) { + setKeyInput(v => v + ch) + } + + return + } + const count = stage === 'provider' ? providers.length : models.length const sel = stage === 'provider' ? providerIdx : modelIdx const setSel = stage === 'provider' ? setProviderIdx : setModelIdx @@ -103,6 +175,18 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke return } + if (provider.authenticated === false) { + // api_key providers: prompt for key inline + if (provider.auth_type === 'api_key' && provider.key_env) { + setStage('key') + setKeyInput('') + setKeyError('') + } + + // Other auth types: no-op (warning shown tells them to run hermes model) + return + } + setStage('model') setModelIdx(0) @@ -161,15 +245,65 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke if (!providers.length) { return ( - no authenticated providers + no providers available Esc/q cancel ) } + // ── Key entry stage ────────────────────────────────────────────────── + if (stage === 'key' && provider) { + const masked = keyInput ? '•'.repeat(Math.min(keyInput.length, 40)) : '' + + return ( + + + Configure {provider.name} + + + + Paste your API key below (saved to ~/.hermes/.env) + + + + + + {provider.key_env}: + + + + {' '}{masked || '(empty)'}{keySaving ? '' : '▎'} + + + + + {keyError ? ( + + error: {keyError} + + ) : keySaving ? ( + + saving… + + ) : ( + + )} + + Enter save · Ctrl+U clear · Esc back + + ) + } + + // ── Provider selection stage ───────────────────────────────────────── if (stage === 'provider') { const rows = providers.map( - (p, i) => `${p.is_current ? '*' : ' '} ${names[i]} · ${p.total_models ?? p.models?.length ?? 0} models` + (p, i) => { + const authMark = p.authenticated === false ? '○' : p.is_current ? '*' : '●' + const modelCount = p.total_models ?? p.models?.length ?? 0 + const suffix = p.authenticated === false ? '(no key)' : `${modelCount} models` + + return `${authMark} ${names[i]} · ${suffix}` + } ) const { items, offset } = windowItems(rows, providerIdx, VISIBLE) @@ -197,11 +331,13 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke {Array.from({ length: VISIBLE }, (_, i) => { const row = items[i] const idx = offset + i + const p = providers[idx] + const dimmed = p?.authenticated === false return row ? (