openclaw/src/flows/model-picker.ts
Peter Steinberger 9dd306de79
refactor(agents): absorb model helper fragments (#112805)
* refactor(agents): absorb model helper fragments

* refactor(agents): keep absorbed helpers internal
2026-07-22 21:06:33 -04:00

1759 lines
55 KiB
TypeScript

// Model picker flow lets users select provider models for config defaults.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { resolveAgentConfig, resolveDefaultAgentDir } from "../agents/agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js";
import {
resolveLogicalModelCatalogEntryState,
resolveLogicalVisibleModelCatalog,
type ModelCatalogAuthChecker,
} from "../agents/model-catalog-visibility.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js";
import {
createProviderAuthChecker,
type ProviderModelAuthChecker,
} from "../agents/model-provider-auth.js";
import { formatLiteralProviderPrefixedModelRef } from "../agents/model-ref-shared.js";
import { createModelPickerVisibleProviderPredicate } from "../agents/model-runtime-aliases.js";
import {
buildConfiguredModelCatalog,
buildModelAliasIndex,
type ModelAliasIndex,
modelKey,
normalizeModelRef,
normalizeProviderId,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../agents/model-selection.js";
import { openAIModelCatalogRoutePolicy } from "../agents/openai-model-routes.js";
import { loadPreparedModelCatalogSnapshot } from "../agents/prepared-model-catalog.js";
import { loadStaticManifestCatalogRowsForList } from "../commands/models/list.manifest-catalog.js";
import { formatTokenK } from "../commands/models/shared.js";
import {
normalizeAgentModelMapForConfig,
normalizeAgentModelRefForConfig,
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "../config/model-input.js";
import { computeModelPolicyAllowlist } from "../config/model-policy-allowlist-migration.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveOwningPluginIdsForProviderRef } from "../plugins/providers.js";
import type { ProviderPlugin } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js";
import { loadPreferredProviderPickerCatalog } from "./model-picker.provider-catalog.js";
export { applyPrimaryModel } from "../plugins/provider-model-primary.js";
const KEEP_VALUE = "__keep__";
const MANUAL_VALUE = "__manual__";
const BROWSE_VALUE = "__browse__";
const PROVIDER_FILTER_THRESHOLD = 30;
const EMPTY_LITERAL_PREFIX_PROVIDERS = new Set<string>();
type ModelRouteRuntimeResolver = (params: {
provider: string;
modelId: string;
api?: string | null;
baseUrl?: unknown;
}) => "codex" | "openclaw" | undefined;
// Internal router models are valid defaults during auth/setup but not manual API targets.
const HIDDEN_ROUTER_MODELS = new Set(["openrouter/auto"]);
function formatKeepCurrentModelLabel(params: {
configuredRaw?: string;
configuredLabel: string;
resolvedKey: string;
}): string {
return params.configuredRaw
? t("wizard.model.keepCurrent", { value: params.configuredLabel })
: t("wizard.model.keepCurrentDefault", { value: params.resolvedKey });
}
function formatModelRefLabel(params: {
provider: string;
model: string;
key: string;
literalPrefixProviders: Set<string>;
}): string {
const providerId = normalizeProviderId(params.provider);
const modelId = params.model.trim().toLowerCase();
return providerId &&
params.literalPrefixProviders.has(providerId) &&
modelId.startsWith(`${providerId}/`)
? formatLiteralProviderPrefixedModelRef(params.provider, params.key)
: params.key;
}
function resolvePickerAgentDir(params: {
cfg: OpenClawConfig;
agentDir?: string;
env?: NodeJS.ProcessEnv;
}): string {
return params.agentDir ?? resolveDefaultAgentDir(params.cfg, params.env ?? process.env);
}
type PromptDefaultModelParams = {
config: OpenClawConfig;
prompter: WizardPrompter;
allowKeep?: boolean;
includeManual?: boolean;
includeProviderPluginSetups?: boolean;
ignoreAllowlist?: boolean;
loadCatalog?: boolean;
browseCatalogOnDemand?: boolean;
preferredProvider?: string;
agentId?: string;
agentDir?: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtime?: RuntimeEnv;
message?: string;
};
type PromptDefaultModelResult = { model?: string; config?: OpenClawConfig };
type PromptModelAllowlistResult = { models?: string[]; scopeKeys?: string[] };
async function loadModelPickerRuntime() {
return import("../commands/model-picker.runtime.js");
}
const loadResolvedModelPickerRuntime = createLazyRuntimeSurface(
loadModelPickerRuntime,
({ modelPickerRuntime }) => modelPickerRuntime,
);
function resolveConfiguredModelRaw(cfg: OpenClawConfig): string {
return resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model) ?? "";
}
function resolveConfiguredModelKeys(cfg: OpenClawConfig): string[] {
const models = cfg.agents?.defaults?.models ?? {};
return Object.keys(models)
.map((key) => key.trim())
.filter((key) => key.length > 0);
}
function resolveModelPickerConfig(cfg: OpenClawConfig, agentId?: string): OpenClawConfig {
const agent = agentId ? resolveAgentConfig(cfg, agentId) : undefined;
if (agent?.model === undefined && agent?.models === undefined) {
return cfg;
}
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
...(agent.model !== undefined ? { model: agent.model } : {}),
...(agent.models !== undefined
? { models: { ...cfg.agents?.defaults?.models, ...agent.models } }
: {}),
},
},
};
}
function toPickerCatalogEntry(
row: ReturnType<typeof loadStaticManifestCatalogRowsForList>[number],
): ModelCatalogEntry {
return {
id: row.id,
name: row.name,
provider: row.provider,
...(row.api !== undefined ? { api: row.api } : {}),
...(row.baseUrl !== undefined ? { baseUrl: row.baseUrl } : {}),
...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
reasoning: row.reasoning,
input: row.input,
};
}
function loadPickerModelCatalog(
cfg: OpenClawConfig,
opts: {
preferredProvider?: string;
preferLiveProviderCatalog?: boolean;
providerScoped?: boolean;
allowStaticFallbackCatalog?: boolean;
agentDir?: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
} = {},
): Promise<ModelCatalogSnapshot> {
const snapshot = (entries: ModelCatalogEntry[]): ModelCatalogSnapshot => ({
entries,
routeVariants: entries,
});
if (cfg.models?.mode === "replace") {
return Promise.resolve(snapshot(buildConfiguredModelCatalog({ cfg })));
}
if (opts.preferredProvider) {
if (opts.preferLiveProviderCatalog) {
return loadPreferredProviderPickerCatalog({
cfg,
preferredProvider: opts.preferredProvider,
...(opts.agentDir !== undefined ? { agentDir: opts.agentDir } : {}),
...(opts.workspaceDir !== undefined ? { workspaceDir: opts.workspaceDir } : {}),
...(opts.env !== undefined ? { env: opts.env } : {}),
}).then((providerCatalog) => {
if (providerCatalog.length > 0) {
return snapshot(providerCatalog);
}
if (opts.allowStaticFallbackCatalog !== false) {
const manifestRows = loadStaticManifestCatalogRowsForList({
cfg,
providerFilter: opts.preferredProvider,
...(opts.env !== undefined ? { env: opts.env } : {}),
});
if (manifestRows.length > 0) {
return snapshot(manifestRows.map(toPickerCatalogEntry));
}
}
return opts.providerScoped
? snapshot([])
: loadPreparedModelCatalogSnapshot({
config: cfg,
});
});
}
const manifestRows = loadStaticManifestCatalogRowsForList({
cfg,
providerFilter: opts.preferredProvider,
...(opts.env !== undefined ? { env: opts.env } : {}),
});
if (manifestRows.length > 0) {
return Promise.resolve(snapshot(manifestRows.map(toPickerCatalogEntry)));
}
if (opts.providerScoped) {
return Promise.resolve(snapshot([]));
}
}
return loadPreparedModelCatalogSnapshot({
config: cfg,
});
}
async function resolvePickerLogicalCatalog(params: {
cfg: OpenClawConfig;
catalog: ModelCatalogEntry[];
routeVariants: readonly ModelCatalogEntry[];
defaultProvider: string;
defaultModel?: string;
agentId?: string;
workspaceDir?: string;
view?: "default" | "configured" | "all";
hasAuth: ProviderModelAuthChecker;
}): Promise<ModelCatalogEntry[]> {
const sourceOrder = new Map<string, number>();
for (const entry of params.catalog) {
const key =
openAIModelCatalogRoutePolicy.resolveIdentity(entry)?.key ?? modelCatalogEntryKey(entry);
if (!sourceOrder.has(key)) {
sourceOrder.set(key, sourceOrder.size);
}
}
const catalog = await resolveLogicalVisibleModelCatalog({
cfg: params.cfg,
catalog: params.catalog,
defaultProvider: params.defaultProvider,
...(params.defaultModel ? { defaultModel: params.defaultModel } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
...(params.view ? { view: params.view } : {}),
routePolicy: openAIModelCatalogRoutePolicy,
routeVariants: params.routeVariants,
evaluateEntry: async (entry, routeVariants) => {
const identity = openAIModelCatalogRoutePolicy.resolveIdentity(entry);
const evaluation = await params.hasAuth.evaluateModelAuth(entry.provider, {
modelId: identity?.id ?? entry.id,
observedRoutes: routeVariants.map((variant) => ({
api: variant.api,
baseUrl: variant.baseUrl,
})),
});
return resolveLogicalModelCatalogEntryState({
entry,
evaluation,
routePolicy: openAIModelCatalogRoutePolicy,
});
},
});
// Picker sources encode product priority: live rows lead static/configured
// supplements. Logical projection must not replace that order with display sorting.
return catalog.toSorted((left, right) => {
const leftKey =
openAIModelCatalogRoutePolicy.resolveIdentity(left)?.key ?? modelCatalogEntryKey(left);
const rightKey =
openAIModelCatalogRoutePolicy.resolveIdentity(right)?.key ?? modelCatalogEntryKey(right);
return (
(sourceOrder.get(leftKey) ?? Number.MAX_SAFE_INTEGER) -
(sourceOrder.get(rightKey) ?? Number.MAX_SAFE_INTEGER)
);
});
}
function normalizeModelKeys(values: string[]): string[] {
const seen = new Set<string>();
const next: string[] = [];
for (const raw of values) {
const value = normalizeAgentModelRefForConfig(raw);
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
next.push(value);
}
return next;
}
function resolveFallbackModelKey(params: {
cfg: OpenClawConfig;
raw: string;
defaultProvider: string;
aliasIndex: ModelAliasIndex;
}): string | undefined {
const raw = normalizeOptionalString(params.raw);
if (!raw) {
return undefined;
}
const resolved = resolveModelRefFromString({
cfg: params.cfg,
raw,
defaultProvider: params.defaultProvider,
aliasIndex: params.aliasIndex,
});
if (!resolved) {
return undefined;
}
return modelKey(resolved.ref.provider, resolved.ref.model);
}
function resolveFallbackModelKeys(params: {
cfg: OpenClawConfig;
rawFallbacks: string[];
defaultProvider: string;
aliasIndex: ModelAliasIndex;
}): string[] {
return normalizeModelKeys(
params.rawFallbacks
.map((raw) =>
resolveFallbackModelKey({
cfg: params.cfg,
raw,
defaultProvider: params.defaultProvider,
aliasIndex: params.aliasIndex,
}),
)
.filter((key): key is string => Boolean(key)),
);
}
function createModelRouteRuntimeResolver(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): ModelRouteRuntimeResolver {
const cache = new Map<string, "codex" | "openclaw" | undefined>();
return (route) => {
const baseUrlKey =
typeof route.baseUrl === "string"
? route.baseUrl
: route.baseUrl == null
? ""
: typeof route.baseUrl;
const key = [route.provider, route.modelId, route.api ?? "", baseUrlKey].join("\0");
if (cache.has(key)) {
return cache.get(key);
}
const policy = resolveAgentHarnessPolicy({
provider: route.provider,
modelId: route.modelId,
modelApi: route.api,
modelBaseUrl: route.baseUrl,
config: params.config,
env: params.env,
});
const runtime =
policy.runtime === "codex" ? "codex" : policy.runtime === "openclaw" ? "openclaw" : undefined;
cache.set(key, runtime);
return runtime;
};
}
function resolveModelRouteHint(params: {
provider: string;
modelId: string;
api?: string | null;
baseUrl?: unknown;
resolveModelRouteRuntime: ModelRouteRuntimeResolver;
}): string | undefined {
if (normalizeProviderId(params.provider) !== "openai") {
return undefined;
}
const runtime = params.resolveModelRouteRuntime({
provider: params.provider,
modelId: params.modelId,
api: params.api,
baseUrl: params.baseUrl,
});
return runtime === "codex"
? "Codex runtime route"
: runtime === "openclaw"
? "OpenClaw runtime route"
: undefined;
}
async function resolveLiteralPrefixProviderIds(params: {
cfg: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Promise<Set<string>> {
const { resolvePluginProviders } = await loadResolvedModelPickerRuntime();
const providers = resolvePluginProviders({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
activate: false,
cache: false,
includeUntrustedWorkspacePlugins: false,
});
const ids = new Set<string>();
for (const provider of providers) {
if (!provider.preserveLiteralProviderPrefix) {
continue;
}
const id = normalizeProviderId(provider.id);
if (id) {
ids.add(id);
}
for (const alias of provider.aliases ?? []) {
const aliasId = normalizeProviderId(alias);
if (aliasId) {
ids.add(aliasId);
}
}
}
return ids;
}
function modelCatalogEntryKey(entry: { provider: string; id: string }): string {
const normalizedRef = normalizeModelRef(entry.provider, entry.id);
return modelKey(normalizedRef.provider, normalizedRef.model);
}
async function addModelSelectOption(params: {
entry: {
provider: string;
id: string;
name?: string;
contextWindow?: number;
reasoning?: boolean;
api?: string | null;
baseUrl?: unknown;
};
options: WizardSelectOption[];
seen: Set<string>;
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
hasAuth: ModelCatalogAuthChecker;
literalPrefixProviders: Set<string>;
isVisibleProvider: (provider: string) => boolean;
resolveModelRouteRuntime: ModelRouteRuntimeResolver;
}) {
const normalizedRef = normalizeModelRef(params.entry.provider, params.entry.id);
const key = modelCatalogEntryKey(params.entry);
if (
params.seen.has(key) ||
HIDDEN_ROUTER_MODELS.has(key) ||
!params.isVisibleProvider(normalizedRef.provider)
) {
return;
}
const hints: string[] = [];
if (params.entry.name && params.entry.name !== params.entry.id) {
hints.push(params.entry.name);
}
if (params.entry.contextWindow) {
hints.push(`ctx ${formatTokenK(params.entry.contextWindow)}`);
}
if (params.entry.reasoning) {
hints.push("reasoning");
}
const aliases = params.aliasIndex.byKey.get(key);
if (aliases?.length) {
hints.push(`alias: ${aliases.join(", ")}`);
}
const routeHint = resolveModelRouteHint({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
api: params.entry.api,
baseUrl: params.entry.baseUrl,
resolveModelRouteRuntime: params.resolveModelRouteRuntime,
});
if (routeHint) {
hints.push(routeHint);
}
if (
!(await params.hasAuth(normalizedRef.provider, {
modelId: normalizedRef.model,
api: params.entry.api,
baseUrl: params.entry.baseUrl,
}))
) {
return;
}
const label = formatModelRefLabel({
provider: normalizedRef.provider,
model: normalizedRef.model,
key,
literalPrefixProviders: params.literalPrefixProviders,
});
params.options.push({
value: key,
label,
hint: hints.length > 0 ? hints.join(" · ") : undefined,
});
params.seen.add(key);
}
function splitModelKey(key: string): { provider: string; id: string } | undefined {
const slashIndex = key.indexOf("/");
if (slashIndex <= 0 || slashIndex >= key.length - 1) {
return undefined;
}
return {
provider: key.slice(0, slashIndex),
id: key.slice(slashIndex + 1),
};
}
async function addModelKeySelectOption(params: {
key: string;
options: WizardSelectOption[];
seen: Set<string>;
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
hasAuth: ModelCatalogAuthChecker;
literalPrefixProviders?: Set<string>;
isVisibleProvider: (provider: string) => boolean;
fallbackHint: string;
resolveModelRouteRuntime: ModelRouteRuntimeResolver;
}) {
const entry = splitModelKey(params.key);
if (!entry) {
return;
}
const before = params.seen.size;
await addModelSelectOption({
entry,
options: params.options,
seen: params.seen,
aliasIndex: params.aliasIndex,
hasAuth: params.hasAuth,
literalPrefixProviders: params.literalPrefixProviders ?? EMPTY_LITERAL_PREFIX_PROVIDERS,
isVisibleProvider: params.isVisibleProvider,
resolveModelRouteRuntime: params.resolveModelRouteRuntime,
});
if (params.seen.size > before) {
const option = params.options.at(-1);
if (option && !option.hint) {
option.hint = params.fallbackHint;
}
}
}
function createPreferredProviderMatcher(params: {
preferredProvider: string;
cfg: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): (entryProvider: string) => boolean {
const normalizedPreferredProvider = normalizeProviderId(params.preferredProvider);
const preferredOwnerPluginIds = resolveOwningPluginIdsForProviderRef({
provider: normalizedPreferredProvider,
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
});
const preferredOwnerPluginIdSet = preferredOwnerPluginIds
? new Set(preferredOwnerPluginIds)
: undefined;
const entryProviderCache = new Map<string, boolean>();
return (entryProvider: string) => {
const normalizedEntryProvider = normalizeProviderId(entryProvider);
if (normalizedEntryProvider === normalizedPreferredProvider) {
return true;
}
const cached = entryProviderCache.get(normalizedEntryProvider);
if (cached !== undefined) {
return cached;
}
if (!preferredOwnerPluginIdSet) {
entryProviderCache.set(normalizedEntryProvider, false);
return false;
}
const value =
resolveOwningPluginIdsForProviderRef({
provider: normalizedEntryProvider,
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
})?.some((pluginId) => preferredOwnerPluginIdSet.has(pluginId)) ?? false;
entryProviderCache.set(normalizedEntryProvider, value);
return value;
};
}
async function promptManualModel(params: {
prompter: WizardPrompter;
allowBlank: boolean;
initialValue?: string;
}): Promise<PromptDefaultModelResult> {
const modelInput = await params.prompter.text({
message: params.allowBlank
? t("wizard.model.defaultModelBlankToKeep")
: t("wizard.model.defaultModel"),
initialValue: params.initialValue,
placeholder: "provider/model",
validate: params.allowBlank
? undefined
: (value) => (normalizeOptionalString(value) ? undefined : t("common.required")),
});
const model = (modelInput ?? "").trim();
if (!model) {
return {};
}
return { model: normalizeAgentModelRefForConfig(model) };
}
function buildModelProviderFilterOptions(
models: Array<{ provider: string }>,
): Array<{ value: string; label: string; hint: string }> {
const providerIds = sortUniqueStrings(models.map((entry) => entry.provider));
return providerIds.map((provider) => {
const count = models.filter((entry) => entry.provider === provider).length;
return {
value: provider,
label: provider,
hint: t("wizard.model.modelCount", { count, plural: count === 1 ? "" : "s" }),
};
});
}
async function maybeFilterModelsByProvider(params: {
models: Array<{
provider: string;
id: string;
name?: string;
contextWindow?: number;
reasoning?: boolean;
}>;
preferredProvider?: string;
prompter: WizardPrompter;
cfg: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
isVisibleProvider: (provider: string) => boolean;
}): Promise<typeof params.models> {
let next = params.models.filter((entry) => params.isVisibleProvider(entry.provider));
const providerIds = sortUniqueStrings(next.map((entry) => entry.provider));
const hasPreferredProvider = Boolean(params.preferredProvider);
const shouldPromptProvider =
!hasPreferredProvider && providerIds.length > 1 && next.length > PROVIDER_FILTER_THRESHOLD;
const matchesPreferredProvider = params.preferredProvider
? createPreferredProviderMatcher({
preferredProvider: params.preferredProvider,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
})
: undefined;
if (shouldPromptProvider) {
const selection = await params.prompter.select({
message: t("wizard.model.filterByProvider"),
options: [
{ value: "*", label: t("wizard.model.allProviders") },
...buildModelProviderFilterOptions(next),
],
searchable: true,
});
if (selection !== "*") {
next = next.filter((entry) => entry.provider === selection);
}
}
if (hasPreferredProvider && params.preferredProvider) {
const filtered = next.filter((entry) => matchesPreferredProvider?.(entry.provider));
if (filtered.length > 0) {
next = filtered;
}
}
return next;
}
async function resolveProviderPluginSetupOptions(params: {
cfg: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Promise<WizardSelectOption[]> {
const runtime = await loadResolvedModelPickerRuntime();
const providerModelPickerOptions =
"resolveProviderModelPickerContributions" in runtime &&
typeof runtime.resolveProviderModelPickerContributions === "function"
? runtime
.resolveProviderModelPickerContributions({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
})
.map((contribution) => contribution.option)
: runtime.resolveProviderModelPickerEntries({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
});
return providerModelPickerOptions.map((entry) =>
Object.assign(
{ value: entry.value, label: entry.label },
entry.hint ? { hint: entry.hint } : {},
),
);
}
async function maybeHandleProviderPluginSelection(params: {
selection: string;
cfg: OpenClawConfig;
prompter: WizardPrompter;
agentDir?: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtime?: RuntimeEnv;
}): Promise<PromptDefaultModelResult | null> {
let pluginResolution: string | null = null;
let pluginProviders: ProviderPlugin[] = [];
if (params.selection.startsWith("provider-plugin:")) {
pluginResolution = params.selection;
} else if (!params.selection.includes("/")) {
const { resolvePluginProviders } = await loadResolvedModelPickerRuntime();
pluginProviders = resolvePluginProviders({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
mode: "setup",
});
pluginResolution = pluginProviders.some(
(provider) => normalizeProviderId(provider.id) === normalizeProviderId(params.selection),
)
? params.selection
: null;
}
if (!pluginResolution) {
return null;
}
if (!params.agentDir || !params.runtime) {
await params.prompter.note(
t("wizard.model.providerSetupUnavailable"),
t("wizard.model.providerSetupUnavailableTitle"),
);
return {};
}
const {
resolvePluginProviders,
resolveProviderPluginChoice,
runProviderModelSelectedHook,
runProviderPluginAuthMethod,
} = await loadResolvedModelPickerRuntime();
if (pluginProviders.length === 0) {
pluginProviders = resolvePluginProviders({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
mode: "setup",
});
}
const resolved = resolveProviderPluginChoice({
providers: pluginProviders,
choice: pluginResolution,
});
if (!resolved) {
return {};
}
const applied = await runProviderPluginAuthMethod({
config: params.cfg,
runtime: params.runtime,
prompter: params.prompter,
method: resolved.method,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
});
if (applied.defaultModel) {
await runProviderModelSelectedHook({
config: applied.config,
model: applied.defaultModel,
prompter: params.prompter,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
env: params.env,
});
}
return { model: applied.defaultModel, config: applied.config };
}
export async function promptDefaultModel(
params: PromptDefaultModelParams,
): Promise<PromptDefaultModelResult> {
const cfg = params.config;
const pickerConfig = resolveModelPickerConfig(cfg, params.agentId);
const pickerAgentDir = resolvePickerAgentDir({
cfg,
...(params.agentDir !== undefined ? { agentDir: params.agentDir } : {}),
...(params.env !== undefined ? { env: params.env } : {}),
});
const allowKeep = params.allowKeep ?? true;
const includeManual = params.includeManual ?? true;
const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false;
const loadCatalog = params.loadCatalog ?? true;
const browseCatalogOnDemand = params.browseCatalogOnDemand ?? false;
const ignoreAllowlist = params.ignoreAllowlist ?? false;
const preferredProviderRaw = normalizeOptionalString(params.preferredProvider);
const preferredProvider = preferredProviderRaw
? normalizeProviderId(preferredProviderRaw)
: undefined;
const configuredRaw = resolveConfiguredModelRaw(pickerConfig);
const useStaticModelNormalization = !loadCatalog || browseCatalogOnDemand;
const resolved = resolveConfiguredModelRef({
cfg: pickerConfig,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
allowPluginNormalization: useStaticModelNormalization ? false : undefined,
});
const resolvedKey = modelKey(resolved.provider, resolved.model);
const configuredKey = configuredRaw ? resolvedKey : "";
let literalPrefixProvidersCache: Set<string> | undefined;
const resolveCachedLiteralPrefixProviders = async () => {
if (!literalPrefixProvidersCache) {
literalPrefixProvidersCache = await resolveLiteralPrefixProviderIds({
cfg,
workspaceDir: params.workspaceDir,
env: params.env,
});
}
return literalPrefixProvidersCache;
};
const resolveConfiguredDisplayLabel = async () => {
const providerId = normalizeProviderId(resolved.provider);
if (!providerId) {
return configuredRaw || resolvedKey;
}
const literalPrefixProviders = await resolveCachedLiteralPrefixProviders();
return formatModelRefLabel({
provider: resolved.provider,
model: resolved.model,
key: configuredRaw || resolvedKey,
literalPrefixProviders,
});
};
if (
loadCatalog &&
browseCatalogOnDemand &&
allowKeep &&
(!preferredProvider || normalizeProviderId(resolved.provider) === preferredProvider)
) {
const configuredLabel = await resolveConfiguredDisplayLabel();
const options: WizardSelectOption[] = [
{
value: KEEP_VALUE,
label: formatKeepCurrentModelLabel({ configuredRaw, configuredLabel, resolvedKey }),
hint:
configuredRaw && configuredRaw !== resolvedKey
? t("wizard.model.resolvesTo", { value: resolvedKey })
: undefined,
},
];
if (includeManual) {
options.push({ value: MANUAL_VALUE, label: t("wizard.model.enterManually") });
}
options.push({
value: BROWSE_VALUE,
label: t("wizard.model.browseAll"),
hint: t("wizard.model.loadsProviderCatalogs"),
});
const selection = await params.prompter.select({
message: params.message ?? t("wizard.model.defaultModel"),
options,
initialValue: KEEP_VALUE,
searchable: false,
});
if (selection === KEEP_VALUE) {
return {};
}
if (selection === MANUAL_VALUE) {
return promptManualModel({
prompter: params.prompter,
allowBlank: false,
initialValue: configuredRaw || resolvedKey || undefined,
});
}
if (selection !== BROWSE_VALUE) {
return { model: selection };
}
}
if (!loadCatalog) {
const configuredLabel = await resolveConfiguredDisplayLabel();
const options: WizardSelectOption[] = [];
if (allowKeep) {
options.push({
value: KEEP_VALUE,
label: formatKeepCurrentModelLabel({ configuredRaw, configuredLabel, resolvedKey }),
hint:
configuredRaw && configuredRaw !== resolvedKey
? t("wizard.model.resolvesTo", { value: resolvedKey })
: undefined,
});
}
if (includeManual) {
options.push({ value: MANUAL_VALUE, label: t("wizard.model.enterManually") });
}
if (configuredKey && !options.some((option) => option.value === configuredKey)) {
options.push({
value: configuredKey,
label: configuredKey,
hint: t("wizard.model.current"),
});
}
if (options.length === 0) {
return promptManualModel({
prompter: params.prompter,
allowBlank: allowKeep,
initialValue: configuredRaw || resolvedKey || undefined,
});
}
const selection = await params.prompter.select({
message: params.message ?? t("wizard.model.defaultModel"),
options,
initialValue: allowKeep ? KEEP_VALUE : configuredKey || MANUAL_VALUE,
searchable: false,
});
if (selection === KEEP_VALUE) {
return {};
}
if (selection === MANUAL_VALUE) {
return promptManualModel({
prompter: params.prompter,
allowBlank: false,
initialValue: configuredRaw || resolvedKey || undefined,
});
}
return { model: selection };
}
const catalogProgress = params.prompter.progress(t("wizard.model.loadingModels"));
let catalogSnapshot: ModelCatalogSnapshot;
try {
const providerScopedCatalog = browseCatalogOnDemand && preferredProvider;
catalogSnapshot = await loadPickerModelCatalog(cfg, {
preferredProvider: providerScopedCatalog ? preferredProvider : undefined,
preferLiveProviderCatalog: Boolean(providerScopedCatalog),
providerScoped: Boolean(providerScopedCatalog),
agentDir: pickerAgentDir,
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
...(params.env !== undefined ? { env: params.env } : {}),
});
} finally {
catalogProgress.stop();
}
const catalog = catalogSnapshot.entries;
if (catalog.length === 0) {
return promptManualModel({
prompter: params.prompter,
allowBlank: allowKeep,
initialValue: configuredRaw || resolvedKey || undefined,
});
}
const aliasIndex = buildModelAliasIndex({
cfg: pickerConfig,
defaultProvider: DEFAULT_PROVIDER,
});
const hasAuth = createProviderAuthChecker({
cfg,
workspaceDir: params.workspaceDir,
agentDir: pickerAgentDir,
env: params.env,
});
const resolveModelRouteRuntime = createModelRouteRuntimeResolver({
config: cfg,
env: params.env,
});
const models = await resolvePickerLogicalCatalog({
cfg: pickerConfig,
catalog,
routeVariants: catalogSnapshot.routeVariants,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: resolved.model,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
...(ignoreAllowlist ? { view: "all" as const } : {}),
hasAuth,
});
if (models.length === 0) {
return promptManualModel({
prompter: params.prompter,
allowBlank: allowKeep,
initialValue: configuredRaw || resolvedKey || undefined,
});
}
const isVisibleProvider = createModelPickerVisibleProviderPredicate({
config: cfg,
env: params.env,
includeSetupRegistry: true,
});
const filteredModels = await maybeFilterModelsByProvider({
models,
preferredProvider,
prompter: params.prompter,
cfg,
workspaceDir: params.workspaceDir,
env: params.env,
isVisibleProvider,
});
if (filteredModels.length === 0) {
return promptManualModel({
prompter: params.prompter,
allowBlank: allowKeep,
initialValue: configuredRaw || resolvedKey || undefined,
});
}
const matchesPreferredProvider = preferredProvider
? createPreferredProviderMatcher({
preferredProvider,
cfg,
workspaceDir: params.workspaceDir,
env: params.env,
})
: undefined;
const hasPreferredProvider = preferredProvider
? filteredModels.some((entry) => matchesPreferredProvider?.(entry.provider))
: false;
const literalPrefixProviders = await resolveCachedLiteralPrefixProviders();
// Show the literal form (e.g. nvidia/nvidia/...) in the "Keep current" label
// for providers that set preserveLiteralProviderPrefix, so the user sees the
// same ref they'll pick from the catalog rows. Config itself stays canonical.
const configuredLabel = formatModelRefLabel({
provider: resolved.provider,
model: resolved.model,
key: configuredRaw || resolvedKey,
literalPrefixProviders,
});
const options: WizardSelectOption[] = [];
if (allowKeep) {
options.push({
value: KEEP_VALUE,
label: formatKeepCurrentModelLabel({ configuredRaw, configuredLabel, resolvedKey }),
});
}
if (includeManual) {
options.push({ value: MANUAL_VALUE, label: t("wizard.model.enterManually") });
}
if (includeProviderPluginSetups && params.agentDir) {
options.push(
...(await resolveProviderPluginSetupOptions({
cfg,
workspaceDir: params.workspaceDir,
env: params.env,
})),
);
}
const seen = new Set<string>();
for (const entry of filteredModels) {
await addModelSelectOption({
entry,
options,
seen,
aliasIndex,
hasAuth,
literalPrefixProviders,
isVisibleProvider,
resolveModelRouteRuntime,
});
}
if (configuredKey && !seen.has(configuredKey)) {
options.push({
value: configuredKey,
label: configuredLabel,
hint: t("wizard.model.currentNotInCatalog"),
});
}
const firstPreferredModel =
preferredProvider && hasPreferredProvider
? filteredModels.find((entry) => matchesPreferredProvider?.(entry.provider))
: undefined;
const firstPreferredModelKey = firstPreferredModel
? modelCatalogEntryKey(firstPreferredModel)
: undefined;
let initialValue: string | undefined = allowKeep ? KEEP_VALUE : configuredKey || undefined;
if (!allowKeep && firstPreferredModelKey) {
initialValue = firstPreferredModelKey;
} else if (
allowKeep &&
firstPreferredModelKey &&
preferredProvider &&
!matchesPreferredProvider?.(resolved.provider)
) {
initialValue = firstPreferredModelKey;
}
const selection = await params.prompter.select({
message: params.message ?? t("wizard.model.defaultModel"),
options,
initialValue,
searchable: true,
});
const selectedValue = selection ?? "";
if (selectedValue === KEEP_VALUE) {
return {};
}
if (selectedValue === MANUAL_VALUE) {
return promptManualModel({
prompter: params.prompter,
allowBlank: false,
initialValue: configuredRaw || resolvedKey || undefined,
});
}
const providerPluginResult = await maybeHandleProviderPluginSelection({
selection: selectedValue,
cfg,
prompter: params.prompter,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
env: params.env,
runtime: params.runtime,
});
if (providerPluginResult) {
return providerPluginResult;
}
const model = normalizeAgentModelRefForConfig(selectedValue);
const { runProviderModelSelectedHook } = await loadResolvedModelPickerRuntime();
await runProviderModelSelectedHook({
config: cfg,
model,
prompter: params.prompter,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
env: params.env,
});
return { model };
}
export async function promptModelAllowlist(params: {
config: OpenClawConfig;
prompter: WizardPrompter;
message?: string;
agentDir?: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
allowedKeys?: string[];
initialSelections?: string[];
preferredProvider?: string;
loadCatalog?: boolean;
providerScopedCatalog?: boolean;
}): Promise<PromptModelAllowlistResult> {
const cfg = params.config;
const pickerAgentDir = resolvePickerAgentDir({
cfg,
...(params.agentDir !== undefined ? { agentDir: params.agentDir } : {}),
...(params.env !== undefined ? { env: params.env } : {}),
});
const existingKeys = resolveConfiguredModelKeys(cfg);
const configuredRaw = resolveConfiguredModelRaw(cfg);
const allowedKeys = normalizeModelKeys(params.allowedKeys ?? []);
const allowedKeySet = allowedKeys.length > 0 ? new Set(allowedKeys) : null;
const preferredProviderRaw = normalizeOptionalString(params.preferredProvider);
const preferredProvider = preferredProviderRaw
? normalizeProviderId(preferredProviderRaw)
: undefined;
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const resolvedKey = modelKey(resolved.provider, resolved.model);
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const fallbackAliasIndex =
resolved.provider === DEFAULT_PROVIDER
? aliasIndex
: buildModelAliasIndex({
cfg,
defaultProvider: resolved.provider,
});
const fallbackKeys = resolveFallbackModelKeys({
cfg,
rawFallbacks: resolveAgentModelFallbackValues(cfg.agents?.defaults?.model),
defaultProvider: resolved.provider,
aliasIndex: fallbackAliasIndex,
});
const initialSeeds = normalizeModelKeys([
...existingKeys,
resolvedKey,
...fallbackKeys,
...(params.initialSelections ?? []),
]);
const hasRealSeed =
existingKeys.length > 0 ||
fallbackKeys.length > 0 ||
(params.initialSelections?.length ?? 0) > 0 ||
configuredRaw.length > 0;
const hasAuth = createProviderAuthChecker({
cfg,
workspaceDir: params.workspaceDir,
agentDir: pickerAgentDir,
env: params.env,
});
const resolveModelRouteRuntime = createModelRouteRuntimeResolver({
config: cfg,
env: params.env,
});
const matchesPreferredProvider = preferredProvider
? createPreferredProviderMatcher({
preferredProvider,
cfg,
workspaceDir: params.workspaceDir,
env: params.env,
})
: undefined;
const loadCatalog = params.loadCatalog ?? true;
const scopedFastKeys =
allowedKeys.length > 0
? allowedKeys
: !loadCatalog && preferredProvider && hasRealSeed
? initialSeeds.filter((key) => {
const entry = splitModelKey(key);
return entry ? matchesPreferredProvider?.(entry.provider) === true : false;
})
: [];
if (scopedFastKeys.length > 0) {
const isVisibleProvider = createModelPickerVisibleProviderPredicate({
config: cfg,
env: params.env,
includeSetupRegistry: true,
});
const scopeKeys = allowedKeys.length > 0 ? allowedKeys : scopedFastKeys;
const scopeKeySet = new Set(scopeKeys);
const initialKeys = normalizeModelKeys(initialSeeds.filter((key) => scopeKeySet.has(key)));
const options: WizardSelectOption[] = [];
const seen = new Set<string>();
for (const key of scopeKeys) {
await addModelKeySelectOption({
key,
options,
seen,
aliasIndex,
hasAuth,
isVisibleProvider,
resolveModelRouteRuntime,
fallbackHint:
allowedKeys.length > 0 ? t("wizard.model.allowed") : t("wizard.model.configured"),
});
}
if (options.length === 0) {
return {};
}
const selection = await params.prompter.multiselect({
message: params.message ?? t("wizard.model.allowlistPicker"),
options,
initialValues: initialKeys.length > 0 ? initialKeys : undefined,
searchable: true,
});
const selected = normalizeModelKeys(selection);
if (selected.length > 0) {
return { models: selected, scopeKeys };
}
const confirmScopedClear = await params.prompter.confirm({
message: t("wizard.model.removeProviderModels"),
initialValue: false,
});
if (!confirmScopedClear) {
return {};
}
return { models: [], scopeKeys };
}
if (!loadCatalog) {
return {};
}
const allowlistProgress = params.prompter.progress(t("wizard.model.loadingModels"));
let catalogSnapshot: ModelCatalogSnapshot;
try {
catalogSnapshot = await loadPickerModelCatalog(cfg, {
preferredProvider,
preferLiveProviderCatalog: Boolean(preferredProvider),
providerScoped: Boolean(preferredProvider && params.providerScopedCatalog),
allowStaticFallbackCatalog: !params.providerScopedCatalog,
agentDir: pickerAgentDir,
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
...(params.env !== undefined ? { env: params.env } : {}),
});
} finally {
allowlistProgress.stop();
}
let catalog = catalogSnapshot.entries;
let providerStaticCatalogRows:
| ReturnType<typeof loadStaticManifestCatalogRowsForList>
| undefined;
const loadProviderStaticCatalogRows = () =>
(providerStaticCatalogRows ??= preferredProvider
? loadStaticManifestCatalogRowsForList({
cfg,
providerFilter: preferredProvider,
...(params.env !== undefined ? { env: params.env } : {}),
})
: []);
const providerScopedCatalogLoaded = Boolean(
preferredProvider && params.providerScopedCatalog && catalog.length > 0,
);
if (providerScopedCatalogLoaded) {
const deprecatedStaticKeys = new Set(
loadProviderStaticCatalogRows()
.filter((entry) => entry.status === "deprecated")
.map((entry) => modelKey(entry.provider, entry.id)),
);
if (deprecatedStaticKeys.size > 0) {
catalog = catalog.filter(
(entry) => !deprecatedStaticKeys.has(modelKey(entry.provider, entry.id)),
);
}
}
if (preferredProvider) {
let configuredCatalog = buildConfiguredModelCatalog({ cfg }).filter(
(entry) => matchesPreferredProvider?.(entry.provider) === true,
);
if (providerScopedCatalogLoaded && configuredCatalog.length > 0) {
const staticKeys = new Set(
loadProviderStaticCatalogRows().map((entry) => modelKey(entry.provider, entry.id)),
);
configuredCatalog = configuredCatalog.filter(
(entry) => !staticKeys.has(modelKey(entry.provider, entry.id)),
);
}
const catalogKeys = new Set(catalog.map((entry) => modelKey(entry.provider, entry.id)));
const mergedCatalog = [...catalog];
for (const entry of configuredCatalog) {
const key = modelKey(entry.provider, entry.id);
if (catalogKeys.has(key)) {
continue;
}
catalogKeys.add(key);
mergedCatalog.push(entry);
}
catalog = mergedCatalog;
}
catalog = await resolvePickerLogicalCatalog({
cfg,
catalog,
routeVariants: catalogSnapshot.routeVariants,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: resolved.model,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
view: "all",
hasAuth,
});
if (catalog.length === 0 && allowedKeys.length === 0) {
const noCatalogInitialKeys =
existingKeys.length > 0 ? normalizeModelKeys([...existingKeys, ...fallbackKeys]) : [];
const raw = await params.prompter.text({
message: params.message ?? t("wizard.model.allowlistText"),
initialValue: noCatalogInitialKeys.join(", "),
placeholder: "provider/model, other-provider/model",
});
const parsed = (raw ?? "")
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (parsed.length === 0) {
return {};
}
return { models: normalizeModelKeys(parsed) };
}
const literalPrefixProviders = await resolveLiteralPrefixProviderIds({
cfg,
workspaceDir: params.workspaceDir,
env: params.env,
});
const isVisibleProvider = createModelPickerVisibleProviderPredicate({
config: cfg,
env: params.env,
includeSetupRegistry: true,
});
const isVisibleModelRef = (ref: string): boolean => {
const separatorIndex = ref.indexOf("/");
return separatorIndex <= 0 || isVisibleProvider(ref.slice(0, separatorIndex));
};
const options: WizardSelectOption[] = [];
const seen = new Set<string>();
const allowedCatalog = (
allowedKeySet
? catalog.filter((entry) => allowedKeySet.has(modelKey(entry.provider, entry.id)))
: catalog
).filter((entry) => isVisibleProvider(entry.provider));
const filteredCatalog =
preferredProvider && allowedCatalog.some((entry) => matchesPreferredProvider?.(entry.provider))
? allowedCatalog.filter((entry) => matchesPreferredProvider?.(entry.provider))
: allowedCatalog;
const scopedConfiguredKeys =
preferredProvider && !allowedKeySet
? existingKeys.filter((key) => {
if (!isVisibleModelRef(key)) {
return false;
}
const entry = splitModelKey(key);
return entry ? matchesPreferredProvider?.(entry.provider) === true : false;
})
: [];
const scopeKeys = allowedKeySet
? allowedKeys
: preferredProvider
? normalizeModelKeys([
...filteredCatalog.map((entry) => modelKey(entry.provider, entry.id)),
...scopedConfiguredKeys,
])
: undefined;
const scopeKeySet = scopeKeys ? new Set(scopeKeys) : null;
const selectableInitialSeeds =
scopeKeySet && !allowedKeySet
? initialSeeds.filter((key) => scopeKeySet.has(key))
: initialSeeds;
const initialKeys = allowedKeySet
? initialSeeds.filter((key) => allowedKeySet.has(key))
: selectableInitialSeeds.filter(isVisibleModelRef);
for (const entry of filteredCatalog) {
await addModelSelectOption({
entry,
options,
seen,
aliasIndex,
hasAuth,
literalPrefixProviders,
isVisibleProvider,
resolveModelRouteRuntime,
});
}
const supplementalKeys = (allowedKeySet ? allowedKeys : selectableInitialSeeds).filter(
isVisibleModelRef,
);
for (const key of supplementalKeys) {
if (seen.has(key)) {
continue;
}
options.push({
value: key,
label: key,
hint: allowedKeySet
? t("wizard.model.allowedNotInCatalog")
: t("wizard.model.configuredNotInCatalog"),
});
seen.add(key);
}
if (options.length === 0) {
return {};
}
const selection = await params.prompter.multiselect({
message: params.message ?? t("wizard.model.allowlistPicker"),
options,
initialValues: initialKeys.length > 0 ? initialKeys : undefined,
searchable: true,
});
const selected = normalizeModelKeys(selection);
if (selected.length > 0) {
return { models: selected, ...(scopeKeys ? { scopeKeys } : {}) };
}
if (scopeKeys) {
const confirmScopedClear = await params.prompter.confirm({
message: t("wizard.model.removeProviderModels"),
initialValue: false,
});
if (!confirmScopedClear) {
return {};
}
return { models: [], scopeKeys };
}
if (existingKeys.length === 0) {
return { models: [] };
}
const confirmClear = await params.prompter.confirm({
message: t("wizard.model.clearAllowlist"),
initialValue: false,
});
if (!confirmClear) {
return {};
}
return { models: [] };
}
export function applyModelAllowlist(
cfg: OpenClawConfig,
models: string[],
opts: { scopeKeys?: string[] } = {},
): OpenClawConfig {
const defaults = cfg.agents?.defaults;
const normalized = normalizeModelKeys(models);
const scopeKeys = opts.scopeKeys ? normalizeModelKeys(opts.scopeKeys) : [];
const scopeKeySet = scopeKeys.length > 0 ? new Set(scopeKeys) : null;
const existingModels = normalizeAgentModelMapForConfig(defaults?.models ?? {});
const legacyAllow = computeModelPolicyAllowlist({
root: cfg,
defaults,
});
const existingAllow = normalizeModelKeys(defaults?.modelPolicy?.allow ?? legacyAllow ?? []);
const scopeProviders = new Set(
scopeKeys.map((key) => normalizeProviderId(key.slice(0, key.indexOf("/")))),
);
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER });
const isPolicyRefInScope = (raw: string): boolean => {
const trimmed = raw.trim();
if (trimmed.endsWith("/*")) {
return scopeProviders.has(normalizeProviderId(trimmed.slice(0, -2)));
}
const resolved = resolveModelRefFromString({
cfg,
raw: trimmed,
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
return Boolean(
resolved && scopeKeySet?.has(modelKey(resolved.ref.provider, resolved.ref.model)),
);
};
if (normalized.length === 0) {
// No agent defaults means no policy/legacy map to edit; nothing to clear.
if (!defaults || (!defaults.modelPolicy && !legacyAllow)) {
return cfg;
}
if (scopeKeySet) {
const nextAllow = existingAllow.filter((key) => !isPolicyRefInScope(key));
const { modelPolicy: _modelPolicy, ...restDefaults } = defaults;
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...restDefaults,
...(nextAllow.length > 0 || legacyAllow
? { modelPolicy: { ...defaults?.modelPolicy, allow: nextAllow } }
: {}),
},
},
};
}
if (legacyAllow) {
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...defaults,
modelPolicy: { ...defaults?.modelPolicy, allow: [] },
},
},
};
}
const { modelPolicy: _modelPolicy, ...restDefaults } = defaults;
return {
...cfg,
agents: {
...cfg.agents,
defaults: restDefaults,
},
};
}
if (scopeKeySet) {
const nextModels = { ...existingModels };
for (const key of normalized) {
nextModels[key] = existingModels[key] ?? {};
}
const nextAllow = existingAllow.filter((key) => !isPolicyRefInScope(key));
for (const key of normalized) {
if (!nextAllow.includes(key)) {
nextAllow.push(key);
}
}
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...defaults,
models: nextModels,
modelPolicy: { ...defaults?.modelPolicy, allow: nextAllow },
},
},
};
}
const nextModels: Record<string, { alias?: string }> = { ...existingModels };
for (const key of normalized) {
nextModels[key] = existingModels[key] ?? {};
}
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...defaults,
models: nextModels,
modelPolicy: { ...defaults?.modelPolicy, allow: normalized },
},
},
};
}
export function applyModelFallbacksFromSelection(
cfg: OpenClawConfig,
selection: string[],
opts: { scopeKeys?: string[] } = {},
): OpenClawConfig {
const normalized = normalizeModelKeys(selection);
const scopeKeys = opts.scopeKeys ? normalizeModelKeys(opts.scopeKeys) : [];
const scopeKeySet = scopeKeys.length > 0 ? new Set(scopeKeys) : null;
if (normalized.length === 0 && !scopeKeySet) {
return cfg;
}
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const resolvedKey = modelKey(resolved.provider, resolved.model);
const includesResolvedPrimary = normalized.includes(resolvedKey);
if (!includesResolvedPrimary && !scopeKeySet) {
return cfg;
}
const defaults = cfg.agents?.defaults;
const existingModel = defaults?.model;
const existingPrimary =
typeof existingModel === "string"
? existingModel
: existingModel && typeof existingModel === "object"
? existingModel.primary
: undefined;
const normalizedExistingPrimary =
existingPrimary != null ? normalizeAgentModelRefForConfig(existingPrimary) : undefined;
const preservedModelFields =
existingModel && typeof existingModel === "object"
? (({ fallbacks: _oldFallbacks, ...rest }) => rest)(existingModel)
: {};
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: resolved.provider,
});
const existingFallbacks =
existingModel && typeof existingModel === "object" && Array.isArray(existingModel.fallbacks)
? resolveFallbackModelKeys({
cfg,
rawFallbacks: existingModel.fallbacks,
defaultProvider: resolved.provider,
aliasIndex,
})
: [];
const existingFallbackSet = new Set(existingFallbacks);
const rawSelectedFallbacks = normalized.filter((key) => key !== resolvedKey);
const selectedFallbacks =
scopeKeySet && !includesResolvedPrimary
? rawSelectedFallbacks.filter((key) => existingFallbackSet.has(key))
: rawSelectedFallbacks;
const isVisibleProvider = createModelPickerVisibleProviderPredicate({
config: cfg,
includeSetupRegistry: true,
});
const isVisibleModelRef = (ref: string): boolean => {
const separatorIndex = ref.indexOf("/");
return separatorIndex <= 0 || isVisibleProvider(ref.slice(0, separatorIndex));
};
const preserveExistingFallback = scopeKeySet
? (fallback: string) => !scopeKeySet.has(fallback)
: (fallback: string) => !isVisibleModelRef(fallback);
const fallbacks = mergeFallbackSelection({
existingFallbacks,
selectedFallbacks,
preserveExistingFallback,
});
const nextModel = {
...preservedModelFields,
...(normalizedExistingPrimary != null ? { primary: normalizedExistingPrimary } : {}),
...(fallbacks.length > 0 ? { fallbacks } : {}),
};
if (Object.keys(nextModel).length === 0) {
if (!defaults || !Object.hasOwn(defaults, "model")) {
return cfg;
}
const { model: _ignoredModel, ...restDefaults } = defaults;
return {
...cfg,
agents: {
...cfg.agents,
defaults: restDefaults,
},
};
}
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...defaults,
model: nextModel,
},
},
};
}
function mergeFallbackSelection(params: {
existingFallbacks: string[];
selectedFallbacks: string[];
preserveExistingFallback: (fallback: string) => boolean;
}): string[] {
const selected = new Set(params.selectedFallbacks);
const fallbacks: string[] = [];
for (const fallback of params.existingFallbacks) {
if (params.preserveExistingFallback(fallback)) {
fallbacks.push(fallback);
continue;
}
if (selected.delete(fallback)) {
fallbacks.push(fallback);
}
}
for (const fallback of params.selectedFallbacks) {
if (selected.has(fallback)) {
fallbacks.push(fallback);
}
}
return fallbacks;
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */