Spaces:
Paused
Paused
| import type { OpenClawConfig } from "../../config/config.js"; | |
| import type { ReplyPayload } from "../types.js"; | |
| import type { CommandHandler } from "./commands-types.js"; | |
| import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; | |
| import { loadModelCatalog } from "../../agents/model-catalog.js"; | |
| import { | |
| buildAllowedModelSet, | |
| buildModelAliasIndex, | |
| normalizeProviderId, | |
| resolveConfiguredModelRef, | |
| resolveModelRefFromString, | |
| } from "../../agents/model-selection.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); | |
| } | |
| // Include config-only allowlist keys that aren't in the curated catalog. | |
| for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) { | |
| addRawModelRef(raw); | |
| } | |
| // Ensure configured defaults/fallbacks/image models show up even when the | |
| // curated catalog doesn't know about them (custom providers, dev builds, etc.). | |
| 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 }; | |
| }; | |