| import { loadModelCatalog } from "../../agents/model-catalog.js"; |
| import { |
| buildAllowedModelSet, |
| buildModelAliasIndex, |
| normalizeProviderId, |
| resolveConfiguredModelRef, |
| resolveModelRefFromString, |
| } from "../../agents/model-selection.js"; |
| import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; |
| import type { OpenClawConfig } from "../../config/config.js"; |
| import type { ReplyPayload } from "../types.js"; |
| import type { CommandHandler } from "./commands-types.js"; |
|
|
| const PAGE_SIZE_DEFAULT = 20; |
| const PAGE_SIZE_MAX = 100; |
|
|
| function formatProviderLine(params: { provider: string; count: number }): string { |
| return `- ${params.provider} (${params.count})`; |
| } |
|
|
| function parseModelsArgs(raw: string): { |
| provider?: string; |
| page: number; |
| pageSize: number; |
| all: boolean; |
| } { |
| const trimmed = raw.trim(); |
| if (!trimmed) { |
| return { page: 1, pageSize: PAGE_SIZE_DEFAULT, all: false }; |
| } |
|
|
| const tokens = trimmed.split(/\s+/g).filter(Boolean); |
| const provider = tokens[0]?.trim(); |
|
|
| let page = 1; |
| let all = false; |
| for (const token of tokens.slice(1)) { |
| const lower = token.toLowerCase(); |
| 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 = token.toLowerCase(); |
| 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 { |
| provider: provider ? normalizeProviderId(provider) : undefined, |
| page, |
| pageSize, |
| all, |
| }; |
| } |
|
|
| export async function resolveModelsCommandReply(params: { |
| cfg: OpenClawConfig; |
| commandBodyNormalized: string; |
| }): Promise<ReplyPayload | null> { |
| const body = params.commandBodyNormalized.trim(); |
| if (!body.startsWith("/models")) { |
| return null; |
| } |
|
|
| const argText = body.replace(/^\/models\b/i, "").trim(); |
| const { provider, page, pageSize, all } = parseModelsArgs(argText); |
|
|
| const resolvedDefault = resolveConfiguredModelRef({ |
| cfg: params.cfg, |
| defaultProvider: DEFAULT_PROVIDER, |
| defaultModel: DEFAULT_MODEL, |
| }); |
|
|
| const catalog = await loadModelCatalog({ config: params.cfg }); |
| const allowed = buildAllowedModelSet({ |
| cfg: params.cfg, |
| catalog, |
| defaultProvider: resolvedDefault.provider, |
| defaultModel: resolvedDefault.model, |
| }); |
|
|
| const aliasIndex = buildModelAliasIndex({ |
| cfg: params.cfg, |
| defaultProvider: resolvedDefault.provider, |
| }); |
|
|
| const byProvider = new Map<string, Set<string>>(); |
| const add = (p: string, m: string) => { |
| const key = normalizeProviderId(p); |
| const set = byProvider.get(key) ?? new Set<string>(); |
| set.add(m); |
| byProvider.set(key, set); |
| }; |
|
|
| const addRawModelRef = (raw?: string) => { |
| const trimmed = raw?.trim(); |
| if (!trimmed) { |
| return; |
| } |
| const resolved = resolveModelRefFromString({ |
| raw: trimmed, |
| defaultProvider: resolvedDefault.provider, |
| aliasIndex, |
| }); |
| if (!resolved) { |
| return; |
| } |
| add(resolved.ref.provider, resolved.ref.model); |
| }; |
|
|
| const addModelConfigEntries = () => { |
| const modelConfig = params.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 = params.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(params.cfg.agents?.defaults?.models ?? {})) { |
| addRawModelRef(raw); |
| } |
|
|
| |
| |
| add(resolvedDefault.provider, resolvedDefault.model); |
| addModelConfigEntries(); |
|
|
| const providers = [...byProvider.keys()].toSorted(); |
|
|
| if (!provider) { |
| const lines: string[] = [ |
| "Providers:", |
| ...providers.map((p) => |
| formatProviderLine({ provider: p, count: byProvider.get(p)?.size ?? 0 }), |
| ), |
| "", |
| "Use: /models <provider>", |
| "Switch: /model <provider/model>", |
| ]; |
| return { text: lines.join("\n") }; |
| } |
|
|
| if (!byProvider.has(provider)) { |
| const lines: string[] = [ |
| `Unknown provider: ${provider}`, |
| "", |
| "Available providers:", |
| ...providers.map((p) => `- ${p}`), |
| "", |
| "Use: /models <provider>", |
| ]; |
| return { text: lines.join("\n") }; |
| } |
|
|
| const models = [...(byProvider.get(provider) ?? new Set<string>())].toSorted(); |
| const total = models.length; |
|
|
| if (total === 0) { |
| const lines: string[] = [ |
| `Models (${provider}) — none`, |
| "", |
| "Browse: /models", |
| "Switch: /model <provider/model>", |
| ]; |
| return { text: lines.join("\n") }; |
| } |
|
|
| 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) { |
| const lines: string[] = [ |
| `Page out of range: ${page} (valid: 1-${pageCount})`, |
| "", |
| `Try: /models ${provider} ${safePage}`, |
| `All: /models ${provider} all`, |
| ]; |
| return { text: lines.join("\n") }; |
| } |
|
|
| const startIndex = (safePage - 1) * effectivePageSize; |
| const endIndexExclusive = Math.min(total, startIndex + effectivePageSize); |
| const pageModels = models.slice(startIndex, endIndexExclusive); |
|
|
| const header = `Models (${provider}) — showing ${startIndex + 1}-${endIndexExclusive} of ${total} (page ${safePage}/${pageCount})`; |
|
|
| const lines: string[] = [header]; |
| for (const id of pageModels) { |
| lines.push(`- ${provider}/${id}`); |
| } |
|
|
| lines.push("", "Switch: /model <provider/model>"); |
| if (!all && safePage < pageCount) { |
| lines.push(`More: /models ${provider} ${safePage + 1}`); |
| } |
| if (!all) { |
| lines.push(`All: /models ${provider} all`); |
| } |
|
|
| const payload: ReplyPayload = { text: lines.join("\n") }; |
| return payload; |
| } |
|
|
| export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => { |
| if (!allowTextCommands) { |
| return null; |
| } |
|
|
| const reply = await resolveModelsCommandReply({ |
| cfg: params.cfg, |
| commandBodyNormalized: params.command.commandBodyNormalized, |
| }); |
| if (!reply) { |
| return null; |
| } |
| return { reply, shouldContinue: false }; |
| }; |
|
|