diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 1ba8c65d069..9ee07cb0af3 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -50,10 +50,15 @@ interface SetupStatus { provider_configured?: boolean } +interface RuntimeCheck { + error?: string + ok?: boolean +} + function isProviderSetupError(error: unknown) { const message = error instanceof Error ? error.message : String(error) - return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY/i.test(message) + return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i.test(message) } interface PromptActionsOptions { @@ -227,11 +232,18 @@ export function usePromptActions({ setAwaitingResponse(true) clearNotifications() - const setup = await requestGateway('setup.status').catch(() => null) + const [setup, runtime] = await Promise.all([ + requestGateway('setup.status').catch(() => null), + requestGateway('setup.runtime_check').catch(() => null) + ]) - if (setup?.provider_configured === false) { + const runtimeReady = runtime?.ok !== undefined ? Boolean(runtime?.ok) : setup?.provider_configured !== false + + if (!runtimeReady) { releaseBusy() - requestDesktopOnboarding('Add a provider credential before sending your first message.') + requestDesktopOnboarding( + runtime?.error || 'Add a provider credential before sending your first message.' + ) return } diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 55f05cfbf9b..3bd3b69068c 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -20,6 +20,13 @@ interface SetupStatus { provider_configured?: boolean } +interface RuntimeCheck { + error?: string + model?: string + ok?: boolean + provider?: string +} + interface ProviderOption { key: string label: string @@ -90,17 +97,30 @@ export function DesktopOnboardingOverlay({ setError(null) try { - const [status, vars] = await Promise.all([requestGateway('setup.status'), getEnvVars()]) + const [status, runtime, vars] = await Promise.all([ + requestGateway('setup.status').catch(() => ({}) as SetupStatus), + requestGateway('setup.runtime_check').catch(() => ({ ok: false }) as RuntimeCheck), + getEnvVars() + ]) if (cancelled) { return } - setProviderConfigured(Boolean(status.provider_configured)) + // The strict runtime check is the source of truth: it runs the same + // resolver the agent uses, so it can't be fooled by fallbacks like + // gh-CLI Copilot tokens when the user's configured model isn't a + // Copilot model. setup.status is kept as a coarse sanity check + // for backends that don't expose setup.runtime_check yet. + const runtimeOk = runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured) + + setProviderConfigured(runtimeOk) setEnvVars(vars) - if (status.provider_configured) { + if (runtimeOk) { completeDesktopOnboarding() + } else if (runtime.error) { + setError(runtime.error) } const firstAvailable = PREFERRED_PROVIDER_KEYS.find(option => vars[option.key]) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 12297e9a9e9..c8b7a18c2db 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4657,6 +4657,34 @@ def _(rid, params: dict) -> dict: return _err(rid, 5016, str(e)) +@method("setup.runtime_check") +def _(rid, params: dict) -> dict: + """Strict provider check: does the configured/default model actually resolve to a usable runtime? + + Unlike setup.status (which returns True if ANY provider auth state is + discoverable, including indirect fallbacks like ``gh auth token`` for + Copilot), this runs the same resolve_runtime_provider() call the agent + uses on session creation. It returns ok=False with the auth error message + when the user's configured model cannot actually be served, so UIs can + surface onboarding before the user submits a doomed prompt. + """ + try: + from hermes_cli.runtime_provider import resolve_runtime_provider + + runtime = resolve_runtime_provider(requested=None) + return _ok( + rid, + { + "ok": True, + "provider": runtime.get("provider"), + "model": runtime.get("model"), + "source": runtime.get("source"), + }, + ) + except Exception as e: + return _ok(rid, {"ok": False, "error": str(e)}) + + # ── Methods: tools & system ──────────────────────────────────────────