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.
- package/README.md +127 -140
- package/package.json +6 -3
- package/src/cli.ts +54 -369
- package/src/commands/init.ts +15 -29
- package/src/db/memory-db.test.ts +45 -0
- package/src/db/memory-db.ts +47 -21
- package/src/db/sqlite-setup.ts +26 -3
- package/src/db/workflow-db.ts +12 -5
- package/src/parser/config-schema.ts +17 -13
- package/src/parser/schema.ts +4 -2
- package/src/runner/__test__/llm-mock-setup.ts +173 -0
- package/src/runner/__test__/llm-test-setup.ts +271 -0
- package/src/runner/engine-executor.test.ts +25 -18
- package/src/runner/executors/blueprint-executor.ts +0 -1
- package/src/runner/executors/dynamic-executor.ts +11 -6
- package/src/runner/executors/engine-executor.ts +5 -1
- package/src/runner/executors/llm-executor.ts +502 -1033
- package/src/runner/executors/memory-executor.ts +35 -19
- package/src/runner/executors/plan-executor.ts +0 -1
- package/src/runner/executors/types.ts +4 -4
- package/src/runner/llm-adapter.integration.test.ts +151 -0
- package/src/runner/llm-adapter.ts +270 -1398
- package/src/runner/llm-clarification.test.ts +91 -106
- package/src/runner/llm-executor.test.ts +217 -1181
- package/src/runner/memoization.test.ts +0 -1
- package/src/runner/recovery-security.test.ts +51 -20
- package/src/runner/reflexion.test.ts +55 -18
- package/src/runner/standard-tools-integration.test.ts +137 -87
- package/src/runner/step-executor.test.ts +36 -80
- package/src/runner/step-executor.ts +0 -2
- package/src/runner/test-harness.ts +3 -29
- package/src/runner/tool-integration.test.ts +122 -73
- package/src/runner/workflow-runner.ts +110 -49
- package/src/runner/workflow-scheduler.ts +11 -1
- package/src/runner/workflow-summary.ts +144 -0
- package/src/utils/auth-manager.test.ts +10 -520
- package/src/utils/auth-manager.ts +3 -756
- package/src/utils/config-loader.ts +12 -0
- package/src/utils/constants.ts +0 -17
- package/src/utils/process-sandbox.ts +15 -3
- package/src/runner/llm-adapter-runtime.test.ts +0 -209
- 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
|
-
});
|