import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; // Unit tests for the shared fallback loop's PR-B hardening: auth-fatal key // rotation + immediate revalidation, the attempt trail in exhaustion bodies, // daily-allocation benching until UTC midnight, the reasoning-truncation // skipBench exemption, the wall-clock retry budget, and the dispatch-outcome // contract enforcement. const { mockCheckKeyHealth } = vi.hoisted(() => ({ mockCheckKeyHealth: vi.fn() })); vi.mock('../../services/health.js', () => ({ checkKeyHealth: mockCheckKeyHealth, // recordUpstreamSuccess touches this on the streak-reset test path. markKeyHealthyFromRequest: vi.fn(), })); import { initDb, getDb } from '../../db/index.js'; import { runFallbackLoop, newFallbackState, cooldownForError, recordRetryableFailure, recordUpstreamSuccess, resetEmptyCompletionStreaks, resetTruncationStreaks, exhaustedRetryError, formatAttemptTrail, classifyAttemptError, msUntilNextUtcMidnight, msUntilNextPacificMidnight, getFallbackTimeBudgetMs, DEFAULT_FALLBACK_TIME_BUDGET_MS, AUTH_FAILURE_COOLDOWN_MS, EMPTY_COMPLETION_STREAK_LIMIT, TRUNCATION_STREAK_LIMIT, TRUNCATION_BENCH_MS, type AttemptRecord, type FallbackHooks, type FallbackState, } from '../../lib/fallback-loop.js'; import { isKeyAuthError, isDailyQuotaExhaustedError, isProviderLevelError, newClientAbortError, } from '../../lib/error-classify.js'; import { invalidToolArgumentsError } from '../../lib/tool-validate.js'; import { getAllPenalties } from '../../services/router.js'; import type { RouteResult } from '../../services/router.js'; // Distinct keyId AND modelDbId per fake route: the router's penalty map and the // cooldown store are module-global, so shared ids would leak state across tests. let keySeq = 100; function fakeRoute(overrides: Partial = {}): RouteResult { const n = ++keySeq; return { provider: {} as any, modelId: 'fake-model', modelDbId: 424000 + n, apiKey: 'k', keyId: n, platform: 'fake', displayName: 'Fake Model', rpdLimit: null, tpdLimit: null, ...overrides, }; } function hooksSkeleton(overrides: Partial): FallbackHooks { return { state: newFallbackState(), timeBudgetMs: 0, // disabled unless a test opts in route: () => fakeRoute(), dispatch: async () => 'done', logFailure: () => {}, onFatal: () => {}, onRoutingExhausted: () => {}, onExhausted: () => {}, ...overrides, }; } const authRecord = (n: number): AttemptRecord[] => Array.from({ length: n }, (_, i) => ({ platform: 'fake', modelId: 'm', keyOrdinal: i + 1, errorClass: 'auth' as const })); beforeAll(() => { process.env.ENCRYPTION_KEY = '0'.repeat(64); initDb(':memory:'); }); beforeEach(() => { mockCheckKeyHealth.mockReset(); mockCheckKeyHealth.mockResolvedValue('invalid'); getDb().prepare('DELETE FROM rate_limit_cooldowns').run(); resetEmptyCompletionStreaks(); resetTruncationStreaks(); }); describe('isKeyAuthError (401 = key-fatal, rotate instead of 502)', () => { it('flags a structured 401 and common invalid-key phrasings', () => { expect(isKeyAuthError(Object.assign(new Error('Unauthorized'), { status: 401 }))).toBe(true); expect(isKeyAuthError(new Error('Groq API error 401: Invalid API Key'))).toBe(true); expect(isKeyAuthError(new Error('unauthorized'))).toBe(true); expect(isKeyAuthError(new Error('invalid_api_key: Incorrect API key provided'))).toBe(true); }); it('does not flag 403s, 429s, or plain validation 400s', () => { expect(isKeyAuthError(Object.assign(new Error('forbidden'), { status: 403 }))).toBe(false); expect(isKeyAuthError(new Error('429 Too Many Requests'))).toBe(false); expect(isKeyAuthError(new Error('400 Bad Request'))).toBe(false); // A structured non-401 status wins over a suspicious message. expect(isKeyAuthError(Object.assign(new Error('unauthorized model'), { status: 403 }))).toBe(false); }); it('flags Google-style HTTP 400 bad-key errors via key-specific substrings only (#268)', () => { // Google reports a bad/expired key as HTTP 400 INVALID_ARGUMENT, and every // adapter attaches err.status (providerHttpError), so the 400 path must // accept the key-specific phrasings... expect(isKeyAuthError(Object.assign(new Error('Google API error 400: API key not valid. Please pass a valid API key.'), { status: 400 }))).toBe(true); expect(isKeyAuthError(Object.assign(new Error('Google API error 400: API key expired. Please renew the API key.'), { status: 400 }))).toBe(true); expect(isKeyAuthError(Object.assign(new Error('400 INVALID_ARGUMENT: API_KEY_INVALID'), { status: 400 }))).toBe(true); // ...while ordinary payload 400s (even with generic auth-ish wording) stay // provider-bad-request, not key-auth. expect(isKeyAuthError(Object.assign(new Error('Google API error 400: Invalid JSON payload received. Unknown name "x"'), { status: 400 }))).toBe(false); expect(isKeyAuthError(Object.assign(new Error('Cerebras API error 400: unauthorized field in tool schema'), { status: 400 }))).toBe(false); }); }); describe('isDailyQuotaExhaustedError + midnight benching (drift: 90s cooldown on a dead-for-the-day provider)', () => { it.each([ ['2026-09-27T12:00:00Z', '2026-09-28T07:00:00Z'], ['2026-01-27T12:00:00Z', '2026-01-28T08:00:00Z'], ['2026-03-08T08:00:00Z', '2026-03-09T07:00:00Z'], ['2026-11-01T07:00:00Z', '2026-11-02T08:00:00Z'], ])('uses the Pacific reset from %s through DST', (now, reset) => { expect(msUntilNextPacificMidnight(Date.parse(now))).toBe(Date.parse(reset) - Date.parse(now)); }); it('keeps a Gemini daily violation benched past a short RetryInfo delay', () => { const err = Object.assign(new Error('Quota exceeded'), { status: 429, dailyQuotaExhausted: true, retryAfterMs: 17_000, }); expect(isDailyQuotaExhaustedError(err)).toBe(true); const duration = cooldownForError(fakeRoute({ platform: 'google' }), err); expect(Math.abs(duration - msUntilNextPacificMidnight())).toBeLessThan(1_000); }); it('keeps a Gemini per-minute violation on the transient cooldown', () => { const err = Object.assign(new Error('Quota exceeded'), { status: 429, dailyQuotaExhausted: false, retryAfterMs: 17_000 }); expect(cooldownForError(fakeRoute({ platform: 'google' }), err)).toBe(90_000); }); it('flags real daily-allocation 429 bodies', () => { expect(isDailyQuotaExhaustedError(new Error('Cloudflare API error 429: you have used up your daily free allocation of 10,000 neurons'))).toBe(true); expect(isDailyQuotaExhaustedError(new Error('Rate limit exceeded: free-models-per-day'))).toBe(true); expect(isDailyQuotaExhaustedError(new Error('You have exceeded your daily request limit'))).toBe(true); }); it('does not flag per-minute 429s or generic errors', () => { expect(isDailyQuotaExhaustedError(new Error('429 Too Many Requests'))).toBe(false); expect(isDailyQuotaExhaustedError(new Error('tokens per minute (TPM): Limit 30000, Requested 33476'))).toBe(false); expect(isDailyQuotaExhaustedError(new Error('503 Service Unavailable'))).toBe(false); }); // Live Groq bodies (2026-09-29): every limit error ends with the same // "Upgrade to Dev Tier today" upsell, which used to read as a daily marker. const GROQ_UPSELL = 'Need more tokens? Upgrade to Dev Tier today at https://console.groq.com/settings/billing'; it('does not read Groq\'s "Upgrade to Dev Tier today" upsell as a daily quota', () => { const tooLarge = Object.assign(new Error(`Groq API error 413: Request too large for model \`openai/gpt-oss-20b\` in organization \`org_x\` service tier \`on_demand\` on tokens per minute (TPM): Limit 8000, Requested 12122, please reduce your message size and try again. ${GROQ_UPSELL}`), { status: 413 }); const perMinute = Object.assign(new Error(`Groq API error 429: Rate limit reached for model \`openai/gpt-oss-120b\` in organization \`org_x\` service tier \`on_demand\` on tokens per minute (TPM): Limit 8000, Used 6000, Requested 4000. Please try again in 15s. ${GROQ_UPSELL}`), { status: 429 }); expect(isDailyQuotaExhaustedError(tooLarge)).toBe(false); expect(classifyAttemptError(tooLarge)).toBe('context_too_large'); expect(isDailyQuotaExhaustedError(perMinute)).toBe(false); }); it('still flags a Groq requests-per-day 429 that carries the upsell', () => { const perDay = Object.assign(new Error(`Groq API error 429: Rate limit reached for model \`llama-3.3-70b-versatile\` in organization \`org_x\` service tier \`on_demand\` on requests per day (RPD): Limit 1000, Used 1000, Requested 1. Please try again in 1m26s. Need more requests? Upgrade to Dev Tier today at https://console.groq.com/settings/billing`), { status: 429 }); expect(isDailyQuotaExhaustedError(perDay)).toBe(true); }); it('cooldownForError benches a daily-allocation 429 until the next UTC midnight', () => { const route = fakeRoute(); const err = Object.assign(new Error('you have used up your daily free allocation of 10,000 neurons'), { status: 429 }); const ms = cooldownForError(route, err); expect(Math.abs(ms - msUntilNextUtcMidnight())).toBeLessThan(5_000); expect(ms).toBeGreaterThanOrEqual(60_000); }); it('msUntilNextUtcMidnight floors at one minute near midnight', () => { const justBeforeMidnight = Date.UTC(2026, 6, 7, 23, 59, 59, 900); expect(msUntilNextUtcMidnight(justBeforeMidnight)).toBe(60_000); }); it('honors an explicit Retry-After over the midnight bench (rolling daily windows)', () => { // Groq-style rolling RPD: the body names a daily limit AND the response // carries Retry-After ("try again in 7m12s"). The provider knows its own // reset time; benching to UTC midnight would over-bench by hours. const route = fakeRoute(); const retryAfterMs = 432_000; // 7m12s const err = Object.assign( new Error('Groq API error 429: Rate limit reached on requests per day (RPD): Limit 1000. Please try again in 7m12s.'), { status: 429, retryAfterMs }, ); expect(cooldownForError(route, err)).toBe(retryAfterMs); }); }); describe('recordRetryableFailure skipBench exemption (reasoning truncation)', () => { it('skips cooldown + penalty but still rules the key out for this request', () => { const route = fakeRoute(); const state = newFallbackState(); const err = Object.assign(new Error(`empty completion from ${route.displayName}`), { skipBench: true }); recordRetryableFailure(route, err, state); expect(state.skipKeys.has(`fake:fake-model:${route.keyId}`)).toBe(true); const cooldown = getDb().prepare('SELECT 1 FROM rate_limit_cooldowns WHERE platform = ? AND key_id = ?').get('fake', route.keyId); expect(cooldown).toBeUndefined(); expect(getAllPenalties().some(p => p.modelDbId === route.modelDbId)).toBe(false); }); it('control: the same error WITHOUT the flag benches and penalizes as before', () => { const route = fakeRoute(); const state = newFallbackState(); recordRetryableFailure(route, new Error(`empty completion from ${route.displayName}`), state); const cooldown = getDb().prepare('SELECT 1 FROM rate_limit_cooldowns WHERE platform = ? AND key_id = ?').get('fake', route.keyId); expect(cooldown).toBeDefined(); // modelDbId 424242 has no sibling key rows, so the penalty fires. expect(getAllPenalties().some(p => p.modelDbId === route.modelDbId)).toBe(true); }); it('skipModelForRequest rules out the whole model, not just the key', () => { // Format-ignore is MODEL behavior: a sibling key reproduces it exactly, so // burning one failover hop per key on the same model was pure waste. const route = fakeRoute(); const state = newFallbackState(); const err = Object.assign( new Error(`${route.displayName} ignored response_format (returned non-JSON despite json_schema)`), { skipBench: true, skipModelForRequest: true }, ); recordRetryableFailure(route, err, state); expect(state.skipModels.has(route.modelDbId)).toBe(true); expect(state.skipKeys.has(`fake:fake-model:${route.keyId}`)).toBe(true); // skipBench still applies: no cooldown, no penalty. const cooldown = getDb().prepare('SELECT 1 FROM rate_limit_cooldowns WHERE platform = ? AND key_id = ?').get('fake', route.keyId); expect(cooldown).toBeUndefined(); expect(getAllPenalties().some(p => p.modelDbId === route.modelDbId)).toBe(false); }); it('classifyAttemptError buckets format-ignore and truncated-JSON as format_ignored', () => { expect(classifyAttemptError(new Error('X ignored response_format (returned non-JSON despite json_object)'))).toBe('format_ignored'); expect(classifyAttemptError(new Error('truncated JSON from X (finish_reason=length — raise max_tokens for this json_schema request)'))).toBe('format_ignored'); }); }); describe('empty-completion streak lifts the skipBench exemption (#751)', () => { const emptyLengthErr = (route: RouteResult) => Object.assign(new Error(`empty completion from ${route.displayName}`), { skipBench: true }); const cooldownFor = (route: RouteResult) => getDb().prepare('SELECT 1 FROM rate_limit_cooldowns WHERE platform = ? AND key_id = ?').get('fake', route.keyId); it('benches + penalizes from the Nth consecutive empty completion on the same model+key', () => { const route = fakeRoute(); for (let i = 1; i < EMPTY_COMPLETION_STREAK_LIMIT; i++) { expect(recordRetryableFailure(route, emptyLengthErr(route), newFallbackState())).toBe(true); expect(cooldownFor(route)).toBeUndefined(); } // Streak limit reached: the exemption lifts and the normal path runs. expect(recordRetryableFailure(route, emptyLengthErr(route), newFallbackState())).toBe(false); expect(cooldownFor(route)).toBeDefined(); expect(getAllPenalties().some(p => p.modelDbId === route.modelDbId)).toBe(true); }); it('a success on the model+key resets the streak', () => { const route = fakeRoute(); for (let i = 1; i < EMPTY_COMPLETION_STREAK_LIMIT; i++) { recordRetryableFailure(route, emptyLengthErr(route), newFallbackState()); } recordUpstreamSuccess(route, 0); // The full pre-limit run is available again. for (let i = 1; i < EMPTY_COMPLETION_STREAK_LIMIT; i++) { expect(recordRetryableFailure(route, emptyLengthErr(route), newFallbackState())).toBe(true); } expect(cooldownFor(route)).toBeUndefined(); }); it('a normally-penalized failure resets the streak too', () => { const route = fakeRoute(); for (let i = 1; i < EMPTY_COMPLETION_STREAK_LIMIT; i++) { recordRetryableFailure(route, emptyLengthErr(route), newFallbackState()); } // A plain 429 takes the cooldown ladder — and breaks the streak. recordRetryableFailure(route, Object.assign(new Error('429 Too Many Requests'), { status: 429 }), newFallbackState()); getDb().prepare('DELETE FROM rate_limit_cooldowns').run(); for (let i = 1; i < EMPTY_COMPLETION_STREAK_LIMIT; i++) { expect(recordRetryableFailure(route, emptyLengthErr(route), newFallbackState())).toBe(true); } expect(cooldownFor(route)).toBeUndefined(); }); it('format violations (skipModelForRequest) stay exempt and never accrue', () => { const route = fakeRoute(); const formatErr = () => Object.assign( new Error(`${route.displayName} ignored response_format (returned non-JSON despite json_schema)`), { skipBench: true, skipModelForRequest: true }, ); for (let i = 0; i < EMPTY_COMPLETION_STREAK_LIMIT * 2; i++) { expect(recordRetryableFailure(route, formatErr(), newFallbackState())).toBe(true); } // …and they did not pre-charge the empty-completion streak either. expect(recordRetryableFailure(route, emptyLengthErr(route), newFallbackState())).toBe(true); expect(cooldownFor(route)).toBeUndefined(); }); it('an over-the-limit empty completion counts toward the breaker', async () => { const route = fakeRoute(); const onExhausted = vi.fn(); const dispatch = vi.fn(async () => { throw emptyLengthErr(route); }); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, breakerLimit: 2, route: () => route, dispatch, onExhausted })); // The first LIMIT-1 failures are exempt (invisible to the breaker); the // next two count and trip the 2-limit breaker. expect(dispatch).toHaveBeenCalledTimes(EMPTY_COMPLETION_STREAK_LIMIT - 1 + 2); expect(onExhausted).toHaveBeenCalledTimes(1); expect(onExhausted.mock.calls[0][0].status).toBe(503); }); }); describe('truncated-stream streak benches a sick route (#1218)', () => { const truncErr = (name: string) => new Error(`${name} stream ended unexpectedly (no [DONE], no finish_reason) — connection reset or truncated upstream`); const cooldownExpiry = (route: RouteResult): number | undefined => { const row = getDb().prepare( 'SELECT expires_at_ms FROM rate_limit_cooldowns WHERE platform = ? AND key_id = ?', ).get('fake', route.keyId) as { expires_at_ms: number } | undefined; return row?.expires_at_ms; }; it('a single truncation keeps the ordinary short transient bench', () => { const route = fakeRoute(); recordRetryableFailure(route, truncErr(route.displayName), newFallbackState()); // The retryable ladder already benches briefly; the streak adds nothing yet. const expiry = cooldownExpiry(route); expect(expiry).toBeDefined(); expect(expiry! - Date.now()).toBeLessThan(TRUNCATION_BENCH_MS); }); it(`extends the bench to TRUNCATION_BENCH_MS from the Nth consecutive truncation on the same model+key`, () => { const route = fakeRoute(); for (let i = 1; i < TRUNCATION_STREAK_LIMIT; i++) { recordRetryableFailure(route, truncErr(route.displayName), newFallbackState()); expect(cooldownExpiry(route)! - Date.now()).toBeLessThan(TRUNCATION_BENCH_MS); } // Streak limit reached: the bench is now the full truncation window. recordRetryableFailure(route, truncErr(route.displayName), newFallbackState()); expect(cooldownExpiry(route)! - Date.now()).toBeGreaterThanOrEqual(TRUNCATION_BENCH_MS - 1000); }); it('a success on the route resets the streak', () => { const route = fakeRoute(); for (let i = 1; i < TRUNCATION_STREAK_LIMIT; i++) { recordRetryableFailure(route, truncErr(route.displayName), newFallbackState()); } recordUpstreamSuccess(route, 0); getDb().prepare('DELETE FROM rate_limit_cooldowns').run(); for (let i = 1; i < TRUNCATION_STREAK_LIMIT; i++) { recordRetryableFailure(route, truncErr(route.displayName), newFallbackState()); } expect(cooldownExpiry(route)! - Date.now()).toBeLessThan(TRUNCATION_BENCH_MS); }); it('a differently-classified failure on the route breaks the streak', () => { const route = fakeRoute(); for (let i = 1; i < TRUNCATION_STREAK_LIMIT; i++) { recordRetryableFailure(route, truncErr(route.displayName), newFallbackState()); } // A 429 takes the cooldown ladder — and breaks the truncation streak. recordRetryableFailure(route, Object.assign(new Error('429 Too Many Requests'), { status: 429 }), newFallbackState()); getDb().prepare('DELETE FROM rate_limit_cooldowns').run(); for (let i = 1; i < TRUNCATION_STREAK_LIMIT; i++) { recordRetryableFailure(route, truncErr(route.displayName), newFallbackState()); } expect(cooldownExpiry(route)! - Date.now()).toBeLessThan(TRUNCATION_BENCH_MS); }); }); describe('exhaustedRetryError attempt trail + auth exhaustion', () => { it('all-auth attempts produce a distinct 502 provider_error body, not a rate-limit 429', () => { const body = exhaustedRetryError(new Error('Groq API error 401: Invalid API Key'), 20, { attempts: authRecord(3) }); expect(body.kind).toBe('auth'); expect(body.status).toBe(502); expect(body.type).toBe('provider_error'); expect(body.message).toContain('failed authentication'); expect(body.message).toContain('Attempt trail:'); expect(body.message).not.toContain('rate-limited'); }); it('mixed attempts keep the rate-limit body and carry the trail + attempt count', () => { const attempts: AttemptRecord[] = [ { platform: 'groq', modelId: 'llama-3.3-70b', keyOrdinal: 1, errorClass: 'rate_limited' }, { platform: 'cloudflare', modelId: 'qwen', keyOrdinal: 2, errorClass: 'daily_quota_exhausted' }, ]; const body = exhaustedRetryError(new Error('429 Too Many Requests'), 20, { attempts }); expect(body.kind).toBe('rate_limit'); expect(body.status).toBe(429); expect(body.message).toContain('after 2 attempts'); expect(body.message).toContain('groq/llama-3.3-70b key1: rate_limited'); expect(body.message).toContain('cloudflare/qwen key2: daily_quota_exhausted'); }); it('a timed-out loop says so in the body', () => { const body = exhaustedRetryError(new Error('429'), 20, { attempts: authRecord(1), timedOut: true, budgetMs: 45000 }); expect(body.message).toContain('retry time budget 45s exceeded'); }); it('provider-invalid exhaustion keeps the 400 invalid_request body (unchanged contract)', () => { const err = Object.assign(new Error('Google API error 400: Invalid JSON payload received'), { status: 400 }); const body = exhaustedRetryError(err, 20, { attempts: [{ platform: 'google', modelId: 'gemini', keyOrdinal: 1, errorClass: 'provider_bad_request' }], }); expect(body.status).toBe(400); expect(body.type).toBe('invalid_request_error'); expect(body.message).toContain('rejected the request as invalid'); expect(body.message).toContain('Attempt trail:'); }); it('formatAttemptTrail caps the shown entries', () => { const trail = formatAttemptTrail(authRecord(14)); expect(trail).toContain('+4 more'); }); it('classifyAttemptError distinguishes the interesting classes', () => { expect(classifyAttemptError(Object.assign(new Error('x'), { status: 401 }))).toBe('auth'); expect(classifyAttemptError(new Error('HuggingFace Router API error 402: Payment required'))).toBe('out_of_credits'); expect(classifyAttemptError(new Error('used up your daily free allocation'))).toBe('daily_quota_exhausted'); expect(classifyAttemptError(new Error('empty completion from X'))).toBe('empty_completion'); expect(classifyAttemptError(new Error('429 Too Many Requests'))).toBe('rate_limited'); expect(classifyAttemptError(Object.assign(new Error('Bad Gateway'), { status: 502 }))).toBe('upstream_error'); }); }); describe('runFallbackLoop: auth rotation (401 is key-fatal, not request-fatal)', () => { it('rotates past a 401 key, benches it, and fires an immediate revalidation', async () => { const badRoute = fakeRoute(); const goodRoute = fakeRoute(); const routes = [badRoute, goodRoute]; const dispatch = vi.fn() .mockRejectedValueOnce(Object.assign(new Error('Invalid API Key'), { status: 401 })) .mockResolvedValueOnce('done'); const onFatal = vi.fn(); const state = newFallbackState(); await runFallbackLoop(hooksSkeleton({ state, route: (attempt) => routes[attempt], dispatch, onFatal, })); expect(dispatch).toHaveBeenCalledTimes(2); // rotated instead of 502 expect(onFatal).not.toHaveBeenCalled(); expect(state.skipKeys.has(`fake:fake-model:${badRoute.keyId}`)).toBe(true); expect(mockCheckKeyHealth).toHaveBeenCalledWith(badRoute.keyId); // Benched to cover the window until revalidation flips the key status. const row = getDb().prepare('SELECT expires_at_ms FROM rate_limit_cooldowns WHERE platform = ? AND key_id = ?').get('fake', badRoute.keyId) as { expires_at_ms: number }; expect(row.expires_at_ms - Date.now()).toBeGreaterThan(AUTH_FAILURE_COOLDOWN_MS - 10_000); // A key problem is not a model problem: no model penalty. expect(getAllPenalties().some(p => p.modelDbId === badRoute.modelDbId)).toBe(false); }); }); describe('runFallbackLoop: wall-clock retry budget', () => { it('always allows one failover hop, then stops and reports timedOut (#751)', async () => { const dispatch = vi.fn(async () => { await new Promise(r => setTimeout(r, 25)); throw Object.assign(new Error('429 Too Many Requests'), { status: 429 }); }); const onExhausted = vi.fn(); await runFallbackLoop(hooksSkeleton({ timeBudgetMs: 20, // spent after the first ~25ms attempt dispatch, onExhausted, })); // Attempt 0 alone consumed the budget, but attempt 1 still starts — a // slow-failing model must never make failover structurally impossible. // Attempt 2 is where the spent budget stops the chain. expect(dispatch).toHaveBeenCalledTimes(2); expect(onExhausted).toHaveBeenCalledTimes(1); const [body, info] = onExhausted.mock.calls[0]; expect(info.timedOut).toBe(true); expect(body.message).toContain('retry time budget'); expect(info.attempts).toHaveLength(2); }); it('budget 0 disables the check', async () => { const dispatch = vi.fn(async () => { await new Promise(r => setTimeout(r, 5)); throw Object.assign(new Error('429 Too Many Requests'), { status: 429 }); }); const onExhausted = vi.fn(); await runFallbackLoop(hooksSkeleton({ maxRetries: 3, timeBudgetMs: 0, dispatch, onExhausted, })); expect(dispatch).toHaveBeenCalledTimes(3); expect(onExhausted.mock.calls[0][1].timedOut).toBe(false); }); it('getFallbackTimeBudgetMs: env var wins over the default; setting wins over env', () => { const original = process.env.FALLBACK_TIME_BUDGET_MS; try { delete process.env.FALLBACK_TIME_BUDGET_MS; expect(getFallbackTimeBudgetMs()).toBe(DEFAULT_FALLBACK_TIME_BUDGET_MS); process.env.FALLBACK_TIME_BUDGET_MS = '12345'; expect(getFallbackTimeBudgetMs()).toBe(12345); getDb().prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('fallback_time_budget_ms', '999')").run(); expect(getFallbackTimeBudgetMs()).toBe(999); } finally { getDb().prepare("DELETE FROM settings WHERE key = 'fallback_time_budget_ms'").run(); if (original === undefined) delete process.env.FALLBACK_TIME_BUDGET_MS; else process.env.FALLBACK_TIME_BUDGET_MS = original; } }); }); describe('runFallbackLoop: dispatch outcome contract', () => { it('fails loudly when dispatch returns neither done nor committed', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const onFatal = vi.fn(); const logFailure = vi.fn(); await runFallbackLoop(hooksSkeleton({ dispatch: (async () => undefined) as any, // a buggy adapter's bare `return` onFatal, logFailure, })); expect(consoleError).toHaveBeenCalledWith('[FallbackLoop]', expect.stringContaining('dispatch contract violation')); expect(logFailure).toHaveBeenCalledTimes(1); expect(onFatal).toHaveBeenCalledTimes(1); // rendered as a non-retryable error, not silently swallowed consoleError.mockRestore(); }); it('never retries a violation whose modelId embeds a retryable-looking digit run', async () => { // The violation message contains route.modelId; "mistral-small-2503" holds // the substring '503', which the retryable classifier would match if the // violation were thrown into the ordinary catch. The guard must bypass // classification entirely: immediate onFatal, exactly one dispatch. const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const onFatal = vi.fn(); const onExhausted = vi.fn(); const dispatch = vi.fn(async () => undefined) as any; await runFallbackLoop(hooksSkeleton({ route: () => fakeRoute({ modelId: 'mistral-small-2503' }), dispatch, onFatal, onExhausted, })); expect(dispatch).toHaveBeenCalledTimes(1); // no re-dispatch of the buggy adapter expect(onFatal).toHaveBeenCalledTimes(1); // immediate 502 render expect(onExhausted).not.toHaveBeenCalled(); // never loops to exhaustion expect(onFatal.mock.calls[0][1].message).toContain('dispatch contract violation'); consoleError.mockRestore(); }); }); describe('runFallbackLoop: circuit-breaker guardrail (max_consecutive_upstream_fails)', () => { const retryable429 = () => Object.assign(new Error('429 Too Many Requests'), { status: 429 }); it('is off by default: a doomed chain still runs to the attempt cap', async () => { const onExhausted = vi.fn(); const dispatch = vi.fn(async () => { throw retryable429(); }); await runFallbackLoop(hooksSkeleton({ maxRetries: 5, dispatch, onExhausted })); expect(dispatch).toHaveBeenCalledTimes(5); expect(onExhausted).toHaveBeenCalledTimes(1); expect(onExhausted.mock.calls[0][0].status).toBe(429); // ordinary exhaustion, no breaker }); it('trips after N consecutive retryable failures and renders a 503 with the trail', async () => { const onExhausted = vi.fn(); const dispatch = vi.fn(async () => { throw retryable429(); }); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, breakerLimit: 3, dispatch, onExhausted })); expect(dispatch).toHaveBeenCalledTimes(3); // stopped at the limit, not the cap expect(onExhausted).toHaveBeenCalledTimes(1); const [body, info] = onExhausted.mock.calls[0]; expect(body.kind).toBe('unavailable'); expect(body.status).toBe(503); expect(body.type).toBe('service_unavailable'); expect(body.message).toContain('circuit-breaker'); expect(body.message).toContain('3 consecutive upstream failures'); expect(body.message).toContain('Attempt trail:'); expect(info.attempts).toHaveLength(3); expect(info.timedOut).toBe(false); }); it('auth failures count toward the breaker, and an all-auth trip keeps the 502 diagnosis', async () => { mockCheckKeyHealth.mockResolvedValue(undefined); const onExhausted = vi.fn(); const dispatch = vi.fn(async () => { throw Object.assign(new Error('Unauthorized'), { status: 401 }); }); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, breakerLimit: 2, dispatch, onExhausted })); expect(dispatch).toHaveBeenCalledTimes(2); // breaker stopped the rotation const [body, info] = onExhausted.mock.calls[0]; expect(body.kind).toBe('auth'); // the more specific all-auth body wins over the breaker body expect(body.status).toBe(502); expect(info.attempts).toHaveLength(2); }); it('skipBench failures (format ignored, hidden reasoning) never trip the breaker', async () => { // Three prose answers to a json_schema request say nothing about pool // health — candidate four must still get its shot. (#511 x #516) const onExhausted = vi.fn(); let calls = 0; const dispatch = vi.fn(async () => { calls += 1; if (calls <= 3) { throw Object.assign( new Error(`Fake ${calls} ignored response_format (returned non-JSON despite json_schema)`), { skipBench: true }, ); } return 'done' as const; }); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, breakerLimit: 3, dispatch, onExhausted })); expect(dispatch).toHaveBeenCalledTimes(4); // 3 skipBench failures did not trip the 3-limit breaker expect(onExhausted).not.toHaveBeenCalled(); }); it('a breaker trip after client disconnect stops the chain without rendering', async () => { const onExhausted = vi.fn(); let gone = false; const dispatch = vi.fn(async () => { gone = true; // client hangs up during the (only) attempt throw retryable429(); }); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, breakerLimit: 1, clientGone: () => gone, dispatch, onExhausted })); expect(dispatch).toHaveBeenCalledTimes(1); expect(onExhausted).not.toHaveBeenCalled(); // no body to a dead socket }); it('a success below the limit ends the request normally (breaker untouched)', async () => { const onExhausted = vi.fn(); let calls = 0; const dispatch = vi.fn(async () => { calls += 1; if (calls < 3) throw retryable429(); return 'done' as const; }); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, breakerLimit: 3, dispatch, onExhausted })); expect(dispatch).toHaveBeenCalledTimes(3); expect(onExhausted).not.toHaveBeenCalled(); }); }); describe('isProviderLevelError (#788: the PROVIDER is sick, not this key)', () => { it('flags what only a sick provider produces: a 5xx, a timeout, a dead socket', () => { expect(isProviderLevelError(Object.assign(new Error('Bad Gateway'), { status: 502 }))).toBe(true); expect(isProviderLevelError(Object.assign(new Error('Groq API error 503: Service Unavailable'), { status: 503 }))).toBe(true); expect(isProviderLevelError(Object.assign(new Error('Internal Server Error'), { status: 500 }))).toBe(true); // Transport-level failures carry no HTTP status at all. expect(isProviderLevelError(new Error('connect ECONNREFUSED 127.0.0.1:443'))).toBe(true); expect(isProviderLevelError(new Error('read ECONNRESET'))).toBe(true); expect(isProviderLevelError(new Error('fetch failed'))).toBe(true); expect(isProviderLevelError(new Error('The operation was aborted (groq, chat, 120s)'))).toBe(true); expect(isProviderLevelError(new Error('Fake stream stalled: no data for 90000ms (timeout)'))).toBe(true); // A DEGRADED deployment is provider health wearing a 400 (#522). expect(isProviderLevelError(Object.assign(new Error("Function id 'x': DEGRADED function cannot be invoked"), { status: 400 }))).toBe(true); }); it('does not misfire on digits or wording that merely LOOK like a 5xx', () => { // The whole point of the structured-status rule: these all carry '500'/'503' // /'unavailable' in their text, and condemning the platform on any of them // would strand every healthy key and model behind a sick-looking sentence. expect(isProviderLevelError(Object.assign(new Error('Request took 5003ms'), { status: 400 }))).toBe(false); expect(isProviderLevelError(Object.assign(new Error("API error 400: This model's maximum context length is 8192 tokens. However, your messages resulted in 5000 tokens"), { status: 400 }))).toBe(false); expect(isProviderLevelError(Object.assign(new Error('API error 429: quota of 5000 requests per day reached'), { status: 429 }))).toBe(false); expect(isProviderLevelError(Object.assign(new Error('API error 403: qwen3-coder-480b is unavailable on your key\'s tier'), { status: 403 }))).toBe(false); }); it('leaves key-scoped failures alone so a dead key still rotates to a sibling', () => { expect(isProviderLevelError(Object.assign(new Error('Invalid API Key'), { status: 401 }))).toBe(false); expect(isProviderLevelError(Object.assign(new Error('429 Too Many Requests'), { status: 429 }))).toBe(false); expect(isProviderLevelError(Object.assign(new Error('Payment required'), { status: 402 }))).toBe(false); // A vanished client says nothing about provider health. expect(isProviderLevelError(newClientAbortError())).toBe(false); }); it('never condemns a platform for MODEL behavior, however the message reads', () => { // These messages quote caller- and model-supplied text: a tool called // `set_timeout`, or an Ajv complaint about the instance path `/timeout`, // puts a timeout marker in the sentence without a timeout having happened. // `skipModelForRequest` is the structured truth and outranks the text. expect(isProviderLevelError(invalidToolArgumentsError( 'alpha alpha-big', ['set_timeout: /timeout must be number'], ))).toBe(false); expect(isProviderLevelError(invalidToolArgumentsError( 'alpha alpha-big', ['run_query: /mode must be equal to one of the allowed values (degraded)'], ))).toBe(false); expect(isProviderLevelError(Object.assign( new Error('alpha alpha-big ignored response_format (returned non-JSON despite json_object)'), { skipBench: true, skipModelForRequest: true }, ))).toBe(false); }); }); describe('runFallbackLoop: a provider-level failure skips the whole platform (#788)', () => { // A miniature routeRequest: walk a fixed candidate list in order and return // the first one this request has not ruled out, applying the same three gates // the real router applies (skipPlatforms, skipModels, skipKeys). const CANDIDATES = [ { platform: 'alpha', modelId: 'alpha-big', modelDbId: 788_001, keyId: 78_801 }, { platform: 'alpha', modelId: 'alpha-big', modelDbId: 788_001, keyId: 78_802 }, { platform: 'alpha', modelId: 'alpha-small', modelDbId: 788_002, keyId: 78_801 }, { platform: 'beta', modelId: 'beta-one', modelDbId: 788_003, keyId: 78_803 }, ]; const miniRouter = (state: FallbackState) => () => { const pick = CANDIDATES.find(c => !state.skipPlatforms.has(c.platform) && !state.skipModels.has(c.modelDbId) && !state.skipKeys.has(`${c.platform}:${c.modelId}:${c.keyId}`)); if (!pick) throw Object.assign(new Error('all candidates exhausted'), { status: 429, diagnostics: [] }); return fakeRoute(pick); }; const platformsTried = (dispatch: ReturnType): string[] => dispatch.mock.calls.map(call => (call[0] as RouteResult).platform); it('a 503 on alpha key1 rules out alpha entirely — every key AND every model', async () => { const state = newFallbackState(); const dispatch = vi.fn(async () => { throw Object.assign(new Error('Alpha API error 503: Service Unavailable'), { status: 503 }); }); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, state, route: miniRouter(state), dispatch })); // Two hops, not four: alpha's sibling key and its second model are skipped // wholesale, so failover spends its budget on the NEXT provider. expect(platformsTried(dispatch)).toEqual(['alpha', 'beta']); expect(state.skipPlatforms.has('alpha')).toBe(true); }); it('a 401 on alpha key1 still rotates to alpha key2 (key-scoped, not provider-scoped)', async () => { const state = newFallbackState(); const dispatch = vi.fn() .mockRejectedValueOnce(Object.assign(new Error('Invalid API Key'), { status: 401 })) .mockResolvedValueOnce('done'); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, state, route: miniRouter(state), dispatch })); expect(dispatch).toHaveBeenCalledTimes(2); const second = dispatch.mock.calls[1][0] as RouteResult; expect(second.platform).toBe('alpha'); expect(second.keyId).toBe(78_802); expect(state.skipPlatforms.size).toBe(0); }); it('a plain 429 also stays key-scoped', async () => { const state = newFallbackState(); const dispatch = vi.fn() .mockRejectedValueOnce(Object.assign(new Error('429 Too Many Requests'), { status: 429 })) .mockResolvedValueOnce('done'); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, state, route: miniRouter(state), dispatch })); expect((dispatch.mock.calls[1][0] as RouteResult).platform).toBe('alpha'); expect(state.skipPlatforms.size).toBe(0); }); it('invalid tool arguments rule out the MODEL, never the platform', async () => { // The provider served the turn perfectly; the model wrote a bad call. The // sibling key would write the same one (skipModelForRequest), but alpha's // OTHER model is still a fine next hop — skipping the platform here would // strand it, and the tool name in the message carries a timeout marker // precisely to prove the text does not drive the decision. const state = newFallbackState(); const dispatch = vi.fn() .mockRejectedValueOnce(invalidToolArgumentsError('alpha alpha-big', ['set_timeout: /timeout must be number'])) .mockResolvedValueOnce('done'); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, state, route: miniRouter(state), dispatch })); expect(dispatch).toHaveBeenCalledTimes(2); const second = dispatch.mock.calls[1][0] as RouteResult; expect(second.platform).toBe('alpha'); expect(second.modelId).toBe('alpha-small'); // not the sibling key of alpha-big expect(state.skipPlatforms.size).toBe(0); expect(state.skipModels.has(788_001)).toBe(true); }); it('a timeout with no HTTP status rules the platform out too', async () => { const state = newFallbackState(); const dispatch = vi.fn() .mockRejectedValueOnce(new Error('The operation was aborted (alpha, chat, 120s)')) .mockResolvedValueOnce('done'); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, state, route: miniRouter(state), dispatch })); expect((dispatch.mock.calls[1][0] as RouteResult).platform).toBe('beta'); expect(state.skipPlatforms.has('alpha')).toBe(true); }); }); describe('runFallbackLoop: client disconnect + attempt log', () => { const retryable = () => Object.assign(new Error('429 Too Many Requests'), { status: 429 }); it('stops starting retries once the client is gone, without rendering exhaustion', async () => { const onExhausted = vi.fn(); let gone = false; const dispatch = vi.fn(async () => { gone = true; throw retryable(); }); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, clientGone: () => gone, dispatch, onExhausted, })); expect(dispatch).toHaveBeenCalledTimes(1); // first attempt ran, no retry started expect(onExhausted).not.toHaveBeenCalled(); // nothing to render to a dead socket }); it('a connected client is unaffected (clientGone false)', async () => { const onExhausted = vi.fn(); const dispatch = vi.fn(async () => { throw retryable(); }); await runFallbackLoop(hooksSkeleton({ maxRetries: 3, clientGone: () => false, dispatch, onExhausted })); expect(dispatch).toHaveBeenCalledTimes(3); expect(onExhausted).toHaveBeenCalledTimes(1); }); it('records failed attempts into the caller-supplied attemptLog', async () => { const attemptLog: AttemptRecord[] = []; let calls = 0; const dispatch = vi.fn(async () => { calls += 1; if (calls < 3) throw retryable(); return 'done' as const; }); await runFallbackLoop(hooksSkeleton({ maxRetries: 20, attemptLog, dispatch })); expect(attemptLog).toHaveLength(2); // the two failures, not the success expect(attemptLog[0].errorClass).toBe('rate_limited'); }); }); // The diagnostics line used to live in each surface's onRoutingExhausted hook, // and only routes/proxy.ts ever implemented it — so an opaque routing_error on // /v1/messages, /v1/responses or an inbound wire recorded nothing about WHY the // pool was empty. It belongs to the loop: these tests pin it there, for every // surface, including one that supplies no identity at all. describe('runFallbackLoop: routing-exhaustion diagnostics belong to the loop', () => { const routeError = (diagnostics: string[]) => Object.assign(new Error('all candidates exhausted'), { status: 429, diagnostics }); const warnedLine = (warn: ReturnType): string | undefined => warn.mock.calls.map(c => String(c[0])).find(l => l.includes('routing exhausted')); it('logs the router per-candidate disposition when no upstream was tried', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { await runFallbackLoop(hooksSkeleton({ logIdentity: { surface: 'anthropic messages', requestId: 'abcdef12-3456-7890', requestedModel: 'claude-x' }, route: () => { throw routeError(['glm-4.7 @zai: cooldown 42s', 'llama @groq: rpd 0 left']); }, })); const line = warnedLine(warn); expect(line).toBeDefined(); expect(line).toContain('anthropic messages'); expect(line).toContain('req=abcdef'); // hyphens stripped, 6 chars expect(line).toContain('requested=claude-x'); expect(line).toContain('candidates=2'); expect(line).toContain('glm-4.7 @zai: cooldown 42s'); expect(line).toContain('llama @groq: rpd 0 left'); } finally { warn.mockRestore(); } }); it('still fires for a surface that supplies no logIdentity', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { await runFallbackLoop(hooksSkeleton({ route: () => { throw routeError(['solo @fake: no usable key']); }, })); const line = warnedLine(warn); expect(line).toBeDefined(); expect(line).toContain('unidentified surface'); expect(line).toContain('candidates=1'); expect(line).toContain('solo @fake: no usable key'); } finally { warn.mockRestore(); } }); it('handles a RouteError carrying no diagnostics at all', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { await runFallbackLoop(hooksSkeleton({ logIdentity: { surface: 'inbound chat' }, route: () => { throw Object.assign(new Error('nothing routable'), { status: 429 }); }, })); const line = warnedLine(warn); expect(line).toBeDefined(); expect(line).toContain('inbound chat'); expect(line).toContain('candidates=0'); } finally { warn.mockRestore(); } }); it('stays silent when attempts already ran — the attempt trail explains those', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { let call = 0; await runFallbackLoop(hooksSkeleton({ logIdentity: { surface: 'chat completions' }, route: () => { if (call++ === 0) return fakeRoute(); throw routeError(['everything @fake: cooldown']); }, dispatch: async () => { throw Object.assign(new Error('Too Many Requests'), { status: 429 }); }, })); expect(warnedLine(warn)).toBeUndefined(); } finally { warn.mockRestore(); } }); });