@hazeljs/ai 0.2.0-beta.55 → 0.2.0-beta.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-enhanced.service.test.d.ts +2 -0
- package/dist/ai-enhanced.service.test.d.ts.map +1 -0
- package/dist/ai-enhanced.service.test.js +501 -0
- package/dist/context/context.manager.test.d.ts +2 -0
- package/dist/context/context.manager.test.d.ts.map +1 -0
- package/dist/context/context.manager.test.js +180 -0
- package/dist/providers/anthropic.provider.test.d.ts +2 -0
- package/dist/providers/anthropic.provider.test.d.ts.map +1 -0
- package/dist/providers/anthropic.provider.test.js +222 -0
- package/dist/providers/cohere.provider.test.d.ts +2 -0
- package/dist/providers/cohere.provider.test.d.ts.map +1 -0
- package/dist/providers/cohere.provider.test.js +267 -0
- package/dist/providers/gemini.provider.test.d.ts +2 -0
- package/dist/providers/gemini.provider.test.d.ts.map +1 -0
- package/dist/providers/gemini.provider.test.js +219 -0
- package/dist/providers/ollama.provider.test.d.ts +2 -0
- package/dist/providers/ollama.provider.test.d.ts.map +1 -0
- package/dist/providers/ollama.provider.test.js +267 -0
- package/dist/providers/openai.provider.test.d.ts +2 -0
- package/dist/providers/openai.provider.test.d.ts.map +1 -0
- package/dist/providers/openai.provider.test.js +364 -0
- package/dist/tracking/token.tracker.test.d.ts +2 -0
- package/dist/tracking/token.tracker.test.d.ts.map +1 -0
- package/dist/tracking/token.tracker.test.js +272 -0
- package/package.json +2 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ai-enhanced.service.test.d.ts","sourceRoot":"","sources":["../src/ai-enhanced.service.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
jest.mock('@hazeljs/core', () => ({
|
|
4
|
+
__esModule: true,
|
|
5
|
+
Service: () => () => undefined,
|
|
6
|
+
default: { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
|
7
|
+
}));
|
|
8
|
+
jest.mock('@hazeljs/cache', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
CacheService: jest.fn(),
|
|
11
|
+
}));
|
|
12
|
+
// Prevent real SDK initialization - mock provider constructors
|
|
13
|
+
jest.mock('./providers/openai.provider', () => ({
|
|
14
|
+
OpenAIProvider: jest.fn().mockImplementation(() => ({
|
|
15
|
+
name: 'openai',
|
|
16
|
+
complete: jest.fn().mockResolvedValue({
|
|
17
|
+
id: '1',
|
|
18
|
+
content: 'openai response',
|
|
19
|
+
role: 'assistant',
|
|
20
|
+
model: 'gpt-4',
|
|
21
|
+
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
|
|
22
|
+
finishReason: 'stop',
|
|
23
|
+
}),
|
|
24
|
+
streamComplete: jest.fn(),
|
|
25
|
+
embed: jest.fn(),
|
|
26
|
+
isAvailable: jest.fn().mockResolvedValue(true),
|
|
27
|
+
})),
|
|
28
|
+
}));
|
|
29
|
+
jest.mock('./providers/anthropic.provider', () => ({
|
|
30
|
+
AnthropicProvider: jest.fn().mockImplementation(() => ({
|
|
31
|
+
name: 'anthropic',
|
|
32
|
+
complete: jest.fn(),
|
|
33
|
+
streamComplete: jest.fn(),
|
|
34
|
+
embed: jest.fn(),
|
|
35
|
+
isAvailable: jest.fn().mockResolvedValue(true),
|
|
36
|
+
})),
|
|
37
|
+
}));
|
|
38
|
+
jest.mock('./providers/gemini.provider', () => ({
|
|
39
|
+
GeminiProvider: jest.fn().mockImplementation(() => ({
|
|
40
|
+
name: 'gemini',
|
|
41
|
+
complete: jest.fn(),
|
|
42
|
+
streamComplete: jest.fn(),
|
|
43
|
+
embed: jest.fn(),
|
|
44
|
+
isAvailable: jest.fn().mockResolvedValue(true),
|
|
45
|
+
})),
|
|
46
|
+
}));
|
|
47
|
+
jest.mock('./providers/cohere.provider', () => ({
|
|
48
|
+
CohereProvider: jest.fn().mockImplementation(() => ({
|
|
49
|
+
name: 'cohere',
|
|
50
|
+
complete: jest.fn(),
|
|
51
|
+
streamComplete: jest.fn(),
|
|
52
|
+
embed: jest.fn(),
|
|
53
|
+
isAvailable: jest.fn().mockResolvedValue(true),
|
|
54
|
+
})),
|
|
55
|
+
}));
|
|
56
|
+
jest.mock('./providers/ollama.provider', () => ({
|
|
57
|
+
OllamaProvider: jest.fn().mockImplementation(() => ({
|
|
58
|
+
name: 'ollama',
|
|
59
|
+
complete: jest.fn().mockResolvedValue({
|
|
60
|
+
id: 'ollama-1',
|
|
61
|
+
content: 'ollama response',
|
|
62
|
+
role: 'assistant',
|
|
63
|
+
model: 'llama2',
|
|
64
|
+
usage: { promptTokens: 5, completionTokens: 10, totalTokens: 15 },
|
|
65
|
+
finishReason: 'stop',
|
|
66
|
+
}),
|
|
67
|
+
streamComplete: jest.fn(),
|
|
68
|
+
embed: jest.fn().mockResolvedValue({
|
|
69
|
+
embeddings: [[0.1, 0.2, 0.3]],
|
|
70
|
+
model: 'llama2',
|
|
71
|
+
usage: { promptTokens: 5, totalTokens: 5 },
|
|
72
|
+
}),
|
|
73
|
+
isAvailable: jest.fn().mockResolvedValue(true),
|
|
74
|
+
})),
|
|
75
|
+
}));
|
|
76
|
+
const ai_enhanced_service_1 = require("./ai-enhanced.service");
|
|
77
|
+
function makeMockProvider(name) {
|
|
78
|
+
return {
|
|
79
|
+
name,
|
|
80
|
+
complete: jest.fn().mockResolvedValue({
|
|
81
|
+
id: 'test-1',
|
|
82
|
+
content: 'test response',
|
|
83
|
+
role: 'assistant',
|
|
84
|
+
model: 'test-model',
|
|
85
|
+
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
|
|
86
|
+
finishReason: 'stop',
|
|
87
|
+
}),
|
|
88
|
+
streamComplete: jest.fn(),
|
|
89
|
+
embed: jest.fn().mockResolvedValue({
|
|
90
|
+
embeddings: [[0.1, 0.2]],
|
|
91
|
+
model: 'test-model',
|
|
92
|
+
usage: { promptTokens: 5, totalTokens: 5 },
|
|
93
|
+
}),
|
|
94
|
+
isAvailable: jest.fn().mockResolvedValue(true),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const REQUEST = {
|
|
98
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
99
|
+
model: 'test-model',
|
|
100
|
+
};
|
|
101
|
+
describe('AIEnhancedService', () => {
|
|
102
|
+
let service;
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
jest.clearAllMocks();
|
|
105
|
+
// No API key env vars — only ollama provider registers
|
|
106
|
+
delete process.env.OPENAI_API_KEY;
|
|
107
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
108
|
+
delete process.env.GEMINI_API_KEY;
|
|
109
|
+
delete process.env.COHERE_API_KEY;
|
|
110
|
+
service = new ai_enhanced_service_1.AIEnhancedService();
|
|
111
|
+
service.setRetryConfig(1, 0); // Fast retries for tests
|
|
112
|
+
});
|
|
113
|
+
describe('constructor', () => {
|
|
114
|
+
it('creates service with default tokenTracker', () => {
|
|
115
|
+
expect(service).toBeDefined();
|
|
116
|
+
});
|
|
117
|
+
it('registers ollama provider by default (no env keys)', () => {
|
|
118
|
+
const providers = service.getAvailableProviders();
|
|
119
|
+
expect(providers).toContain('ollama');
|
|
120
|
+
});
|
|
121
|
+
it('registers openai when OPENAI_API_KEY is set', () => {
|
|
122
|
+
process.env.OPENAI_API_KEY = 'test-key';
|
|
123
|
+
const s = new ai_enhanced_service_1.AIEnhancedService();
|
|
124
|
+
expect(s.getAvailableProviders()).toContain('openai');
|
|
125
|
+
delete process.env.OPENAI_API_KEY;
|
|
126
|
+
});
|
|
127
|
+
it('registers anthropic when ANTHROPIC_API_KEY is set', () => {
|
|
128
|
+
process.env.ANTHROPIC_API_KEY = 'test-key';
|
|
129
|
+
const s = new ai_enhanced_service_1.AIEnhancedService();
|
|
130
|
+
expect(s.getAvailableProviders()).toContain('anthropic');
|
|
131
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
132
|
+
});
|
|
133
|
+
it('registers gemini when GEMINI_API_KEY is set', () => {
|
|
134
|
+
process.env.GEMINI_API_KEY = 'test-key';
|
|
135
|
+
const s = new ai_enhanced_service_1.AIEnhancedService();
|
|
136
|
+
expect(s.getAvailableProviders()).toContain('gemini');
|
|
137
|
+
delete process.env.GEMINI_API_KEY;
|
|
138
|
+
});
|
|
139
|
+
it('registers cohere when COHERE_API_KEY is set', () => {
|
|
140
|
+
process.env.COHERE_API_KEY = 'test-key';
|
|
141
|
+
const s = new ai_enhanced_service_1.AIEnhancedService();
|
|
142
|
+
expect(s.getAvailableProviders()).toContain('cohere');
|
|
143
|
+
delete process.env.COHERE_API_KEY;
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe('registerProvider()', () => {
|
|
147
|
+
it('adds a custom provider', () => {
|
|
148
|
+
const mock = makeMockProvider('openai');
|
|
149
|
+
service.registerProvider(mock);
|
|
150
|
+
expect(service.getAvailableProviders()).toContain('openai');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('setDefaultProvider()', () => {
|
|
154
|
+
it('sets default to a registered provider', () => {
|
|
155
|
+
const mock = makeMockProvider('openai');
|
|
156
|
+
service.registerProvider(mock);
|
|
157
|
+
expect(() => service.setDefaultProvider('openai')).not.toThrow();
|
|
158
|
+
});
|
|
159
|
+
it('throws for an unregistered provider', () => {
|
|
160
|
+
expect(() => service.setDefaultProvider('anthropic')).toThrow('Provider anthropic is not registered');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
describe('createContext() and getContext()', () => {
|
|
164
|
+
it('createContext returns an AIContextManager', () => {
|
|
165
|
+
const ctx = service.createContext(2048);
|
|
166
|
+
expect(ctx).toBeDefined();
|
|
167
|
+
expect(ctx.maxTokens).toBe(2048);
|
|
168
|
+
});
|
|
169
|
+
it('getContext returns undefined before createContext()', () => {
|
|
170
|
+
expect(service.getContext()).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
it('getContext returns manager after createContext()', () => {
|
|
173
|
+
service.createContext();
|
|
174
|
+
expect(service.getContext()).toBeDefined();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
describe('complete()', () => {
|
|
178
|
+
it('calls provider.complete() and returns response', async () => {
|
|
179
|
+
const mock = makeMockProvider('openai');
|
|
180
|
+
service.registerProvider(mock);
|
|
181
|
+
service.setDefaultProvider('openai');
|
|
182
|
+
const result = await service.complete(REQUEST);
|
|
183
|
+
expect(mock.complete).toHaveBeenCalledWith(REQUEST);
|
|
184
|
+
expect(result.content).toBe('test response');
|
|
185
|
+
});
|
|
186
|
+
it('uses specified provider from config', async () => {
|
|
187
|
+
const ollamaMock = makeMockProvider('ollama');
|
|
188
|
+
service.registerProvider(ollamaMock);
|
|
189
|
+
await service.complete(REQUEST, { provider: 'ollama' });
|
|
190
|
+
expect(ollamaMock.complete).toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
it('throws when provider is not registered', async () => {
|
|
193
|
+
await expect(service.complete(REQUEST, { provider: 'anthropic' })).rejects.toThrow('Provider anthropic is not registered or available');
|
|
194
|
+
});
|
|
195
|
+
it('tracks token usage after successful completion', async () => {
|
|
196
|
+
const mock = makeMockProvider('openai');
|
|
197
|
+
service.registerProvider(mock);
|
|
198
|
+
service.setDefaultProvider('openai');
|
|
199
|
+
await service.complete(REQUEST, { userId: 'user1' });
|
|
200
|
+
const stats = service.getTokenStats('user1');
|
|
201
|
+
expect(stats.requestCount).toBe(1);
|
|
202
|
+
});
|
|
203
|
+
it('throws when rate limit exceeded', async () => {
|
|
204
|
+
const mock = makeMockProvider('openai');
|
|
205
|
+
service.registerProvider(mock);
|
|
206
|
+
service.setDefaultProvider('openai');
|
|
207
|
+
// Set a very tight token limit
|
|
208
|
+
const tracker = service.tokenTracker;
|
|
209
|
+
tracker.updateConfig({ maxTokensPerRequest: 1 });
|
|
210
|
+
await expect(service.complete(REQUEST)).rejects.toThrow('Rate limit exceeded');
|
|
211
|
+
});
|
|
212
|
+
it('uses cache when cacheService and cacheKey provided (cache hit)', async () => {
|
|
213
|
+
const cachedResponse = {
|
|
214
|
+
id: 'cached',
|
|
215
|
+
content: 'cached response',
|
|
216
|
+
role: 'assistant',
|
|
217
|
+
model: 'gpt-4',
|
|
218
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
219
|
+
finishReason: 'stop',
|
|
220
|
+
};
|
|
221
|
+
const mockCache = {
|
|
222
|
+
get: jest.fn().mockResolvedValue(cachedResponse),
|
|
223
|
+
set: jest.fn().mockResolvedValue(undefined),
|
|
224
|
+
};
|
|
225
|
+
const s = new ai_enhanced_service_1.AIEnhancedService(undefined, mockCache);
|
|
226
|
+
s.setRetryConfig(1, 0);
|
|
227
|
+
const mock = makeMockProvider('openai');
|
|
228
|
+
s.registerProvider(mock);
|
|
229
|
+
s.setDefaultProvider('openai');
|
|
230
|
+
const result = await s.complete(REQUEST, { cacheKey: 'my-key' });
|
|
231
|
+
expect(result.content).toBe('cached response');
|
|
232
|
+
expect(mock.complete).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
it('caches response on cache miss', async () => {
|
|
235
|
+
const mockCache = {
|
|
236
|
+
get: jest.fn().mockResolvedValue(null),
|
|
237
|
+
set: jest.fn().mockResolvedValue(undefined),
|
|
238
|
+
};
|
|
239
|
+
const s = new ai_enhanced_service_1.AIEnhancedService(undefined, mockCache);
|
|
240
|
+
s.setRetryConfig(1, 0);
|
|
241
|
+
const mock = makeMockProvider('openai');
|
|
242
|
+
s.registerProvider(mock);
|
|
243
|
+
s.setDefaultProvider('openai');
|
|
244
|
+
await s.complete(REQUEST, { cacheKey: 'new-key', cacheTTL: 60 });
|
|
245
|
+
expect(mockCache.set).toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
it('does not check cache when no cacheKey provided', async () => {
|
|
248
|
+
const mockCache = {
|
|
249
|
+
get: jest.fn().mockResolvedValue(null),
|
|
250
|
+
set: jest.fn().mockResolvedValue(undefined),
|
|
251
|
+
};
|
|
252
|
+
const s = new ai_enhanced_service_1.AIEnhancedService(undefined, mockCache);
|
|
253
|
+
s.setRetryConfig(1, 0);
|
|
254
|
+
const mock = makeMockProvider('openai');
|
|
255
|
+
s.registerProvider(mock);
|
|
256
|
+
s.setDefaultProvider('openai');
|
|
257
|
+
await s.complete(REQUEST); // No cacheKey
|
|
258
|
+
expect(mockCache.get).not.toHaveBeenCalled();
|
|
259
|
+
});
|
|
260
|
+
it('uses default cache TTL of 3600 when not specified', async () => {
|
|
261
|
+
const mockCache = {
|
|
262
|
+
get: jest.fn().mockResolvedValue(null),
|
|
263
|
+
set: jest.fn().mockResolvedValue(undefined),
|
|
264
|
+
};
|
|
265
|
+
const s = new ai_enhanced_service_1.AIEnhancedService(undefined, mockCache);
|
|
266
|
+
s.setRetryConfig(1, 0);
|
|
267
|
+
const mock = makeMockProvider('openai');
|
|
268
|
+
s.registerProvider(mock);
|
|
269
|
+
s.setDefaultProvider('openai');
|
|
270
|
+
await s.complete(REQUEST, { cacheKey: 'key' });
|
|
271
|
+
expect(mockCache.set).toHaveBeenCalledWith(expect.any(String), expect.any(Object), 3600);
|
|
272
|
+
});
|
|
273
|
+
it('skips token tracking when response has no usage', async () => {
|
|
274
|
+
const mock = makeMockProvider('openai');
|
|
275
|
+
mock.complete.mockResolvedValueOnce({
|
|
276
|
+
id: 'no-usage',
|
|
277
|
+
content: 'response',
|
|
278
|
+
role: 'assistant',
|
|
279
|
+
model: 'test',
|
|
280
|
+
finishReason: 'stop',
|
|
281
|
+
});
|
|
282
|
+
service.registerProvider(mock);
|
|
283
|
+
service.setDefaultProvider('openai');
|
|
284
|
+
await expect(service.complete(REQUEST)).resolves.toBeDefined();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
describe('complete() retry logic', () => {
|
|
288
|
+
it('retries on transient failure and succeeds', async () => {
|
|
289
|
+
service.setRetryConfig(2, 0);
|
|
290
|
+
const mock = makeMockProvider('openai');
|
|
291
|
+
mock.complete.mockRejectedValueOnce(new Error('transient error')).mockResolvedValueOnce({
|
|
292
|
+
id: '2',
|
|
293
|
+
content: 'success after retry',
|
|
294
|
+
role: 'assistant',
|
|
295
|
+
model: 'test',
|
|
296
|
+
usage: { promptTokens: 5, completionTokens: 5, totalTokens: 10 },
|
|
297
|
+
finishReason: 'stop',
|
|
298
|
+
});
|
|
299
|
+
service.registerProvider(mock);
|
|
300
|
+
service.setDefaultProvider('openai');
|
|
301
|
+
const result = await service.complete(REQUEST);
|
|
302
|
+
expect(result.content).toBe('success after retry');
|
|
303
|
+
expect(mock.complete).toHaveBeenCalledTimes(2);
|
|
304
|
+
});
|
|
305
|
+
it('throws after all retries exhausted', async () => {
|
|
306
|
+
service.setRetryConfig(2, 0);
|
|
307
|
+
const mock = makeMockProvider('openai');
|
|
308
|
+
mock.complete.mockRejectedValue(new Error('always fails'));
|
|
309
|
+
service.registerProvider(mock);
|
|
310
|
+
service.setDefaultProvider('openai');
|
|
311
|
+
await expect(service.complete(REQUEST)).rejects.toThrow('always fails');
|
|
312
|
+
expect(mock.complete).toHaveBeenCalledTimes(2);
|
|
313
|
+
});
|
|
314
|
+
it('throws non-Error objects as wrapped error', async () => {
|
|
315
|
+
service.setRetryConfig(1, 0);
|
|
316
|
+
const mock = makeMockProvider('openai');
|
|
317
|
+
mock.complete.mockRejectedValue('string error');
|
|
318
|
+
service.registerProvider(mock);
|
|
319
|
+
service.setDefaultProvider('openai');
|
|
320
|
+
await expect(service.complete(REQUEST)).rejects.toThrow('Unknown error');
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
describe('streamComplete()', () => {
|
|
324
|
+
it('yields chunks from provider', async () => {
|
|
325
|
+
const chunks = [
|
|
326
|
+
{ id: 'c1', content: 'Hello', delta: 'Hello', done: false },
|
|
327
|
+
{
|
|
328
|
+
id: 'c2',
|
|
329
|
+
content: 'Hello world',
|
|
330
|
+
delta: ' world',
|
|
331
|
+
done: true,
|
|
332
|
+
usage: { promptTokens: 5, completionTokens: 10, totalTokens: 15 },
|
|
333
|
+
},
|
|
334
|
+
];
|
|
335
|
+
async function* mockStream() {
|
|
336
|
+
for (const chunk of chunks) {
|
|
337
|
+
yield chunk;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const mock = makeMockProvider('openai');
|
|
341
|
+
mock.streamComplete.mockReturnValue(mockStream());
|
|
342
|
+
service.registerProvider(mock);
|
|
343
|
+
service.setDefaultProvider('openai');
|
|
344
|
+
const results = [];
|
|
345
|
+
for await (const chunk of service.streamComplete(REQUEST)) {
|
|
346
|
+
results.push(chunk);
|
|
347
|
+
}
|
|
348
|
+
expect(results).toHaveLength(2);
|
|
349
|
+
});
|
|
350
|
+
it('tracks token usage from final chunk', async () => {
|
|
351
|
+
async function* mockStream() {
|
|
352
|
+
yield {
|
|
353
|
+
id: 'c1',
|
|
354
|
+
content: 'Hi',
|
|
355
|
+
delta: 'Hi',
|
|
356
|
+
done: true,
|
|
357
|
+
usage: { promptTokens: 3, completionTokens: 2, totalTokens: 5 },
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const mock = makeMockProvider('openai');
|
|
361
|
+
mock.streamComplete.mockReturnValue(mockStream());
|
|
362
|
+
service.registerProvider(mock);
|
|
363
|
+
service.setDefaultProvider('openai');
|
|
364
|
+
for await (const _chunk of service.streamComplete(REQUEST, { userId: 'u1' })) {
|
|
365
|
+
// consume
|
|
366
|
+
}
|
|
367
|
+
const stats = service.getTokenStats('u1');
|
|
368
|
+
expect(stats.requestCount).toBe(1);
|
|
369
|
+
});
|
|
370
|
+
it('throws rate limit error before streaming', async () => {
|
|
371
|
+
const mock = makeMockProvider('openai');
|
|
372
|
+
service.registerProvider(mock);
|
|
373
|
+
service.setDefaultProvider('openai');
|
|
374
|
+
const tracker = service.tokenTracker;
|
|
375
|
+
tracker.updateConfig({ maxTokensPerRequest: 1 });
|
|
376
|
+
const gen = service.streamComplete(REQUEST);
|
|
377
|
+
await expect(gen.next()).rejects.toThrow('Rate limit exceeded');
|
|
378
|
+
});
|
|
379
|
+
it('rethrows provider streaming errors', async () => {
|
|
380
|
+
async function* failStream() {
|
|
381
|
+
throw new Error('stream failed');
|
|
382
|
+
yield { id: '1', content: '', delta: '', done: false };
|
|
383
|
+
}
|
|
384
|
+
const mock = makeMockProvider('openai');
|
|
385
|
+
mock.streamComplete.mockReturnValue(failStream());
|
|
386
|
+
service.registerProvider(mock);
|
|
387
|
+
service.setDefaultProvider('openai');
|
|
388
|
+
const results = [];
|
|
389
|
+
await expect(async () => {
|
|
390
|
+
for await (const chunk of service.streamComplete(REQUEST)) {
|
|
391
|
+
results.push(chunk);
|
|
392
|
+
}
|
|
393
|
+
}).rejects.toThrow('stream failed');
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
describe('embed()', () => {
|
|
397
|
+
it('returns embeddings from provider', async () => {
|
|
398
|
+
const mock = makeMockProvider('openai');
|
|
399
|
+
service.registerProvider(mock);
|
|
400
|
+
service.setDefaultProvider('openai');
|
|
401
|
+
const result = await service.embed({ input: 'test' });
|
|
402
|
+
expect(result.embeddings).toHaveLength(1);
|
|
403
|
+
});
|
|
404
|
+
it('tracks embedding token usage', async () => {
|
|
405
|
+
const mock = makeMockProvider('openai');
|
|
406
|
+
service.registerProvider(mock);
|
|
407
|
+
service.setDefaultProvider('openai');
|
|
408
|
+
await service.embed({ input: 'test' }, { userId: 'u-embed' });
|
|
409
|
+
const stats = service.getTokenStats('u-embed');
|
|
410
|
+
expect(stats.requestCount).toBe(1);
|
|
411
|
+
});
|
|
412
|
+
it('returns cached embeddings on cache hit', async () => {
|
|
413
|
+
const cached = {
|
|
414
|
+
embeddings: [[9, 9]],
|
|
415
|
+
model: 'test',
|
|
416
|
+
usage: { promptTokens: 0, totalTokens: 0 },
|
|
417
|
+
};
|
|
418
|
+
const mockCache = {
|
|
419
|
+
get: jest.fn().mockResolvedValue(cached),
|
|
420
|
+
set: jest.fn(),
|
|
421
|
+
};
|
|
422
|
+
const s = new ai_enhanced_service_1.AIEnhancedService(undefined, mockCache);
|
|
423
|
+
s.setRetryConfig(1, 0);
|
|
424
|
+
const mock = makeMockProvider('openai');
|
|
425
|
+
s.registerProvider(mock);
|
|
426
|
+
s.setDefaultProvider('openai');
|
|
427
|
+
const result = await s.embed({ input: 'test' }, { cacheKey: 'embed-key' });
|
|
428
|
+
expect(result.embeddings[0]).toEqual([9, 9]);
|
|
429
|
+
expect(mock.embed).not.toHaveBeenCalled();
|
|
430
|
+
});
|
|
431
|
+
it('caches embeddings on miss with default TTL', async () => {
|
|
432
|
+
const mockCache = {
|
|
433
|
+
get: jest.fn().mockResolvedValue(null),
|
|
434
|
+
set: jest.fn().mockResolvedValue(undefined),
|
|
435
|
+
};
|
|
436
|
+
const s = new ai_enhanced_service_1.AIEnhancedService(undefined, mockCache);
|
|
437
|
+
s.setRetryConfig(1, 0);
|
|
438
|
+
const mock = makeMockProvider('openai');
|
|
439
|
+
s.registerProvider(mock);
|
|
440
|
+
s.setDefaultProvider('openai');
|
|
441
|
+
await s.embed({ input: 'test' }, { cacheKey: 'k' });
|
|
442
|
+
expect(mockCache.set).toHaveBeenCalledWith(expect.any(String), expect.any(Object), 86400);
|
|
443
|
+
});
|
|
444
|
+
it('skips token tracking when usage is missing', async () => {
|
|
445
|
+
const mock = makeMockProvider('openai');
|
|
446
|
+
mock.embed.mockResolvedValueOnce({ embeddings: [[0.1]], model: 'test' }); // no usage
|
|
447
|
+
service.registerProvider(mock);
|
|
448
|
+
service.setDefaultProvider('openai');
|
|
449
|
+
await expect(service.embed({ input: 'test' })).resolves.toBeDefined();
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
describe('isProviderAvailable()', () => {
|
|
453
|
+
it('returns true for an available registered provider', async () => {
|
|
454
|
+
const mock = makeMockProvider('openai');
|
|
455
|
+
mock.isAvailable.mockResolvedValue(true);
|
|
456
|
+
service.registerProvider(mock);
|
|
457
|
+
expect(await service.isProviderAvailable('openai')).toBe(true);
|
|
458
|
+
});
|
|
459
|
+
it('returns false for an unregistered provider', async () => {
|
|
460
|
+
expect(await service.isProviderAvailable('anthropic')).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
it('returns false when provider.isAvailable() returns false', async () => {
|
|
463
|
+
const mock = makeMockProvider('openai');
|
|
464
|
+
mock.isAvailable.mockResolvedValue(false);
|
|
465
|
+
service.registerProvider(mock);
|
|
466
|
+
expect(await service.isProviderAvailable('openai')).toBe(false);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
describe('getAvailableProviders()', () => {
|
|
470
|
+
it('returns list of provider names', () => {
|
|
471
|
+
const providers = service.getAvailableProviders();
|
|
472
|
+
expect(Array.isArray(providers)).toBe(true);
|
|
473
|
+
expect(providers.length).toBeGreaterThan(0);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
describe('getTokenStats()', () => {
|
|
477
|
+
it('returns global stats when no userId', () => {
|
|
478
|
+
const stats = service.getTokenStats();
|
|
479
|
+
expect(stats).toBeDefined();
|
|
480
|
+
});
|
|
481
|
+
it('returns user-specific stats when userId provided', () => {
|
|
482
|
+
const stats = service.getTokenStats('user42', 30);
|
|
483
|
+
expect(stats).toBeDefined();
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
describe('configureModel()', () => {
|
|
487
|
+
it('succeeds for a registered provider', () => {
|
|
488
|
+
const mock = makeMockProvider('openai');
|
|
489
|
+
service.registerProvider(mock);
|
|
490
|
+
expect(() => service.configureModel({ provider: 'openai', model: 'gpt-4-turbo', temperature: 0.7 })).not.toThrow();
|
|
491
|
+
});
|
|
492
|
+
it('throws for an unregistered provider', () => {
|
|
493
|
+
expect(() => service.configureModel({ provider: 'anthropic', model: 'claude-3', temperature: 0.5 })).toThrow('Provider anthropic not found');
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
describe('setRetryConfig()', () => {
|
|
497
|
+
it('updates retry configuration', () => {
|
|
498
|
+
expect(() => service.setRetryConfig(5, 500)).not.toThrow();
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.manager.test.d.ts","sourceRoot":"","sources":["../../src/context/context.manager.test.ts"],"names":[],"mappings":""}
|