fix(agents): harden model fallback failover paths

This commit is contained in:
Peter Steinberger
2026-02-25 03:46:34 +00:00
parent 480cc4b85c
commit d2597d5ecf
10 changed files with 187 additions and 11 deletions

View File

@@ -8,7 +8,7 @@ import type { AuthProfileStore } from "./auth-profiles.js";
import { saveAuthProfileStore } from "./auth-profiles.js";
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
import { isAnthropicBillingError } from "./live-auth-keys.js";
import { runWithModelFallback } from "./model-fallback.js";
import { runWithImageModelFallback, runWithModelFallback } from "./model-fallback.js";
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
const makeCfg = makeModelFallbackCfg;
@@ -581,6 +581,39 @@ describe("runWithModelFallback", () => {
expect(calls).toEqual([{ provider: "anthropic", model: "claude-opus-4-5" }]);
});
it("keeps explicit fallbacks reachable when models allowlist is present", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4",
fallbacks: ["openai/gpt-4o", "ollama/llama-3"],
},
models: {
"anthropic/claude-sonnet-4": {},
},
},
},
});
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 }))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-sonnet-4",
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([
["anthropic", "claude-sonnet-4"],
["openai", "gpt-4o"],
]);
});
it("defaults provider/model when missing (regression #946)", async () => {
const cfg = makeCfg({
agents: {
@@ -721,6 +754,39 @@ describe("runWithModelFallback", () => {
});
});
describe("runWithImageModelFallback", () => {
it("keeps explicit image fallbacks reachable when models allowlist is present", async () => {
const cfg = makeCfg({
agents: {
defaults: {
imageModel: {
primary: "openai/gpt-image-1",
fallbacks: ["google/gemini-2.5-flash-image-preview"],
},
models: {
"openai/gpt-image-1": {},
},
},
},
});
const run = vi
.fn()
.mockRejectedValueOnce(new Error("rate limited"))
.mockResolvedValueOnce("ok");
const result = await runWithImageModelFallback({
cfg,
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([
["openai", "gpt-image-1"],
["google", "gemini-2.5-flash-image-preview"],
]);
});
});
describe("isAnthropicBillingError", () => {
it("does not false-positive on plain 'a 402' prose", () => {
const samples = [