keystone-cli 1.3.0 → 2.0.1

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 (42) hide show
  1. package/README.md +127 -140
  2. package/package.json +6 -3
  3. package/src/cli.ts +54 -369
  4. package/src/commands/init.ts +15 -29
  5. package/src/db/memory-db.test.ts +45 -0
  6. package/src/db/memory-db.ts +47 -21
  7. package/src/db/sqlite-setup.ts +26 -3
  8. package/src/db/workflow-db.ts +12 -5
  9. package/src/parser/config-schema.ts +17 -13
  10. package/src/parser/schema.ts +4 -2
  11. package/src/runner/__test__/llm-mock-setup.ts +173 -0
  12. package/src/runner/__test__/llm-test-setup.ts +271 -0
  13. package/src/runner/engine-executor.test.ts +25 -18
  14. package/src/runner/executors/blueprint-executor.ts +0 -1
  15. package/src/runner/executors/dynamic-executor.ts +11 -6
  16. package/src/runner/executors/engine-executor.ts +5 -1
  17. package/src/runner/executors/llm-executor.ts +502 -1033
  18. package/src/runner/executors/memory-executor.ts +35 -19
  19. package/src/runner/executors/plan-executor.ts +0 -1
  20. package/src/runner/executors/types.ts +4 -4
  21. package/src/runner/llm-adapter.integration.test.ts +151 -0
  22. package/src/runner/llm-adapter.ts +270 -1398
  23. package/src/runner/llm-clarification.test.ts +91 -106
  24. package/src/runner/llm-executor.test.ts +217 -1181
  25. package/src/runner/memoization.test.ts +0 -1
  26. package/src/runner/recovery-security.test.ts +51 -20
  27. package/src/runner/reflexion.test.ts +55 -18
  28. package/src/runner/standard-tools-integration.test.ts +137 -87
  29. package/src/runner/step-executor.test.ts +36 -80
  30. package/src/runner/step-executor.ts +0 -2
  31. package/src/runner/test-harness.ts +3 -29
  32. package/src/runner/tool-integration.test.ts +122 -73
  33. package/src/runner/workflow-runner.ts +110 -49
  34. package/src/runner/workflow-scheduler.ts +11 -1
  35. package/src/runner/workflow-summary.ts +144 -0
  36. package/src/utils/auth-manager.test.ts +10 -520
  37. package/src/utils/auth-manager.ts +3 -756
  38. package/src/utils/config-loader.ts +12 -0
  39. package/src/utils/constants.ts +0 -17
  40. package/src/utils/process-sandbox.ts +15 -3
  41. package/src/runner/llm-adapter-runtime.test.ts +0 -209
  42. package/src/runner/llm-adapter.test.ts +0 -1012
@@ -1,1012 +0,0 @@
1
- import {
2
- afterAll,
3
- afterEach,
4
- beforeAll,
5
- beforeEach,
6
- describe,
7
- expect,
8
- it,
9
- mock,
10
- spyOn,
11
- } from 'bun:test';
12
- import * as fs from 'node:fs';
13
- import { join } from 'node:path';
14
- import { AuthManager } from '../utils/auth-manager';
15
- import { ConfigLoader } from '../utils/config-loader';
16
- import { ConsoleLogger } from '../utils/logger';
17
- import {
18
- AnthropicAdapter,
19
- AnthropicClaudeAdapter,
20
- CopilotAdapter,
21
- GoogleGeminiAdapter,
22
- type LLMMessage,
23
- LocalEmbeddingAdapter,
24
- OpenAIAdapter,
25
- OpenAIChatGPTAdapter,
26
- getAdapter,
27
- resetRuntimeHelpers,
28
- } from './llm-adapter';
29
-
30
- // Set a temporary auth path for all tests to avoid state leakage
31
- process.env.KEYSTONE_AUTH_PATH = join(process.cwd(), 'temp-auth-adapter-test.json');
32
-
33
- interface MockFetch {
34
- mock: {
35
- calls: unknown[][];
36
- };
37
- }
38
-
39
- describe('OpenAIAdapter', () => {
40
- const originalFetch = global.fetch;
41
-
42
- beforeEach(() => {
43
- // @ts-ignore
44
- global.fetch = mock();
45
- });
46
-
47
- afterEach(() => {
48
- global.fetch = originalFetch;
49
- mock.restore();
50
- });
51
-
52
- it('should call the OpenAI API correctly', async () => {
53
- const mockResponse = {
54
- choices: [{ message: { role: 'assistant', content: 'hello' } }],
55
- usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
56
- };
57
-
58
- // @ts-ignore
59
- global.fetch = mock(() =>
60
- Promise.resolve(
61
- new Response(JSON.stringify(mockResponse), {
62
- status: 200,
63
- headers: { 'Content-Type': 'application/json' },
64
- })
65
- )
66
- );
67
-
68
- const adapter = new OpenAIAdapter('fake-key');
69
- const response = await adapter.chat([{ role: 'user', content: 'hi' }]);
70
-
71
- expect(response.message.content).toBe('hello');
72
- expect(response.usage?.total_tokens).toBe(15);
73
-
74
- // @ts-ignore
75
- const fetchMock = global.fetch;
76
- // @ts-ignore
77
- const fetchCall = fetchMock.mock.calls[0];
78
- expect(fetchCall[0]).toBe('https://api.openai.com/v1/chat/completions');
79
- expect(fetchCall[1].headers.Authorization).toBe('Bearer fake-key');
80
- });
81
-
82
- it('should handle API errors', async () => {
83
- // @ts-ignore
84
- global.fetch = mock(() =>
85
- Promise.resolve(
86
- new Response('Error message', {
87
- status: 400,
88
- statusText: 'Bad Request',
89
- })
90
- )
91
- );
92
-
93
- const adapter = new OpenAIAdapter('fake-key');
94
- await expect(adapter.chat([])).rejects.toThrow(/OpenAI API error: 400 Bad Request/);
95
- });
96
-
97
- it('should call the embeddings endpoint', async () => {
98
- const mockResponse = {
99
- data: [{ embedding: [0.1, 0.2, 0.3] }],
100
- };
101
-
102
- // @ts-ignore
103
- global.fetch = mock(() =>
104
- Promise.resolve(
105
- new Response(JSON.stringify(mockResponse), {
106
- status: 200,
107
- headers: { 'Content-Type': 'application/json' },
108
- })
109
- )
110
- );
111
-
112
- const adapter = new OpenAIAdapter('fake-key');
113
- const embedding = await adapter.embed('hello');
114
- expect(embedding).toEqual([0.1, 0.2, 0.3]);
115
-
116
- // @ts-ignore
117
- const fetchMock = global.fetch as MockFetch;
118
- // @ts-ignore
119
- const [url, init] = fetchMock.mock.calls[0] as [string, any];
120
- expect(url).toBe('https://api.openai.com/v1/embeddings');
121
- expect(init.headers.Authorization).toBe('Bearer fake-key');
122
- });
123
- });
124
-
125
- describe('GoogleGeminiAdapter', () => {
126
- it('should handle Gemini API errors', async () => {
127
- // @ts-ignore
128
- global.fetch = mock(() =>
129
- Promise.resolve(
130
- new Response(
131
- JSON.stringify({ error: { message: 'Bad Request', status: 'INVALID_ARGUMENT' } }),
132
- {
133
- status: 400,
134
- }
135
- )
136
- )
137
- );
138
-
139
- spyOn(AuthManager, 'getGoogleGeminiToken').mockResolvedValue('fake-token');
140
- const adapter = new GoogleGeminiAdapter('gemini-1.5-pro');
141
- await expect(adapter.chat([])).rejects.toThrow(/Bad Request/);
142
- });
143
- });
144
-
145
- describe('OpenAIChatGPTAdapter Message Filtering', () => {
146
- const originalFetch = global.fetch;
147
-
148
- beforeEach(() => {
149
- mock.restore();
150
- });
151
-
152
- afterEach(() => {
153
- global.fetch = originalFetch;
154
- mock.restore();
155
- });
156
-
157
- it('should filter developer messages correctly', async () => {
158
- spyOn(AuthManager, 'getOpenAIChatGPTToken').mockResolvedValue('fake-token');
159
- const adapter = new OpenAIChatGPTAdapter();
160
- // @ts-ignore
161
- global.fetch = mock(() =>
162
- Promise.resolve(
163
- new Response(
164
- JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'filtered' } }] })
165
- )
166
- )
167
- );
168
-
169
- await adapter.chat(
170
- [
171
- { role: 'system', content: 'sys' },
172
- { role: 'user', content: 'hi' },
173
- ],
174
- { model: 'gpt-4o' }
175
- );
176
-
177
- const call = (global.fetch as any).mock.calls[0];
178
- const body = JSON.parse(call[1].body);
179
- expect(body.messages[0].role).toBe('developer'); // gpt-4o maps system to developer
180
- });
181
- });
182
-
183
- describe('AnthropicAdapter Token Accumulation', () => {
184
- it('should accumulate tokens from usage metadata in streaming', async () => {
185
- const adapter = new AnthropicAdapter('claude-3-5-sonnet-20241022');
186
- const mockStream = (async function* () {
187
- yield { type: 'message_start', message: { usage: { input_tokens: 10 } } };
188
- yield { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } };
189
- yield { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hello' } };
190
- yield { type: 'message_delta', usage: { output_tokens: 5 } };
191
- yield { type: 'message_stop' };
192
- })();
193
-
194
- // @ts-ignore
195
- global.fetch = mock(() =>
196
- Promise.resolve(
197
- new Response(
198
- new ReadableStream({
199
- async start(controller) {
200
- for await (const chunk of mockStream) {
201
- controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`));
202
- }
203
- controller.close();
204
- },
205
- }),
206
- { status: 200 }
207
- )
208
- )
209
- );
210
-
211
- const response = await adapter.chat([{ role: 'user', content: 'hi' }], {
212
- onStream: () => {},
213
- });
214
- expect(response.usage).toEqual({
215
- prompt_tokens: 10,
216
- completion_tokens: 5,
217
- total_tokens: 15,
218
- });
219
- });
220
- });
221
-
222
- describe('AnthropicAdapter', () => {
223
- const originalFetch = global.fetch;
224
-
225
- beforeEach(() => {
226
- // @ts-ignore
227
- global.fetch = mock();
228
- });
229
-
230
- afterEach(() => {
231
- global.fetch = originalFetch;
232
- mock.restore();
233
- });
234
-
235
- it('should map messages correctly and call Anthropic API', async () => {
236
- const mockResponse = {
237
- content: [{ type: 'text', text: 'hello from claude' }],
238
- usage: { input_tokens: 10, output_tokens: 5 },
239
- };
240
-
241
- // @ts-ignore
242
- global.fetch.mockResolvedValue(
243
- new Response(JSON.stringify(mockResponse), {
244
- status: 200,
245
- headers: { 'Content-Type': 'application/json' },
246
- })
247
- );
248
-
249
- const adapter = new AnthropicAdapter('fake-anthropic-key');
250
- const response = await adapter.chat([
251
- { role: 'system', content: 'You are a bot' },
252
- { role: 'user', content: 'hi' },
253
- ]);
254
-
255
- expect(response.message.content).toBe('hello from claude');
256
- expect(response.usage?.total_tokens).toBe(15);
257
-
258
- // @ts-ignore
259
- const fetchMock = global.fetch as MockFetch;
260
- // @ts-ignore
261
- // @ts-ignore
262
- const [url, init] = fetchMock.mock.calls[0] as [string, any];
263
-
264
- expect(url).toBe('https://api.anthropic.com/v1/messages');
265
- expect(init.headers['x-api-key']).toBe('fake-anthropic-key');
266
-
267
- const body = JSON.parse(init.body);
268
- expect(body.system).toBe('You are a bot');
269
- expect(body.messages[0].role).toBe('user');
270
- expect(body.messages[0].content).toBe('hi');
271
- });
272
-
273
- it('should handle tool calls correctly', async () => {
274
- const mockResponse = {
275
- content: [
276
- {
277
- type: 'tool_use',
278
- id: 'tool_1',
279
- name: 'get_weather',
280
- input: { city: 'San Francisco' },
281
- },
282
- ],
283
- usage: { input_tokens: 10, output_tokens: 5 },
284
- };
285
-
286
- // @ts-ignore
287
- global.fetch.mockResolvedValue(
288
- new Response(JSON.stringify(mockResponse), {
289
- status: 200,
290
- headers: { 'Content-Type': 'application/json' },
291
- })
292
- );
293
-
294
- const adapter = new AnthropicAdapter('fake-key');
295
- const response = await adapter.chat([{ role: 'user', content: 'what is the weather?' }], {
296
- tools: [
297
- {
298
- type: 'function',
299
- function: {
300
- name: 'get_weather',
301
- parameters: { type: 'object', properties: { city: { type: 'string' } } },
302
- },
303
- },
304
- ],
305
- });
306
-
307
- expect(response.message.tool_calls?.[0].function.name).toBe('get_weather');
308
- // @ts-ignore
309
- expect(JSON.parse(response.message.tool_calls?.[0].function.arguments)).toEqual({
310
- city: 'San Francisco',
311
- });
312
- });
313
-
314
- it('should map assistant tool calls correctly', async () => {
315
- // @ts-ignore
316
- global.fetch.mockResolvedValue(
317
- new Response(JSON.stringify({ content: [], usage: { input_tokens: 0, output_tokens: 0 } }))
318
- );
319
-
320
- const adapter = new AnthropicAdapter('fake-key');
321
- await adapter.chat([
322
- {
323
- role: 'assistant',
324
- content: 'I will call a tool',
325
- tool_calls: [
326
- {
327
- id: 'call_1',
328
- type: 'function',
329
- function: { name: 'my_tool', arguments: '{"arg": 1}' },
330
- },
331
- ],
332
- },
333
- ]);
334
-
335
- // @ts-ignore
336
- const init = global.fetch.mock.calls[0][1] as any;
337
- const body = JSON.parse(init.body);
338
- expect(body.messages[0].role).toBe('assistant');
339
- expect(body.messages[0].content).toHaveLength(2);
340
- expect(body.messages[0].content[0]).toEqual({ type: 'text', text: 'I will call a tool' });
341
- expect(body.messages[0].content[1]).toEqual({
342
- type: 'tool_use',
343
- id: 'call_1',
344
- name: 'my_tool',
345
- input: { arg: 1 },
346
- });
347
- });
348
-
349
- it('should map tool results correctly', async () => {
350
- // @ts-ignore
351
- global.fetch.mockResolvedValue(
352
- new Response(JSON.stringify({ content: [], usage: { input_tokens: 0, output_tokens: 0 } }))
353
- );
354
-
355
- const adapter = new AnthropicAdapter('fake-key');
356
- await adapter.chat([
357
- {
358
- role: 'tool',
359
- content: 'result',
360
- tool_call_id: 'call_1',
361
- },
362
- ]);
363
- });
364
-
365
- it('should handle tool calls with reasoning blocks', async () => {
366
- // @ts-ignore
367
- global.fetch.mockResolvedValue(
368
- new Response(
369
- JSON.stringify({
370
- content: [
371
- { type: 'thinking', thinking: 'I should call a tool' },
372
- { type: 'tool_use', id: 't1', name: 'test_tool', input: {} },
373
- ],
374
- role: 'assistant',
375
- usage: { input_tokens: 10, output_tokens: 5 },
376
- }),
377
- {
378
- status: 200,
379
- headers: { 'Content-Type': 'application/json' },
380
- }
381
- )
382
- );
383
-
384
- const adapter = new AnthropicAdapter('fake-key');
385
- const response = await adapter.chat([{ role: 'user', content: 'hi' }]);
386
-
387
- expect(response.message.content).toContain('<thinking>\nI should call a tool\n</thinking>');
388
- expect(response.message.tool_calls?.[0].function.name).toBe('test_tool');
389
- });
390
- });
391
-
392
- describe('AnthropicClaudeAdapter', () => {
393
- const originalFetch = global.fetch;
394
-
395
- beforeEach(() => {
396
- // @ts-ignore
397
- global.fetch = mock();
398
- });
399
-
400
- afterEach(() => {
401
- global.fetch = originalFetch;
402
- mock.restore();
403
- });
404
-
405
- it('should call Anthropic API with OAuth bearer and beta headers', async () => {
406
- const mockResponse = {
407
- content: [{ type: 'text', text: 'hello from claude' }],
408
- usage: { input_tokens: 1, output_tokens: 1 },
409
- };
410
-
411
- const authSpy = spyOn(AuthManager, 'getAnthropicClaudeToken').mockResolvedValue('claude-token');
412
-
413
- // @ts-ignore
414
- global.fetch.mockResolvedValue(
415
- new Response(JSON.stringify(mockResponse), {
416
- status: 200,
417
- headers: { 'Content-Type': 'application/json' },
418
- })
419
- );
420
-
421
- const adapter = new AnthropicClaudeAdapter();
422
- await adapter.chat([{ role: 'user', content: 'hi' }]);
423
-
424
- // @ts-ignore
425
- const fetchMock = global.fetch as MockFetch;
426
- // @ts-ignore
427
- const [url, init] = fetchMock.mock.calls[0] as [string, any];
428
-
429
- expect(url).toBe('https://api.anthropic.com/v1/messages');
430
- expect(init.headers.Authorization).toBe('Bearer claude-token');
431
- expect(init.headers['anthropic-beta']).toContain('oauth-2025-04-20');
432
- expect(init.headers['x-api-key']).toBeUndefined();
433
-
434
- authSpy.mockRestore();
435
- });
436
- });
437
-
438
- describe('CopilotAdapter', () => {
439
- const originalFetch = global.fetch;
440
-
441
- beforeEach(() => {
442
- // @ts-ignore
443
- global.fetch = mock();
444
- });
445
-
446
- afterEach(() => {
447
- global.fetch = originalFetch;
448
- mock.restore();
449
- });
450
-
451
- it('should get token from AuthManager and call Copilot API', async () => {
452
- const mockResponse = {
453
- choices: [{ message: { role: 'assistant', content: 'hello from copilot' } }],
454
- };
455
-
456
- const spy = spyOn(AuthManager, 'getCopilotToken').mockResolvedValue('mock-token');
457
-
458
- // @ts-ignore
459
- global.fetch.mockResolvedValue(
460
- new Response(JSON.stringify(mockResponse), {
461
- status: 200,
462
- headers: { 'Content-Type': 'application/json' },
463
- })
464
- );
465
-
466
- const adapter = new CopilotAdapter();
467
- const response = await adapter.chat([{ role: 'user', content: 'hi' }]);
468
-
469
- expect(response.message.content).toBe('hello from copilot');
470
- expect(AuthManager.getCopilotToken).toHaveBeenCalled();
471
-
472
- // @ts-ignore
473
- const fetchMock = global.fetch as MockFetch;
474
- // @ts-ignore
475
- // @ts-ignore
476
- const [url, init] = fetchMock.mock.calls[0] as [string, any];
477
- expect(url).toBe('https://api.githubcopilot.com/chat/completions');
478
- expect(init.headers.Authorization).toBe('Bearer mock-token');
479
- spy.mockRestore();
480
- });
481
-
482
- it('should throw error if token not found', async () => {
483
- const spy = spyOn(AuthManager, 'getCopilotToken').mockResolvedValue(undefined);
484
-
485
- const adapter = new CopilotAdapter();
486
- await expect(adapter.chat([])).rejects.toThrow(/GitHub Copilot token not found/);
487
- spy.mockRestore();
488
- });
489
- });
490
-
491
- describe('LocalEmbeddingAdapter', () => {
492
- it('should throw on chat', async () => {
493
- const adapter = new LocalEmbeddingAdapter();
494
- await expect(adapter.chat([])).rejects.toThrow(
495
- /Local models in Keystone currently only support/
496
- );
497
- });
498
- });
499
-
500
- describe('OpenAIChatGPTAdapter', () => {
501
- const originalFetch = global.fetch;
502
-
503
- beforeEach(() => {
504
- // @ts-ignore
505
- global.fetch = mock();
506
- });
507
-
508
- afterEach(() => {
509
- global.fetch = originalFetch;
510
- mock.restore();
511
- });
512
-
513
- it('should call the ChatGPT API correctly with store: false and ID filtering', async () => {
514
- const mockResponse = {
515
- choices: [
516
- {
517
- message: {
518
- role: 'assistant',
519
- content: 'hello',
520
- reasoning: { encrypted_content: 'r1' },
521
- },
522
- },
523
- ],
524
- };
525
-
526
- const mcpManager = {
527
- getClient: mock(async () => ({
528
- request: mock(async () => ({ content: [{ type: 'text', text: 'mcp-result' }] })),
529
- })),
530
- getGlobalServers: mock(() => []),
531
- } as any;
532
-
533
- const authSpy = spyOn(AuthManager, 'getOpenAIChatGPTToken').mockResolvedValue('chatgpt-token');
534
-
535
- // @ts-ignore
536
- global.fetch.mockResolvedValue(
537
- new Response(JSON.stringify(mockResponse), {
538
- status: 200,
539
- headers: { 'Content-Type': 'application/json' },
540
- })
541
- );
542
-
543
- const adapter = new OpenAIChatGPTAdapter();
544
- const messageWithId: LLMMessage & { id: string } = {
545
- role: 'user',
546
- content: 'hi',
547
- id: 'msg_1',
548
- };
549
- const response = await adapter.chat([messageWithId]);
550
-
551
- expect(response.message.content).toBe('hello');
552
- expect(response.message.reasoning?.encrypted_content).toBe('r1');
553
-
554
- // @ts-ignore
555
- const fetchMock = global.fetch as MockFetch;
556
- // @ts-ignore
557
- const [url, init] = fetchMock.mock.calls[0] as [string, any];
558
-
559
- expect(url).toBe('https://api.openai.com/v1/chat/completions');
560
- expect(init.headers.Authorization).toBe('Bearer chatgpt-token');
561
-
562
- const body = JSON.parse(init.body);
563
- expect(body.messages[0].id).toBeUndefined();
564
- expect(body.store).toBe(false);
565
- expect(body.include).toContain('reasoning.encrypted_content');
566
-
567
- authSpy.mockRestore();
568
- });
569
-
570
- it('should handle usage limits gracefully', async () => {
571
- const mockError = 'Your ChatGPT subscription limit has been reached.';
572
-
573
- spyOn(AuthManager, 'getOpenAIChatGPTToken').mockResolvedValue('chatgpt-token');
574
-
575
- // @ts-ignore
576
- global.fetch.mockResolvedValue(
577
- new Response(mockError, {
578
- status: 429,
579
- statusText: 'Too Many Requests',
580
- })
581
- );
582
-
583
- const adapter = new OpenAIChatGPTAdapter();
584
- await expect(adapter.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(
585
- /ChatGPT subscription limit reached/
586
- );
587
- });
588
- });
589
-
590
- describe('GoogleGeminiAdapter', () => {
591
- const originalFetch = global.fetch;
592
-
593
- beforeEach(() => {
594
- // @ts-ignore
595
- global.fetch = mock();
596
- });
597
-
598
- afterEach(() => {
599
- global.fetch = originalFetch;
600
- mock.restore();
601
- });
602
-
603
- it('should call Gemini API with OAuth token and wrapped request', async () => {
604
- const mockResponse = {
605
- candidates: [
606
- {
607
- content: {
608
- parts: [{ text: 'hello from gemini' }],
609
- },
610
- },
611
- ],
612
- usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 2, totalTokenCount: 3 },
613
- };
614
-
615
- const authSpy = spyOn(AuthManager, 'getGoogleGeminiToken').mockResolvedValue('gemini-token');
616
-
617
- // @ts-ignore
618
- global.fetch.mockResolvedValue(
619
- new Response(JSON.stringify(mockResponse), {
620
- status: 200,
621
- headers: { 'Content-Type': 'application/json' },
622
- })
623
- );
624
-
625
- const adapter = new GoogleGeminiAdapter('https://cloudcode-pa.googleapis.com', 'project-123');
626
- const response = await adapter.chat([{ role: 'user', content: 'hi' }], {
627
- model: 'gemini-3-pro-high',
628
- });
629
-
630
- expect(response.message.content).toBe('hello from gemini');
631
- expect(response.usage?.total_tokens).toBe(3);
632
-
633
- // @ts-ignore
634
- const fetchMock = global.fetch as MockFetch;
635
- // @ts-ignore
636
- const [url, init] = fetchMock.mock.calls[0] as [string, any];
637
-
638
- expect(url).toBe('https://cloudcode-pa.googleapis.com/v1internal:generateContent');
639
- expect(init.headers.Authorization).toBe('Bearer gemini-token');
640
-
641
- const body = JSON.parse(init.body);
642
- expect(body.project).toBe('project-123');
643
- expect(body.model).toBe('gemini-3-pro-high');
644
- expect(body.request.contents[0].role).toBe('user');
645
-
646
- authSpy.mockRestore();
647
- });
648
-
649
- it('should throw error if token not found', async () => {
650
- const authSpy = spyOn(AuthManager, 'getGoogleGeminiToken').mockResolvedValue(undefined);
651
-
652
- const adapter = new GoogleGeminiAdapter();
653
- await expect(adapter.chat([])).rejects.toThrow(/Google Gemini authentication not found/);
654
-
655
- authSpy.mockRestore();
656
- });
657
- });
658
-
659
- describe('getAdapter', () => {
660
- beforeEach(() => {
661
- resetRuntimeHelpers();
662
- ConfigLoader.clear();
663
- // Setup a clean config for each test
664
- ConfigLoader.setConfig({
665
- default_provider: 'openai',
666
- providers: {
667
- openai: { type: 'openai', api_key_env: 'OPENAI_API_KEY' },
668
- anthropic: { type: 'anthropic', api_key_env: 'ANTHROPIC_API_KEY' },
669
- copilot: { type: 'copilot', base_url: 'https://copilot.com' },
670
- 'chatgpt-provider': { type: 'openai-chatgpt', base_url: 'https://chat.openai.com' },
671
- 'claude-subscription': { type: 'anthropic-claude' },
672
- 'gemini-subscription': { type: 'google-gemini', project_id: 'test-project' },
673
- 'openai-chatgpt': { type: 'openai-chatgpt', base_url: 'https://chat.openai.com' },
674
- 'google-gemini': { type: 'google-gemini', project_id: 'test-project' },
675
- 'anthropic-claude': { type: 'anthropic-claude' },
676
- },
677
- model_mappings: {
678
- 'claude-4*': 'claude-subscription',
679
- 'claude-*': 'anthropic',
680
- 'gpt-5*': 'chatgpt-provider',
681
- 'gpt-*': 'openai',
682
- 'gemini-*': 'gemini-subscription',
683
- 'copilot:*': 'copilot',
684
- 'claude-3-opus-20240229': 'anthropic-claude',
685
- 'gemini-3-pro-high': 'google-gemini',
686
- },
687
- storage: { retention_days: 30, redact_secrets_at_rest: true },
688
- mcp_servers: {},
689
- engines: { allowlist: {}, denylist: [] },
690
- concurrency: { default: 10, pools: { llm: 2, shell: 5, http: 10, engine: 2 } },
691
- expression: { strict: false },
692
- log_level: 'info',
693
- } as any);
694
- });
695
-
696
- afterEach(() => {
697
- ConfigLoader.clear();
698
- mock.restore();
699
- });
700
-
701
- it('should return OpenAIAdapter for gpt models', () => {
702
- // ConfigLoader.getProviderForModel logic will handle this
703
- const { adapter, resolvedModel } = getAdapter('gpt-4');
704
- expect(adapter).toBeInstanceOf(OpenAIAdapter);
705
- expect(resolvedModel).toBe('gpt-4');
706
- });
707
-
708
- it('should return AnthropicAdapter for claude models', () => {
709
- // Explicit mapping in our mock config above covers this if ConfigLoader logic works
710
- // Or we rely on model name prefix if ConfigLoader has that default logic
711
- // Let's ensure the mapping exists if we removed the spy
712
- // ConfigLoader.getProviderForModel uses: explicit mapping OR default provider
713
- const { adapter, resolvedModel } = getAdapter('claude-3');
714
- expect(adapter).toBeInstanceOf(AnthropicAdapter);
715
- expect(resolvedModel).toBe('claude-3');
716
- });
717
-
718
- it('should return AnthropicClaudeAdapter for claude subscription models', () => {
719
- spyOn(ConfigLoader, 'getSecret').mockImplementation((key: string) => {
720
- if (key === 'ANTHROPIC_API_KEY') return 'fake-key';
721
- return undefined;
722
- });
723
- const { adapter, resolvedModel } = getAdapter('claude-3-opus-20240229');
724
- expect(adapter).toBeInstanceOf(AnthropicClaudeAdapter);
725
- expect(resolvedModel).toBe('claude-3-opus-20240229');
726
- });
727
-
728
- it('should return CopilotAdapter for copilot models', () => {
729
- const { adapter, resolvedModel } = getAdapter('copilot:gpt-4');
730
- expect(adapter).toBeInstanceOf(CopilotAdapter);
731
- expect(resolvedModel).toBe('gpt-4');
732
- });
733
-
734
- it('should handle Copilot API errors', async () => {
735
- // @ts-ignore
736
- global.fetch = mock(() =>
737
- Promise.resolve(
738
- new Response('Copilot error', {
739
- status: 401,
740
- statusText: 'Unauthorized',
741
- })
742
- )
743
- );
744
-
745
- const adapter = new CopilotAdapter();
746
- // mock auth token
747
- spyOn(AuthManager, 'getCopilotToken').mockResolvedValue('fake-token');
748
-
749
- await expect(adapter.chat([])).rejects.toThrow(/Copilot API error: 401 Unauthorized/);
750
- });
751
-
752
- it('should return LocalEmbeddingAdapter for local models', () => {
753
- const { adapter, resolvedModel } = getAdapter('local');
754
- expect(adapter).toBeInstanceOf(LocalEmbeddingAdapter);
755
- expect(resolvedModel).toBe('Xenova/all-MiniLM-L6-v2');
756
- });
757
-
758
- it('should return OpenAIChatGPTAdapter for openai-chatgpt provider', () => {
759
- spyOn(ConfigLoader, 'getSecret').mockImplementation((key: string) => {
760
- if (key === 'OPENAI_CHATGPT_API_KEY') return 'fake-key';
761
- return undefined;
762
- });
763
- const { adapter, resolvedModel } = getAdapter('openai-chatgpt:gpt-5.1');
764
- expect(adapter).toBeInstanceOf(OpenAIChatGPTAdapter);
765
- expect(resolvedModel).toBe('gpt-5.1');
766
- });
767
-
768
- it('should return GoogleGeminiAdapter for gemini subscription models', () => {
769
- spyOn(ConfigLoader, 'getSecret').mockImplementation((key: string) => {
770
- if (key === 'GOOGLE_GEMINI_KEY') return 'fake-key';
771
- return undefined;
772
- });
773
- const { adapter, resolvedModel } = getAdapter('gemini-3-pro-high');
774
- expect(adapter).toBeInstanceOf(GoogleGeminiAdapter);
775
- expect(resolvedModel).toBe('gemini-3-pro-high');
776
- });
777
-
778
- it('should handle Gemini API errors', async () => {
779
- // @ts-ignore
780
- global.fetch = mock(() =>
781
- Promise.resolve(
782
- new Response(JSON.stringify({ error: { message: 'Gemini error' } }), {
783
- status: 400,
784
- statusText: 'Bad Request',
785
- })
786
- )
787
- );
788
-
789
- const adapter = new GoogleGeminiAdapter('fake-key');
790
- // Mock the token to avoid auth failure before API error test
791
- spyOn(AuthManager, 'getGoogleGeminiToken').mockResolvedValue('fake-token');
792
-
793
- await expect(adapter.chat([])).rejects.toThrow(/Gemini API error: 400 Bad Request/);
794
- });
795
-
796
- it('should throw error for unknown provider', () => {
797
- // Set config with empty providers to force error
798
- ConfigLoader.setConfig({
799
- default_provider: 'unknown',
800
- providers: {}, // No providers configured
801
- model_mappings: {},
802
- storage: { retention_days: 30, redact_secrets_at_rest: true },
803
- mcp_servers: {},
804
- engines: { allowlist: {}, denylist: [] },
805
- concurrency: { default: 10, pools: { llm: 2, shell: 5, http: 10, engine: 2 } },
806
- expression: { strict: false },
807
- });
808
-
809
- expect(() => getAdapter('unknown-model')).toThrow();
810
- });
811
- });
812
-
813
- describe('AnthropicAdapter Streaming Errors', () => {
814
- const originalFetch = global.fetch;
815
-
816
- beforeEach(() => {
817
- // @ts-ignore
818
- global.fetch = mock();
819
- });
820
-
821
- afterEach(() => {
822
- global.fetch = originalFetch;
823
- });
824
-
825
- it('should log warning for non-SyntaxError chunk processing failures', async () => {
826
- const stream = new ReadableStream({
827
- start(controller) {
828
- controller.enqueue(
829
- new TextEncoder().encode(
830
- 'data: {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "hi"}}\n\n'
831
- )
832
- );
833
- controller.enqueue(new TextEncoder().encode('data: invalid-json\n\n'));
834
- controller.close();
835
- },
836
- });
837
-
838
- // @ts-ignore
839
- global.fetch = mock(() =>
840
- Promise.resolve(
841
- new Response(stream, {
842
- status: 200,
843
- headers: { 'Content-Type': 'text/event-stream' },
844
- })
845
- )
846
- );
847
-
848
- const logger = new ConsoleLogger();
849
- const warnSpy = spyOn(logger, 'warn').mockImplementation(() => {});
850
- // @ts-ignore - reaching into private defaultLogger is hard, but we can check if it logs to console if it used ConsoleLogger
851
- // Actually AnthropicAdapter uses defaultLogger which is a constant in the file.
852
-
853
- const adapter = new AnthropicAdapter('fake-key');
854
- let chunks = '';
855
- await adapter.chat([{ role: 'user', content: 'hi' }], {
856
- onStream: (c) => {
857
- chunks += c;
858
- },
859
- });
860
-
861
- expect(chunks).toBe('hi');
862
- });
863
- });
864
-
865
- describe('OpenAIChatGPTAdapter Usage Limits', () => {
866
- const originalFetch = global.fetch;
867
-
868
- beforeEach(() => {
869
- mock.restore();
870
- spyOn(AuthManager, 'getOpenAIChatGPTToken').mockResolvedValue('fake-token');
871
- // @ts-ignore
872
- global.fetch = mock();
873
- });
874
-
875
- afterEach(() => {
876
- global.fetch = originalFetch;
877
- mock.restore();
878
- });
879
-
880
- it('should throw specific error for usage limits', async () => {
881
- const mockError = {
882
- error: {
883
- code: 'rate_limit_reached',
884
- message: 'You exceeded your current limit, please check your plan and billing details.',
885
- },
886
- };
887
-
888
- // @ts-ignore
889
- global.fetch = mock(() =>
890
- Promise.resolve(
891
- new Response(JSON.stringify(mockError), {
892
- status: 429,
893
- headers: { 'Content-Type': 'application/json' },
894
- })
895
- )
896
- );
897
-
898
- const adapter = new OpenAIChatGPTAdapter('fake-key');
899
- await expect(adapter.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(
900
- /ChatGPT subscription limit reached/
901
- );
902
- });
903
-
904
- it('should process streaming responses correctly', async () => {
905
- const chunks = [
906
- 'data: {"choices": [{"index": 0, "delta": {"content": "th"}, "finish_reason": null}]}\n\n',
907
- 'data: {"choices": [{"index": 0, "delta": {"content": "inking"}, "finish_reason": null}]}\n\n',
908
- 'data: {"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}}\n\n',
909
- 'data: [DONE]\n\n',
910
- ];
911
-
912
- const stream = new ReadableStream({
913
- start(controller) {
914
- for (const chunk of chunks) {
915
- controller.enqueue(new TextEncoder().encode(chunk));
916
- }
917
- controller.close();
918
- },
919
- });
920
-
921
- // @ts-ignore
922
- global.fetch = mock(() =>
923
- Promise.resolve(
924
- new Response(stream, {
925
- status: 200,
926
- headers: { 'Content-Type': 'text/event-stream' },
927
- })
928
- )
929
- );
930
-
931
- const adapter = new OpenAIChatGPTAdapter('fake-key');
932
- let capturedStream = '';
933
- const response = await adapter.chat([{ role: 'user', content: 'hi' }], {
934
- onStream: (chunk) => {
935
- capturedStream += chunk;
936
- },
937
- });
938
-
939
- expect(capturedStream).toBe('thinking');
940
- expect(response.message.content).toBe('thinking');
941
- expect(response.usage?.total_tokens).toBe(7);
942
- });
943
-
944
- it('should extract response usage and tool calls correctly', async () => {
945
- const mockResponse = {
946
- choices: [
947
- {
948
- message: {
949
- role: 'assistant',
950
- content: 'I will call a tool',
951
- tool_calls: [
952
- {
953
- id: 'call_1',
954
- type: 'function',
955
- function: { name: 'test_tool', arguments: '{"arg": 1}' },
956
- },
957
- ],
958
- },
959
- },
960
- ],
961
- usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
962
- };
963
-
964
- // @ts-ignore
965
- global.fetch = mock(() =>
966
- Promise.resolve(
967
- new Response(JSON.stringify(mockResponse), {
968
- status: 200,
969
- headers: { 'Content-Type': 'application/json' },
970
- })
971
- )
972
- );
973
-
974
- const adapter = new OpenAIChatGPTAdapter('fake-key');
975
- const response = await adapter.chat([{ role: 'user', content: 'hi' }]);
976
-
977
- expect(response.message.content).toBe('I will call a tool');
978
- expect(response.message.tool_calls?.[0].function.name).toBe('test_tool');
979
- expect(response.usage?.total_tokens).toBe(15);
980
- });
981
- });
982
-
983
- describe('LocalEmbeddingAdapter', () => {
984
- it('should throw error on chat', async () => {
985
- const adapter = new LocalEmbeddingAdapter();
986
- await expect(adapter.chat([])).rejects.toThrow(
987
- /Local models in Keystone currently only support memory\/embedding operations/
988
- );
989
- });
990
- });
991
-
992
- describe('Runtime Resolution Helpers', () => {
993
- it('should handle hasOnnxRuntimeLibrary with existing files', () => {
994
- const readdirSpy = spyOn(fs, 'readdirSync').mockReturnValue([
995
- {
996
- name: 'libonnxruntime.so',
997
- isFile: () => true,
998
- isDirectory: () => false,
999
- isBlockDevice: () => false,
1000
- isCharacterDevice: () => false,
1001
- isSymbolicLink: () => false,
1002
- isFIFO: () => false,
1003
- isSocket: () => false,
1004
- },
1005
- ] as any);
1006
-
1007
- // We need to access the private function or test it via side effect.
1008
- // Since it's not exported, we'll skip direct testing of private functions for now
1009
- // and focus on exported ones if possible.
1010
- readdirSpy.mockRestore();
1011
- });
1012
- });