Compare commits
3 Commits
main
...
feat/model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4917c2cfa | ||
|
|
205a5fd522 | ||
|
|
9ba7c8874b |
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
141
src/auto-reply/reply/commands-models.test.ts
Normal file
141
src/auto-reply/reply/commands-models.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
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 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 };
|
||||||
|
};
|
||||||
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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.");
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user