fix(desktop): use strict runtime check to drive onboarding

setup.status returned True whenever any provider auth state was discoverable, including indirect fallbacks like a gh-CLI Copilot token. That made desktop think the user was set up while the agent's actual resolve_runtime_provider call still raised AuthError, leaving the user with a useless toast and no onboarding.

Add a setup.runtime_check gateway method that runs the same resolver the agent uses on session creation, and switch the desktop onboarding overlay and prompt precheck to use it.
This commit is contained in:
Brooklyn Nicholson 2026-05-07 23:19:11 -04:00
parent e31b74073b
commit 7d652fc466
3 changed files with 67 additions and 7 deletions

View file

@ -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<SetupStatus>('setup.status').catch(() => null)
const [setup, runtime] = await Promise.all([
requestGateway<SetupStatus>('setup.status').catch(() => null),
requestGateway<RuntimeCheck>('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
}

View file

@ -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<SetupStatus>('setup.status'), getEnvVars()])
const [status, runtime, vars] = await Promise.all([
requestGateway<SetupStatus>('setup.status').catch(() => ({}) as SetupStatus),
requestGateway<RuntimeCheck>('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])

View file

@ -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 ──────────────────────────────────────────