Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Steinberger
f4917c2cfa fix: improve /models listings and model directive UX (#1398) (thanks @vignesh07) 2026-01-21 21:52:30 +00:00
Vignesh Natarajan
205a5fd522 fix(models): handle out-of-range pages 2026-01-21 21:03:02 +00:00
Vignesh Natarajan
9ba7c8874b feat(commands): add /models and fix /model listing UX 2026-01-21 21:03:02 +00:00
19 changed files with 693 additions and 157 deletions

View File

@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. - Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen. - Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
- Model picker: list the full catalog when no model allowlist is configured. - Model picker: list the full catalog when no model allowlist is configured.
- Chat: include configured defaults/providers in `/models` output and normalize all-mode paging. (#1398) Thanks @vignesh07.
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo. - Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204. - BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387) - Infra: preserve fetch helper methods when wrapping abort signals. (#1387)

View File

@ -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({ defineChatCommand({
key: "queue", key: "queue",
nativeName: "queue", nativeName: "queue",
@ -485,7 +493,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
registerAlias(commands, "verbose", "/v"); registerAlias(commands, "verbose", "/v");
registerAlias(commands, "reasoning", "/reason"); registerAlias(commands, "reasoning", "/reason");
registerAlias(commands, "elevated", "/elev"); registerAlias(commands, "elevated", "/elev");
registerAlias(commands, "model", "/models");
assertCommandRegistry(commands); assertCommandRegistry(commands);
return commands; return commands;

View File

@ -25,6 +25,7 @@ describe("commands registry", () => {
it("builds command text with args", () => { it("builds command text with args", () => {
expect(buildCommandText("status")).toBe("/status"); expect(buildCommandText("status")).toBe("/status");
expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5"); expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5");
expect(buildCommandText("models")).toBe("/models");
}); });
it("exposes native specs", () => { it("exposes native specs", () => {

View File

@ -10,11 +10,10 @@ describe("extractModelDirective", () => {
expect(result.cleaned).toBe(""); expect(result.cleaned).toBe("");
}); });
it("extracts /models with argument", () => { it("does not extract /models with argument", () => {
const result = extractModelDirective("/models gpt-5"); const result = extractModelDirective("/models gpt-5");
expect(result.hasDirective).toBe(true); expect(result.hasDirective).toBe(false);
expect(result.rawModel).toBe("gpt-5"); expect(result.cleaned).toBe("/models gpt-5");
expect(result.cleaned).toBe("");
}); });
it("extracts /model with provider/model format", () => { it("extracts /model with provider/model format", () => {
@ -114,10 +113,11 @@ describe("extractModelDirective", () => {
}); });
describe("edge cases", () => { describe("edge cases", () => {
it("preserves spacing when /model is followed by a path segment", () => { it("extracts models with multiple path segments", () => {
const result = extractModelDirective("thats not /model gpt-5/tmp/hello"); const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
expect(result.hasDirective).toBe(true); expect(result.hasDirective).toBe(true);
expect(result.cleaned).toBe("thats not /hello"); expect(result.rawModel).toBe("gpt-5/tmp/hello");
expect(result.cleaned).toBe("thats not");
}); });
it("handles alias with special regex characters", () => { it("handles alias with special regex characters", () => {

View File

@ -14,7 +14,7 @@ export function extractModelDirective(
if (!body) return { cleaned: "", hasDirective: false }; if (!body) return { cleaned: "", hasDirective: false };
const modelMatch = body.match( const modelMatch = body.match(
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i, /(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
); );
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean); const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);

View File

@ -60,13 +60,13 @@ describe("directive behavior", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("lists allowlisted models on /model list", async () => { it("lists allowlisted models on /models <provider>", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, { Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
{}, {},
{ {
agents: { agents: {
@ -84,9 +84,29 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Models (anthropic)");
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
const openaiRes = await getReplyFromConfig(
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
},
);
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
expect(openaiText).toContain("Models (openai)");
expect(openaiText).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@ -97,7 +117,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true }, { Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
{}, {},
{ {
agents: { agents: {
@ -115,9 +135,29 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Models (anthropic)");
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
const openaiRes = await getReplyFromConfig(
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
},
);
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
expect(openaiText).toContain("Models (openai)");
expect(openaiText).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@ -132,7 +172,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, { Body: "/models xai", From: "+1222", To: "+1222", CommandAuthorized: true },
{}, {},
{ {
agents: { agents: {
@ -150,10 +190,29 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("Models (xai)");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("minimax/MiniMax-M2.1");
expect(text).toContain("xai/grok-4"); expect(text).toContain("xai/grok-4");
const minimaxRes = await getReplyFromConfig(
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["openai/gpt-4.1-mini"],
},
imageModel: { primary: "minimax/MiniMax-M2.1" },
workspace: path.join(home, "clawd"),
},
},
session: { store: storePath },
},
);
const minimaxText = Array.isArray(minimaxRes) ? minimaxRes[0]?.text : minimaxRes?.text;
expect(minimaxText).toContain("Models (minimax)");
expect(minimaxText).toContain("minimax/MiniMax-M2.1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@ -173,7 +232,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, { Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
{}, {},
{ {
agents: { agents: {
@ -202,8 +261,7 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("Models (minimax)");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("minimax/MiniMax-M2.1"); expect(text).toContain("minimax/MiniMax-M2.1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
@ -231,6 +289,7 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model listing moved.");
expect(text).not.toContain("missing (missing)"); expect(text).not.toContain("missing (missing)");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });

View File

@ -62,13 +62,13 @@ describe("directive behavior", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("prefers alias matches when fuzzy selection is ambiguous", async () => { it("selects exact alias matches even when ambiguous", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
await getReplyFromConfig( await getReplyFromConfig(
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true }, { Body: "/model Kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{}, {},
{ {
agents: { agents: {

View File

@ -65,7 +65,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
await getReplyFromConfig( const res = await getReplyFromConfig(
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true }, { Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{}, {},
{ {
@ -94,10 +94,10 @@ describe("directive behavior", () => {
}, },
); );
assertModelSelection(storePath, { const text = Array.isArray(res) ? res[0]?.text : res?.text;
model: "kimi-k2-0905-preview", expect(text).toContain("Did you mean:");
provider: "moonshot", expect(text).toContain("moonshot/kimi-k2-0905-preview");
}); assertModelSelection(storePath);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@ -106,7 +106,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/model kimi-k2-0905-preview", Body: "/model kimi-k2-0905-preview",
From: "+1222", From: "+1222",
@ -140,10 +140,10 @@ describe("directive behavior", () => {
}, },
); );
assertModelSelection(storePath, { const text = Array.isArray(res) ? res[0]?.text : res?.text;
model: "kimi-k2-0905-preview", expect(text).toContain("Did you mean:");
provider: "moonshot", expect(text).toContain("moonshot/kimi-k2-0905-preview");
}); assertModelSelection(storePath);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });
@ -152,7 +152,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
await getReplyFromConfig( const res = await getReplyFromConfig(
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true }, { Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{}, {},
{ {
@ -181,10 +181,10 @@ describe("directive behavior", () => {
}, },
); );
assertModelSelection(storePath, { const text = Array.isArray(res) ? res[0]?.text : res?.text;
model: "kimi-k2-0905-preview", expect(text).toContain("Did you mean:");
provider: "moonshot", expect(text).toContain("moonshot/kimi-k2-0905-preview");
}); assertModelSelection(storePath);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });

View File

@ -187,7 +187,7 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
}); });
}); });
it("lists allowlisted models on /model", async () => { it("shows a model summary on /model", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
@ -211,10 +211,9 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Pick: /model <#> or /model <provider/model>"); expect(text).toContain("Browse: /models");
expect(text).toContain("openai/gpt-4.1-mini"); expect(text).toContain("Switch: /model <provider/model>");
expect(text).not.toContain("claude-sonnet-4-1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });

View File

@ -95,7 +95,7 @@ afterEach(() => {
}); });
describe("trigger handling", () => { describe("trigger handling", () => {
it("shows a quick /model picker listing provider/model pairs", async () => { it("shows a /model summary with browse hints", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
@ -115,23 +115,18 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? ""); const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Pick: /model <#> or /model <provider/model>"); expect(normalized).toContain("Current: anthropic/claude-opus-4-5");
// Each provider/model combo is listed separately for clear selection expect(normalized).toContain("Browse: /models");
expect(normalized).toContain("anthropic/claude-opus-4-5"); expect(normalized).toContain("Switch: /model <provider/model>");
expect(normalized).toContain("openrouter/anthropic/claude-opus-4-5");
expect(normalized).toContain("openai/gpt-5.2");
expect(normalized).toContain("openai-codex/gpt-5.2");
expect(normalized).toContain("More: /model status"); expect(normalized).toContain("More: /model status");
expect(normalized).not.toContain("reasoning");
expect(normalized).not.toContain("image");
}); });
}); });
it("orders provider/model pairs by provider preference", async () => { it("lists providers on /models", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/model", Body: "/models",
From: "telegram:111", From: "telegram:111",
To: "telegram:111", To: "telegram:111",
ChatType: "direct", ChatType: "direct",
@ -146,54 +141,22 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? ""); const normalized = normalizeTestText(text ?? "");
const anthropicIndex = normalized.indexOf("anthropic/claude-opus-4-5"); expect(normalized).toContain("Providers:");
const openrouterIndex = normalized.indexOf("openrouter/anthropic/claude-opus-4-5"); expect(normalized).toContain("anthropic");
const openaiIndex = normalized.indexOf("openai/gpt-4.1-mini"); expect(normalized).toContain("openrouter");
const codexIndex = normalized.indexOf("openai-codex/gpt-5.2"); expect(normalized).toContain("openai");
expect(anthropicIndex).toBeGreaterThanOrEqual(0); expect(normalized).toContain("openai-codex");
expect(openrouterIndex).toBeGreaterThanOrEqual(0); expect(normalized).toContain("minimax");
expect(openaiIndex).toBeGreaterThanOrEqual(0);
expect(codexIndex).toBeGreaterThanOrEqual(0);
expect(anthropicIndex).toBeLessThan(openrouterIndex);
expect(openaiIndex).toBeLessThan(codexIndex);
}); });
}); });
it("selects the exact provider/model pair for openrouter by index", async () => { it("selects the exact provider/model pair for openrouter", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111"; const sessionKey = "telegram:slash:111";
const list = await getReplyFromConfig(
{
Body: "/model",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: sessionKey,
CommandAuthorized: true,
},
{},
cfg,
);
const listText = Array.isArray(list) ? list[0]?.text : list?.text;
const lines = normalizeTestText(listText ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const targetLine = lines.find((line) =>
line.includes("openrouter/anthropic/claude-opus-4-5"),
);
expect(targetLine).toBeDefined();
const match = targetLine?.match(/^(\d+)\)/);
expect(match?.[1]).toBeDefined();
const index = Number.parseInt(match?.[1] ?? "", 10);
expect(Number.isFinite(index)).toBe(true);
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: `/model ${index}`, Body: "/model openrouter/anthropic/claude-opus-4-5",
From: "telegram:111", From: "telegram:111",
To: "telegram:111", To: "telegram:111",
ChatType: "direct", ChatType: "direct",
@ -238,7 +201,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain( expect(normalizeTestText(text ?? "")).toContain(
'Invalid model selection "99". Use /model to list.', "Numeric model selection is not supported in chat.",
); );
const store = loadSessionStore(cfg.session.store); const store = loadSessionStore(cfg.session.store);
@ -246,15 +209,14 @@ describe("trigger handling", () => {
expect(store[sessionKey]?.modelOverride).toBeUndefined(); expect(store[sessionKey]?.modelOverride).toBeUndefined();
}); });
}); });
it("selects exact provider/model combo by index via /model <#>", async () => { it("selects exact provider/model combo via /model <provider/model>", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111"; const sessionKey = "telegram:slash:111";
// /model 1 should select the first item (anthropic/claude-opus-4-5)
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/model 1", Body: "/model anthropic/claude-opus-4-5",
From: "telegram:111", From: "telegram:111",
To: "telegram:111", To: "telegram:111",
ChatType: "direct", ChatType: "direct",
@ -268,8 +230,9 @@ describe("trigger handling", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
// Selecting the default model shows "reset to default" instead of "set to" expect(normalizeTestText(text ?? "")).toContain(
expect(normalizeTestText(text ?? "")).toContain("anthropic/claude-opus-4-5"); "Model reset to default (anthropic/claude-opus-4-5).",
);
const store = loadSessionStore(cfg.session.store); const store = loadSessionStore(cfg.session.store);
// When selecting the default, overrides are cleared // When selecting the default, overrides are cleared
@ -277,14 +240,14 @@ describe("trigger handling", () => {
expect(store[sessionKey]?.modelOverride).toBeUndefined(); expect(store[sessionKey]?.modelOverride).toBeUndefined();
}); });
}); });
it("selects a model by index via /model <#>", async () => { it("selects a model via /model <provider/model>", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111"; const sessionKey = "telegram:slash:111";
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/model 3", Body: "/model openai/gpt-5.2",
From: "telegram:111", From: "telegram:111",
To: "telegram:111", To: "telegram:111",
ChatType: "direct", ChatType: "direct",

View File

@ -15,6 +15,7 @@ import {
} from "./commands-info.js"; } from "./commands-info.js";
import { handleAllowlistCommand } from "./commands-allowlist.js"; import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleSubagentsCommand } from "./commands-subagents.js"; import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleModelsCommand } from "./commands-models.js";
import { import {
handleAbortTrigger, handleAbortTrigger,
handleActivationCommand, handleActivationCommand,
@ -44,6 +45,7 @@ const HANDLERS: CommandHandler[] = [
handleSubagentsCommand, handleSubagentsCommand,
handleConfigCommand, handleConfigCommand,
handleDebugCommand, handleDebugCommand,
handleModelsCommand,
handleStopCommand, handleStopCommand,
handleCompactCommand, handleCompactCommand,
handleAbortTrigger, handleAbortTrigger,

View File

@ -0,0 +1,141 @@
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("includes configured providers and defaults", async () => {
const configuredCfg = {
commands: { text: true },
agents: {
defaults: {
model: {
primary: "synthetic/synth-1",
fallbacks: ["synthetic/synth-2"],
},
imageModel: {
primary: "synthetic/synth-image",
fallbacks: ["synthetic/synth-image-2"],
},
},
},
models: {
providers: {
synthetic: {
baseUrl: "https://example.com",
models: [
{
id: "synth-3",
name: "Synth 3",
},
],
},
},
},
} as unknown as ClawdbotConfig;
const providersResult = await handleCommands(buildParams("/models", configuredCfg));
expect(providersResult.shouldContinue).toBe(false);
expect(providersResult.reply?.text).toContain("synthetic");
const modelsResult = await handleCommands(buildParams("/models synthetic", configuredCfg));
expect(modelsResult.shouldContinue).toBe(false);
expect(modelsResult.reply?.text).toContain("synthetic/synth-1");
expect(modelsResult.reply?.text).toContain("synthetic/synth-2");
expect(modelsResult.reply?.text).toContain("synthetic/synth-3");
expect(modelsResult.reply?.text).toContain("synthetic/synth-image");
expect(modelsResult.reply?.text).toContain("synthetic/synth-image-2");
});
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");
});
});

View 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 addModelRef(map: Map<string, Set<string>>, provider: string, model: string): void {
const normalizedProvider = normalizeProviderId(provider);
const normalizedModel = String(model ?? "").trim();
if (!normalizedProvider || !normalizedModel) return;
const set = map.get(normalizedProvider) ?? new Set<string>();
set.add(normalizedModel);
map.set(normalizedProvider, set);
}
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<string, Set<string>>();
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: resolvedDefault.provider,
});
const addRaw = (raw?: string) => {
const value = String(raw ?? "").trim();
if (!value) return;
const resolved = resolveModelRefFromString({
raw: value,
defaultProvider: resolvedDefault.provider,
aliasIndex,
});
if (!resolved) return;
addModelRef(byProvider, resolved.ref.provider, resolved.ref.model);
};
for (const entry of allowed.allowedCatalog) {
addModelRef(byProvider, entry.provider, entry.id);
}
addModelRef(byProvider, resolvedDefault.provider, resolvedDefault.model);
const modelConfig = params.cfg.agents?.defaults?.model;
const modelFallbacks =
modelConfig && typeof modelConfig === "object" ? (modelConfig.fallbacks ?? []) : [];
for (const fallback of modelFallbacks) {
addRaw(String(fallback ?? ""));
}
const imageConfig = params.cfg.agents?.defaults?.imageModel;
if (imageConfig) {
if (typeof imageConfig === "string") {
addRaw(imageConfig);
} else {
addRaw(imageConfig.primary);
for (const fallback of imageConfig.fallbacks ?? []) {
addRaw(String(fallback ?? ""));
}
}
}
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
addRaw(String(raw ?? ""));
}
for (const [provider, providerConfig] of Object.entries(params.cfg.models?.providers ?? {})) {
for (const modelDef of providerConfig?.models ?? []) {
addModelRef(byProvider, provider, modelDef?.id ?? "");
}
}
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 };
};

View File

@ -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");
});
});

View File

@ -16,7 +16,6 @@ import {
resolveProfileOverride, resolveProfileOverride,
} from "./directive-handling.auth.js"; } from "./directive-handling.auth.js";
import { import {
buildModelPickerItems,
type ModelPickerCatalogEntry, type ModelPickerCatalogEntry,
resolveProviderEndpointLabel, resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.js"; } from "./directive-handling.model-picker.js";
@ -169,8 +168,9 @@ export async function maybeHandleModelDirectiveInfo(params: {
const rawDirective = params.directives.rawModelDirective?.trim(); const rawDirective = params.directives.rawModelDirective?.trim();
const directive = rawDirective?.toLowerCase(); const directive = rawDirective?.toLowerCase();
const wantsStatus = directive === "status"; const wantsStatus = directive === "status";
const wantsList = !rawDirective || directive === "list"; const wantsSummary = !rawDirective;
if (!wantsList && !wantsStatus) return undefined; const wantsLegacyList = directive === "list";
if (!wantsSummary && !wantsStatus && !wantsLegacyList) return undefined;
if (params.directives.rawModelProfile) { if (params.directives.rawModelProfile) {
return { text: "Auth profile override requires a model selection." }; return { text: "Auth profile override requires a model selection." };
@ -184,16 +184,28 @@ export async function maybeHandleModelDirectiveInfo(params: {
allowedModelCatalog: params.allowedModelCatalog, allowedModelCatalog: params.allowedModelCatalog,
}); });
if (wantsList) { if (wantsLegacyList) {
const items = buildModelPickerItems(pickerCatalog); return {
if (items.length === 0) return { text: "No models available." }; text: [
const current = `${params.provider}/${params.model}`; "Model listing moved.",
const lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model <provider/model>"]; "",
for (const [idx, item] of items.entries()) { "Use: /models (providers) or /models <provider> (models)",
lines.push(`${idx + 1}) ${item.provider}/${item.model}`); "Switch: /model <provider/model>",
].join("\n"),
};
} }
lines.push("", "More: /model status");
return { text: lines.join("\n") }; if (wantsSummary) {
const current = `${params.provider}/${params.model}`;
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`; const modelsPath = `${params.agentDir}/models.json`;
@ -285,31 +297,36 @@ export function resolveModelSelectionFromDirective(params: {
let modelSelection: ModelDirectiveSelection | undefined; let modelSelection: ModelDirectiveSelection | undefined;
if (/^[0-9]+$/.test(raw)) { 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 { return {
errorText: `Invalid model selection "${raw}". Use /model to list.`, errorText: [
"Numeric model selection is not supported in chat.",
"",
"Browse: /models or /models <provider>",
"Switch: /model <provider/model>",
].join("\n"),
}; };
} }
const key = `${item.provider}/${item.model}`;
const aliases = params.aliasIndex.byKey.get(key); const explicit = resolveModelRefFromString({
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined; 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 = { modelSelection = {
provider: item.provider, provider: explicit.ref.provider,
model: item.model, model: explicit.ref.model,
isDefault: item.provider === params.defaultProvider && item.model === params.defaultModel, isDefault:
...(alias ? { alias } : {}), explicit.ref.provider === params.defaultProvider &&
explicit.ref.model === params.defaultModel,
...(explicit.alias ? { alias: explicit.alias } : {}),
}; };
} else { }
}
if (!modelSelection) {
const resolved = resolveModelDirectiveSelection({ const resolved = resolveModelDirectiveSelection({
raw, raw,
defaultProvider: params.defaultProvider, defaultProvider: params.defaultProvider,
@ -317,10 +334,24 @@ export function resolveModelSelectionFromDirective(params: {
aliasIndex: params.aliasIndex, aliasIndex: params.aliasIndex,
allowedModelKeys: params.allowedModelKeys, allowedModelKeys: params.allowedModelKeys,
}); });
if (resolved.error) { if (resolved.error) {
return { errorText: 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; let profileOverride: string | undefined;

View File

@ -46,6 +46,38 @@ const FUZZY_VARIANT_TOKENS = [
"nano", "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 = Array.from({ length: bLen + 1 }, (_, index) => index);
const curr = Array.from({ length: bLen + 1 }, () => 0);
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: { function scoreFuzzyMatch(params: {
provider: string; provider: string;
model: string; model: string;
@ -94,6 +126,13 @@ function scoreFuzzyMatch(params: {
includes: 80, 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) ?? []; const aliases = params.aliasIndex.byKey.get(key) ?? [];
for (const alias of aliases) { for (const alias of aliases) {
score += scoreFragment(alias.toLowerCase(), { score += scoreFragment(alias.toLowerCase(), {
@ -293,18 +332,17 @@ export function resolveModelDirectiveSelection(params: {
const fragment = params.fragment.trim().toLowerCase(); const fragment = params.fragment.trim().toLowerCase();
if (!fragment) return {}; if (!fragment) return {};
const providerFilter = params.provider ? normalizeProviderId(params.provider) : undefined;
const candidates: Array<{ provider: string; model: string }> = []; const candidates: Array<{ provider: string; model: string }> = [];
for (const key of allowedModelKeys) { for (const key of allowedModelKeys) {
const slash = key.indexOf("/"); const slash = key.indexOf("/");
if (slash <= 0) continue; if (slash <= 0) continue;
const provider = normalizeProviderId(key.slice(0, slash)); const provider = normalizeProviderId(key.slice(0, slash));
const model = key.slice(slash + 1); const model = key.slice(slash + 1);
if (params.provider && provider !== normalizeProviderId(params.provider)) continue; if (providerFilter && provider !== providerFilter) continue;
const haystack = `${provider}/${model}`.toLowerCase();
if (haystack.includes(fragment) || model.toLowerCase().includes(fragment)) {
candidates.push({ provider, model }); candidates.push({ provider, model });
} }
}
// Also allow partial alias matches when the user didn't specify a provider. // Also allow partial alias matches when the user didn't specify a provider.
if (!params.provider) { if (!params.provider) {
@ -325,11 +363,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 {}; if (candidates.length === 0) return {};
const scored = candidates const scored = candidates
@ -354,8 +387,13 @@ export function resolveModelDirectiveSelection(params: {
return a.key.localeCompare(b.key); return a.key.localeCompare(b.key);
}); });
const best = scored[0]?.candidate; const bestScored = scored[0];
if (!best) return {}; 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) }; return { selection: buildSelection(best.provider, best.model) };
}; };
@ -369,7 +407,7 @@ export function resolveModelDirectiveSelection(params: {
const fuzzy = resolveFuzzy({ fragment: rawTrimmed }); const fuzzy = resolveFuzzy({ fragment: rawTrimmed });
if (fuzzy.selection || fuzzy.error) return fuzzy; if (fuzzy.selection || fuzzy.error) return fuzzy;
return { 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 +438,7 @@ export function resolveModelDirectiveSelection(params: {
if (fuzzy.selection || fuzzy.error) return fuzzy; if (fuzzy.selection || fuzzy.error) return fuzzy;
return { 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.`,
}; };
} }

View File

@ -205,7 +205,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
if (defaults.contextPruning?.mode === undefined) { if (defaults.contextPruning?.mode === undefined) {
nextDefaults.contextPruning = { nextDefaults.contextPruning = {
...(defaults.contextPruning ?? {}), ...defaults.contextPruning,
mode: "cache-ttl", mode: "cache-ttl",
ttl: defaults.contextPruning?.ttl ?? "1h", ttl: defaults.contextPruning?.ttl ?? "1h",
}; };
@ -214,7 +214,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
if (defaults.heartbeat?.every === undefined) { if (defaults.heartbeat?.every === undefined) {
nextDefaults.heartbeat = { nextDefaults.heartbeat = {
...(defaults.heartbeat ?? {}), ...defaults.heartbeat,
every: authMode === "oauth" ? "1h" : "30m", every: authMode === "oauth" ? "1h" : "30m",
}; };
mutated = true; mutated = true;

View File

@ -7,7 +7,11 @@ export function migrateLegacyConfig(raw: unknown): {
changes: string[]; changes: string[];
} { } {
const { next, changes } = applyLegacyMigrations(raw); const { next, changes } = applyLegacyMigrations(raw);
if (!next) return { config: null, changes: [] }; if (!next) {
const validated = validateConfigObjectWithPlugins(raw);
if (!validated.ok) return { config: null, changes: [] };
return { config: validated.config, changes: [] };
}
const validated = validateConfigObjectWithPlugins(next); const validated = validateConfigObjectWithPlugins(next);
if (!validated.ok) { if (!validated.ok) {
changes.push("Migration applied, but config still invalid; fix remaining issues manually."); changes.push("Migration applied, but config still invalid; fix remaining issues manually.");

View File

@ -89,7 +89,7 @@ function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
} }
} }
function parseActiveHoursTime(raw?: string, opts: { allow24: boolean }): number | null { function parseActiveHoursTime(raw: string | undefined, opts: { allow24: boolean }): number | null {
if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null; if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null;
const [hourStr, minuteStr] = raw.split(":"); const [hourStr, minuteStr] = raw.split(":");
const hour = Number(hourStr); const hour = Number(hourStr);