Files
openclaw/src/auto-reply/reply/commands-models.ts
Vincent Koc aa27e27f36 fix(models): normalize provider runtime selection (#71259)
* fix(models): normalize provider runtime selection

* fix(models): reverse codex-only runtime migration

* fix(models): default runtime selection to pi

* fix(status): label model runtime clearly

* fix(status): align pi runtime label

* fix(plugins): align tool result middleware runtime naming

* fix(models): validate runtime overrides
2026-04-24 16:56:49 -07:00

530 lines
15 KiB
TypeScript

import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveModelAuthLabel } from "../../agents/model-auth-label.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { isModelPickerVisibleProvider } from "../../agents/model-picker-visibility.js";
import { listLegacyRuntimeModelProviderAliases } from "../../agents/model-runtime-aliases.js";
import {
buildAllowedModelSet,
buildModelAliasIndex,
normalizeProviderId,
resolveBareModelDefaultProvider,
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import type { ReplyPayload } from "../types.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";
const PAGE_SIZE_DEFAULT = 20;
const PAGE_SIZE_MAX = 100;
const MODELS_ADD_DEPRECATED_TEXT =
"⚠️ /models add is deprecated. Use /models to browse providers and /model to switch models.";
type ModelsCommandSessionEntry = Partial<
Pick<SessionEntry, "authProfileOverride" | "modelProvider" | "model">
>;
export type ModelsProviderData = {
byProvider: Map<string, Set<string>>;
providers: string[];
resolvedDefault: { provider: string; model: string };
modelNames: Map<string, string>;
runtimeChoicesByProvider?: Map<string, ModelsRuntimeChoice[]>;
};
export type ModelsRuntimeChoice = {
id: string;
label: string;
description: string;
};
type ParsedModelsCommand =
| { action: "providers" }
| {
action: "list";
provider?: string;
page: number;
pageSize: number;
all: boolean;
}
| {
action: "add";
provider?: string;
modelId?: string;
};
export async function buildModelsProviderData(
cfg: OpenClawConfig,
agentId?: string,
): Promise<ModelsProviderData> {
const resolvedDefault = resolveDefaultModelForAgent({
cfg,
agentId,
});
const catalog = await loadModelCatalog({ config: cfg });
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
agentId,
});
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: resolvedDefault.provider,
});
const byProvider = new Map<string, Set<string>>();
const add = (p: string, m: string) => {
const key = normalizeProviderId(p);
if (!isModelPickerVisibleProvider(key)) {
return;
}
const set = byProvider.get(key) ?? new Set<string>();
set.add(m);
byProvider.set(key, set);
};
const addRawModelRef = (raw?: string) => {
const trimmed = normalizeOptionalString(raw);
if (!trimmed) {
return;
}
const defaultProvider = !trimmed.includes("/")
? resolveBareModelDefaultProvider({
cfg,
catalog,
model: trimmed,
defaultProvider: resolvedDefault.provider,
})
: resolvedDefault.provider;
const resolved = resolveModelRefFromString({
raw: trimmed,
defaultProvider,
aliasIndex,
});
if (!resolved) {
return;
}
add(resolved.ref.provider, resolved.ref.model);
};
const addModelConfigEntries = () => {
const modelConfig = cfg.agents?.defaults?.model;
if (typeof modelConfig === "string") {
addRawModelRef(modelConfig);
} else if (modelConfig && typeof modelConfig === "object") {
addRawModelRef(modelConfig.primary);
for (const fallback of modelConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
const imageConfig = cfg.agents?.defaults?.imageModel;
if (typeof imageConfig === "string") {
addRawModelRef(imageConfig);
} else if (imageConfig && typeof imageConfig === "object") {
addRawModelRef(imageConfig.primary);
for (const fallback of imageConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
};
for (const entry of allowed.allowedCatalog) {
add(entry.provider, entry.id);
}
for (const raw of Object.keys(cfg.agents?.defaults?.models ?? {})) {
addRawModelRef(raw);
}
add(resolvedDefault.provider, resolvedDefault.model);
addModelConfigEntries();
const providers = [...byProvider.keys()].toSorted();
const modelNames = new Map<string, string>();
for (const entry of catalog) {
if (entry.name && entry.name !== entry.id) {
modelNames.set(`${normalizeProviderId(entry.provider)}/${entry.id}`, entry.name);
}
}
const runtimeChoicesByProvider = new Map<string, ModelsRuntimeChoice[]>();
for (const alias of listLegacyRuntimeModelProviderAliases()) {
const provider = normalizeProviderId(alias.provider);
const choices = runtimeChoicesByProvider.get(provider) ?? [
{
id: "pi",
label: "OpenClaw Pi Default",
description: "Use the built-in OpenClaw Pi runtime.",
},
];
choices.push({
id: alias.runtime,
label: alias.runtime,
description: alias.cli
? `Run ${provider} models through ${alias.runtime}.`
: `Run ${provider} models through the ${alias.runtime} harness.`,
});
runtimeChoicesByProvider.set(provider, choices);
}
return { byProvider, providers, resolvedDefault, modelNames, runtimeChoicesByProvider };
}
function formatProviderLine(params: { provider: string; count: number }): string {
return `- ${params.provider} (${params.count})`;
}
function parseListArgs(tokens: string[]): Extract<ParsedModelsCommand, { action: "list" }> {
const provider = normalizeOptionalString(tokens[0]);
let page = 1;
let all = false;
for (const token of tokens.slice(1)) {
const lower = normalizeLowercaseStringOrEmpty(token);
if (lower === "all" || lower === "--all") {
all = true;
continue;
}
if (lower.startsWith("page=")) {
const value = Number.parseInt(lower.slice("page=".length), 10);
if (Number.isFinite(value) && value > 0) {
page = value;
}
continue;
}
if (/^[0-9]+$/.test(lower)) {
const value = Number.parseInt(lower, 10);
if (Number.isFinite(value) && value > 0) {
page = value;
}
}
}
let pageSize = PAGE_SIZE_DEFAULT;
for (const token of tokens) {
const lower = normalizeLowercaseStringOrEmpty(token);
if (lower.startsWith("limit=") || lower.startsWith("size=")) {
const rawValue = lower.slice(lower.indexOf("=") + 1);
const value = Number.parseInt(rawValue, 10);
if (Number.isFinite(value) && value > 0) {
pageSize = Math.min(PAGE_SIZE_MAX, value);
}
}
}
return {
action: "list",
provider: provider ? normalizeProviderId(provider) : undefined,
page,
pageSize,
all,
};
}
function parseModelsArgs(raw: string): ParsedModelsCommand {
const trimmed = raw.trim();
if (!trimmed) {
return { action: "providers" };
}
const tokens = trimmed.split(/\s+/g).filter(Boolean);
const first = normalizeLowercaseStringOrEmpty(tokens[0]);
switch (first) {
case "providers":
return { action: "providers" };
case "list":
return parseListArgs(tokens.slice(1));
case "add":
return {
action: "add",
provider: normalizeOptionalString(tokens[1]),
modelId: normalizeOptionalString(tokens.slice(2).join(" ")),
};
default:
return parseListArgs(tokens);
}
}
function resolveProviderLabel(params: {
provider: string;
cfg: OpenClawConfig;
agentDir?: string;
sessionEntry?: ModelsCommandSessionEntry;
}): string {
const authLabel = resolveModelAuthLabel({
provider: params.provider,
cfg: params.cfg,
sessionEntry: params.sessionEntry,
agentDir: params.agentDir,
});
if (!authLabel || authLabel === "unknown") {
return params.provider;
}
return `${params.provider} · 🔑 ${authLabel}`;
}
export function formatModelsAvailableHeader(params: {
provider: string;
total: number;
cfg: OpenClawConfig;
agentDir?: string;
sessionEntry?: ModelsCommandSessionEntry;
}): string {
const providerLabel = resolveProviderLabel({
provider: params.provider,
cfg: params.cfg,
agentDir: params.agentDir,
sessionEntry: params.sessionEntry,
});
return `Models (${providerLabel}) — ${params.total} available`;
}
function buildModelsMenuText(params: {
providers: string[];
byProvider: ReadonlyMap<string, ReadonlySet<string>>;
}): string {
return [
"Providers:",
...params.providers.map((provider) =>
formatProviderLine({
provider,
count: params.byProvider.get(provider)?.size ?? 0,
}),
),
"",
"Use: /models <provider>",
"Switch: /model <provider/model>",
].join("\n");
}
function buildProviderInfos(params: {
providers: string[];
byProvider: ReadonlyMap<string, ReadonlySet<string>>;
}): Array<{ id: string; count: number }> {
return params.providers.map((provider) => ({
id: provider,
count: params.byProvider.get(provider)?.size ?? 0,
}));
}
export async function resolveModelsCommandReply(params: {
cfg: OpenClawConfig;
commandBodyNormalized: string;
surface?: string;
currentModel?: string;
agentId?: string;
agentDir?: string;
sessionEntry?: ModelsCommandSessionEntry;
}): Promise<ReplyPayload | null> {
const body = params.commandBodyNormalized.trim();
if (!body.startsWith("/models")) {
return null;
}
const argText = body.replace(/^\/models\b/i, "").trim();
const parsed = parseModelsArgs(argText);
const { byProvider, providers, modelNames } = await buildModelsProviderData(
params.cfg,
params.agentId,
);
const commandPlugin = params.surface ? getChannelPlugin(params.surface) : null;
const providerInfos = buildProviderInfos({ providers, byProvider });
if (parsed.action === "providers") {
const channelData =
commandPlugin?.commands?.buildModelsMenuChannelData?.({
providers: providerInfos,
}) ??
commandPlugin?.commands?.buildModelsProviderChannelData?.({
providers: providerInfos,
});
if (channelData) {
return {
text: "Select a provider:",
channelData,
};
}
return {
text: buildModelsMenuText({ providers, byProvider }),
};
}
if (parsed.action === "add") {
return { text: MODELS_ADD_DEPRECATED_TEXT };
}
const { provider, page, pageSize, all } = parsed;
if (!provider) {
const channelData = commandPlugin?.commands?.buildModelsProviderChannelData?.({
providers: providerInfos,
});
if (channelData) {
return {
text: "Select a provider:",
channelData,
};
}
return {
text: buildModelsMenuText({ providers, byProvider }),
};
}
if (!byProvider.has(provider)) {
return {
text: [
`Unknown provider: ${provider}`,
"",
"Available providers:",
...providers.map((entry) => `- ${entry}`),
"",
"Use: /models <provider>",
].join("\n"),
};
}
const models = [...(byProvider.get(provider) ?? new Set<string>())].toSorted();
const total = models.length;
if (total === 0) {
const emptyProviderLabel = resolveProviderLabel({
provider,
cfg: params.cfg,
agentDir: params.agentDir,
sessionEntry: params.sessionEntry,
});
return {
text: [
`Models (${emptyProviderLabel}) — none`,
"",
"Browse: /models",
"Switch: /model <provider/model>",
].join("\n"),
};
}
const interactivePageSize = 8;
const interactiveTotalPages = Math.max(1, Math.ceil(total / interactivePageSize));
const interactivePage = Math.max(1, Math.min(page, interactiveTotalPages));
const interactiveChannelData = commandPlugin?.commands?.buildModelsListChannelData?.({
provider,
models,
currentModel: params.currentModel,
currentPage: interactivePage,
totalPages: interactiveTotalPages,
pageSize: interactivePageSize,
modelNames,
});
if (interactiveChannelData) {
return {
text: formatModelsAvailableHeader({
provider,
total,
cfg: params.cfg,
agentDir: params.agentDir,
sessionEntry: params.sessionEntry,
}),
channelData: interactiveChannelData,
};
}
const effectivePageSize = all ? total : pageSize;
const pageCount = effectivePageSize > 0 ? Math.ceil(total / effectivePageSize) : 1;
const safePage = all ? 1 : Math.max(1, Math.min(page, pageCount));
if (!all && page !== safePage) {
return {
text: [
`Page out of range: ${page} (valid: 1-${pageCount})`,
"",
`Try: /models list ${provider} ${safePage}`,
`All: /models list ${provider} all`,
].join("\n"),
};
}
const startIndex = (safePage - 1) * effectivePageSize;
const endIndexExclusive = Math.min(total, startIndex + effectivePageSize);
const pageModels = models.slice(startIndex, endIndexExclusive);
const providerLabel = resolveProviderLabel({
provider,
cfg: params.cfg,
agentDir: params.agentDir,
sessionEntry: params.sessionEntry,
});
const lines = [
`Models (${providerLabel}) — showing ${startIndex + 1}-${endIndexExclusive} of ${total} (page ${safePage}/${pageCount})`,
];
for (const id of pageModels) {
lines.push(`- ${provider}/${id}`);
}
lines.push("", "Switch: /model <provider/model>");
if (!all && safePage < pageCount) {
lines.push(`More: /models list ${provider} ${safePage + 1}`);
}
if (!all) {
lines.push(`All: /models list ${provider} all`);
}
return { text: lines.join("\n") };
}
export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const commandBodyNormalized = params.command.commandBodyNormalized.trim();
if (!commandBodyNormalized.startsWith("/models")) {
return null;
}
const parsed = parseModelsArgs(commandBodyNormalized.replace(/^\/models\b/i, "").trim());
const unauthorized = rejectUnauthorizedCommand(params, "/models");
if (unauthorized) {
return unauthorized;
}
if (parsed.action === "add") {
return { shouldContinue: false, reply: { text: MODELS_ADD_DEPRECATED_TEXT } };
}
const modelsAgentId = params.sessionKey
? resolveSessionAgentId({
sessionKey: params.sessionKey,
config: params.cfg,
})
: (params.agentId ?? "main");
const currentAgentId = params.agentId ?? "main";
const modelsAgentDir =
modelsAgentId === currentAgentId && params.agentDir
? params.agentDir
: resolveAgentDir(params.cfg, modelsAgentId);
const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry;
const reply = await resolveModelsCommandReply({
cfg: params.cfg,
commandBodyNormalized,
surface: params.ctx.Surface,
currentModel: params.model ? `${params.provider}/${params.model}` : undefined,
agentId: modelsAgentId,
agentDir: modelsAgentDir,
sessionEntry: targetSessionEntry,
});
if (!reply) {
return null;
}
return { reply, shouldContinue: false };
};