From 7a5daf4515871ddbfdf91947bb4f38a116fabbc9 Mon Sep 17 00:00:00 2001 From: Aayam Bansal Date: Sun, 5 Jul 2026 00:21:04 +0530 Subject: [PATCH] fix(provider): fall back gracefully when the configured model has no provider With a saved config model (e.g. anthropic/claude-sonnet-4-6) but no API key and no managed session, sending a prompt crashed the server loop with a raw unhandled ProviderModelNotFoundError stack while the web UI stayed silent. - Provider.defaultModel: only honor cfg.model when its provider and model are actually available; otherwise warn and fall through to priority selection. The no-providers throw now carries an actionable message. - SessionPrompt.lastModel: validate the historical model of the last user message is still available before reusing it; fall back to defaultModel. - SessionPrompt.loop: catch ModelNotFoundError from getModel, publish a session.error event with an actionable message, finalize an assistant message carrying the error, and exit the loop cleanly. The fire-and-forget ensureTitle call is caught too so it can no longer reject unhandled. - SessionPrompt.prompt: publish session.error when user-message creation fails (e.g. zero providers) so the UI is never silent. - prompt_async route: catch the fire-and-forget prompt so nothing becomes an unhandled rejection. --- backend/cli/src/provider/provider.ts | 19 ++++++-- backend/cli/src/server/routes/session.ts | 6 ++- backend/cli/src/session/prompt.ts | 62 ++++++++++++++++++++++-- 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/backend/cli/src/provider/provider.ts b/backend/cli/src/provider/provider.ts index 77b6ef03..d756f8b5 100644 --- a/backend/cli/src/provider/provider.ts +++ b/backend/cli/src/provider/provider.ts @@ -1404,6 +1404,9 @@ export namespace Provider { return undefined } + export const NO_PROVIDER_HINT = + "No model providers are available. Add an API key for a provider (`openscience auth login`) or connect a managed account (`openscience connect login`), then choose a model." + const priority = ["claude-sonnet-4", "claude-opus-4", "gpt-5", "gemini-3-pro"] export function sort(models: Model[]) { return sortBy( @@ -1420,10 +1423,18 @@ export namespace Provider { export async function defaultModel() { const cfg = await Config.get() - if (cfg.model) return parseModel(cfg.model) + const available = await list() + if (cfg.model) { + // Only honor the configured model when its provider is actually available + // (e.g. a saved `anthropic/...` model with no API key must not be returned) + // — otherwise fall through to the priority-based selection below. + const parsed = parseModel(cfg.model) + if (available[parsed.providerID]?.models[parsed.modelID]) return parsed + log.warn("configured model is not available, falling back to default selection", parsed) + } const managed = await hasManagedSession() - const providers = await list().then((val) => Object.values(val)) + const providers = Object.values(available) const configured = (p: Info) => !cfg.provider || Object.keys(cfg.provider).includes(p.id) // Drop the hosted `openscience` provider from DEFAULT priority unless a managed // session is active, then pick the first provider that actually has models. @@ -1434,9 +1445,9 @@ export namespace Provider { candidates.find((p) => Object.keys(p.models).length > 0) ?? candidates[0] ?? providers.find(configured) - if (!provider) throw new Error("no providers found") + if (!provider) throw new Error(NO_PROVIDER_HINT) const [model] = sort(Object.values(provider.models)) - if (!model) throw new Error("no models found") + if (!model) throw new Error(NO_PROVIDER_HINT) return { providerID: provider.id, modelID: model.id, diff --git a/backend/cli/src/server/routes/session.ts b/backend/cli/src/server/routes/session.ts index cedf17cb..08f86084 100644 --- a/backend/cli/src/server/routes/session.ts +++ b/backend/cli/src/server/routes/session.ts @@ -700,7 +700,11 @@ export const SessionRoutes = lazy(() => return stream(c, async () => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - SessionPrompt.prompt({ ...body, sessionID }) + // fire-and-forget: session-level failures are published as session.error + // events inside prompt(); catch here so nothing becomes an unhandled rejection + SessionPrompt.prompt({ ...body, sessionID }).catch((error) => { + log.error("prompt_async failed", { sessionID, error }) + }) }) }, ) diff --git a/backend/cli/src/session/prompt.ts b/backend/cli/src/session/prompt.ts index 460bb848..32785066 100644 --- a/backend/cli/src/session/prompt.ts +++ b/backend/cli/src/session/prompt.ts @@ -170,7 +170,16 @@ export namespace SessionPrompt { const session = await Session.get(input.sessionID) await SessionRevert.cleanup(session) - const message = await createUserMessage(input) + const message = await createUserMessage(input).catch((e) => { + // e.g. no providers are available at all — surface the failure to the + // session (the web UI listens for session.error) instead of only throwing. + const message = e instanceof Error ? e.message : String(e) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ message }).toObject(), + }) + throw e + }) await Session.touch(input.sessionID) // this is backwards compatibility for allowing `tools` to be specified when @@ -331,9 +340,47 @@ export namespace SessionPrompt { modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, history: msgs, - }) + }).catch((error) => log.error("failed to generate session title", { error })) - const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) + const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID).catch((e) => { + if (Provider.ModelNotFoundError.isInstance(e)) return undefined + throw e + }) + // The requested model has no available provider (e.g. the API key was + // removed) — surface a session error instead of crashing the loop. + if (!model) { + const error = new NamedError.Unknown({ + message: `Model ${lastUser.model.providerID}/${lastUser.model.modelID} is not available. Add an API key for a provider (\`openscience auth login\`) or connect a managed account (\`openscience connect login\`), then choose a model.`, + }).toObject() + Bus.publish(Session.Event.Error, { sessionID, error }) + await Session.updateMessage({ + id: await MessageV2.nextMessageID(sessionID), + role: "assistant", + parentID: lastUser.id, + sessionID, + mode: lastUser.agent, + agent: lastUser.agent, + path: { + cwd: Instance.directory, + root: Instance.worktree, + }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: lastUser.model.modelID, + providerID: lastUser.model.providerID, + error, + time: { + created: Date.now(), + completed: Date.now(), + }, + }) + break + } const task = tasks.pop() // pending subtask @@ -686,7 +733,14 @@ export namespace SessionPrompt { async function lastModel(sessionID: string) { for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user" && item.info.model) return item.info.model + if (item.info.role !== "user" || !item.info.model) continue + // A historical model can reference a provider that is no longer available + // (e.g. its API key was removed) — validate before reusing it. + const model = item.info.model + const provider = await Provider.getProvider(model.providerID) + if (provider?.models[model.modelID]) return model + log.warn("last used model is no longer available, falling back to default", model) + break } return Provider.defaultModel() }