181 lines
5.1 KiB
TypeScript
181 lines
5.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('./claude-usage.js', () => ({
|
|
fetchClaudeUsage: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./env.js', () => {
|
|
const store: Record<string, string> = {
|
|
FALLBACK_PROVIDER_NAME: 'kimi',
|
|
FALLBACK_BASE_URL: 'https://api.kimi.com/coding/',
|
|
FALLBACK_AUTH_TOKEN: 'test-kimi-key',
|
|
FALLBACK_MODEL: 'kimi-k2.5',
|
|
FALLBACK_SMALL_MODEL: 'kimi-k2.5',
|
|
FALLBACK_COOLDOWN_MS: '600000',
|
|
};
|
|
return {
|
|
readEnvFile: vi.fn((keys: string[]) => {
|
|
const result: Record<string, string> = {};
|
|
for (const k of keys) {
|
|
if (store[k]) result[k] = store[k];
|
|
}
|
|
return result;
|
|
}),
|
|
getEnv: vi.fn((key: string) => store[key]),
|
|
};
|
|
});
|
|
|
|
vi.mock('./logger.js', () => ({
|
|
logger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { fetchClaudeUsage } from './claude-usage.js';
|
|
import {
|
|
clearPrimaryCooldown,
|
|
detectFallbackTrigger,
|
|
getActiveProvider,
|
|
getCooldownInfo,
|
|
isPrimaryNoFallbackCooldownActive,
|
|
markPrimaryCooldown,
|
|
resetFallbackConfig,
|
|
} from './provider-fallback.js';
|
|
|
|
describe('provider fallback usage recovery', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-03-24T00:00:00.000Z'));
|
|
vi.clearAllMocks();
|
|
clearPrimaryCooldown();
|
|
resetFallbackConfig();
|
|
delete process.env.FALLBACK_PROVIDER_NAME;
|
|
delete process.env.FALLBACK_BASE_URL;
|
|
delete process.env.FALLBACK_AUTH_TOKEN;
|
|
delete process.env.FALLBACK_MODEL;
|
|
delete process.env.FALLBACK_SMALL_MODEL;
|
|
delete process.env.FALLBACK_COOLDOWN_MS;
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearPrimaryCooldown();
|
|
resetFallbackConfig();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('keeps the fallback provider active while Claude usage is still exhausted', async () => {
|
|
vi.mocked(fetchClaudeUsage).mockResolvedValue({
|
|
five_hour: {
|
|
utilization: 100,
|
|
resets_at: '2026-03-24T04:00:00.000+09:00',
|
|
},
|
|
});
|
|
|
|
markPrimaryCooldown('usage-exhausted', 1_000);
|
|
vi.advanceTimersByTime(5_000);
|
|
|
|
await expect(getActiveProvider()).resolves.toBe('kimi');
|
|
expect(getCooldownInfo()).toMatchObject({
|
|
active: true,
|
|
reason: 'usage-exhausted',
|
|
remainingMs: 0,
|
|
});
|
|
});
|
|
|
|
it('returns to Claude immediately when usage is no longer exhausted', async () => {
|
|
vi.mocked(fetchClaudeUsage).mockResolvedValue({
|
|
five_hour: {
|
|
utilization: 72,
|
|
resets_at: '2026-03-24T04:00:00.000+09:00',
|
|
},
|
|
seven_day: {
|
|
utilization: 55,
|
|
resets_at: '2026-03-31T04:00:00.000+09:00',
|
|
},
|
|
});
|
|
|
|
markPrimaryCooldown('usage-exhausted', 600_000);
|
|
|
|
await expect(getActiveProvider()).resolves.toBe('claude');
|
|
expect(getCooldownInfo()).toEqual({ active: false });
|
|
});
|
|
|
|
it('falls back to time-based retry when usage status cannot be fetched', async () => {
|
|
vi.mocked(fetchClaudeUsage).mockResolvedValue(null);
|
|
|
|
markPrimaryCooldown('usage-exhausted', 1_000);
|
|
vi.advanceTimersByTime(5_000);
|
|
|
|
await expect(getActiveProvider()).resolves.toBe('claude');
|
|
expect(getCooldownInfo()).toEqual({ active: false });
|
|
});
|
|
|
|
it('treats terminated 401 auth failures as an auth-expired fallback trigger', () => {
|
|
expect(
|
|
detectFallbackTrigger(
|
|
'Failed to authenticate. API Error: 401 terminated',
|
|
),
|
|
).toEqual({
|
|
shouldFallback: true,
|
|
reason: 'auth-expired',
|
|
});
|
|
});
|
|
|
|
it('treats invalid authentication credentials as an auth-expired fallback trigger', () => {
|
|
expect(
|
|
detectFallbackTrigger(
|
|
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
|
|
),
|
|
).toEqual({
|
|
shouldFallback: true,
|
|
reason: 'auth-expired',
|
|
});
|
|
});
|
|
|
|
it('treats Cloudflare 502 HTML as an overloaded fallback trigger', () => {
|
|
expect(
|
|
detectFallbackTrigger(
|
|
'API Error: 502 <html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center><hr><center>cloudflare</center></body></html>',
|
|
),
|
|
).toEqual({
|
|
shouldFallback: true,
|
|
reason: 'overloaded',
|
|
});
|
|
});
|
|
|
|
it('treats Claude org access denied banners as an org-access-denied fallback trigger', () => {
|
|
expect(
|
|
detectFallbackTrigger(
|
|
'Your organization does not have access to Claude. Please login again or contact your administrator.',
|
|
),
|
|
).toEqual({
|
|
shouldFallback: true,
|
|
reason: 'org-access-denied',
|
|
});
|
|
});
|
|
|
|
it('treats terminated 403 auth failures as an org-access-denied fallback trigger', () => {
|
|
expect(
|
|
detectFallbackTrigger(
|
|
'Failed to authenticate. API Error: 403 terminated',
|
|
),
|
|
).toEqual({
|
|
shouldFallback: true,
|
|
reason: 'org-access-denied',
|
|
});
|
|
});
|
|
|
|
it('marks org-access-denied as a no-fallback cooldown reason', () => {
|
|
markPrimaryCooldown('org-access-denied', 60_000);
|
|
|
|
expect(isPrimaryNoFallbackCooldownActive()).toBe(true);
|
|
expect(getCooldownInfo()).toMatchObject({
|
|
active: true,
|
|
reason: 'org-access-denied',
|
|
});
|
|
});
|
|
});
|