fix: improve /models listings and model directive UX (#1398) (thanks @vignesh07)
This commit is contained in:
parent
205a5fd522
commit
f4917c2cfa
@ -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: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||
- 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.
|
||||
- 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)
|
||||
|
||||
@ -10,11 +10,10 @@ describe("extractModelDirective", () => {
|
||||
expect(result.cleaned).toBe("");
|
||||
});
|
||||
|
||||
it("extracts /models with argument", () => {
|
||||
it("does not extract /models with argument", () => {
|
||||
const result = extractModelDirective("/models gpt-5");
|
||||
expect(result.hasDirective).toBe(true);
|
||||
expect(result.rawModel).toBe("gpt-5");
|
||||
expect(result.cleaned).toBe("");
|
||||
expect(result.hasDirective).toBe(false);
|
||||
expect(result.cleaned).toBe("/models gpt-5");
|
||||
});
|
||||
|
||||
it("extracts /model with provider/model format", () => {
|
||||
@ -114,10 +113,11 @@ describe("extractModelDirective", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
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", () => {
|
||||
|
||||
@ -14,7 +14,7 @@ export function extractModelDirective(
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
|
||||
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);
|
||||
|
||||
@ -60,13 +60,13 @@ describe("directive behavior", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("lists allowlisted models on /model list", async () => {
|
||||
it("lists allowlisted models on /models <provider>", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@ -84,9 +84,29 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
@ -97,7 +117,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@ -115,9 +135,29 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
@ -132,7 +172,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/models xai", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@ -150,10 +190,29 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("openai/gpt-4.1-mini");
|
||||
expect(text).toContain("minimax/MiniMax-M2.1");
|
||||
expect(text).toContain("Models (xai)");
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -173,7 +232,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@ -202,8 +261,7 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("openai/gpt-4.1-mini");
|
||||
expect(text).toContain("Models (minimax)");
|
||||
expect(text).toContain("minimax/MiniMax-M2.1");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -231,6 +289,7 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Model listing moved.");
|
||||
expect(text).not.toContain("missing (missing)");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -62,13 +62,13 @@ describe("directive behavior", () => {
|
||||
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) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/model Kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
|
||||
@ -65,7 +65,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
@ -94,10 +94,10 @@ describe("directive behavior", () => {
|
||||
},
|
||||
);
|
||||
|
||||
assertModelSelection(storePath, {
|
||||
model: "kimi-k2-0905-preview",
|
||||
provider: "moonshot",
|
||||
});
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Did you mean:");
|
||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||
assertModelSelection(storePath);
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -106,7 +106,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/model kimi-k2-0905-preview",
|
||||
From: "+1222",
|
||||
@ -140,10 +140,10 @@ describe("directive behavior", () => {
|
||||
},
|
||||
);
|
||||
|
||||
assertModelSelection(storePath, {
|
||||
model: "kimi-k2-0905-preview",
|
||||
provider: "moonshot",
|
||||
});
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Did you mean:");
|
||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||
assertModelSelection(storePath);
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -152,7 +152,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
@ -181,10 +181,10 @@ describe("directive behavior", () => {
|
||||
},
|
||||
);
|
||||
|
||||
assertModelSelection(storePath, {
|
||||
model: "kimi-k2-0905-preview",
|
||||
provider: "moonshot",
|
||||
});
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Did you mean:");
|
||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||
assertModelSelection(storePath);
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -187,7 +187,7 @@ describe("directive behavior", () => {
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
it("lists allowlisted models on /model", async () => {
|
||||
it("shows a model summary on /model", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
@ -211,10 +211,9 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
|
||||
expect(text).toContain("openai/gpt-4.1-mini");
|
||||
expect(text).not.toContain("claude-sonnet-4-1");
|
||||
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("Browse: /models");
|
||||
expect(text).toContain("Switch: /model <provider/model>");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -95,7 +95,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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) => {
|
||||
const cfg = makeCfg(home);
|
||||
const res = await getReplyFromConfig(
|
||||
@ -115,23 +115,18 @@ describe("trigger handling", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const normalized = normalizeTestText(text ?? "");
|
||||
expect(normalized).toContain("Pick: /model <#> or /model <provider/model>");
|
||||
// Each provider/model combo is listed separately for clear selection
|
||||
expect(normalized).toContain("anthropic/claude-opus-4-5");
|
||||
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("Current: anthropic/claude-opus-4-5");
|
||||
expect(normalized).toContain("Browse: /models");
|
||||
expect(normalized).toContain("Switch: /model <provider/model>");
|
||||
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) => {
|
||||
const cfg = makeCfg(home);
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/model",
|
||||
Body: "/models",
|
||||
From: "telegram:111",
|
||||
To: "telegram:111",
|
||||
ChatType: "direct",
|
||||
@ -146,54 +141,22 @@ describe("trigger handling", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const normalized = normalizeTestText(text ?? "");
|
||||
const anthropicIndex = normalized.indexOf("anthropic/claude-opus-4-5");
|
||||
const openrouterIndex = normalized.indexOf("openrouter/anthropic/claude-opus-4-5");
|
||||
const openaiIndex = normalized.indexOf("openai/gpt-4.1-mini");
|
||||
const codexIndex = normalized.indexOf("openai-codex/gpt-5.2");
|
||||
expect(anthropicIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(openrouterIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(openaiIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(codexIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(anthropicIndex).toBeLessThan(openrouterIndex);
|
||||
expect(openaiIndex).toBeLessThan(codexIndex);
|
||||
expect(normalized).toContain("Providers:");
|
||||
expect(normalized).toContain("anthropic");
|
||||
expect(normalized).toContain("openrouter");
|
||||
expect(normalized).toContain("openai");
|
||||
expect(normalized).toContain("openai-codex");
|
||||
expect(normalized).toContain("minimax");
|
||||
});
|
||||
});
|
||||
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) => {
|
||||
const cfg = makeCfg(home);
|
||||
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(
|
||||
{
|
||||
Body: `/model ${index}`,
|
||||
Body: "/model openrouter/anthropic/claude-opus-4-5",
|
||||
From: "telegram:111",
|
||||
To: "telegram:111",
|
||||
ChatType: "direct",
|
||||
@ -238,7 +201,7 @@ describe("trigger handling", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
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);
|
||||
@ -246,15 +209,14 @@ describe("trigger handling", () => {
|
||||
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) => {
|
||||
const cfg = makeCfg(home);
|
||||
const sessionKey = "telegram:slash:111";
|
||||
|
||||
// /model 1 should select the first item (anthropic/claude-opus-4-5)
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/model 1",
|
||||
Body: "/model anthropic/claude-opus-4-5",
|
||||
From: "telegram:111",
|
||||
To: "telegram:111",
|
||||
ChatType: "direct",
|
||||
@ -268,8 +230,9 @@ describe("trigger handling", () => {
|
||||
);
|
||||
|
||||
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("anthropic/claude-opus-4-5");
|
||||
expect(normalizeTestText(text ?? "")).toContain(
|
||||
"Model reset to default (anthropic/claude-opus-4-5).",
|
||||
);
|
||||
|
||||
const store = loadSessionStore(cfg.session.store);
|
||||
// When selecting the default, overrides are cleared
|
||||
@ -277,14 +240,14 @@ describe("trigger handling", () => {
|
||||
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) => {
|
||||
const cfg = makeCfg(home);
|
||||
const sessionKey = "telegram:slash:111";
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/model 3",
|
||||
Body: "/model openai/gpt-5.2",
|
||||
From: "telegram:111",
|
||||
To: "telegram:111",
|
||||
ChatType: "direct",
|
||||
|
||||
@ -80,6 +80,49 @@ describe("/models command", () => {
|
||||
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);
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
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";
|
||||
@ -15,6 +17,15 @@ 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;
|
||||
@ -90,27 +101,55 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
||||
});
|
||||
|
||||
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 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) {
|
||||
add(entry.provider, entry.id);
|
||||
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 ?? ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
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();
|
||||
|
||||
@ -16,7 +16,6 @@ import {
|
||||
resolveProfileOverride,
|
||||
} from "./directive-handling.auth.js";
|
||||
import {
|
||||
buildModelPickerItems,
|
||||
type ModelPickerCatalogEntry,
|
||||
resolveProviderEndpointLabel,
|
||||
} from "./directive-handling.model-picker.js";
|
||||
|
||||
@ -54,9 +54,8 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
|
||||
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;
|
||||
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;
|
||||
|
||||
@ -205,7 +205,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
|
||||
|
||||
if (defaults.contextPruning?.mode === undefined) {
|
||||
nextDefaults.contextPruning = {
|
||||
...(defaults.contextPruning ?? {}),
|
||||
...defaults.contextPruning,
|
||||
mode: "cache-ttl",
|
||||
ttl: defaults.contextPruning?.ttl ?? "1h",
|
||||
};
|
||||
@ -214,7 +214,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
|
||||
|
||||
if (defaults.heartbeat?.every === undefined) {
|
||||
nextDefaults.heartbeat = {
|
||||
...(defaults.heartbeat ?? {}),
|
||||
...defaults.heartbeat,
|
||||
every: authMode === "oauth" ? "1h" : "30m",
|
||||
};
|
||||
mutated = true;
|
||||
|
||||
@ -7,7 +7,11 @@ export function migrateLegacyConfig(raw: unknown): {
|
||||
changes: string[];
|
||||
} {
|
||||
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);
|
||||
if (!validated.ok) {
|
||||
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
|
||||
|
||||
@ -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;
|
||||
const [hourStr, minuteStr] = raw.split(":");
|
||||
const hour = Number(hourStr);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user