2026-03-10 01:12:10 +03:00
|
|
|
|
import os from "node:os";
|
|
|
|
|
|
import path from "node:path";
|
2026-02-16 10:03:35 -03:00
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
|
import type { OpenClawConfig } from "../config/config.js";
|
2026-03-13 00:16:12 -07:00
|
|
|
|
import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js";
|
2026-03-13 00:21:10 -07:00
|
|
|
|
import type { AuthProfileStore } from "./auth-profiles.js";
|
2026-02-22 20:01:43 +00:00
|
|
|
|
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
|
2026-02-16 10:03:35 -03:00
|
|
|
|
|
|
|
|
|
|
// Mock auth-profiles module — must be before importing model-fallback
|
|
|
|
|
|
vi.mock("./auth-profiles.js", () => ({
|
|
|
|
|
|
ensureAuthProfileStore: vi.fn(),
|
|
|
|
|
|
getSoonestCooldownExpiry: vi.fn(),
|
|
|
|
|
|
isProfileInCooldown: vi.fn(),
|
2026-02-22 16:10:24 -08:00
|
|
|
|
resolveProfilesUnavailableReason: vi.fn(),
|
2026-02-16 10:03:35 -03:00
|
|
|
|
resolveAuthProfileOrder: vi.fn(),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
ensureAuthProfileStore,
|
|
|
|
|
|
getSoonestCooldownExpiry,
|
|
|
|
|
|
isProfileInCooldown,
|
2026-02-22 16:10:24 -08:00
|
|
|
|
resolveProfilesUnavailableReason,
|
2026-02-16 10:03:35 -03:00
|
|
|
|
resolveAuthProfileOrder,
|
|
|
|
|
|
} from "./auth-profiles.js";
|
|
|
|
|
|
import { _probeThrottleInternals, runWithModelFallback } from "./model-fallback.js";
|
|
|
|
|
|
|
|
|
|
|
|
const mockedEnsureAuthProfileStore = vi.mocked(ensureAuthProfileStore);
|
|
|
|
|
|
const mockedGetSoonestCooldownExpiry = vi.mocked(getSoonestCooldownExpiry);
|
|
|
|
|
|
const mockedIsProfileInCooldown = vi.mocked(isProfileInCooldown);
|
2026-02-22 16:10:24 -08:00
|
|
|
|
const mockedResolveProfilesUnavailableReason = vi.mocked(resolveProfilesUnavailableReason);
|
2026-02-16 10:03:35 -03:00
|
|
|
|
const mockedResolveAuthProfileOrder = vi.mocked(resolveAuthProfileOrder);
|
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
|
const makeCfg = makeModelFallbackCfg;
|
2026-03-10 01:12:10 +03:00
|
|
|
|
let unregisterLogTransport: (() => void) | undefined;
|
2026-02-16 10:03:35 -03:00
|
|
|
|
|
2026-02-22 17:11:17 +00:00
|
|
|
|
function expectFallbackUsed(
|
|
|
|
|
|
result: { result: unknown; attempts: Array<{ reason?: string }> },
|
|
|
|
|
|
run: {
|
|
|
|
|
|
(...args: unknown[]): unknown;
|
|
|
|
|
|
mock: { calls: unknown[][] };
|
|
|
|
|
|
},
|
|
|
|
|
|
) {
|
|
|
|
|
|
expect(result.result).toBe("ok");
|
|
|
|
|
|
expect(run).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(run).toHaveBeenCalledWith("anthropic", "claude-haiku-3-5");
|
|
|
|
|
|
expect(result.attempts[0]?.reason).toBe("rate_limit");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 19:07:08 +00:00
|
|
|
|
function expectPrimarySkippedForReason(
|
|
|
|
|
|
result: { result: unknown; attempts: Array<{ reason?: string }> },
|
|
|
|
|
|
run: {
|
|
|
|
|
|
(...args: unknown[]): unknown;
|
|
|
|
|
|
mock: { calls: unknown[][] };
|
|
|
|
|
|
},
|
|
|
|
|
|
reason: string,
|
|
|
|
|
|
) {
|
|
|
|
|
|
expect(result.result).toBe("ok");
|
|
|
|
|
|
expect(run).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(run).toHaveBeenCalledWith("anthropic", "claude-haiku-3-5");
|
|
|
|
|
|
expect(result.attempts[0]?.reason).toBe(reason);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
|
function expectPrimaryProbeSuccess(
|
|
|
|
|
|
result: { result: unknown },
|
|
|
|
|
|
run: {
|
|
|
|
|
|
(...args: unknown[]): unknown;
|
|
|
|
|
|
mock: { calls: unknown[][] };
|
|
|
|
|
|
},
|
|
|
|
|
|
expectedResult: unknown,
|
|
|
|
|
|
) {
|
|
|
|
|
|
expect(result.result).toBe(expectedResult);
|
|
|
|
|
|
expect(run).toHaveBeenCalledTimes(1);
|
2026-03-05 20:02:36 -08:00
|
|
|
|
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", {
|
2026-03-07 01:42:11 +03:00
|
|
|
|
allowTransientCooldownProbe: true,
|
2026-03-05 20:02:36 -08:00
|
|
|
|
});
|
2026-02-22 20:01:43 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 17:20:31 +00:00
|
|
|
|
async function expectProbeFailureFallsBack({
|
|
|
|
|
|
reason,
|
|
|
|
|
|
probeError,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
reason: "rate_limit" | "overloaded";
|
|
|
|
|
|
probeError: Error & { status: number };
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const cfg = makeCfg({
|
|
|
|
|
|
agents: {
|
|
|
|
|
|
defaults: {
|
|
|
|
|
|
model: {
|
|
|
|
|
|
primary: "openai/gpt-4.1-mini",
|
|
|
|
|
|
fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
} as Partial<OpenClawConfig>);
|
|
|
|
|
|
|
|
|
|
|
|
mockedIsProfileInCooldown.mockReturnValue(true);
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(1_700_000_000_000 + 30 * 1000);
|
|
|
|
|
|
mockedResolveProfilesUnavailableReason.mockReturnValue(reason);
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockRejectedValueOnce(probeError).mockResolvedValue("fallback-ok");
|
|
|
|
|
|
|
|
|
|
|
|
const result = await runWithModelFallback({
|
|
|
|
|
|
cfg,
|
|
|
|
|
|
provider: "openai",
|
|
|
|
|
|
model: "gpt-4.1-mini",
|
|
|
|
|
|
run,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.result).toBe("fallback-ok");
|
|
|
|
|
|
expect(run).toHaveBeenCalledTimes(2);
|
|
|
|
|
|
expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", {
|
|
|
|
|
|
allowTransientCooldownProbe: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5", {
|
|
|
|
|
|
allowTransientCooldownProbe: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 10:03:35 -03:00
|
|
|
|
describe("runWithModelFallback – probe logic", () => {
|
|
|
|
|
|
let realDateNow: () => number;
|
|
|
|
|
|
const NOW = 1_700_000_000_000;
|
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
|
const runPrimaryCandidate = (
|
|
|
|
|
|
cfg: OpenClawConfig,
|
|
|
|
|
|
run: (provider: string, model: string) => Promise<unknown>,
|
|
|
|
|
|
) =>
|
|
|
|
|
|
runWithModelFallback({
|
|
|
|
|
|
cfg,
|
|
|
|
|
|
provider: "openai",
|
|
|
|
|
|
model: "gpt-4.1-mini",
|
|
|
|
|
|
run,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-16 10:03:35 -03:00
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
realDateNow = Date.now;
|
|
|
|
|
|
Date.now = vi.fn(() => NOW);
|
|
|
|
|
|
|
|
|
|
|
|
// Clear throttle state between tests
|
|
|
|
|
|
_probeThrottleInternals.lastProbeAttempt.clear();
|
|
|
|
|
|
|
|
|
|
|
|
// Default: ensureAuthProfileStore returns a fake store
|
|
|
|
|
|
const fakeStore: AuthProfileStore = {
|
|
|
|
|
|
version: 1,
|
|
|
|
|
|
profiles: {},
|
|
|
|
|
|
};
|
|
|
|
|
|
mockedEnsureAuthProfileStore.mockReturnValue(fakeStore);
|
|
|
|
|
|
|
|
|
|
|
|
// Default: resolveAuthProfileOrder returns profiles only for "openai" provider
|
|
|
|
|
|
mockedResolveAuthProfileOrder.mockImplementation(({ provider }: { provider: string }) => {
|
|
|
|
|
|
if (provider === "openai") {
|
|
|
|
|
|
return ["openai-profile-1"];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (provider === "anthropic") {
|
|
|
|
|
|
return ["anthropic-profile-1"];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (provider === "google") {
|
|
|
|
|
|
return ["google-profile-1"];
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
});
|
|
|
|
|
|
// Default: only openai profiles are in cooldown; fallback providers are available
|
|
|
|
|
|
mockedIsProfileInCooldown.mockImplementation((_store, profileId: string) => {
|
|
|
|
|
|
return profileId.startsWith("openai");
|
|
|
|
|
|
});
|
2026-02-22 16:10:24 -08:00
|
|
|
|
mockedResolveProfilesUnavailableReason.mockReturnValue("rate_limit");
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
Date.now = realDateNow;
|
2026-03-10 01:12:10 +03:00
|
|
|
|
unregisterLogTransport?.();
|
|
|
|
|
|
unregisterLogTransport = undefined;
|
|
|
|
|
|
setLoggerOverride(null);
|
|
|
|
|
|
resetLogger();
|
2026-02-16 10:03:35 -03:00
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("skips primary model when far from cooldown expiry (30 min remaining)", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
// Cooldown expires in 30 min — well beyond the 2-min margin
|
|
|
|
|
|
const expiresIn30Min = NOW + 30 * 60 * 1000;
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("ok");
|
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
2026-02-16 10:03:35 -03:00
|
|
|
|
|
|
|
|
|
|
// Should skip primary and use fallback
|
2026-02-22 17:11:17 +00:00
|
|
|
|
expectFallbackUsed(result, run);
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-22 16:10:24 -08:00
|
|
|
|
it("uses inferred unavailable reason when skipping a cooldowned primary model", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
const expiresIn30Min = NOW + 30 * 60 * 1000;
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
|
|
|
|
|
|
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("ok");
|
|
|
|
|
|
|
|
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
2026-03-13 19:07:08 +00:00
|
|
|
|
expectPrimarySkippedForReason(result, run, "billing");
|
2026-02-22 16:10:24 -08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-16 10:03:35 -03:00
|
|
|
|
it("probes primary model when within 2-min margin of cooldown expiry", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
// Cooldown expires in 1 minute — within 2-min probe margin
|
|
|
|
|
|
const expiresIn1Min = NOW + 60 * 1000;
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn1Min);
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("probed-ok");
|
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
|
|
|
|
|
expectPrimaryProbeSuccess(result, run, "probed-ok");
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-10 01:12:10 +03:00
|
|
|
|
it("logs primary metadata on probe success and failure fallback decisions", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
const records: Array<Record<string, unknown>> = [];
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 60 * 1000);
|
|
|
|
|
|
setLoggerOverride({
|
|
|
|
|
|
level: "trace",
|
|
|
|
|
|
consoleLevel: "silent",
|
|
|
|
|
|
file: path.join(os.tmpdir(), `openclaw-model-fallback-probe-${Date.now()}.log`),
|
|
|
|
|
|
});
|
|
|
|
|
|
unregisterLogTransport = registerLogTransport((record) => {
|
|
|
|
|
|
records.push(record);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("probed-ok");
|
|
|
|
|
|
|
|
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
|
|
|
|
|
|
|
|
|
|
|
expectPrimaryProbeSuccess(result, run, "probed-ok");
|
|
|
|
|
|
|
|
|
|
|
|
_probeThrottleInternals.lastProbeAttempt.clear();
|
|
|
|
|
|
|
|
|
|
|
|
const fallbackCfg = makeCfg({
|
|
|
|
|
|
agents: {
|
|
|
|
|
|
defaults: {
|
|
|
|
|
|
model: {
|
|
|
|
|
|
primary: "openai/gpt-4.1-mini",
|
|
|
|
|
|
fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
} as Partial<OpenClawConfig>);
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 60 * 1000);
|
|
|
|
|
|
const fallbackRun = vi
|
|
|
|
|
|
.fn()
|
|
|
|
|
|
.mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 }))
|
|
|
|
|
|
.mockResolvedValueOnce("fallback-ok");
|
|
|
|
|
|
|
|
|
|
|
|
const fallbackResult = await runPrimaryCandidate(fallbackCfg, fallbackRun);
|
|
|
|
|
|
|
|
|
|
|
|
expect(fallbackResult.result).toBe("fallback-ok");
|
|
|
|
|
|
expect(fallbackRun).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", {
|
|
|
|
|
|
allowTransientCooldownProbe: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(fallbackRun).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5");
|
|
|
|
|
|
|
|
|
|
|
|
const decisionPayloads = records
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
(record) =>
|
|
|
|
|
|
record["2"] === "model fallback decision" &&
|
|
|
|
|
|
record["1"] &&
|
|
|
|
|
|
typeof record["1"] === "object",
|
|
|
|
|
|
)
|
|
|
|
|
|
.map((record) => record["1"] as Record<string, unknown>);
|
|
|
|
|
|
|
|
|
|
|
|
expect(decisionPayloads).toEqual(
|
|
|
|
|
|
expect.arrayContaining([
|
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
|
event: "model_fallback_decision",
|
|
|
|
|
|
decision: "probe_cooldown_candidate",
|
|
|
|
|
|
candidateProvider: "openai",
|
|
|
|
|
|
candidateModel: "gpt-4.1-mini",
|
|
|
|
|
|
allowTransientCooldownProbe: true,
|
|
|
|
|
|
}),
|
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
|
event: "model_fallback_decision",
|
|
|
|
|
|
decision: "candidate_succeeded",
|
|
|
|
|
|
candidateProvider: "openai",
|
|
|
|
|
|
candidateModel: "gpt-4.1-mini",
|
|
|
|
|
|
isPrimary: true,
|
|
|
|
|
|
requestedModelMatched: true,
|
|
|
|
|
|
}),
|
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
|
event: "model_fallback_decision",
|
|
|
|
|
|
decision: "candidate_failed",
|
|
|
|
|
|
candidateProvider: "openai",
|
|
|
|
|
|
candidateModel: "gpt-4.1-mini",
|
|
|
|
|
|
isPrimary: true,
|
|
|
|
|
|
requestedModelMatched: true,
|
|
|
|
|
|
nextCandidateProvider: "anthropic",
|
|
|
|
|
|
nextCandidateModel: "claude-haiku-3-5",
|
|
|
|
|
|
}),
|
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
|
event: "model_fallback_decision",
|
|
|
|
|
|
decision: "candidate_succeeded",
|
|
|
|
|
|
candidateProvider: "anthropic",
|
|
|
|
|
|
candidateModel: "claude-haiku-3-5",
|
|
|
|
|
|
isPrimary: false,
|
|
|
|
|
|
requestedModelMatched: false,
|
|
|
|
|
|
}),
|
|
|
|
|
|
]),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-16 10:03:35 -03:00
|
|
|
|
it("probes primary model when cooldown already expired", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
// Cooldown expired 5 min ago
|
|
|
|
|
|
const expiredAlready = NOW - 5 * 60 * 1000;
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(expiredAlready);
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("recovered");
|
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
|
|
|
|
|
expectPrimaryProbeSuccess(result, run, "recovered");
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-25 19:35:40 -06:00
|
|
|
|
it("attempts non-primary fallbacks during rate-limit cooldown after primary probe failure", async () => {
|
2026-03-07 17:20:31 +00:00
|
|
|
|
await expectProbeFailureFallsBack({
|
|
|
|
|
|
reason: "rate_limit",
|
|
|
|
|
|
probeError: Object.assign(new Error("rate limited"), { status: 429 }),
|
2026-03-07 01:42:11 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("attempts non-primary fallbacks during overloaded cooldown after primary probe failure", async () => {
|
2026-03-07 17:20:31 +00:00
|
|
|
|
await expectProbeFailureFallsBack({
|
|
|
|
|
|
reason: "overloaded",
|
|
|
|
|
|
probeError: Object.assign(new Error("service overloaded"), { status: 503 }),
|
2026-03-05 20:02:36 -08:00
|
|
|
|
});
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-08 12:21:41 +00:00
|
|
|
|
it("keeps walking remaining fallbacks after an abort-wrapped RESOURCE_EXHAUSTED probe failure", async () => {
|
|
|
|
|
|
const cfg = makeCfg({
|
|
|
|
|
|
agents: {
|
|
|
|
|
|
defaults: {
|
|
|
|
|
|
model: {
|
|
|
|
|
|
primary: "google/gemini-3-flash-preview",
|
|
|
|
|
|
fallbacks: ["anthropic/claude-haiku-3-5", "deepseek/deepseek-chat"],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
} as Partial<OpenClawConfig>);
|
|
|
|
|
|
|
|
|
|
|
|
mockedResolveAuthProfileOrder.mockImplementation(({ provider }: { provider: string }) => {
|
|
|
|
|
|
if (provider === "google") {
|
|
|
|
|
|
return ["google-profile-1"];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (provider === "anthropic") {
|
|
|
|
|
|
return ["anthropic-profile-1"];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (provider === "deepseek") {
|
|
|
|
|
|
return ["deepseek-profile-1"];
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
});
|
|
|
|
|
|
mockedIsProfileInCooldown.mockImplementation((_store, profileId: string) =>
|
|
|
|
|
|
profileId.startsWith("google"),
|
|
|
|
|
|
);
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 1000);
|
|
|
|
|
|
mockedResolveProfilesUnavailableReason.mockReturnValue("rate_limit");
|
|
|
|
|
|
|
|
|
|
|
|
// Simulate Google Vertex abort-wrapped RESOURCE_EXHAUSTED (the shape that was
|
|
|
|
|
|
// previously swallowed by shouldRethrowAbort before the fallback loop could continue)
|
|
|
|
|
|
const primaryAbort = Object.assign(new Error("request aborted"), {
|
|
|
|
|
|
name: "AbortError",
|
|
|
|
|
|
cause: {
|
|
|
|
|
|
error: {
|
|
|
|
|
|
code: 429,
|
|
|
|
|
|
message: "Resource has been exhausted (e.g. check quota).",
|
|
|
|
|
|
status: "RESOURCE_EXHAUSTED",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const run = vi
|
|
|
|
|
|
.fn()
|
|
|
|
|
|
.mockRejectedValueOnce(primaryAbort)
|
|
|
|
|
|
.mockRejectedValueOnce(
|
|
|
|
|
|
Object.assign(new Error("fallback still rate limited"), { status: 429 }),
|
|
|
|
|
|
)
|
|
|
|
|
|
.mockRejectedValueOnce(
|
|
|
|
|
|
Object.assign(new Error("final fallback still rate limited"), { status: 429 }),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
runWithModelFallback({
|
|
|
|
|
|
cfg,
|
|
|
|
|
|
provider: "google",
|
|
|
|
|
|
model: "gemini-3-flash-preview",
|
|
|
|
|
|
run,
|
|
|
|
|
|
}),
|
|
|
|
|
|
).rejects.toThrow(/All models failed \(3\)/);
|
|
|
|
|
|
|
|
|
|
|
|
// All three candidates must be attempted — the abort must not short-circuit
|
|
|
|
|
|
expect(run).toHaveBeenCalledTimes(3);
|
2026-03-09 22:39:49 +00:00
|
|
|
|
|
2026-03-08 12:21:41 +00:00
|
|
|
|
expect(run).toHaveBeenNthCalledWith(1, "google", "gemini-3-flash-preview", {
|
|
|
|
|
|
allowTransientCooldownProbe: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5");
|
|
|
|
|
|
expect(run).toHaveBeenNthCalledWith(3, "deepseek", "deepseek-chat");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-16 10:03:35 -03:00
|
|
|
|
it("throttles probe when called within 30s interval", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
// Cooldown just about to expire (within probe margin)
|
|
|
|
|
|
const almostExpired = NOW + 30 * 1000;
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
|
|
|
|
|
|
|
|
|
|
|
|
// Simulate a recent probe 10s ago
|
|
|
|
|
|
_probeThrottleInternals.lastProbeAttempt.set("openai", NOW - 10_000);
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("ok");
|
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
2026-02-16 10:03:35 -03:00
|
|
|
|
|
|
|
|
|
|
// Should be throttled → skip primary, use fallback
|
2026-02-22 17:11:17 +00:00
|
|
|
|
expectFallbackUsed(result, run);
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("allows probe when 30s have passed since last probe", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
const almostExpired = NOW + 30 * 1000;
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
|
|
|
|
|
|
|
|
|
|
|
|
// Last probe was 31s ago — should NOT be throttled
|
|
|
|
|
|
_probeThrottleInternals.lastProbeAttempt.set("openai", NOW - 31_000);
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("probed-ok");
|
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
|
|
|
|
|
expectPrimaryProbeSuccess(result, run, "probed-ok");
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-10 00:58:51 +03:00
|
|
|
|
it("prunes stale probe throttle entries before checking eligibility", () => {
|
|
|
|
|
|
_probeThrottleInternals.lastProbeAttempt.set(
|
|
|
|
|
|
"stale",
|
|
|
|
|
|
NOW - _probeThrottleInternals.PROBE_STATE_TTL_MS - 1,
|
|
|
|
|
|
);
|
|
|
|
|
|
_probeThrottleInternals.lastProbeAttempt.set("fresh", NOW - 5_000);
|
|
|
|
|
|
|
|
|
|
|
|
expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(true);
|
|
|
|
|
|
|
|
|
|
|
|
expect(_probeThrottleInternals.isProbeThrottleOpen(NOW, "fresh")).toBe(false);
|
|
|
|
|
|
|
|
|
|
|
|
expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(false);
|
|
|
|
|
|
expect(_probeThrottleInternals.lastProbeAttempt.has("fresh")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("caps probe throttle state by evicting the oldest entries", () => {
|
|
|
|
|
|
for (let i = 0; i < _probeThrottleInternals.MAX_PROBE_KEYS; i += 1) {
|
|
|
|
|
|
_probeThrottleInternals.lastProbeAttempt.set(`key-${i}`, NOW - (i + 1));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_probeThrottleInternals.markProbeAttempt(NOW, "freshest");
|
|
|
|
|
|
|
|
|
|
|
|
expect(_probeThrottleInternals.lastProbeAttempt.size).toBe(
|
|
|
|
|
|
_probeThrottleInternals.MAX_PROBE_KEYS,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(_probeThrottleInternals.lastProbeAttempt.has("freshest")).toBe(true);
|
|
|
|
|
|
expect(_probeThrottleInternals.lastProbeAttempt.has("key-255")).toBe(false);
|
|
|
|
|
|
expect(_probeThrottleInternals.lastProbeAttempt.has("key-0")).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-16 10:03:35 -03:00
|
|
|
|
it("handles non-finite soonest safely (treats as probe-worthy)", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
|
|
|
|
|
|
// Return Infinity — should be treated as "probe" per the guard
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(Infinity);
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("ok-infinity");
|
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
|
|
|
|
|
expectPrimaryProbeSuccess(result, run, "ok-infinity");
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("handles NaN soonest safely (treats as probe-worthy)", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(NaN);
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("ok-nan");
|
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
|
|
|
|
|
expectPrimaryProbeSuccess(result, run, "ok-nan");
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("handles null soonest safely (treats as probe-worthy)", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(null);
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("ok-null");
|
|
|
|
|
|
|
2026-02-22 20:01:43 +00:00
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
|
|
|
|
|
expectPrimaryProbeSuccess(result, run, "ok-null");
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-16 08:07:14 -05:00
|
|
|
|
it("single candidate skips with rate_limit and exhausts candidates", async () => {
|
2026-02-16 10:03:35 -03:00
|
|
|
|
const cfg = makeCfg({
|
|
|
|
|
|
agents: {
|
|
|
|
|
|
defaults: {
|
|
|
|
|
|
model: {
|
|
|
|
|
|
primary: "openai/gpt-4.1-mini",
|
2026-02-16 08:07:14 -05:00
|
|
|
|
fallbacks: [],
|
2026-02-16 10:03:35 -03:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
} as Partial<OpenClawConfig>);
|
|
|
|
|
|
|
|
|
|
|
|
const almostExpired = NOW + 30 * 1000;
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
|
|
|
|
|
|
|
2026-02-16 08:07:14 -05:00
|
|
|
|
const run = vi.fn().mockResolvedValue("unreachable");
|
2026-02-16 10:03:35 -03:00
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
runWithModelFallback({
|
|
|
|
|
|
cfg,
|
|
|
|
|
|
provider: "openai",
|
|
|
|
|
|
model: "gpt-4.1-mini",
|
|
|
|
|
|
fallbacksOverride: [],
|
|
|
|
|
|
run,
|
|
|
|
|
|
}),
|
2026-02-16 08:07:14 -05:00
|
|
|
|
).rejects.toThrow("All models failed");
|
2026-02-16 10:03:35 -03:00
|
|
|
|
|
2026-02-16 08:07:14 -05:00
|
|
|
|
expect(run).not.toHaveBeenCalled();
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-16 08:07:14 -05:00
|
|
|
|
it("scopes probe throttling by agentDir to avoid cross-agent suppression", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
2026-02-16 10:03:35 -03:00
|
|
|
|
const almostExpired = NOW + 30 * 1000;
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
|
|
|
|
|
|
|
2026-02-16 08:07:14 -05:00
|
|
|
|
const run = vi.fn().mockResolvedValue("probed-ok");
|
2026-02-16 10:03:35 -03:00
|
|
|
|
|
2026-02-16 08:07:14 -05:00
|
|
|
|
await runWithModelFallback({
|
|
|
|
|
|
cfg,
|
|
|
|
|
|
provider: "openai",
|
|
|
|
|
|
model: "gpt-4.1-mini",
|
|
|
|
|
|
agentDir: "/tmp/agent-a",
|
|
|
|
|
|
run,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await runWithModelFallback({
|
|
|
|
|
|
cfg,
|
|
|
|
|
|
provider: "openai",
|
|
|
|
|
|
model: "gpt-4.1-mini",
|
|
|
|
|
|
agentDir: "/tmp/agent-b",
|
|
|
|
|
|
run,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-05 20:02:36 -08:00
|
|
|
|
expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", {
|
2026-03-07 01:42:11 +03:00
|
|
|
|
allowTransientCooldownProbe: true,
|
2026-03-05 20:02:36 -08:00
|
|
|
|
});
|
|
|
|
|
|
expect(run).toHaveBeenNthCalledWith(2, "openai", "gpt-4.1-mini", {
|
2026-03-07 01:42:11 +03:00
|
|
|
|
allowTransientCooldownProbe: true,
|
2026-03-05 20:02:36 -08:00
|
|
|
|
});
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|
2026-03-08 01:27:01 -06:00
|
|
|
|
|
2026-03-10 00:58:51 +03:00
|
|
|
|
it("probes billing-cooldowned primary when no fallback candidates exist", async () => {
|
2026-03-08 01:27:01 -06:00
|
|
|
|
const cfg = makeCfg({
|
|
|
|
|
|
agents: {
|
|
|
|
|
|
defaults: {
|
|
|
|
|
|
model: {
|
|
|
|
|
|
primary: "openai/gpt-4.1-mini",
|
|
|
|
|
|
fallbacks: [],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
} as Partial<OpenClawConfig>);
|
|
|
|
|
|
|
2026-03-10 00:58:51 +03:00
|
|
|
|
// Single-provider setups need periodic probes even when the billing
|
|
|
|
|
|
// cooldown is far from expiry, otherwise topping up credits never recovers
|
|
|
|
|
|
// without a restart.
|
2026-03-08 01:27:01 -06:00
|
|
|
|
const expiresIn30Min = NOW + 30 * 60 * 1000;
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
|
|
|
|
|
|
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
|
|
|
|
|
|
|
2026-03-10 00:58:51 +03:00
|
|
|
|
const run = vi.fn().mockResolvedValue("billing-recovered");
|
|
|
|
|
|
|
|
|
|
|
|
const result = await runWithModelFallback({
|
|
|
|
|
|
cfg,
|
|
|
|
|
|
provider: "openai",
|
|
|
|
|
|
model: "gpt-4.1-mini",
|
|
|
|
|
|
fallbacksOverride: [],
|
|
|
|
|
|
run,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.result).toBe("billing-recovered");
|
|
|
|
|
|
expect(run).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", {
|
|
|
|
|
|
allowTransientCooldownProbe: true,
|
|
|
|
|
|
});
|
2026-03-08 01:27:01 -06:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("probes billing-cooldowned primary with fallbacks when near cooldown expiry", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
// Cooldown expires in 1 minute — within 2-min probe margin
|
|
|
|
|
|
const expiresIn1Min = NOW + 60 * 1000;
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn1Min);
|
|
|
|
|
|
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("billing-probe-ok");
|
|
|
|
|
|
|
|
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.result).toBe("billing-probe-ok");
|
|
|
|
|
|
expect(run).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", {
|
|
|
|
|
|
allowTransientCooldownProbe: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("skips billing-cooldowned primary with fallbacks when far from cooldown expiry", async () => {
|
|
|
|
|
|
const cfg = makeCfg();
|
|
|
|
|
|
const expiresIn30Min = NOW + 30 * 60 * 1000;
|
|
|
|
|
|
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
|
|
|
|
|
|
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
|
|
|
|
|
|
|
|
|
|
|
|
const run = vi.fn().mockResolvedValue("ok");
|
|
|
|
|
|
|
|
|
|
|
|
const result = await runPrimaryCandidate(cfg, run);
|
2026-03-13 19:07:08 +00:00
|
|
|
|
expectPrimarySkippedForReason(result, run, "billing");
|
2026-03-08 01:27:01 -06:00
|
|
|
|
});
|
2026-02-16 10:03:35 -03:00
|
|
|
|
});
|