@soleri/core 0.0.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/brain/brain.d.ts +85 -0
- package/dist/brain/brain.d.ts.map +1 -0
- package/dist/brain/brain.js +506 -0
- package/dist/brain/brain.js.map +1 -0
- package/dist/cognee/client.d.ts +35 -0
- package/dist/cognee/client.d.ts.map +1 -0
- package/dist/cognee/client.js +289 -0
- package/dist/cognee/client.js.map +1 -0
- package/dist/cognee/types.d.ts +46 -0
- package/dist/cognee/types.d.ts.map +1 -0
- package/dist/cognee/types.js +3 -0
- package/dist/cognee/types.js.map +1 -0
- package/dist/facades/facade-factory.d.ts +5 -0
- package/dist/facades/facade-factory.d.ts.map +1 -0
- package/dist/facades/facade-factory.js +49 -0
- package/dist/facades/facade-factory.js.map +1 -0
- package/dist/facades/types.d.ts +42 -0
- package/dist/facades/types.d.ts.map +1 -0
- package/dist/facades/types.js +6 -0
- package/dist/facades/types.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/intelligence/loader.d.ts +3 -0
- package/dist/intelligence/loader.d.ts.map +1 -0
- package/dist/intelligence/loader.js +41 -0
- package/dist/intelligence/loader.js.map +1 -0
- package/dist/intelligence/types.d.ts +20 -0
- package/dist/intelligence/types.d.ts.map +1 -0
- package/dist/intelligence/types.js +2 -0
- package/dist/intelligence/types.js.map +1 -0
- package/dist/llm/key-pool.d.ts +38 -0
- package/dist/llm/key-pool.d.ts.map +1 -0
- package/dist/llm/key-pool.js +154 -0
- package/dist/llm/key-pool.js.map +1 -0
- package/dist/llm/types.d.ts +80 -0
- package/dist/llm/types.d.ts.map +1 -0
- package/dist/llm/types.js +37 -0
- package/dist/llm/types.js.map +1 -0
- package/dist/llm/utils.d.ts +26 -0
- package/dist/llm/utils.d.ts.map +1 -0
- package/dist/llm/utils.js +197 -0
- package/dist/llm/utils.js.map +1 -0
- package/dist/planning/planner.d.ts +48 -0
- package/dist/planning/planner.d.ts.map +1 -0
- package/dist/planning/planner.js +109 -0
- package/dist/planning/planner.js.map +1 -0
- package/dist/vault/vault.d.ts +80 -0
- package/dist/vault/vault.d.ts.map +1 -0
- package/dist/vault/vault.js +353 -0
- package/dist/vault/vault.js.map +1 -0
- package/package.json +56 -4
- package/src/__tests__/brain.test.ts +740 -0
- package/src/__tests__/cognee-client.test.ts +524 -0
- package/src/__tests__/llm.test.ts +556 -0
- package/src/__tests__/loader.test.ts +176 -0
- package/src/__tests__/planner.test.ts +261 -0
- package/src/__tests__/vault.test.ts +494 -0
- package/src/brain/brain.ts +678 -0
- package/src/cognee/client.ts +350 -0
- package/src/cognee/types.ts +62 -0
- package/src/facades/facade-factory.ts +64 -0
- package/src/facades/types.ts +42 -0
- package/src/index.ts +75 -0
- package/src/intelligence/loader.ts +42 -0
- package/src/intelligence/types.ts +20 -0
- package/src/llm/key-pool.ts +190 -0
- package/src/llm/types.ts +116 -0
- package/src/llm/utils.ts +248 -0
- package/src/planning/planner.ts +151 -0
- package/src/vault/vault.ts +455 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SecretString, LLMError } from '../llm/types.js';
|
|
3
|
+
import {
|
|
4
|
+
CircuitBreaker,
|
|
5
|
+
CircuitOpenError,
|
|
6
|
+
computeDelay,
|
|
7
|
+
retry,
|
|
8
|
+
parseRateLimitHeaders,
|
|
9
|
+
} from '../llm/utils.js';
|
|
10
|
+
import { KeyPool, loadKeyPoolConfig } from '../llm/key-pool.js';
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// SecretString
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
describe('SecretString', () => {
|
|
17
|
+
it('should expose the original value', () => {
|
|
18
|
+
const s = new SecretString('my-secret-key');
|
|
19
|
+
expect(s.expose()).toBe('my-secret-key');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should redact on toString', () => {
|
|
23
|
+
const s = new SecretString('sk-123456');
|
|
24
|
+
expect(String(s)).toBe('[REDACTED]');
|
|
25
|
+
expect(`${s}`).toBe('[REDACTED]');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should redact on JSON.stringify', () => {
|
|
29
|
+
const s = new SecretString('sk-123456');
|
|
30
|
+
const json = JSON.stringify({ key: s });
|
|
31
|
+
expect(json).toContain('[REDACTED]');
|
|
32
|
+
expect(json).not.toContain('sk-123456');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should report isSet correctly', () => {
|
|
36
|
+
expect(new SecretString('value').isSet).toBe(true);
|
|
37
|
+
expect(new SecretString('').isSet).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should redact in console.log via inspect', () => {
|
|
41
|
+
const s = new SecretString('sk-secret');
|
|
42
|
+
const inspectKey = Symbol.for('nodejs.util.inspect.custom');
|
|
43
|
+
expect((s as any)[inspectKey]()).toBe('[REDACTED]');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// LLMError
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
describe('LLMError', () => {
|
|
52
|
+
it('should create with default non-retryable', () => {
|
|
53
|
+
const err = new LLMError('test error');
|
|
54
|
+
expect(err.message).toBe('test error');
|
|
55
|
+
expect(err.retryable).toBe(false);
|
|
56
|
+
expect(err.name).toBe('LLMError');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should create retryable error', () => {
|
|
60
|
+
const err = new LLMError('rate limited', { retryable: true, statusCode: 429 });
|
|
61
|
+
expect(err.retryable).toBe(true);
|
|
62
|
+
expect(err.statusCode).toBe(429);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should be instanceof Error', () => {
|
|
66
|
+
const err = new LLMError('test');
|
|
67
|
+
expect(err).toBeInstanceOf(Error);
|
|
68
|
+
expect(err).toBeInstanceOf(LLMError);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// =============================================================================
|
|
73
|
+
// CircuitBreaker
|
|
74
|
+
// =============================================================================
|
|
75
|
+
|
|
76
|
+
describe('CircuitBreaker', () => {
|
|
77
|
+
it('should start in closed state', () => {
|
|
78
|
+
const cb = new CircuitBreaker({ name: 'test' });
|
|
79
|
+
const state = cb.getState();
|
|
80
|
+
expect(state.state).toBe('closed');
|
|
81
|
+
expect(state.failureCount).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should pass through successful calls', async () => {
|
|
85
|
+
const cb = new CircuitBreaker({ name: 'test' });
|
|
86
|
+
const result = await cb.call(async () => 'ok');
|
|
87
|
+
expect(result).toBe('ok');
|
|
88
|
+
expect(cb.getState().state).toBe('closed');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should count retryable failures', async () => {
|
|
92
|
+
const cb = new CircuitBreaker({ name: 'test', failureThreshold: 3 });
|
|
93
|
+
const retryableError = new LLMError('fail', { retryable: true });
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < 2; i++) {
|
|
96
|
+
await expect(
|
|
97
|
+
cb.call(async () => {
|
|
98
|
+
throw retryableError;
|
|
99
|
+
}),
|
|
100
|
+
).rejects.toThrow();
|
|
101
|
+
}
|
|
102
|
+
expect(cb.getState().failureCount).toBe(2);
|
|
103
|
+
expect(cb.getState().state).toBe('closed');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should open after reaching failure threshold', async () => {
|
|
107
|
+
const cb = new CircuitBreaker({ name: 'test', failureThreshold: 3, resetTimeoutMs: 60_000 });
|
|
108
|
+
const retryableError = new LLMError('fail', { retryable: true });
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < 3; i++) {
|
|
111
|
+
await expect(
|
|
112
|
+
cb.call(async () => {
|
|
113
|
+
throw retryableError;
|
|
114
|
+
}),
|
|
115
|
+
).rejects.toThrow();
|
|
116
|
+
}
|
|
117
|
+
expect(cb.getState().state).toBe('open');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should reject calls when open', async () => {
|
|
121
|
+
const cb = new CircuitBreaker({
|
|
122
|
+
name: 'test-reject',
|
|
123
|
+
failureThreshold: 1,
|
|
124
|
+
resetTimeoutMs: 60_000,
|
|
125
|
+
});
|
|
126
|
+
const retryableError = new LLMError('fail', { retryable: true });
|
|
127
|
+
|
|
128
|
+
await expect(
|
|
129
|
+
cb.call(async () => {
|
|
130
|
+
throw retryableError;
|
|
131
|
+
}),
|
|
132
|
+
).rejects.toThrow();
|
|
133
|
+
await expect(cb.call(async () => 'ok')).rejects.toThrow(CircuitOpenError);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should not count non-retryable failures', async () => {
|
|
137
|
+
const cb = new CircuitBreaker({ name: 'test', failureThreshold: 3 });
|
|
138
|
+
const nonRetryable = new LLMError('auth fail', { retryable: false });
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < 5; i++) {
|
|
141
|
+
await expect(
|
|
142
|
+
cb.call(async () => {
|
|
143
|
+
throw nonRetryable;
|
|
144
|
+
}),
|
|
145
|
+
).rejects.toThrow();
|
|
146
|
+
}
|
|
147
|
+
expect(cb.getState().state).toBe('closed');
|
|
148
|
+
expect(cb.getState().failureCount).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should reset to closed on success', async () => {
|
|
152
|
+
const cb = new CircuitBreaker({ name: 'test', failureThreshold: 3 });
|
|
153
|
+
const retryableError = new LLMError('fail', { retryable: true });
|
|
154
|
+
|
|
155
|
+
await expect(
|
|
156
|
+
cb.call(async () => {
|
|
157
|
+
throw retryableError;
|
|
158
|
+
}),
|
|
159
|
+
).rejects.toThrow();
|
|
160
|
+
await expect(
|
|
161
|
+
cb.call(async () => {
|
|
162
|
+
throw retryableError;
|
|
163
|
+
}),
|
|
164
|
+
).rejects.toThrow();
|
|
165
|
+
expect(cb.getState().failureCount).toBe(2);
|
|
166
|
+
|
|
167
|
+
await cb.call(async () => 'recovered');
|
|
168
|
+
expect(cb.getState().failureCount).toBe(0);
|
|
169
|
+
expect(cb.getState().state).toBe('closed');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should trip synchronously via recordFailure', () => {
|
|
173
|
+
const cb = new CircuitBreaker({ name: 'sync-trip', failureThreshold: 2 });
|
|
174
|
+
cb.recordFailure();
|
|
175
|
+
expect(cb.getState().state).toBe('closed');
|
|
176
|
+
expect(cb.getState().failureCount).toBe(1);
|
|
177
|
+
cb.recordFailure();
|
|
178
|
+
expect(cb.getState().state).toBe('open');
|
|
179
|
+
expect(cb.getState().failureCount).toBe(2);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should transition to half-open after timeout', async () => {
|
|
183
|
+
const cb = new CircuitBreaker({
|
|
184
|
+
name: 'test-halfopen',
|
|
185
|
+
failureThreshold: 1,
|
|
186
|
+
resetTimeoutMs: 10,
|
|
187
|
+
});
|
|
188
|
+
const retryableError = new LLMError('fail', { retryable: true });
|
|
189
|
+
|
|
190
|
+
await expect(
|
|
191
|
+
cb.call(async () => {
|
|
192
|
+
throw retryableError;
|
|
193
|
+
}),
|
|
194
|
+
).rejects.toThrow();
|
|
195
|
+
expect(cb.getState().state).toBe('open');
|
|
196
|
+
|
|
197
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
198
|
+
|
|
199
|
+
expect(cb.getState().state).toBe('half-open');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should close on successful probe in half-open', async () => {
|
|
203
|
+
const cb = new CircuitBreaker({ name: 'test-probe', failureThreshold: 1, resetTimeoutMs: 10 });
|
|
204
|
+
const retryableError = new LLMError('fail', { retryable: true });
|
|
205
|
+
|
|
206
|
+
await expect(
|
|
207
|
+
cb.call(async () => {
|
|
208
|
+
throw retryableError;
|
|
209
|
+
}),
|
|
210
|
+
).rejects.toThrow();
|
|
211
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
212
|
+
|
|
213
|
+
const result = await cb.call(async () => 'probe-success');
|
|
214
|
+
expect(result).toBe('probe-success');
|
|
215
|
+
expect(cb.getState().state).toBe('closed');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should re-open on failed probe in half-open', async () => {
|
|
219
|
+
const cb = new CircuitBreaker({
|
|
220
|
+
name: 'test-reprobe',
|
|
221
|
+
failureThreshold: 1,
|
|
222
|
+
resetTimeoutMs: 10,
|
|
223
|
+
});
|
|
224
|
+
const retryableError = new LLMError('fail', { retryable: true });
|
|
225
|
+
|
|
226
|
+
await expect(
|
|
227
|
+
cb.call(async () => {
|
|
228
|
+
throw retryableError;
|
|
229
|
+
}),
|
|
230
|
+
).rejects.toThrow();
|
|
231
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
cb.call(async () => {
|
|
235
|
+
throw retryableError;
|
|
236
|
+
}),
|
|
237
|
+
).rejects.toThrow();
|
|
238
|
+
expect(cb.getState().state).toBe('open');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should support manual reset', async () => {
|
|
242
|
+
const cb = new CircuitBreaker({
|
|
243
|
+
name: 'test-reset',
|
|
244
|
+
failureThreshold: 1,
|
|
245
|
+
resetTimeoutMs: 60_000,
|
|
246
|
+
});
|
|
247
|
+
const retryableError = new LLMError('fail', { retryable: true });
|
|
248
|
+
|
|
249
|
+
await expect(
|
|
250
|
+
cb.call(async () => {
|
|
251
|
+
throw retryableError;
|
|
252
|
+
}),
|
|
253
|
+
).rejects.toThrow();
|
|
254
|
+
expect(cb.getState().state).toBe('open');
|
|
255
|
+
|
|
256
|
+
cb.reset();
|
|
257
|
+
expect(cb.getState().state).toBe('closed');
|
|
258
|
+
expect(cb.getState().failureCount).toBe(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('isOpen should report correctly', async () => {
|
|
262
|
+
const cb = new CircuitBreaker({
|
|
263
|
+
name: 'test-isopen',
|
|
264
|
+
failureThreshold: 1,
|
|
265
|
+
resetTimeoutMs: 60_000,
|
|
266
|
+
});
|
|
267
|
+
expect(cb.isOpen()).toBe(false);
|
|
268
|
+
|
|
269
|
+
const retryableError = new LLMError('fail', { retryable: true });
|
|
270
|
+
await expect(
|
|
271
|
+
cb.call(async () => {
|
|
272
|
+
throw retryableError;
|
|
273
|
+
}),
|
|
274
|
+
).rejects.toThrow();
|
|
275
|
+
expect(cb.isOpen()).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// =============================================================================
|
|
280
|
+
// Retry
|
|
281
|
+
// =============================================================================
|
|
282
|
+
|
|
283
|
+
describe('retry', () => {
|
|
284
|
+
it('should return on first success', async () => {
|
|
285
|
+
const result = await retry(async () => 'ok', { maxAttempts: 3 });
|
|
286
|
+
expect(result).toBe('ok');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should retry on retryable errors', async () => {
|
|
290
|
+
let attempts = 0;
|
|
291
|
+
const result = await retry(
|
|
292
|
+
async () => {
|
|
293
|
+
attempts++;
|
|
294
|
+
if (attempts < 3) {
|
|
295
|
+
const err = new LLMError('fail', { retryable: true });
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
return 'ok';
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
maxAttempts: 3,
|
|
302
|
+
baseDelayMs: 1,
|
|
303
|
+
maxDelayMs: 10,
|
|
304
|
+
shouldRetry: (e) => (e as any).retryable === true,
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
expect(result).toBe('ok');
|
|
308
|
+
expect(attempts).toBe(3);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('should not retry non-retryable errors', async () => {
|
|
312
|
+
let attempts = 0;
|
|
313
|
+
await expect(
|
|
314
|
+
retry(
|
|
315
|
+
async () => {
|
|
316
|
+
attempts++;
|
|
317
|
+
throw new LLMError('auth fail', { retryable: false });
|
|
318
|
+
},
|
|
319
|
+
{ maxAttempts: 3, baseDelayMs: 1 },
|
|
320
|
+
),
|
|
321
|
+
).rejects.toThrow('auth fail');
|
|
322
|
+
expect(attempts).toBe(1);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should throw last error after exhausting attempts', async () => {
|
|
326
|
+
let attempts = 0;
|
|
327
|
+
await expect(
|
|
328
|
+
retry(
|
|
329
|
+
async () => {
|
|
330
|
+
attempts++;
|
|
331
|
+
throw new LLMError(`fail ${attempts}`, { retryable: true });
|
|
332
|
+
},
|
|
333
|
+
{ maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 5, shouldRetry: () => true },
|
|
334
|
+
),
|
|
335
|
+
).rejects.toThrow('fail 2');
|
|
336
|
+
expect(attempts).toBe(2);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should call onRetry callback', async () => {
|
|
340
|
+
const retries: number[] = [];
|
|
341
|
+
let attempts = 0;
|
|
342
|
+
await retry(
|
|
343
|
+
async () => {
|
|
344
|
+
attempts++;
|
|
345
|
+
if (attempts < 2) throw new LLMError('fail', { retryable: true });
|
|
346
|
+
return 'ok';
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
maxAttempts: 3,
|
|
350
|
+
baseDelayMs: 1,
|
|
351
|
+
maxDelayMs: 5,
|
|
352
|
+
shouldRetry: () => true,
|
|
353
|
+
onRetry: (_err, attempt) => retries.push(attempt),
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
expect(retries).toEqual([1]);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// =============================================================================
|
|
361
|
+
// computeDelay
|
|
362
|
+
// =============================================================================
|
|
363
|
+
|
|
364
|
+
describe('computeDelay', () => {
|
|
365
|
+
it('should compute exponential backoff', () => {
|
|
366
|
+
const config = { maxAttempts: 3, baseDelayMs: 1000, maxDelayMs: 30000, jitter: 0 };
|
|
367
|
+
expect(computeDelay(null, 0, config)).toBe(1000);
|
|
368
|
+
expect(computeDelay(null, 1, config)).toBe(2000);
|
|
369
|
+
expect(computeDelay(null, 2, config)).toBe(4000);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should cap at maxDelayMs', () => {
|
|
373
|
+
const config = { maxAttempts: 3, baseDelayMs: 1000, maxDelayMs: 5000, jitter: 0 };
|
|
374
|
+
expect(computeDelay(null, 10, config)).toBe(5000);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('should respect retryAfterMs from error', () => {
|
|
378
|
+
const config = { maxAttempts: 3, baseDelayMs: 1000, maxDelayMs: 30000, jitter: 0 };
|
|
379
|
+
const error = { retryAfterMs: 2500 };
|
|
380
|
+
expect(computeDelay(error, 0, config)).toBe(2500);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should apply jitter within range', () => {
|
|
384
|
+
const config = { maxAttempts: 3, baseDelayMs: 1000, maxDelayMs: 30000, jitter: 0.1 };
|
|
385
|
+
const delays = new Set<number>();
|
|
386
|
+
for (let i = 0; i < 20; i++) {
|
|
387
|
+
delays.add(computeDelay(null, 0, config));
|
|
388
|
+
}
|
|
389
|
+
for (const d of delays) {
|
|
390
|
+
expect(d).toBeGreaterThanOrEqual(900);
|
|
391
|
+
expect(d).toBeLessThanOrEqual(1100);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// =============================================================================
|
|
397
|
+
// parseRateLimitHeaders
|
|
398
|
+
// =============================================================================
|
|
399
|
+
|
|
400
|
+
describe('parseRateLimitHeaders', () => {
|
|
401
|
+
it('should parse remaining requests header', () => {
|
|
402
|
+
const headers = new Headers({
|
|
403
|
+
'x-ratelimit-remaining-requests': '42',
|
|
404
|
+
});
|
|
405
|
+
const info = parseRateLimitHeaders(headers);
|
|
406
|
+
expect(info.remaining).toBe(42);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('should parse reset duration (6m0s)', () => {
|
|
410
|
+
const headers = new Headers({
|
|
411
|
+
'x-ratelimit-reset-requests': '6m0s',
|
|
412
|
+
});
|
|
413
|
+
const info = parseRateLimitHeaders(headers);
|
|
414
|
+
expect(info.resetMs).toBe(360000);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should parse reset duration (200ms)', () => {
|
|
418
|
+
const headers = new Headers({
|
|
419
|
+
'x-ratelimit-reset-requests': '200ms',
|
|
420
|
+
});
|
|
421
|
+
const info = parseRateLimitHeaders(headers);
|
|
422
|
+
expect(info.resetMs).toBe(200);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('should parse reset duration (1s)', () => {
|
|
426
|
+
const headers = new Headers({
|
|
427
|
+
'x-ratelimit-reset-requests': '1s',
|
|
428
|
+
});
|
|
429
|
+
const info = parseRateLimitHeaders(headers);
|
|
430
|
+
expect(info.resetMs).toBe(1000);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should parse retry-after header (seconds)', () => {
|
|
434
|
+
const headers = new Headers({
|
|
435
|
+
'retry-after': '2.5',
|
|
436
|
+
});
|
|
437
|
+
const info = parseRateLimitHeaders(headers);
|
|
438
|
+
expect(info.retryAfterMs).toBe(2500);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('should return nulls for missing headers', () => {
|
|
442
|
+
const headers = new Headers({});
|
|
443
|
+
const info = parseRateLimitHeaders(headers);
|
|
444
|
+
expect(info.remaining).toBeNull();
|
|
445
|
+
expect(info.resetMs).toBeNull();
|
|
446
|
+
expect(info.retryAfterMs).toBeNull();
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// =============================================================================
|
|
451
|
+
// KeyPool
|
|
452
|
+
// =============================================================================
|
|
453
|
+
|
|
454
|
+
describe('KeyPool', () => {
|
|
455
|
+
it('should initialize with keys', () => {
|
|
456
|
+
const pool = new KeyPool({ keys: ['sk-1', 'sk-2'] });
|
|
457
|
+
expect(pool.hasKeys).toBe(true);
|
|
458
|
+
expect(pool.poolSize).toBe(2);
|
|
459
|
+
expect(pool.activeKeyIndex).toBe(0);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should filter empty keys', () => {
|
|
463
|
+
const pool = new KeyPool({ keys: ['sk-1', '', 'sk-2'] });
|
|
464
|
+
expect(pool.poolSize).toBe(2);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('should report no keys when empty', () => {
|
|
468
|
+
const pool = new KeyPool({ keys: [] });
|
|
469
|
+
expect(pool.hasKeys).toBe(false);
|
|
470
|
+
expect(pool.exhausted).toBe(true);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('should return active key as SecretString', () => {
|
|
474
|
+
const pool = new KeyPool({ keys: ['sk-test-key'] });
|
|
475
|
+
const key = pool.getActiveKey();
|
|
476
|
+
expect(key.expose()).toBe('sk-test-key');
|
|
477
|
+
expect(String(key)).toBe('[REDACTED]');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('should rotate on error', () => {
|
|
481
|
+
const pool = new KeyPool({ keys: ['sk-1', 'sk-2', 'sk-3'] });
|
|
482
|
+
expect(pool.activeKeyIndex).toBe(0);
|
|
483
|
+
|
|
484
|
+
const newKey = pool.rotateOnError();
|
|
485
|
+
expect(newKey).not.toBeNull();
|
|
486
|
+
expect(pool.activeKeyIndex).toBe(1);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('should preemptively rotate when quota low', () => {
|
|
490
|
+
const pool = new KeyPool({ keys: ['sk-1', 'sk-2'], preemptiveThreshold: 50 });
|
|
491
|
+
pool.updateQuota(0, 10);
|
|
492
|
+
const rotated = pool.rotatePreemptive();
|
|
493
|
+
expect(rotated).toBe(true);
|
|
494
|
+
expect(pool.activeKeyIndex).toBe(1);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('should not rotate when quota above threshold', () => {
|
|
498
|
+
const pool = new KeyPool({ keys: ['sk-1', 'sk-2'], preemptiveThreshold: 50 });
|
|
499
|
+
pool.updateQuota(0, 100);
|
|
500
|
+
const rotated = pool.rotatePreemptive();
|
|
501
|
+
expect(rotated).toBe(false);
|
|
502
|
+
expect(pool.activeKeyIndex).toBe(0);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('should report exhausted when all keys breakers are open', () => {
|
|
506
|
+
const pool = new KeyPool({ keys: ['sk-1'] });
|
|
507
|
+
expect(pool.exhausted).toBe(false);
|
|
508
|
+
|
|
509
|
+
pool.rotateOnError();
|
|
510
|
+
pool.rotateOnError();
|
|
511
|
+
pool.rotateOnError();
|
|
512
|
+
expect(pool.exhausted).toBe(true);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('should return status with per-key info', () => {
|
|
516
|
+
const pool = new KeyPool({ keys: ['sk-1', 'sk-2'] });
|
|
517
|
+
pool.updateQuota(0, 100);
|
|
518
|
+
const status = pool.getStatus();
|
|
519
|
+
expect(status.poolSize).toBe(2);
|
|
520
|
+
expect(status.activeKeyIndex).toBe(0);
|
|
521
|
+
expect(status.exhausted).toBe(false);
|
|
522
|
+
expect(status.perKeyStatus).toHaveLength(2);
|
|
523
|
+
expect(status.perKeyStatus[0].remainingQuota).toBe(100);
|
|
524
|
+
expect(status.perKeyStatus[1].remainingQuota).toBeNull();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('should wrap around when rotating past last key', () => {
|
|
528
|
+
const pool = new KeyPool({ keys: ['sk-1', 'sk-2'] });
|
|
529
|
+
pool.rotateOnError();
|
|
530
|
+
expect(pool.activeKeyIndex).toBe(1);
|
|
531
|
+
pool.rotateOnError();
|
|
532
|
+
expect(pool.activeKeyIndex).toBe(0);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// =============================================================================
|
|
537
|
+
// loadKeyPoolConfig
|
|
538
|
+
// =============================================================================
|
|
539
|
+
|
|
540
|
+
describe('loadKeyPoolConfig', () => {
|
|
541
|
+
it('should return empty pools when no keys configured', () => {
|
|
542
|
+
const origOpenai = process.env.OPENAI_API_KEY;
|
|
543
|
+
const origAnthropic = process.env.ANTHROPIC_API_KEY;
|
|
544
|
+
delete process.env.OPENAI_API_KEY;
|
|
545
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
546
|
+
|
|
547
|
+
try {
|
|
548
|
+
const config = loadKeyPoolConfig('test-agent-nonexistent');
|
|
549
|
+
expect(config.openai.keys).toBeDefined();
|
|
550
|
+
expect(config.anthropic.keys).toBeDefined();
|
|
551
|
+
} finally {
|
|
552
|
+
if (origOpenai) process.env.OPENAI_API_KEY = origOpenai;
|
|
553
|
+
if (origAnthropic) process.env.ANTHROPIC_API_KEY = origAnthropic;
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
});
|