feat: add multi-key auth profile rotation for model providers
This commit is contained in:
@@ -7,6 +7,7 @@ export { SyntheticClient, type SyntheticClientConfig } from './synthetic.js';
|
||||
export { OllamaClient, type OllamaClientConfig } from './local/index.js';
|
||||
export { LlamaCppClient, type LlamaCppClientConfig } from './local/index.js';
|
||||
export { ModelRouter, type ModelRouterConfig, type ModelTier } from './router.js';
|
||||
export { RotatingModelClient } from './rotating.js';
|
||||
export { withRetry, isRetryable, DEFAULT_RETRY_CONFIG, type RetryConfig } from './retry.js';
|
||||
export { estimateCost, MODEL_COSTS_PER_MILLION } from './costs.js';
|
||||
export { supportsAudioInput } from './capabilities.js';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ModelClient } from './types.js';
|
||||
import { RotatingModelClient } from './rotating.js';
|
||||
|
||||
function makeClient(chatImpl: ModelClient['chat']): ModelClient {
|
||||
return { chat: chatImpl };
|
||||
}
|
||||
|
||||
describe('RotatingModelClient', () => {
|
||||
it('throws when created with no clients', () => {
|
||||
expect(() => new RotatingModelClient([])).toThrow(/at least one client/i);
|
||||
});
|
||||
|
||||
it('falls through to the next profile when the first fails', async () => {
|
||||
const first = makeClient(vi.fn().mockRejectedValue(new Error('rate limited')));
|
||||
const second = makeClient(vi.fn().mockResolvedValue({ content: 'ok' }));
|
||||
const rotating = new RotatingModelClient([first, second]);
|
||||
|
||||
const response = await rotating.chat({ messages: [{ role: 'user', content: 'hello' }] });
|
||||
expect(response.content).toBe('ok');
|
||||
expect(first.chat).toHaveBeenCalledTimes(1);
|
||||
expect(second.chat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sticks to the last successful profile until it fails', async () => {
|
||||
const first = makeClient(vi.fn().mockRejectedValue(new Error('429')));
|
||||
const second = makeClient(vi.fn().mockResolvedValue({ content: 'ok' }));
|
||||
const rotating = new RotatingModelClient([first, second]);
|
||||
|
||||
await rotating.chat({ messages: [{ role: 'user', content: 'a' }] });
|
||||
await rotating.chat({ messages: [{ role: 'user', content: 'b' }] });
|
||||
|
||||
expect(first.chat).toHaveBeenCalledTimes(1);
|
||||
expect(second.chat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient } from './types.js';
|
||||
|
||||
/**
|
||||
* Model client wrapper that rotates across equivalent auth profiles (e.g. API keys).
|
||||
* Sticky-by-success behavior: keep using the last successful profile until it fails.
|
||||
*/
|
||||
export class RotatingModelClient implements ModelClient {
|
||||
private readonly clients: ModelClient[];
|
||||
private currentIndex = 0;
|
||||
|
||||
constructor(clients: ModelClient[]) {
|
||||
if (clients.length === 0) {
|
||||
throw new Error('RotatingModelClient requires at least one client');
|
||||
}
|
||||
this.clients = clients;
|
||||
}
|
||||
|
||||
async chat(request: ChatRequest): Promise<ChatResponse> {
|
||||
const start = this.currentIndex;
|
||||
const errors: Error[] = [];
|
||||
|
||||
for (let offset = 0; offset < this.clients.length; offset += 1) {
|
||||
const index = (start + offset) % this.clients.length;
|
||||
const client = this.clients[index];
|
||||
try {
|
||||
const response = await client.chat(request);
|
||||
this.currentIndex = index;
|
||||
return response;
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`All auth profiles failed: ${errors.map((e) => e.message).join(', ')}`);
|
||||
}
|
||||
|
||||
async *chatStream(request: ChatRequest): AsyncIterable<ChatStreamEvent> {
|
||||
const start = this.currentIndex;
|
||||
|
||||
for (let offset = 0; offset < this.clients.length; offset += 1) {
|
||||
const index = (start + offset) % this.clients.length;
|
||||
const client = this.clients[index];
|
||||
if (!client.chatStream) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
for await (const event of client.chatStream(request)) {
|
||||
if (event.type === 'error') {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
|
||||
if (!failed) {
|
||||
this.currentIndex = index;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: 'error', error: new Error('All auth profiles failed for streaming') };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user