diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 691bdd36e..4ce176b1d 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -429,6 +429,14 @@ function buildChatCommands(): ChatCommandDefinition[] { }, ], }), + defineChatCommand({ + key: "models", + nativeName: "models", + description: "List model providers or provider models.", + textAlias: "/models", + argsParsing: "none", + acceptsArgs: true, + }), defineChatCommand({ key: "queue", nativeName: "queue", @@ -485,7 +493,6 @@ function buildChatCommands(): ChatCommandDefinition[] { registerAlias(commands, "verbose", "/v"); registerAlias(commands, "reasoning", "/reason"); registerAlias(commands, "elevated", "/elev"); - registerAlias(commands, "model", "/models"); assertCommandRegistry(commands); return commands; diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 54fb558bd..4296e06cd 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -25,6 +25,7 @@ describe("commands registry", () => { it("builds command text with args", () => { expect(buildCommandText("status")).toBe("/status"); expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5"); + expect(buildCommandText("models")).toBe("/models"); }); it("exposes native specs", () => { diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 9abe5e677..f974dec74 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -15,6 +15,7 @@ import { } from "./commands-info.js"; import { handleAllowlistCommand } from "./commands-allowlist.js"; import { handleSubagentsCommand } from "./commands-subagents.js"; +import { handleModelsCommand } from "./commands-models.js"; import { handleAbortTrigger, handleActivationCommand, @@ -44,6 +45,7 @@ const HANDLERS: CommandHandler[] = [ handleSubagentsCommand, handleConfigCommand, handleDebugCommand, + handleModelsCommand, handleStopCommand, handleCompactCommand, handleAbortTrigger, diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts new file mode 100644 index 000000000..0dd321399 --- /dev/null +++ b/src/auto-reply/reply/commands-models.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import type { MsgContext } from "../templating.js"; +import { buildCommandContext, handleCommands } from "./commands.js"; +import { parseInlineDirectives } from "./directive-handling.js"; + +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(async () => [ + { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" }, + { provider: "openai", id: "gpt-4.1", name: "GPT-4.1" }, + { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, + { provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" }, + ]), +})); + +function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { + const ctx = { + Body: commandBody, + CommandBody: commandBody, + CommandSource: "text", + CommandAuthorized: true, + Provider: "telegram", + Surface: "telegram", + ...ctxOverrides, + } as MsgContext; + + const command = buildCommandContext({ + ctx, + cfg, + isGroup: false, + triggerBodyNormalized: commandBody.trim().toLowerCase(), + commandAuthorized: true, + }); + + return { + ctx, + cfg, + command, + directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, + sessionKey: "agent:main:main", + workspaceDir: "/tmp", + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off" as const, + resolvedReasoningLevel: "off" as const, + resolveDefaultThinkingLevel: async () => undefined, + provider: "anthropic", + model: "claude-opus-4-5", + contextTokens: 16000, + isGroup: false, + }; +} + +describe("/models command", () => { + const cfg = { + commands: { text: true }, + // allowlist is empty => allowAny, but still okay for listing + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, + } as unknown as ClawdbotConfig; + + it.each(["telegram", "discord", "whatsapp"])("lists providers on %s", async (surface) => { + const params = buildParams("/models", cfg, { Provider: surface, Surface: surface }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Providers:"); + expect(result.reply?.text).toContain("anthropic"); + expect(result.reply?.text).toContain("Use: /models "); + }); + + it("lists provider models with pagination hints", async () => { + const params = buildParams("/models anthropic", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Models (anthropic)"); + expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); + expect(result.reply?.text).toContain("Switch: /model "); + expect(result.reply?.text).toContain("All: /models anthropic all"); + }); + + it("handles unknown providers", async () => { + const params = buildParams("/models not-a-provider", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Unknown provider"); + expect(result.reply?.text).toContain("Available providers"); + }); +}); diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts new file mode 100644 index 000000000..fbc5560f5 --- /dev/null +++ b/src/auto-reply/reply/commands-models.ts @@ -0,0 +1,170 @@ +import { loadModelCatalog } from "../../agents/model-catalog.js"; +import { + buildAllowedModelSet, + normalizeProviderId, + resolveConfiguredModelRef, +} from "../../agents/model-selection.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.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 const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) return null; + + const body = params.command.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 byProvider = new Map>(); + const add = (p: string, m: string) => { + const key = normalizeProviderId(p); + const set = byProvider.get(key) ?? new Set(); + set.add(m); + byProvider.set(key, set); + }; + + 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 ?? {})) { + const rawKey = String(raw ?? "").trim(); + if (!rawKey) continue; + const slash = rawKey.indexOf("/"); + if (slash === -1) continue; + const p = normalizeProviderId(rawKey.slice(0, slash)); + const m = rawKey.slice(slash + 1).trim(); + if (!p || !m) continue; + add(p, m); + } + + const providers = [...byProvider.keys()].sort(); + + if (!provider) { + const lines: string[] = [ + "Providers:", + ...providers.map((p) => + formatProviderLine({ provider: p, count: byProvider.get(p)?.size ?? 0 }), + ), + "", + "Use: /models ", + "Switch: /model ", + ]; + return { reply: { text: lines.join("\n") }, shouldContinue: false }; + } + + if (!byProvider.has(provider)) { + const lines: string[] = [ + `Unknown provider: ${provider}`, + "", + "Available providers:", + ...providers.map((p) => `- ${p}`), + "", + "Use: /models ", + ]; + return { reply: { text: lines.join("\n") }, shouldContinue: false }; + } + + const models = [...(byProvider.get(provider) ?? new Set())].sort(); + const total = models.length; + + const effectivePageSize = all ? total : pageSize; + const startIndex = (page - 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}`; + + const lines: string[] = [header]; + for (const id of pageModels) { + lines.push(`- ${provider}/${id}`); + } + + const pageCount = effectivePageSize > 0 ? Math.ceil(total / effectivePageSize) : 1; + + lines.push("", "Switch: /model "); + if (!all && page < pageCount) { + lines.push(`More: /models ${provider} ${page + 1}`); + } + if (!all) { + lines.push(`All: /models ${provider} all`); + } + + const payload: ReplyPayload = { text: lines.join("\n") }; + return { reply: payload, shouldContinue: false }; +}; diff --git a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts b/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts new file mode 100644 index 000000000..1e8b2dc7b --- /dev/null +++ b/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import type { ModelAliasIndex } from "../../agents/model-selection.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { parseInlineDirectives } from "./directive-handling.js"; +import { + maybeHandleModelDirectiveInfo, + resolveModelSelectionFromDirective, +} from "./directive-handling.model.js"; + +function baseAliasIndex(): ModelAliasIndex { + return { byAlias: new Map(), byKey: new Map() }; +} + +describe("/model chat UX", () => { + it("shows summary for /model with no args", async () => { + const directives = parseInlineDirectives("/model"); + const cfg = { commands: { text: true } } as unknown as ClawdbotConfig; + + const reply = await maybeHandleModelDirectiveInfo({ + directives, + cfg, + agentDir: "/tmp/agent", + activeAgentId: "main", + provider: "anthropic", + model: "claude-opus-4-5", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelCatalog: [], + resetModelOverride: false, + }); + + expect(reply?.text).toContain("Current:"); + expect(reply?.text).toContain("Browse: /models"); + expect(reply?.text).toContain("Switch: /model "); + }); + + it("suggests closest match for typos without switching", () => { + const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5"); + const cfg = { commands: { text: true } } as unknown as ClawdbotConfig; + + const resolved = resolveModelSelectionFromDirective({ + directives, + cfg, + agentDir: "/tmp/agent", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]), + allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }], + provider: "anthropic", + }); + + expect(resolved.modelSelection).toBeUndefined(); + expect(resolved.errorText).toContain("Did you mean:"); + expect(resolved.errorText).toContain("anthropic/claude-opus-4-5"); + expect(resolved.errorText).toContain("Try: /model anthropic/claude-opus-4-5"); + }); +}); diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 3f4200222..6cfef7828 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -169,8 +169,9 @@ export async function maybeHandleModelDirectiveInfo(params: { const rawDirective = params.directives.rawModelDirective?.trim(); const directive = rawDirective?.toLowerCase(); const wantsStatus = directive === "status"; - const wantsList = !rawDirective || directive === "list"; - if (!wantsList && !wantsStatus) return undefined; + const wantsSummary = !rawDirective; + const wantsLegacyList = directive === "list"; + if (!wantsSummary && !wantsStatus && !wantsLegacyList) return undefined; if (params.directives.rawModelProfile) { return { text: "Auth profile override requires a model selection." }; @@ -184,16 +185,28 @@ export async function maybeHandleModelDirectiveInfo(params: { allowedModelCatalog: params.allowedModelCatalog, }); - if (wantsList) { - const items = buildModelPickerItems(pickerCatalog); - if (items.length === 0) return { text: "No models available." }; + if (wantsLegacyList) { + return { + text: [ + "Model listing moved.", + "", + "Use: /models (providers) or /models (models)", + "Switch: /model ", + ].join("\n"), + }; + } + + if (wantsSummary) { const current = `${params.provider}/${params.model}`; - const lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model "]; - for (const [idx, item] of items.entries()) { - lines.push(`${idx + 1}) ${item.provider}/${item.model}`); - } - lines.push("", "More: /model status"); - return { text: lines.join("\n") }; + return { + text: [ + `Current: ${current}`, + "", + "Switch: /model ", + "Browse: /models (providers) or /models (models)", + "More: /model status", + ].join("\n"), + }; } const modelsPath = `${params.agentDir}/models.json`; @@ -285,31 +298,36 @@ export function resolveModelSelectionFromDirective(params: { let modelSelection: ModelDirectiveSelection | undefined; if (/^[0-9]+$/.test(raw)) { - const pickerCatalog = buildModelPickerCatalog({ - cfg: params.cfg, - defaultProvider: params.defaultProvider, - defaultModel: params.defaultModel, - aliasIndex: params.aliasIndex, - allowedModelCatalog: params.allowedModelCatalog, - }); - const items = buildModelPickerItems(pickerCatalog); - const index = Number.parseInt(raw, 10) - 1; - const item = Number.isFinite(index) ? items[index] : undefined; - if (!item) { - return { - errorText: `Invalid model selection "${raw}". Use /model to list.`, + return { + errorText: [ + "Numeric model selection is not supported in chat.", + "", + "Browse: /models or /models ", + "Switch: /model ", + ].join("\n"), + }; + } + + const explicit = resolveModelRefFromString({ + raw, + defaultProvider: params.defaultProvider, + aliasIndex: params.aliasIndex, + }); + if (explicit) { + const explicitKey = modelKey(explicit.ref.provider, explicit.ref.model); + if (params.allowedModelKeys.size === 0 || params.allowedModelKeys.has(explicitKey)) { + modelSelection = { + provider: explicit.ref.provider, + model: explicit.ref.model, + isDefault: + explicit.ref.provider === params.defaultProvider && + explicit.ref.model === params.defaultModel, + ...(explicit.alias ? { alias: explicit.alias } : {}), }; } - const key = `${item.provider}/${item.model}`; - const aliases = params.aliasIndex.byKey.get(key); - const alias = aliases && aliases.length > 0 ? aliases[0] : undefined; - modelSelection = { - provider: item.provider, - model: item.model, - isDefault: item.provider === params.defaultProvider && item.model === params.defaultModel, - ...(alias ? { alias } : {}), - }; - } else { + } + + if (!modelSelection) { const resolved = resolveModelDirectiveSelection({ raw, defaultProvider: params.defaultProvider, @@ -317,10 +335,24 @@ export function resolveModelSelectionFromDirective(params: { aliasIndex: params.aliasIndex, allowedModelKeys: params.allowedModelKeys, }); + if (resolved.error) { return { errorText: resolved.error }; } - modelSelection = resolved.selection; + + if (resolved.selection) { + const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`; + return { + errorText: [ + `Unrecognized model: ${raw}`, + "", + `Did you mean: ${suggestion}`, + `Try: /model ${suggestion}`, + "", + "Browse: /models or /models ", + ].join("\n"), + }; + } } let profileOverride: string | undefined; diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index f1d9948a8..fe06c1c06 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -46,6 +46,39 @@ const FUZZY_VARIANT_TOKENS = [ "nano", ]; +function boundedLevenshteinDistance(a: string, b: string, maxDistance: number): number | null { + if (a === b) return 0; + if (!a || !b) return null; + const aLen = a.length; + const bLen = b.length; + if (Math.abs(aLen - bLen) > maxDistance) return null; + + // Standard DP with early exit. O(maxDistance * minLen) in common cases. + const prev = new Array(bLen + 1); + const curr = new Array(bLen + 1); + for (let j = 0; j <= bLen; j++) prev[j] = j; + + for (let i = 1; i <= aLen; i++) { + curr[0] = i; + let rowMin = curr[0]; + + const aChar = a.charCodeAt(i - 1); + for (let j = 1; j <= bLen; j++) { + const cost = aChar === b.charCodeAt(j - 1) ? 0 : 1; + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + if (curr[j] < rowMin) rowMin = curr[j]; + } + + if (rowMin > maxDistance) return null; + + for (let j = 0; j <= bLen; j++) prev[j] = curr[j] ?? 0; + } + + const dist = prev[bLen] ?? null; + if (dist == null || dist > maxDistance) return null; + return dist; +} + function scoreFuzzyMatch(params: { provider: string; model: string; @@ -94,6 +127,13 @@ function scoreFuzzyMatch(params: { includes: 80, }); + // Best-effort typo tolerance for common near-misses like "claud" vs "claude". + // Bounded to keep this cheap across large model sets. + const distModel = boundedLevenshteinDistance(fragment, modelLower, 3); + if (distModel != null) { + score += (3 - distModel) * 70; + } + const aliases = params.aliasIndex.byKey.get(key) ?? []; for (const alias of aliases) { score += scoreFragment(alias.toLowerCase(), { @@ -293,17 +333,16 @@ export function resolveModelDirectiveSelection(params: { const fragment = params.fragment.trim().toLowerCase(); if (!fragment) return {}; + const providerFilter = params.provider ? normalizeProviderId(params.provider) : undefined; + const candidates: Array<{ provider: string; model: string }> = []; for (const key of allowedModelKeys) { const slash = key.indexOf("/"); if (slash <= 0) continue; const provider = normalizeProviderId(key.slice(0, slash)); const model = key.slice(slash + 1); - if (params.provider && provider !== normalizeProviderId(params.provider)) continue; - const haystack = `${provider}/${model}`.toLowerCase(); - if (haystack.includes(fragment) || model.toLowerCase().includes(fragment)) { - candidates.push({ provider, model }); - } + if (providerFilter && provider !== providerFilter) continue; + candidates.push({ provider, model }); } // Also allow partial alias matches when the user didn't specify a provider. @@ -325,11 +364,6 @@ export function resolveModelDirectiveSelection(params: { } } - if (candidates.length === 1) { - const match = candidates[0]; - if (!match) return {}; - return { selection: buildSelection(match.provider, match.model) }; - } if (candidates.length === 0) return {}; const scored = candidates @@ -354,8 +388,13 @@ export function resolveModelDirectiveSelection(params: { return a.key.localeCompare(b.key); }); - const best = scored[0]?.candidate; - if (!best) return {}; + const bestScored = scored[0]; + const best = bestScored?.candidate; + if (!best || !bestScored) return {}; + + const minScore = providerFilter ? 90 : 120; + if (bestScored.score < minScore) return {}; + return { selection: buildSelection(best.provider, best.model) }; }; @@ -369,7 +408,7 @@ export function resolveModelDirectiveSelection(params: { const fuzzy = resolveFuzzy({ fragment: rawTrimmed }); if (fuzzy.selection || fuzzy.error) return fuzzy; return { - error: `Unrecognized model "${rawTrimmed}". Use /model to list available models.`, + error: `Unrecognized model "${rawTrimmed}". Use /models to list providers, or /models to list models.`, }; } @@ -400,7 +439,7 @@ export function resolveModelDirectiveSelection(params: { if (fuzzy.selection || fuzzy.error) return fuzzy; return { - error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /model to list available models.`, + error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /models to list providers, or /models to list models.`, }; }