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