mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 13:10:43 +00:00
1191 lines
35 KiB
TypeScript
1191 lines
35 KiB
TypeScript
import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js";
|
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
|
import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js";
|
|
import { loadModelCatalog } from "../agents/model-catalog.js";
|
|
import {
|
|
isModelPickerVisibleModelRef,
|
|
isModelPickerVisibleProvider,
|
|
} from "../agents/model-picker-visibility.js";
|
|
import {
|
|
buildAllowedModelSet,
|
|
buildModelAliasIndex,
|
|
type ModelAliasIndex,
|
|
modelKey,
|
|
normalizeProviderId,
|
|
resolveConfiguredModelRef,
|
|
resolveModelRefFromString,
|
|
} from "../agents/model-selection.js";
|
|
import { formatTokenK } from "../commands/models/shared.js";
|
|
import {
|
|
resolveAgentModelFallbackValues,
|
|
resolveAgentModelPrimaryValue,
|
|
} from "../config/model-input.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { applyPrimaryModel } from "../plugins/provider-model-primary.js";
|
|
import { resolveOwningPluginIdsForProvider } 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 { normalizeOptionalString } from "../shared/string-coerce.js";
|
|
import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.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;
|
|
|
|
// Internal router models are valid defaults during auth/setup but not manual API targets.
|
|
const HIDDEN_ROUTER_MODELS = new Set(["openrouter/auto"]);
|
|
|
|
export type PromptDefaultModelParams = {
|
|
config: OpenClawConfig;
|
|
prompter: WizardPrompter;
|
|
allowKeep?: boolean;
|
|
includeManual?: boolean;
|
|
includeProviderPluginSetups?: boolean;
|
|
ignoreAllowlist?: boolean;
|
|
loadCatalog?: boolean;
|
|
browseCatalogOnDemand?: boolean;
|
|
preferredProvider?: string;
|
|
agentDir?: string;
|
|
workspaceDir?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
runtime?: RuntimeEnv;
|
|
message?: string;
|
|
};
|
|
|
|
export type PromptDefaultModelResult = { model?: string; config?: OpenClawConfig };
|
|
export type PromptModelAllowlistResult = { models?: string[]; scopeKeys?: string[] };
|
|
|
|
async function loadModelPickerRuntime() {
|
|
return import("../commands/model-picker.runtime.js");
|
|
}
|
|
|
|
const loadResolvedModelPickerRuntime = createLazyRuntimeSurface(
|
|
loadModelPickerRuntime,
|
|
({ modelPickerRuntime }) => modelPickerRuntime,
|
|
);
|
|
|
|
function hasAuthForProvider(
|
|
provider: string,
|
|
cfg: OpenClawConfig,
|
|
store: ReturnType<typeof ensureAuthProfileStore>,
|
|
) {
|
|
if (listProfilesForProvider(store, provider).length > 0) {
|
|
return true;
|
|
}
|
|
if (resolveEnvApiKey(provider)) {
|
|
return true;
|
|
}
|
|
if (hasUsableCustomProviderApiKey(cfg, provider)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function createProviderAuthChecker(params: {
|
|
cfg: OpenClawConfig;
|
|
agentDir?: string;
|
|
}): (provider: string) => boolean {
|
|
const authStore = ensureAuthProfileStore(params.agentDir, {
|
|
allowKeychainPrompt: false,
|
|
});
|
|
const authCache = new Map<string, boolean>();
|
|
return (provider: string) => {
|
|
const cached = authCache.get(provider);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
const value = hasAuthForProvider(provider, params.cfg, authStore);
|
|
authCache.set(provider, value);
|
|
return value;
|
|
};
|
|
}
|
|
|
|
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 normalizeModelKeys(values: string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const next: string[] = [];
|
|
for (const raw of values) {
|
|
const value = raw.trim();
|
|
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 resolveModelRouteHint(provider: string): string | undefined {
|
|
const normalized = normalizeProviderId(provider);
|
|
if (normalized === "openai") {
|
|
return "API key route";
|
|
}
|
|
if (normalized === "openai-codex") {
|
|
return "ChatGPT OAuth route";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function addModelSelectOption(params: {
|
|
entry: {
|
|
provider: string;
|
|
id: string;
|
|
name?: string;
|
|
contextWindow?: number;
|
|
reasoning?: boolean;
|
|
};
|
|
options: WizardSelectOption[];
|
|
seen: Set<string>;
|
|
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
|
|
hasAuth: (provider: string) => boolean;
|
|
}) {
|
|
const key = modelKey(params.entry.provider, params.entry.id);
|
|
if (
|
|
params.seen.has(key) ||
|
|
HIDDEN_ROUTER_MODELS.has(key) ||
|
|
!isModelPickerVisibleProvider(params.entry.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(params.entry.provider);
|
|
if (routeHint) {
|
|
hints.push(routeHint);
|
|
}
|
|
if (!params.hasAuth(params.entry.provider)) {
|
|
hints.push("auth missing");
|
|
}
|
|
params.options.push({
|
|
value: key,
|
|
label: key,
|
|
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),
|
|
};
|
|
}
|
|
|
|
function addModelKeySelectOption(params: {
|
|
key: string;
|
|
options: WizardSelectOption[];
|
|
seen: Set<string>;
|
|
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
|
|
hasAuth: (provider: string) => boolean;
|
|
fallbackHint: string;
|
|
}) {
|
|
const entry = splitModelKey(params.key);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
const before = params.seen.size;
|
|
addModelSelectOption({
|
|
entry,
|
|
options: params.options,
|
|
seen: params.seen,
|
|
aliasIndex: params.aliasIndex,
|
|
hasAuth: params.hasAuth,
|
|
});
|
|
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 = resolveOwningPluginIdsForProvider({
|
|
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;
|
|
}
|
|
const value =
|
|
!!preferredOwnerPluginIdSet &&
|
|
!!resolveOwningPluginIdsForProvider({
|
|
provider: normalizedEntryProvider,
|
|
config: params.cfg,
|
|
workspaceDir: params.workspaceDir,
|
|
env: params.env,
|
|
})?.some((pluginId) => preferredOwnerPluginIdSet.has(pluginId));
|
|
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 ? "Default model (blank to keep)" : "Default model",
|
|
initialValue: params.initialValue,
|
|
placeholder: "provider/model",
|
|
validate: params.allowBlank
|
|
? undefined
|
|
: (value) => (normalizeOptionalString(value) ? undefined : "Required"),
|
|
});
|
|
const model = (modelInput ?? "").trim();
|
|
if (!model) {
|
|
return {};
|
|
}
|
|
return { model };
|
|
}
|
|
|
|
function buildModelProviderFilterOptions(
|
|
models: Array<{ provider: string }>,
|
|
): Array<{ value: string; label: string; hint: string }> {
|
|
const providerIds = Array.from(new Set(models.map((entry) => entry.provider))).toSorted((a, b) =>
|
|
a.localeCompare(b),
|
|
);
|
|
return providerIds.map((provider) => {
|
|
const count = models.filter((entry) => entry.provider === provider).length;
|
|
return {
|
|
value: provider,
|
|
label: provider,
|
|
hint: `${count} model${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;
|
|
}): Promise<typeof params.models> {
|
|
let next = params.models.filter((entry) => isModelPickerVisibleProvider(entry.provider));
|
|
const providerIds = Array.from(new Set(next.map((entry) => entry.provider))).toSorted((a, b) =>
|
|
a.localeCompare(b),
|
|
);
|
|
const hasPreferredProvider = !!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: "Filter models by provider",
|
|
options: [{ value: "*", label: "All providers" }, ...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(
|
|
"Provider setup requires agent and runtime context.",
|
|
"Provider setup unavailable",
|
|
);
|
|
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 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(cfg);
|
|
const useStaticModelNormalization = !loadCatalog || browseCatalogOnDemand;
|
|
const resolved = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: DEFAULT_PROVIDER,
|
|
defaultModel: DEFAULT_MODEL,
|
|
allowPluginNormalization: useStaticModelNormalization ? false : undefined,
|
|
});
|
|
const resolvedKey = modelKey(resolved.provider, resolved.model);
|
|
const configuredKey = configuredRaw ? resolvedKey : "";
|
|
|
|
if (
|
|
loadCatalog &&
|
|
browseCatalogOnDemand &&
|
|
preferredProvider &&
|
|
allowKeep &&
|
|
normalizeProviderId(resolved.provider) === preferredProvider
|
|
) {
|
|
const options: WizardSelectOption[] = [
|
|
{
|
|
value: KEEP_VALUE,
|
|
label: configuredRaw
|
|
? `Keep current (${configuredRaw})`
|
|
: `Keep current (default: ${resolvedKey})`,
|
|
hint:
|
|
configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined,
|
|
},
|
|
];
|
|
if (includeManual) {
|
|
options.push({ value: MANUAL_VALUE, label: "Enter model manually" });
|
|
}
|
|
options.push({
|
|
value: BROWSE_VALUE,
|
|
label: "Browse all models",
|
|
hint: "loads provider catalogs",
|
|
});
|
|
|
|
const selection = await params.prompter.select({
|
|
message: params.message ?? "Default model",
|
|
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 options: WizardSelectOption[] = [];
|
|
if (allowKeep) {
|
|
options.push({
|
|
value: KEEP_VALUE,
|
|
label: configuredRaw
|
|
? `Keep current (${configuredRaw})`
|
|
: `Keep current (default: ${resolvedKey})`,
|
|
hint:
|
|
configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined,
|
|
});
|
|
}
|
|
if (includeManual) {
|
|
options.push({ value: MANUAL_VALUE, label: "Enter model manually" });
|
|
}
|
|
if (configuredKey && !options.some((option) => option.value === configuredKey)) {
|
|
options.push({
|
|
value: configuredKey,
|
|
label: configuredKey,
|
|
hint: "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 ?? "Default model",
|
|
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("Loading available models");
|
|
let catalog: Awaited<ReturnType<typeof loadModelCatalog>>;
|
|
try {
|
|
catalog = await loadModelCatalog({ config: cfg });
|
|
} finally {
|
|
catalogProgress.stop();
|
|
}
|
|
if (catalog.length === 0) {
|
|
return promptManualModel({
|
|
prompter: params.prompter,
|
|
allowBlank: allowKeep,
|
|
initialValue: configuredRaw || resolvedKey || undefined,
|
|
});
|
|
}
|
|
|
|
const aliasIndex = buildModelAliasIndex({
|
|
cfg,
|
|
defaultProvider: DEFAULT_PROVIDER,
|
|
});
|
|
const models = ignoreAllowlist
|
|
? catalog
|
|
: (() => {
|
|
const { allowedCatalog } = buildAllowedModelSet({
|
|
cfg,
|
|
catalog,
|
|
defaultProvider: DEFAULT_PROVIDER,
|
|
});
|
|
return allowedCatalog.length > 0 ? allowedCatalog : catalog;
|
|
})();
|
|
if (models.length === 0) {
|
|
return promptManualModel({
|
|
prompter: params.prompter,
|
|
allowBlank: allowKeep,
|
|
initialValue: configuredRaw || resolvedKey || undefined,
|
|
});
|
|
}
|
|
|
|
const filteredModels = await maybeFilterModelsByProvider({
|
|
models,
|
|
preferredProvider,
|
|
prompter: params.prompter,
|
|
cfg,
|
|
workspaceDir: params.workspaceDir,
|
|
env: params.env,
|
|
});
|
|
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 hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir });
|
|
|
|
const options: WizardSelectOption[] = [];
|
|
if (allowKeep) {
|
|
options.push({
|
|
value: KEEP_VALUE,
|
|
label: configuredRaw
|
|
? `Keep current (${configuredRaw})`
|
|
: `Keep current (default: ${resolvedKey})`,
|
|
hint:
|
|
configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined,
|
|
});
|
|
}
|
|
if (includeManual) {
|
|
options.push({ value: MANUAL_VALUE, label: "Enter model manually" });
|
|
}
|
|
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) {
|
|
addModelSelectOption({ entry, options, seen, aliasIndex, hasAuth });
|
|
}
|
|
if (configuredKey && !seen.has(configuredKey)) {
|
|
options.push({
|
|
value: configuredKey,
|
|
label: configuredKey,
|
|
hint: "current (not in catalog)",
|
|
});
|
|
}
|
|
|
|
let initialValue: string | undefined = allowKeep ? KEEP_VALUE : configuredKey || undefined;
|
|
if (
|
|
allowKeep &&
|
|
hasPreferredProvider &&
|
|
preferredProvider &&
|
|
!matchesPreferredProvider?.(resolved.provider)
|
|
) {
|
|
const firstModel = filteredModels[0];
|
|
if (firstModel) {
|
|
initialValue = modelKey(firstModel.provider, firstModel.id);
|
|
}
|
|
}
|
|
|
|
const selection = await params.prompter.select({
|
|
message: params.message ?? "Default model",
|
|
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 = 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;
|
|
allowedKeys?: string[];
|
|
initialSelections?: string[];
|
|
preferredProvider?: string;
|
|
}): Promise<PromptModelAllowlistResult> {
|
|
const cfg = params.config;
|
|
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, agentDir: params.agentDir });
|
|
const matchesPreferredProvider = preferredProvider
|
|
? createPreferredProviderMatcher({
|
|
preferredProvider,
|
|
cfg,
|
|
})
|
|
: undefined;
|
|
|
|
const scopedFastKeys =
|
|
allowedKeys.length > 0
|
|
? allowedKeys
|
|
: preferredProvider && hasRealSeed
|
|
? initialSeeds.filter((key) => {
|
|
const entry = splitModelKey(key);
|
|
return entry ? matchesPreferredProvider?.(entry.provider) === true : false;
|
|
})
|
|
: [];
|
|
if (scopedFastKeys.length > 0) {
|
|
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) {
|
|
addModelKeySelectOption({
|
|
key,
|
|
options,
|
|
seen,
|
|
aliasIndex,
|
|
hasAuth,
|
|
fallbackHint: allowedKeys.length > 0 ? "allowed" : "configured",
|
|
});
|
|
}
|
|
if (options.length === 0) {
|
|
return {};
|
|
}
|
|
const selection = await params.prompter.multiselect({
|
|
message: params.message ?? "Models in /model picker (multi-select)",
|
|
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: "Remove these provider models from the /model picker?",
|
|
initialValue: false,
|
|
});
|
|
if (!confirmScopedClear) {
|
|
return {};
|
|
}
|
|
return { models: [], scopeKeys };
|
|
}
|
|
|
|
const allowlistProgress = params.prompter.progress("Loading available models");
|
|
let catalog: Awaited<ReturnType<typeof loadModelCatalog>>;
|
|
try {
|
|
catalog = await loadModelCatalog({ config: cfg });
|
|
} finally {
|
|
allowlistProgress.stop();
|
|
}
|
|
if (catalog.length === 0 && allowedKeys.length === 0) {
|
|
const noCatalogInitialKeys =
|
|
existingKeys.length > 0 ? normalizeModelKeys([...existingKeys, ...fallbackKeys]) : [];
|
|
const raw = await params.prompter.text({
|
|
message:
|
|
params.message ??
|
|
"Allowlist models (comma-separated provider/model; blank to keep current)",
|
|
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 options: WizardSelectOption[] = [];
|
|
const seen = new Set<string>();
|
|
const allowedCatalog = (
|
|
allowedKeySet
|
|
? catalog.filter((entry) => allowedKeySet.has(modelKey(entry.provider, entry.id)))
|
|
: catalog
|
|
).filter((entry) => isModelPickerVisibleProvider(entry.provider));
|
|
const filteredCatalog =
|
|
preferredProvider && allowedCatalog.some((entry) => matchesPreferredProvider?.(entry.provider))
|
|
? allowedCatalog.filter((entry) => matchesPreferredProvider?.(entry.provider))
|
|
: allowedCatalog;
|
|
|
|
const scopeKeys = allowedKeySet
|
|
? allowedKeys
|
|
: preferredProvider
|
|
? filteredCatalog.map((entry) => modelKey(entry.provider, entry.id))
|
|
: 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(isModelPickerVisibleModelRef);
|
|
|
|
for (const entry of filteredCatalog) {
|
|
addModelSelectOption({ entry, options, seen, aliasIndex, hasAuth });
|
|
}
|
|
|
|
const supplementalKeys = (allowedKeySet ? allowedKeys : selectableInitialSeeds).filter(
|
|
isModelPickerVisibleModelRef,
|
|
);
|
|
for (const key of supplementalKeys) {
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
options.push({
|
|
value: key,
|
|
label: key,
|
|
hint: allowedKeySet ? "allowed (not in catalog)" : "configured (not in catalog)",
|
|
});
|
|
seen.add(key);
|
|
}
|
|
if (options.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
const selection = await params.prompter.multiselect({
|
|
message: params.message ?? "Models in /model picker (multi-select)",
|
|
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: "Remove these provider models from the /model picker?",
|
|
initialValue: false,
|
|
});
|
|
if (!confirmScopedClear) {
|
|
return {};
|
|
}
|
|
return { models: [], scopeKeys };
|
|
}
|
|
if (existingKeys.length === 0) {
|
|
return { models: [] };
|
|
}
|
|
const confirmClear = await params.prompter.confirm({
|
|
message: "Clear the model allowlist? (shows all models)",
|
|
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;
|
|
if (normalized.length === 0) {
|
|
if (!defaults?.models) {
|
|
return cfg;
|
|
}
|
|
if (scopeKeySet) {
|
|
const nextModels = { ...defaults.models };
|
|
for (const key of scopeKeySet) {
|
|
delete nextModels[key];
|
|
}
|
|
const { models: _ignored, ...restDefaults } = defaults;
|
|
return {
|
|
...cfg,
|
|
agents: {
|
|
...cfg.agents,
|
|
defaults:
|
|
Object.keys(nextModels).length > 0 ? { ...defaults, models: nextModels } : restDefaults,
|
|
},
|
|
};
|
|
}
|
|
const { models: _ignored, ...restDefaults } = defaults;
|
|
return {
|
|
...cfg,
|
|
agents: {
|
|
...cfg.agents,
|
|
defaults: restDefaults,
|
|
},
|
|
};
|
|
}
|
|
|
|
const existingModels = defaults?.models ?? {};
|
|
if (scopeKeySet) {
|
|
const nextModels = { ...existingModels };
|
|
for (const key of scopeKeySet) {
|
|
delete nextModels[key];
|
|
}
|
|
for (const key of normalized) {
|
|
nextModels[key] = existingModels[key] ?? {};
|
|
}
|
|
return {
|
|
...cfg,
|
|
agents: {
|
|
...cfg.agents,
|
|
defaults: {
|
|
...defaults,
|
|
models: nextModels,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
const nextModels: Record<string, { alias?: string }> = {};
|
|
for (const key of normalized) {
|
|
nextModels[key] = existingModels[key] ?? {};
|
|
}
|
|
|
|
return {
|
|
...cfg,
|
|
agents: {
|
|
...cfg.agents,
|
|
defaults: {
|
|
...defaults,
|
|
models: nextModels,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
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 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 preserveExistingFallback = scopeKeySet
|
|
? (fallback: string) => !scopeKeySet.has(fallback)
|
|
: (fallback: string) => !isModelPickerVisibleModelRef(fallback);
|
|
const fallbacks = mergeFallbackSelection({
|
|
existingFallbacks,
|
|
selectedFallbacks,
|
|
preserveExistingFallback,
|
|
});
|
|
const nextModel = {
|
|
...preservedModelFields,
|
|
...(existingPrimary != null ? { primary: existingPrimary } : {}),
|
|
...(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;
|
|
}
|