Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions backend/cli/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.
Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion backend/cli/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
})
},
)
Expand Down
62 changes: 58 additions & 4 deletions backend/cli/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
Loading