Skip to content

Commit 8264fed

Browse files
committed
feat: add ApiKeyPool with weighted round-robin and quota cooldown
1 parent 6119864 commit 8264fed

2 files changed

Lines changed: 224 additions & 0 deletions

File tree

src/core/providers/ApiKeyPool.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* @module core/providers/ApiKeyPool
3+
*
4+
* Weighted round-robin API key pool with quota-exhaustion cooldown.
5+
*
6+
* Accepts a single key or comma-separated keys. The first key gets
7+
* higher rotation weight (configurable). Exhausted keys are temporarily
8+
* removed from rotation and re-enter after a cooldown period.
9+
*
10+
* This is a core AgentOS primitive -- every provider that accepts an
11+
* API key can use it for automatic multi-key rotation and failover.
12+
*
13+
* @example
14+
* ```ts
15+
* // Single key (backward compatible, zero overhead):
16+
* const pool = new ApiKeyPool('sk_abc');
17+
* pool.next(); // 'sk_abc'
18+
*
19+
* // Multiple keys with round-robin + first-key priority:
20+
* const pool = new ApiKeyPool('sk_primary,sk_backup,sk_overflow');
21+
* pool.next(); // weighted rotation, primary selected ~2x more
22+
*
23+
* // Mark exhausted on quota error:
24+
* pool.markExhausted(key); // skipped for 15min cooldown
25+
* pool.next(); // returns next available key
26+
* ```
27+
*/
28+
29+
/** Configuration for an ApiKeyPool instance. */
30+
export interface ApiKeyPoolConfig {
31+
/** Cooldown before retrying an exhausted key. Default: 15 minutes. */
32+
cooldownMs?: number;
33+
/** Weight multiplier for the first key. Default: 2. Set to 1 for equal weighting. */
34+
primaryWeight?: number;
35+
}
36+
37+
interface KeyState {
38+
key: string;
39+
exhaustedUntil: number;
40+
}
41+
42+
const DEFAULT_COOLDOWN_MS = 15 * 60_000;
43+
const DEFAULT_PRIMARY_WEIGHT = 2;
44+
45+
export class ApiKeyPool {
46+
private readonly keys: KeyState[];
47+
private readonly weightedSlots: number[];
48+
private slotIndex = 0;
49+
private readonly cooldownMs: number;
50+
51+
constructor(keys: string | string[], config?: ApiKeyPoolConfig) {
52+
this.cooldownMs = config?.cooldownMs ?? DEFAULT_COOLDOWN_MS;
53+
const primaryWeight = config?.primaryWeight ?? DEFAULT_PRIMARY_WEIGHT;
54+
55+
const keyList = Array.isArray(keys)
56+
? keys
57+
: keys.split(',').map((k) => k.trim()).filter((k) => k.length > 0);
58+
59+
this.keys = keyList.map((key) => ({ key, exhaustedUntil: 0 }));
60+
61+
// Build weighted slot array: first key appears `primaryWeight` times, rest once each.
62+
this.weightedSlots = [];
63+
for (let i = 0; i < this.keys.length; i++) {
64+
const times = i === 0 ? primaryWeight : 1;
65+
for (let t = 0; t < times; t++) {
66+
this.weightedSlots.push(i);
67+
}
68+
}
69+
}
70+
71+
/** Number of keys in the pool (including temporarily exhausted ones). */
72+
get size(): number {
73+
return this.keys.length;
74+
}
75+
76+
/** Whether any key exists at all. */
77+
get hasKeys(): boolean {
78+
return this.keys.length > 0;
79+
}
80+
81+
/**
82+
* Get the next available key via weighted round-robin.
83+
* Skips keys currently in cooldown. Returns empty string if pool is empty.
84+
*/
85+
next(): string {
86+
if (this.keys.length === 0) return '';
87+
if (this.keys.length === 1) return this.keys[0].key;
88+
89+
const now = Date.now();
90+
const totalSlots = this.weightedSlots.length;
91+
92+
for (let i = 0; i < totalSlots; i++) {
93+
const slotIdx = (this.slotIndex + i) % totalSlots;
94+
const keyIdx = this.weightedSlots[slotIdx];
95+
const state = this.keys[keyIdx];
96+
if (state.exhaustedUntil <= now) {
97+
this.slotIndex = (slotIdx + 1) % totalSlots;
98+
return state.key;
99+
}
100+
}
101+
102+
// All keys exhausted -- return the one whose cooldown expires soonest.
103+
const sorted = [...this.keys].sort((a, b) => a.exhaustedUntil - b.exhaustedUntil);
104+
return sorted[0].key;
105+
}
106+
107+
/**
108+
* Mark a key as quota-exhausted. It will be skipped during
109+
* rotation until the cooldown expires.
110+
*/
111+
markExhausted(key: string): void {
112+
const state = this.keys.find((k) => k.key === key);
113+
if (state) {
114+
state.exhaustedUntil = Date.now() + this.cooldownMs;
115+
}
116+
}
117+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { ApiKeyPool } from '../ApiKeyPool.js';
3+
4+
describe('ApiKeyPool', () => {
5+
describe('construction', () => {
6+
it('parses comma-separated string into keys', () => {
7+
const pool = new ApiKeyPool('sk_a,sk_b,sk_c');
8+
expect(pool.size).toBe(3);
9+
expect(pool.hasKeys).toBe(true);
10+
});
11+
12+
it('accepts a single key string', () => {
13+
const pool = new ApiKeyPool('sk_a');
14+
expect(pool.size).toBe(1);
15+
});
16+
17+
it('accepts an array of keys', () => {
18+
const pool = new ApiKeyPool(['sk_a', 'sk_b']);
19+
expect(pool.size).toBe(2);
20+
});
21+
22+
it('trims whitespace from keys', () => {
23+
const pool = new ApiKeyPool(' sk_a , sk_b ');
24+
expect(pool.next()).toBe('sk_a');
25+
});
26+
27+
it('filters empty segments', () => {
28+
const pool = new ApiKeyPool('sk_a,,sk_b,');
29+
expect(pool.size).toBe(2);
30+
});
31+
32+
it('handles empty string', () => {
33+
const pool = new ApiKeyPool('');
34+
expect(pool.size).toBe(0);
35+
expect(pool.hasKeys).toBe(false);
36+
});
37+
});
38+
39+
describe('round-robin', () => {
40+
it('single key always returns the same key', () => {
41+
const pool = new ApiKeyPool('sk_only');
42+
expect(pool.next()).toBe('sk_only');
43+
expect(pool.next()).toBe('sk_only');
44+
expect(pool.next()).toBe('sk_only');
45+
});
46+
47+
it('rotates through keys in order', () => {
48+
const pool = new ApiKeyPool('sk_a,sk_b,sk_c', { primaryWeight: 1 });
49+
expect(pool.next()).toBe('sk_a');
50+
expect(pool.next()).toBe('sk_b');
51+
expect(pool.next()).toBe('sk_c');
52+
expect(pool.next()).toBe('sk_a');
53+
});
54+
55+
it('gives first key higher weight with default primaryWeight=2', () => {
56+
const pool = new ApiKeyPool('sk_a,sk_b');
57+
const counts: Record<string, number> = { sk_a: 0, sk_b: 0 };
58+
for (let i = 0; i < 90; i++) counts[pool.next()]++;
59+
expect(counts.sk_a).toBeGreaterThan(counts.sk_b);
60+
});
61+
});
62+
63+
describe('exhaustion', () => {
64+
beforeEach(() => { vi.useFakeTimers(); });
65+
afterEach(() => { vi.useRealTimers(); });
66+
67+
it('skips exhausted key', () => {
68+
const pool = new ApiKeyPool('sk_a,sk_b', { primaryWeight: 1 });
69+
pool.next(); // sk_a
70+
pool.markExhausted('sk_a');
71+
expect(pool.next()).toBe('sk_b');
72+
expect(pool.next()).toBe('sk_b');
73+
});
74+
75+
it('re-includes key after cooldown expires', () => {
76+
const pool = new ApiKeyPool('sk_a,sk_b', { primaryWeight: 1, cooldownMs: 1000 });
77+
pool.next(); // sk_a
78+
pool.markExhausted('sk_a');
79+
expect(pool.next()).toBe('sk_b');
80+
81+
vi.advanceTimersByTime(1001);
82+
const next3 = [pool.next(), pool.next()];
83+
expect(next3).toContain('sk_a');
84+
});
85+
86+
it('returns least-exhausted key when all are exhausted', () => {
87+
const pool = new ApiKeyPool('sk_a,sk_b', { primaryWeight: 1, cooldownMs: 10_000 });
88+
pool.markExhausted('sk_a');
89+
vi.advanceTimersByTime(5000);
90+
pool.markExhausted('sk_b');
91+
expect(pool.next()).toBe('sk_a');
92+
});
93+
});
94+
95+
describe('edge cases', () => {
96+
it('next() returns empty string for empty pool', () => {
97+
const pool = new ApiKeyPool('');
98+
expect(pool.next()).toBe('');
99+
});
100+
101+
it('markExhausted on unknown key is a no-op', () => {
102+
const pool = new ApiKeyPool('sk_a');
103+
pool.markExhausted('sk_unknown');
104+
expect(pool.next()).toBe('sk_a');
105+
});
106+
});
107+
});

0 commit comments

Comments
 (0)