Merge pull request #1398 from vignesh07/feat/models-command
fix(chat): add /models and stop /model from dumping full model list
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
133
src/auto-reply/reply/commands-models.test.ts
Normal file
133
src/auto-reply/reply/commands-models.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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<MsgContext>) {
|
||||
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 <provider>");
|
||||
});
|
||||
|
||||
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("page 1/");
|
||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(result.reply?.text).toContain("Switch: /model <provider/model>");
|
||||
expect(result.reply?.text).toContain("All: /models anthropic all");
|
||||
});
|
||||
|
||||
it("ignores page argument when all flag is present", async () => {
|
||||
const params = buildParams("/models anthropic 3 all", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||
expect(result.reply?.text).toContain("page 1/1");
|
||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(result.reply?.text).not.toContain("Page out of range");
|
||||
});
|
||||
|
||||
it("errors on out-of-range pages", async () => {
|
||||
const params = buildParams("/models anthropic 4", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Page out of range");
|
||||
expect(result.reply?.text).toContain("valid: 1-");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("lists configured models outside the curated catalog", async () => {
|
||||
const customCfg = {
|
||||
commands: { text: true },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "localai/ultra-chat",
|
||||
fallbacks: ["anthropic/claude-opus-4-5"],
|
||||
},
|
||||
imageModel: "visionpro/studio-v1",
|
||||
},
|
||||
},
|
||||
} as unknown as ClawdbotConfig;
|
||||
|
||||
const providerList = await handleCommands(buildParams("/models", customCfg));
|
||||
expect(providerList.reply?.text).toContain("localai");
|
||||
expect(providerList.reply?.text).toContain("visionpro");
|
||||
|
||||
const result = await handleCommands(buildParams("/models localai", customCfg));
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (localai)");
|
||||
expect(result.reply?.text).toContain("localai/ultra-chat");
|
||||
expect(result.reply?.text).not.toContain("Unknown provider");
|
||||
});
|
||||
});
|
||||
230
src/auto-reply/reply/commands-models.ts
Normal file
230
src/auto-reply/reply/commands-models.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
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 { 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 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()].sort();
|
||||
|
||||
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 { 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 <provider>",
|
||||
];
|
||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
||||
}
|
||||
|
||||
const models = [...(byProvider.get(provider) ?? new Set<string>())].sort();
|
||||
const total = models.length;
|
||||
|
||||
if (total === 0) {
|
||||
const lines: string[] = [
|
||||
`Models (${provider}) — none`,
|
||||
"",
|
||||
"Browse: /models",
|
||||
"Switch: /model <provider/model>",
|
||||
];
|
||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
||||
}
|
||||
|
||||
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 { reply: { text: lines.join("\n") }, shouldContinue: false };
|
||||
}
|
||||
|
||||
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 { reply: payload, shouldContinue: false };
|
||||
};
|
||||
@@ -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 <provider/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");
|
||||
});
|
||||
});
|
||||
@@ -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 <provider> (models)",
|
||||
"Switch: /model <provider/model>",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
if (wantsSummary) {
|
||||
const current = `${params.provider}/${params.model}`;
|
||||
const lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model <provider/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 <provider/model>",
|
||||
"Browse: /models (providers) or /models <provider> (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 <provider>",
|
||||
"Switch: /model <provider/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 <provider>",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let profileOverride: string | undefined;
|
||||
|
||||
@@ -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<number>(bLen + 1);
|
||||
const curr = new Array<number>(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 <provider> 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 <provider> to list models.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user