This commit is contained in:
tombii 2026-01-30 09:52:59 +01:00 committed by GitHub
commit e75fc4cb72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 163 additions and 2 deletions

View File

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

View File

@ -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 [];
})();

View File

@ -16,6 +16,8 @@ export type AgentModelEntryConfig = {
alias?: string;
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
params?: Record<string, unknown>;
/** Per-model fallbacks (tried before global fallbacks). */
fallbacks?: string[];
};
export type AgentModelListConfig = {

View File

@ -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(),
)