import type {
AgentBackupModels,
AgentModelChoice,
AgentModelOption,
AgentProviderState,
AgentProviderStatus,
AgentReasoningEffort,
AgentSummary,
ProviderAccountStatus,
UpdateAgentInput,
} from "@openbot/contracts/ipc";
import {
AGENT_PROVIDERS,
DEFAULT_PROVIDER_ACCOUNT_ID,
defaultProviderModel,
sameAgentModelChoice,
} from "@openbot/contracts/ipc";
import { sourceText } from "@openbot/i18n/source";
import type { AgentProvider } from "../agent-client";
import { isEncodedModelOf } from "../encoded-model-id";
import { AgentSettingsRefused } from "./agent-settings-refused";
import { providerLabel } from "./thread-items";
export interface ModelRequest {
provider?: AgentProvider | undefined;
model?: string | undefined;
reasoningEffort?: AgentReasoningEffort | undefined;
}
function providerDefault(provider: AgentProvider, models: AgentModelOption[]): AgentModelOption | null {
return (
models.find((model) => model.provider === provider && model.id === defaultProviderModel(provider)) ??
models.find((model) => model.provider === provider) ??
null
);
}
export type ProviderAccountRows = (provider: AgentProvider) => ProviderAccountStatus[] | undefined;
interface ProviderModels {
provider: AgentProvider;
defaultModel: string;
models: ToolModel[];
omittedModels?: number;
accounts?: (Pick<ProviderAccountStatus, "id" | "label" | "state"> & { models?: ToolModel[] })[];
}
type ToolModel = Pick<AgentModelOption, "id" | "name"> &
Partial<Pick<AgentModelOption, "defaultReasoningEffort" | "supportedReasoningEfforts">>;
function toolModel(model: AgentModelOption): ToolModel {
return {
id: model.id,
name: model.name,
...(model.reasoningEffortConfigurable === false
? {}
: {
defaultReasoningEffort: model.defaultReasoningEffort,
supportedReasoningEfforts: model.supportedReasoningEfforts,
}),
};
}
export type ProviderStatusRow = Pick<AgentProviderStatus, "id" | "off" | "state" | "message">;
* Whether an agent can run on the provider now or soon: it runs, OpenBot checks it, or it is not
* started yet. `workflowProvider` uses the same rule. A row has its accounts only while it is available, so a
* provider whose own sign-in is out cannot run. The catalogue keeps a built-in list of some
* providers' models when they are signed out or missing, so a listed model is no proof (#7866). A
* provider with no row has nothing against it. Custom agents share one row, and one that fails does
* not stop the others.
*/
export function providerCanRun(provider: AgentProvider, statuses: readonly ProviderStatusRow[]): boolean {
const row = statuses.find((candidate) => candidate.id === provider);
if (!row || provider === "acp") return true;
return !row.off && (row.state === "available" || row.state === "checking" || row.state === "not-started");
}
export function requireRunnableProvider(
model: Pick<AgentModelOption, "provider" | "id">,
statuses: readonly ProviderStatusRow[],
): void {
if (providerCanRun(model.provider, statuses)) return;
throw new AgentSettingsRefused(
sourceText("error.agent.modelProviderNotConnected", { model: model.id, provider: providerLabel(model.provider) }),
);
}
* Refuses a settings change that moves an agent to a model whose provider cannot run. A call that
* names the model the agent has passes, so its other fields still apply.
*/
export function requireRunnableChange(
target: Pick<AgentSummary, "provider" | "model">,
runtime: Pick<UpdateAgentInput, "provider" | "model">,
statuses: readonly ProviderStatusRow[],
): void {
const { provider, model } = runtime;
if (provider === undefined || model === undefined) return;
if (provider === target.provider && model === target.model) return;
requireRunnableProvider({ provider, id: model }, statuses);
}
const NEEDS_USER_STATES: ReadonlySet<AgentProviderState> = new Set(["sign-in-required", "outdated", "error"]);
const FIX_IN = "Server settings > Providers";
const SHORT_LIST_LENGTH = 8;
const QUERY_RESULT_LIMIT = 20;
export interface ListModelsRequest {
provider?: AgentProvider | undefined;
query?: string | undefined;
inUse?: readonly Pick<AgentModelChoice, "provider" | "model">[];
}
* The models of one provider in a short list, in catalog order: each model in `pinned`, then the first
* models of the catalog up to `SHORT_LIST_LENGTH`.
*/
function shortList(own: AgentModelOption[], pinned: ReadonlySet<string>): AgentModelOption[] {
let room = SHORT_LIST_LENGTH - own.filter((model) => pinned.has(model.id)).length;
return own.filter((model) => {
if (pinned.has(model.id)) return true;
room -= 1;
return room >= 0;
});
}
function matchesQuery(model: AgentModelOption, words: readonly string[]): boolean {
const text = `${model.id} ${model.name}`.toLowerCase();
return words.every((word) => text.includes(word));
}
* The `list_models` payload: each provider that lists a model, in `AGENT_PROVIDERS` order. The models
* are the ones the model picker shows, so the ones `create_agent` accepts. With no query, a provider
* shows its default model, the models in use, and its first models; `omittedModels` counts the rest.
* A query searches all models. An account has its id, the name the user gave it and the models that
* only it serves, never its email, its credentials or its folder. A provider that is on but lists no model until the user signs in,
* updates or repairs it is in `unavailableProviders`, with the text of its settings row and where
* that row is: without it, the agent could not tell the user why the provider is missing (#6053).
* A provider that cannot run lists no model, also when the catalogue keeps its built-in models (#7866).
* A provider that the request names is there in each state in which it has no model, also when it is
* off or not downloaded: a CLI of the user's own on `PATH` does not make it available, and the agent
* got an empty list with no reason (#7647). A list of all providers leaves those rows out.
*/
export function listModelsPayload(
models: AgentModelOption[],
preferredProvider: AgentProvider,
accounts: ProviderAccountRows,
statuses: readonly ProviderStatusRow[],
request: ListModelsRequest = {},
): {
preferredProvider: AgentProvider;
providers: ProviderModels[];
unavailableProviders?: {
provider: AgentProvider;
state: AgentProviderState;
off?: true;
message?: string;
fixIn: string;
}[];
} {
const words = request.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
let resultRoom = QUERY_RESULT_LIMIT;
const providers = AGENT_PROVIDERS.filter((id) => request.provider === undefined || id === request.provider).flatMap(
(id) => {
const fallback = providerDefault(id, models);
if (!fallback || !providerCanRun(id, statuses)) return [];
const own = models.filter((model) => model.provider === id);
const candidates = words.length > 0 ? own.filter((model) => matchesQuery(model, words)) : own;
let listed: AgentModelOption[];
if (words.length > 0) {
listed = candidates.slice(0, resultRoom);
resultRoom -= listed.length;
} else {
const inUse = (request.inUse ?? []).filter((choice) => choice.provider === id).map((choice) => choice.model);
listed = shortList(own, new Set([fallback.id, ...inUse]));
}
const omitted = candidates.length - listed.length;
const rows = accounts(id);
return [
{
provider: id,
defaultModel: fallback.id,
models: listed.map(toolModel),
...(omitted > 0 ? { omittedModels: omitted } : {}),
...(rows
? {
accounts: rows.map((account) => {
const extra = (account.models ?? []).filter(
(model) => words.length === 0 || matchesQuery(model, words),
);
return {
id: account.id,
label: account.label,
state: account.state,
...(extra.length > 0 ? { models: extra.map(toolModel) } : {}),
};
}),
}
: {}),
},
];
},
);
const unavailableProviders = statuses.flatMap((row) =>
(request.provider === undefined ? !row.off && NEEDS_USER_STATES.has(row.state) : row.id === request.provider) &&
!providers.some((listed) => listed.provider === row.id)
? [
{
provider: row.id,
state: row.state,
...(row.off ? { off: true as const } : {}),
...(row.message ? { message: row.message } : {}),
fixIn: FIX_IN,
},
]
: [],
);
return { preferredProvider, providers, ...(unavailableProviders.length > 0 ? { unavailableProviders } : {}) };
}
export function agentAccountSetup(agent: AgentSummary) {
const choice = (model: AgentModelChoice) => ({
provider: model.provider,
model: model.model,
providerAccountId: model.providerAccountId ?? DEFAULT_PROVIDER_ACCOUNT_ID,
});
return {
providerAccountId: agent.providerAccountId ?? DEFAULT_PROVIDER_ACCOUNT_ID,
backupModels: agent.backupModels
? { automatic: agent.backupModels.automatic, order: agent.backupModels.order.map(choice) }
: null,
usageLimitReturn: agent.usageLimitReturn
? {
...choice(agent.usageLimitReturn),
reasoningEffort: agent.usageLimitReturn.reasoningEffort,
...(agent.usageLimitReturn.cause === "provider-error" ? { cause: "provider-error" } : {}),
}
: null,
};
}
* The backup models that an `update_profile` request names, checked against the models and accounts
* of this computer, the ones the settings page offers. `modelsOf` gives the models of one account,
* with the ones that only that account serves. As the settings page writes it, the stored
* order omits the provider's own sign-in and the agent's own model, has each choice once, and an
* order that asks first with no model is null.
*/
export function requestedBackupModels(
request: AgentBackupModels | null,
modelsOf: (choice: Pick<AgentModelChoice, "provider" | "providerAccountId">) => AgentModelOption[],
accounts: ProviderAccountRows,
own: AgentModelChoice,
): AgentBackupModels | null {
if (request === null) return null;
const order: AgentModelChoice[] = [];
for (const { provider, model, providerAccountId } of request.order) {
const accountId = providerAccountId ?? DEFAULT_PROVIDER_ACCOUNT_ID;
const rows = accounts(provider);
if (rows ? !rows.some((account) => account.id === accountId) : accountId !== DEFAULT_PROVIDER_ACCOUNT_ID) {
throw new AgentSettingsRefused(
sourceText("error.provider.accountNotFound", { provider: providerLabel(provider) }),
);
}
const listed = requestedToolModel({ provider, model }, modelsOf({ provider, providerAccountId }));
const choice: AgentModelChoice = {
provider,
model: listed?.id ?? model,
...(accountId === DEFAULT_PROVIDER_ACCOUNT_ID ? {} : { providerAccountId: accountId }),
};
if (![own, ...order].some((other) => sameAgentModelChoice(other, choice))) order.push(choice);
}
return request.automatic || order.length > 0 ? { automatic: request.automatic, order } : null;
}
* The model ids that an error names: for each provider, the short list that `list_models` shows with
* no query, and "…" when the list leaves models out.
*/
export function modelList(models: AgentModelOption[], provider?: AgentProvider): string {
const ids = (id: AgentProvider) => {
const own = models.filter((model) => model.provider === id);
const fallback = providerDefault(id, models);
const listed = shortList(own, new Set(fallback ? [fallback.id] : [])).map((model) => model.id);
return [...listed, ...(listed.length < own.length ? ["…"] : [])].join(", ");
};
if (provider !== undefined) return ids(provider);
return AGENT_PROVIDERS.flatMap((id) =>
models.some((model) => model.provider === id) ? [`${id}: ${ids(id)}`] : [],
).join("; ");
}
* The model a `create_agent` call names, checked before the agent exists, with the same pick as
* `creationModel`. Unlike the settings path, an unknown model or an effort the model does not
* support is an error that names the valid values, so the calling agent can correct its request.
* `null` means the request names no provider and no model.
*/
export function requestedToolModel(request: ModelRequest, models: AgentModelOption[]): AgentModelOption | null {
const { provider, model } = request;
if (provider !== undefined && !models.some((candidate) => candidate.provider === provider)) {
throw new AgentSettingsRefused(sourceText("error.agent.providerNotListed", { provider: providerLabel(provider) }));
}
let chosen: AgentModelOption | null = null;
if (model !== undefined) {
const own = (candidate: AgentModelOption) => provider === undefined || candidate.provider === provider;
chosen =
models.find((candidate) => candidate.id === model && own(candidate)) ??
models.find((candidate) => isEncodedModelOf(candidate.id, model) && own(candidate)) ??
null;
if (!chosen && models.length === 0) throw new AgentSettingsRefused(sourceText("error.agent.modelUnavailable"));
if (!chosen) {
const base = `${model.includes("[") ? model.slice(0, model.indexOf("[")) : model}[`;
const first = models.find(
(candidate) => candidate.id.startsWith(base) && (provider === undefined || candidate.provider === provider),
);
const variants = first
? models.filter((candidate) => candidate.provider === first.provider && candidate.id.startsWith(base))
: [];
throw new AgentSettingsRefused(
first
? sourceText("error.agent.modelVariantNotListed", {
model,
provider: providerLabel(first.provider),
models: variants.map((candidate) => candidate.id).join(", "),
})
: sourceText("error.agent.modelNotListed", { model, models: modelList(models, provider) }),
);
}
} else if (provider !== undefined) {
chosen = providerDefault(provider, models);
}
if (chosen && request.reasoningEffort !== undefined) requireReasoningEffort(chosen, request.reasoningEffort);
return chosen;
}
export function requireReasoningEffort(model: AgentModelOption, effort: AgentReasoningEffort): void {
if (model.supportedReasoningEfforts.includes(effort)) return;
throw new AgentSettingsRefused(
model.reasoningEffortConfigurable === false
? sourceText("error.agent.reasoningEffortNotConfigurable", { model: model.id })
: sourceText("error.agent.reasoningEffortUnsupported", {
model: model.id,
effort,
efforts: model.supportedReasoningEfforts.join(", "),
}),
);
}
* The effort of an agent as the agent tools report it. A model with no effort setting gets none:
* its stored effort never reaches the provider, and `list_models` shows no effort for it (#6372).
*/
export function agentReasoningEffort(
agent: Pick<AgentSummary, "provider" | "model" | "reasoningEffort">,
models: AgentModelOption[],
): { reasoningEffort?: AgentReasoningEffort } {
const model = models.find((candidate) => candidate.provider === agent.provider && candidate.id === agent.model);
return model?.reasoningEffortConfigurable === false ? {} : { reasoningEffort: agent.reasoningEffort };
}
* The provider, model and effort that a settings request names, checked against what the CLIs list
* now. Like `create_agent`, a model or an effort that is not listed is an error that names the valid
* values. A new model keeps the agent's effort when it supports it, else takes the model's default.
*/
export function requestedRuntime(
target: Pick<AgentSummary, "provider" | "model" | "reasoningEffort">,
request: ModelRequest,
models: AgentModelOption[],
): Pick<UpdateAgentInput, "provider" | "model" | "reasoningEffort"> {
if (request.provider === undefined && request.model === undefined && request.reasoningEffort === undefined) {
return {};
}
const requested = requestedToolModel(request, models);
if (requested) {
const keptEffort = requested.supportedReasoningEfforts.includes(target.reasoningEffort)
? target.reasoningEffort
: requested.defaultReasoningEffort;
return {
provider: requested.provider,
model: requested.id,
reasoningEffort: request.reasoningEffort ?? keptEffort,
};
}
if (request.reasoningEffort === undefined) return {};
const current = models.find((model) => model.provider === target.provider && model.id === target.model);
if (!current) {
throw new AgentSettingsRefused(
sourceText("error.agent.modelNotListed", { model: target.model, models: modelList(models, target.provider) }),
);
}
requireReasoningEffort(current, request.reasoningEffort);
return { reasoningEffort: request.reasoningEffort };
}