diff --git a/CHANGELOG.md b/CHANGELOG.md index 5909c9899..8a5cbd40f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.molt.bot Status: beta. ### Changes +- Agents: add per-model fallbacks support in `agents.defaults.models[...].fallbacks` to configure model-specific fallback chains. - Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope. - Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. - macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index f8a94560c..a7e5f868c 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -538,4 +538,154 @@ describe("runWithModelFallback", () => { expect(result.provider).toBe("openai"); expect(result.model).toBe("gpt-4.1-mini"); }); + + it("uses per-model fallbacks from model catalog entry", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-4.1-mini" }, + models: { + "openai/gpt-4.1-mini": { + alias: "GPT-4", + fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-4o"], + }, + "anthropic/claude-haiku-3-5": {}, + "openai/gpt-4o": {}, + }, + }, + }, + } as MoltbotConfig; + + const run = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("rate limit"), { status: 429 })) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }); + + expect(result.result).toBe("ok"); + expect(result.provider).toBe("anthropic"); + expect(result.model).toBe("claude-haiku-3-5"); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("per-model fallbacks take precedence over global fallbacks", async () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "openrouter/z-ai/glm-4.5-air:free", + fallbacks: ["anthropic/claude-opus-4"], + }, + models: { + "openrouter/z-ai/glm-4.5-air:free": { + alias: "GLM-Air", + fallbacks: ["zai/glm-4.5-air", "anthropic/claude-haiku-3-5"], + }, + "zai/glm-4.5-air": {}, + "anthropic/claude-haiku-3-5": {}, + "anthropic/claude-opus-4": {}, + }, + }, + }, + } as MoltbotConfig; + + const calls: Array<{ provider: string; model: string }> = []; + + const result = await runWithModelFallback({ + cfg, + provider: "openrouter", + model: "z-ai/glm-4.5-air:free", + run: async (provider, model) => { + calls.push({ provider, model }); + if (calls.length === 1) { + throw Object.assign(new Error("rate limit"), { status: 429 }); + } + if (calls.length === 2) { + throw Object.assign(new Error("auth error"), { status: 401 }); + } + return "ok"; + }, + }); + + expect(result.result).toBe("ok"); + expect(result.provider).toBe("anthropic"); + expect(result.model).toBe("claude-haiku-3-5"); + expect(calls).toEqual([ + { provider: "openrouter", model: "z-ai/glm-4.5-air:free" }, + { provider: "zai", model: "glm-4.5-air" }, + { provider: "anthropic", model: "claude-haiku-3-5" }, + ]); + }); + + it("empty per-model fallbacks array disables global fallbacks for that model", async () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-haiku-3-5"], + }, + models: { + "openai/gpt-4.1-mini": { + fallbacks: [], + }, + }, + }, + }, + } as MoltbotConfig; + + const calls: Array<{ provider: string; model: string }> = []; + + await expect( + runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run: async (provider, model) => { + calls.push({ provider, model }); + throw Object.assign(new Error("rate limit"), { status: 429 }); + }, + }), + ).rejects.toThrow("rate limit"); + + expect(calls).toEqual([{ provider: "openai", model: "gpt-4.1-mini" }]); + }); + + it("per-model fallbacks respect allowlist", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-4.1-mini" }, + models: { + "openai/gpt-4.1-mini": { + fallbacks: ["anthropic/claude-haiku-3-5"], + }, + "anthropic/claude-haiku-3-5": {}, + }, + }, + }, + } as MoltbotConfig; + + const run = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("rate limit"), { status: 429 })) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }); + + expect(result.result).toBe("ok"); + expect(result.provider).toBe("anthropic"); + expect(result.model).toBe("claude-haiku-3-5"); + }); }); diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index b87135f9f..f16be481e 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -165,11 +165,18 @@ function resolveFallbackCandidates(params: { const modelFallbacks = (() => { if (params.fallbacksOverride !== undefined) return params.fallbacksOverride; - const model = params.cfg?.agents?.defaults?.model as + + // Check for per-model fallbacks first + const key = modelKey(provider, model); + const modelConfig = params.cfg?.agents?.defaults?.models?.[key]; + if (modelConfig?.fallbacks) return modelConfig.fallbacks; + + // Fall back to global model.fallbacks + const modelCfg = params.cfg?.agents?.defaults?.model as | { fallbacks?: string[] } | string | undefined; - if (model && typeof model === "object") return model.fallbacks ?? []; + if (modelCfg && typeof modelCfg === "object") return modelCfg.fallbacks ?? []; return []; })(); diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 9c6ce0211..189b7afde 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -16,6 +16,8 @@ export type AgentModelEntryConfig = { alias?: string; /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ params?: Record; + /** Per-model fallbacks (tried before global fallbacks). */ + fallbacks?: string[]; }; export type AgentModelListConfig = { diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index a849078ed..396bfa6dd 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -37,6 +37,8 @@ export const AgentDefaultsSchema = z alias: z.string().optional(), /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ params: z.record(z.string(), z.unknown()).optional(), + /** Per-model fallbacks (tried before global fallbacks). */ + fallbacks: z.array(z.string()).optional(), }) .strict(), )