fix(router): align fallback semantics and oauth provider behavior

This commit is contained in:
William Valentin
2026-02-23 17:11:15 -08:00
parent 00b2d646f7
commit 092a9baeae
10 changed files with 118 additions and 32 deletions
+51
View File
@@ -47,6 +47,57 @@ describe('ModelRouter', () => {
expect(fallbackClient.chat).toHaveBeenCalled();
});
it('skips duplicate fallback clients that already failed as primary', async () => {
const failingPrimary = createMockClient('primary', true);
const fallbackClient = createMockClient('fallback');
const router = new ModelRouter({
default: failingPrimary,
fallbackChain: [failingPrimary, fallbackClient],
});
const response = await router.chat({ messages: [{ role: 'user', content: 'Hi' }] });
expect(response.content).toBe('Response from fallback');
expect(failingPrimary.chat).toHaveBeenCalledTimes(1);
expect(fallbackClient.chat).toHaveBeenCalledTimes(1);
});
it('applies retry policy to fallback clients', async () => {
const failingPrimary = createMockClient('primary', true);
let attempts = 0;
const flakyFallback: ModelClient = {
chat: vi.fn().mockImplementation(async () => {
attempts += 1;
if (attempts === 1) {
throw new Error('transient');
}
return {
content: 'Recovered fallback',
stopReason: 'end_turn',
usage: { inputTokens: 1, outputTokens: 1 },
} satisfies ChatResponse;
}),
};
const router = new ModelRouter({
default: failingPrimary,
fallbackChain: [flakyFallback],
retryConfig: {
maxRetries: 1,
initialDelayMs: 1,
backoffMultiplier: 1,
maxDelayMs: 1,
nonRetryablePatterns: [],
},
});
const response = await router.chat({ messages: [{ role: 'user', content: 'retry fallback' }] });
expect(response.content).toBe('Recovered fallback');
expect(flakyFallback.chat).toHaveBeenCalledTimes(2);
});
it('throws when all providers fail', async () => {
const failing1 = createMockClient('primary', true);
const failing2 = createMockClient('fallback', true);