@bernierllc/ai-provider-openai 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +35 -0
- package/README.md +294 -0
- package/__tests__/OpenAIProvider.test.ts +574 -0
- package/__tests__/error-handling.test.ts +315 -0
- package/__tests__/model-registry.test.ts +270 -0
- package/__tests__/openai-specific.test.ts +333 -0
- package/dist/OpenAIProvider.d.ts +22 -0
- package/dist/OpenAIProvider.d.ts.map +1 -0
- package/dist/OpenAIProvider.js +320 -0
- package/dist/OpenAIProvider.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/models/model-registry.d.ts +8 -0
- package/dist/models/model-registry.d.ts.map +1 -0
- package/dist/models/model-registry.js +147 -0
- package/dist/models/model-registry.js.map +1 -0
- package/dist/types/openai-types.d.ts +28 -0
- package/dist/types/openai-types.d.ts.map +1 -0
- package/dist/types/openai-types.js +3 -0
- package/dist/types/openai-types.js.map +1 -0
- package/dist/utils/error-handling.d.ts +5 -0
- package/dist/utils/error-handling.d.ts.map +1 -0
- package/dist/utils/error-handling.js +67 -0
- package/dist/utils/error-handling.js.map +1 -0
- package/jest.config.cjs +29 -0
- package/package.json +63 -0
- package/src/OpenAIProvider.ts +435 -0
- package/src/index.ts +12 -0
- package/src/models/model-registry.ts +178 -0
- package/src/types/openai-types.ts +51 -0
- package/src/utils/error-handling.ts +101 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code only within the scope of the project it was delivered for.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { OpenAIProvider } from '../src/OpenAIProvider';
|
|
10
|
+
import { OpenAIProviderConfig } from '../src/types/openai-types';
|
|
11
|
+
import OpenAI from 'openai';
|
|
12
|
+
|
|
13
|
+
// Mock the OpenAI SDK
|
|
14
|
+
jest.mock('openai');
|
|
15
|
+
|
|
16
|
+
describe('OpenAIProvider', () => {
|
|
17
|
+
let provider: OpenAIProvider;
|
|
18
|
+
let mockClient: jest.Mocked<OpenAI>;
|
|
19
|
+
|
|
20
|
+
const config: OpenAIProviderConfig = {
|
|
21
|
+
providerName: 'openai',
|
|
22
|
+
apiKey: 'test-api-key',
|
|
23
|
+
defaultModel: 'gpt-4-turbo',
|
|
24
|
+
version: '1.0.0'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
jest.clearAllMocks();
|
|
29
|
+
|
|
30
|
+
// Setup mock OpenAI client
|
|
31
|
+
mockClient = {
|
|
32
|
+
chat: {
|
|
33
|
+
completions: {
|
|
34
|
+
create: jest.fn() as any
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
embeddings: {
|
|
38
|
+
create: jest.fn() as any
|
|
39
|
+
},
|
|
40
|
+
moderations: {
|
|
41
|
+
create: jest.fn() as any
|
|
42
|
+
},
|
|
43
|
+
models: {
|
|
44
|
+
list: jest.fn() as any,
|
|
45
|
+
retrieve: jest.fn() as any
|
|
46
|
+
}
|
|
47
|
+
} as any;
|
|
48
|
+
|
|
49
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => mockClient);
|
|
50
|
+
|
|
51
|
+
provider = new OpenAIProvider(config);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('Constructor', () => {
|
|
55
|
+
it('should create provider with valid config', () => {
|
|
56
|
+
expect(provider).toBeInstanceOf(OpenAIProvider);
|
|
57
|
+
expect(provider.getProviderName()).toBe('openai');
|
|
58
|
+
expect(provider.getProviderVersion()).toBe('1.0.0');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should initialize OpenAI client with correct options', () => {
|
|
62
|
+
const customConfig: OpenAIProviderConfig = {
|
|
63
|
+
providerName: 'openai',
|
|
64
|
+
apiKey: 'test-key',
|
|
65
|
+
organizationId: 'org-123',
|
|
66
|
+
baseURL: 'https://custom.openai.com',
|
|
67
|
+
timeout: 30000,
|
|
68
|
+
maxRetries: 5
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
new OpenAIProvider(customConfig);
|
|
72
|
+
|
|
73
|
+
expect(OpenAI).toHaveBeenCalledWith({
|
|
74
|
+
apiKey: 'test-key',
|
|
75
|
+
organization: 'org-123',
|
|
76
|
+
baseURL: 'https://custom.openai.com',
|
|
77
|
+
timeout: 30000,
|
|
78
|
+
maxRetries: 5
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should use default timeout and maxRetries if not provided', () => {
|
|
83
|
+
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({
|
|
84
|
+
timeout: 60000,
|
|
85
|
+
maxRetries: 3
|
|
86
|
+
}));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('complete()', () => {
|
|
91
|
+
it('should generate completion successfully', async () => {
|
|
92
|
+
const mockResponse = {
|
|
93
|
+
id: 'chatcmpl-123',
|
|
94
|
+
model: 'gpt-4-turbo',
|
|
95
|
+
created: Date.now(),
|
|
96
|
+
system_fingerprint: 'fp-123',
|
|
97
|
+
choices: [
|
|
98
|
+
{
|
|
99
|
+
message: {
|
|
100
|
+
role: 'assistant',
|
|
101
|
+
content: 'This is a test response'
|
|
102
|
+
},
|
|
103
|
+
finish_reason: 'stop'
|
|
104
|
+
}
|
|
105
|
+
],
|
|
106
|
+
usage: {
|
|
107
|
+
prompt_tokens: 10,
|
|
108
|
+
completion_tokens: 20,
|
|
109
|
+
total_tokens: 30
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
mockClient.chat.completions.create.mockResolvedValue(mockResponse as any);
|
|
114
|
+
|
|
115
|
+
const result = await provider.complete({
|
|
116
|
+
messages: [
|
|
117
|
+
{ role: 'user', content: 'Hello' }
|
|
118
|
+
]
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(result.success).toBe(true);
|
|
122
|
+
expect(result.content).toBe('This is a test response');
|
|
123
|
+
expect(result.finishReason).toBe('stop');
|
|
124
|
+
expect(result.usage).toEqual({
|
|
125
|
+
promptTokens: 10,
|
|
126
|
+
completionTokens: 20,
|
|
127
|
+
totalTokens: 30
|
|
128
|
+
});
|
|
129
|
+
expect(result.model).toBe('gpt-4-turbo');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should handle empty message content', async () => {
|
|
133
|
+
const mockResponse = {
|
|
134
|
+
id: 'chatcmpl-123',
|
|
135
|
+
model: 'gpt-4-turbo',
|
|
136
|
+
created: Date.now(),
|
|
137
|
+
choices: [
|
|
138
|
+
{
|
|
139
|
+
message: {
|
|
140
|
+
role: 'assistant',
|
|
141
|
+
content: null
|
|
142
|
+
},
|
|
143
|
+
finish_reason: 'stop'
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
mockClient.chat.completions.create.mockResolvedValue(mockResponse as any);
|
|
149
|
+
|
|
150
|
+
const result = await provider.complete({
|
|
151
|
+
messages: [{ role: 'user', content: 'Hello' }]
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(result.success).toBe(true);
|
|
155
|
+
expect(result.content).toBe('');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should pass all request parameters to OpenAI', async () => {
|
|
159
|
+
mockClient.chat.completions.create.mockResolvedValue({
|
|
160
|
+
choices: [{ message: { content: 'test' }, finish_reason: 'stop' }]
|
|
161
|
+
} as any);
|
|
162
|
+
|
|
163
|
+
await provider.complete({
|
|
164
|
+
messages: [{ role: 'user', content: 'Test' }],
|
|
165
|
+
model: 'gpt-3.5-turbo',
|
|
166
|
+
maxTokens: 100,
|
|
167
|
+
temperature: 0.8,
|
|
168
|
+
topP: 0.9,
|
|
169
|
+
frequencyPenalty: 0.5,
|
|
170
|
+
presencePenalty: 0.6,
|
|
171
|
+
stop: ['STOP'],
|
|
172
|
+
user: 'user-123'
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(mockClient.chat.completions.create).toHaveBeenCalledWith({
|
|
176
|
+
model: 'gpt-3.5-turbo',
|
|
177
|
+
messages: [{ role: 'user', content: 'Test' }],
|
|
178
|
+
max_tokens: 100,
|
|
179
|
+
temperature: 0.8,
|
|
180
|
+
top_p: 0.9,
|
|
181
|
+
frequency_penalty: 0.5,
|
|
182
|
+
presence_penalty: 0.6,
|
|
183
|
+
stop: ['STOP'],
|
|
184
|
+
user: 'user-123'
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should use default model if not specified', async () => {
|
|
189
|
+
mockClient.chat.completions.create.mockResolvedValue({
|
|
190
|
+
choices: [{ message: { content: 'test' }, finish_reason: 'stop' }]
|
|
191
|
+
} as any);
|
|
192
|
+
|
|
193
|
+
await provider.complete({
|
|
194
|
+
messages: [{ role: 'user', content: 'Test' }]
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(mockClient.chat.completions.create).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
model: 'gpt-4-turbo'
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should return error for invalid request', async () => {
|
|
205
|
+
const result = await provider.complete({
|
|
206
|
+
messages: []
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(result.success).toBe(false);
|
|
210
|
+
expect(result.error).toContain('messages');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should handle API errors', async () => {
|
|
214
|
+
const apiError = new OpenAI.APIError(
|
|
215
|
+
400,
|
|
216
|
+
{
|
|
217
|
+
error: {
|
|
218
|
+
message: 'Invalid request',
|
|
219
|
+
type: 'invalid_request_error',
|
|
220
|
+
param: null,
|
|
221
|
+
code: null
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
'Invalid request',
|
|
225
|
+
{}
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
mockClient.chat.completions.create.mockRejectedValue(apiError);
|
|
229
|
+
|
|
230
|
+
const result = await provider.complete({
|
|
231
|
+
messages: [{ role: 'user', content: 'Test' }]
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(result.success).toBe(false);
|
|
235
|
+
expect(result.error).toContain('OpenAI API Error');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('streamComplete()', () => {
|
|
240
|
+
it('should stream completion chunks', async () => {
|
|
241
|
+
const mockChunks = [
|
|
242
|
+
{
|
|
243
|
+
choices: [{
|
|
244
|
+
delta: { content: 'Hello' },
|
|
245
|
+
finish_reason: null
|
|
246
|
+
}]
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
choices: [{
|
|
250
|
+
delta: { content: ' world' },
|
|
251
|
+
finish_reason: null
|
|
252
|
+
}]
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
choices: [{
|
|
256
|
+
delta: { content: '!' },
|
|
257
|
+
finish_reason: 'stop'
|
|
258
|
+
}],
|
|
259
|
+
usage: {
|
|
260
|
+
prompt_tokens: 5,
|
|
261
|
+
completion_tokens: 10,
|
|
262
|
+
total_tokens: 15
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
mockClient.chat.completions.create.mockResolvedValue({
|
|
268
|
+
async *[Symbol.asyncIterator]() {
|
|
269
|
+
for (const chunk of mockChunks) {
|
|
270
|
+
yield chunk;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} as any);
|
|
274
|
+
|
|
275
|
+
const chunks = [];
|
|
276
|
+
for await (const chunk of provider.streamComplete({
|
|
277
|
+
messages: [{ role: 'user', content: 'Test' }]
|
|
278
|
+
})) {
|
|
279
|
+
chunks.push(chunk);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
expect(chunks).toHaveLength(3);
|
|
283
|
+
expect(chunks[0].delta).toBe('Hello');
|
|
284
|
+
expect(chunks[1].delta).toBe(' world');
|
|
285
|
+
expect(chunks[2].delta).toBe('!');
|
|
286
|
+
expect(chunks[2].finishReason).toBe('stop');
|
|
287
|
+
expect(chunks[2].usage).toEqual({
|
|
288
|
+
promptTokens: 5,
|
|
289
|
+
completionTokens: 10,
|
|
290
|
+
totalTokens: 15
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should handle empty deltas', async () => {
|
|
295
|
+
const mockChunks = [
|
|
296
|
+
{ choices: [{ delta: {}, finish_reason: null }] },
|
|
297
|
+
{ choices: [{ delta: { content: 'test' }, finish_reason: 'stop' }] }
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
mockClient.chat.completions.create.mockResolvedValue({
|
|
301
|
+
async *[Symbol.asyncIterator]() {
|
|
302
|
+
for (const chunk of mockChunks) {
|
|
303
|
+
yield chunk;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} as any);
|
|
307
|
+
|
|
308
|
+
const chunks = [];
|
|
309
|
+
for await (const chunk of provider.streamComplete({
|
|
310
|
+
messages: [{ role: 'user', content: 'Test' }]
|
|
311
|
+
})) {
|
|
312
|
+
chunks.push(chunk);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
expect(chunks[0].delta).toBe('');
|
|
316
|
+
expect(chunks[1].delta).toBe('test');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should throw error for invalid request', async () => {
|
|
320
|
+
await expect(async () => {
|
|
321
|
+
for await (const _ of provider.streamComplete({ messages: [] })) {
|
|
322
|
+
// Should throw before yielding
|
|
323
|
+
}
|
|
324
|
+
}).rejects.toThrow();
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('generateEmbeddings()', () => {
|
|
329
|
+
it('should generate embeddings successfully', async () => {
|
|
330
|
+
const mockResponse = {
|
|
331
|
+
model: 'text-embedding-3-small',
|
|
332
|
+
data: [
|
|
333
|
+
{ embedding: [0.1, 0.2, 0.3] },
|
|
334
|
+
{ embedding: [0.4, 0.5, 0.6] }
|
|
335
|
+
],
|
|
336
|
+
usage: {
|
|
337
|
+
prompt_tokens: 10,
|
|
338
|
+
total_tokens: 10
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
mockClient.embeddings.create.mockResolvedValue(mockResponse as any);
|
|
343
|
+
|
|
344
|
+
const result = await provider.generateEmbeddings({
|
|
345
|
+
input: ['text 1', 'text 2']
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
expect(result.success).toBe(true);
|
|
349
|
+
expect(result.embeddings).toHaveLength(2);
|
|
350
|
+
expect(result.embeddings![0]).toEqual([0.1, 0.2, 0.3]);
|
|
351
|
+
expect(result.embeddings![1]).toEqual([0.4, 0.5, 0.6]);
|
|
352
|
+
expect(result.usage).toEqual({
|
|
353
|
+
promptTokens: 10,
|
|
354
|
+
completionTokens: 0,
|
|
355
|
+
totalTokens: 10
|
|
356
|
+
});
|
|
357
|
+
expect(result.model).toBe('text-embedding-3-small');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should use default embedding model if not specified', async () => {
|
|
361
|
+
mockClient.embeddings.create.mockResolvedValue({
|
|
362
|
+
data: [{ embedding: [1, 2, 3] }],
|
|
363
|
+
usage: { prompt_tokens: 5, total_tokens: 5 }
|
|
364
|
+
} as any);
|
|
365
|
+
|
|
366
|
+
await provider.generateEmbeddings({
|
|
367
|
+
input: 'test'
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
expect(mockClient.embeddings.create).toHaveBeenCalledWith({
|
|
371
|
+
model: 'text-embedding-3-small',
|
|
372
|
+
input: 'test',
|
|
373
|
+
user: undefined
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('should handle API errors', async () => {
|
|
378
|
+
mockClient.embeddings.create.mockRejectedValue(new Error('API Error'));
|
|
379
|
+
|
|
380
|
+
const result = await provider.generateEmbeddings({
|
|
381
|
+
input: 'test'
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
expect(result.success).toBe(false);
|
|
385
|
+
expect(result.error).toBeDefined();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe('moderate()', () => {
|
|
390
|
+
it('should check moderation successfully', async () => {
|
|
391
|
+
const mockResponse = {
|
|
392
|
+
results: [
|
|
393
|
+
{
|
|
394
|
+
flagged: true,
|
|
395
|
+
categories: {
|
|
396
|
+
hate: true,
|
|
397
|
+
'hate/threatening': false,
|
|
398
|
+
harassment: false,
|
|
399
|
+
'harassment/threatening': false,
|
|
400
|
+
'self-harm': false,
|
|
401
|
+
'self-harm/intent': false,
|
|
402
|
+
'self-harm/instructions': false,
|
|
403
|
+
sexual: false,
|
|
404
|
+
'sexual/minors': false,
|
|
405
|
+
violence: false,
|
|
406
|
+
'violence/graphic': false
|
|
407
|
+
},
|
|
408
|
+
category_scores: {
|
|
409
|
+
hate: 0.9,
|
|
410
|
+
'hate/threatening': 0.1,
|
|
411
|
+
harassment: 0.05,
|
|
412
|
+
'harassment/threatening': 0.01,
|
|
413
|
+
'self-harm': 0.0,
|
|
414
|
+
'self-harm/intent': 0.0,
|
|
415
|
+
'self-harm/instructions': 0.0,
|
|
416
|
+
sexual: 0.0,
|
|
417
|
+
'sexual/minors': 0.0,
|
|
418
|
+
violence: 0.0,
|
|
419
|
+
'violence/graphic': 0.0
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
]
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
mockClient.moderations.create.mockResolvedValue(mockResponse as any);
|
|
426
|
+
|
|
427
|
+
const result = await provider.moderate('test content');
|
|
428
|
+
|
|
429
|
+
expect(result.success).toBe(true);
|
|
430
|
+
expect(result.flagged).toBe(true);
|
|
431
|
+
expect(result.categories).toEqual(mockResponse.results[0].categories);
|
|
432
|
+
expect(result.categoryScores).toEqual(mockResponse.results[0].category_scores);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should handle clean content', async () => {
|
|
436
|
+
const mockResponse = {
|
|
437
|
+
results: [
|
|
438
|
+
{
|
|
439
|
+
flagged: false,
|
|
440
|
+
categories: {},
|
|
441
|
+
category_scores: {}
|
|
442
|
+
}
|
|
443
|
+
]
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
mockClient.moderations.create.mockResolvedValue(mockResponse as any);
|
|
447
|
+
|
|
448
|
+
const result = await provider.moderate('clean content');
|
|
449
|
+
|
|
450
|
+
expect(result.success).toBe(true);
|
|
451
|
+
expect(result.flagged).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should handle API errors', async () => {
|
|
455
|
+
mockClient.moderations.create.mockRejectedValue(new Error('API Error'));
|
|
456
|
+
|
|
457
|
+
const result = await provider.moderate('test');
|
|
458
|
+
|
|
459
|
+
expect(result.success).toBe(false);
|
|
460
|
+
expect(result.flagged).toBe(false);
|
|
461
|
+
expect(result.error).toBeDefined();
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
describe('getAvailableModels()', () => {
|
|
466
|
+
it('should fetch and filter models from API', async () => {
|
|
467
|
+
const mockResponse = {
|
|
468
|
+
data: [
|
|
469
|
+
{ id: 'gpt-4', object: 'model' },
|
|
470
|
+
{ id: 'gpt-3.5-turbo', object: 'model' },
|
|
471
|
+
{ id: 'text-embedding-3-small', object: 'model' },
|
|
472
|
+
{ id: 'babbage', object: 'model' }, // Should be filtered out
|
|
473
|
+
{ id: 'davinci', object: 'model' } // Should be filtered out
|
|
474
|
+
]
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
mockClient.models.list.mockResolvedValue(mockResponse as any);
|
|
478
|
+
|
|
479
|
+
const models = await provider.getAvailableModels();
|
|
480
|
+
|
|
481
|
+
expect(models).toHaveLength(3);
|
|
482
|
+
expect(models.find(m => m.id === 'gpt-4')).toBeDefined();
|
|
483
|
+
expect(models.find(m => m.id === 'gpt-3.5-turbo')).toBeDefined();
|
|
484
|
+
expect(models.find(m => m.id === 'text-embedding-3-small')).toBeDefined();
|
|
485
|
+
expect(models.find(m => m.id === 'babbage')).toBeUndefined();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('should return cached models if API fails', async () => {
|
|
489
|
+
mockClient.models.list.mockRejectedValue(new Error('API Error'));
|
|
490
|
+
|
|
491
|
+
const models = await provider.getAvailableModels();
|
|
492
|
+
|
|
493
|
+
expect(models.length).toBeGreaterThan(0);
|
|
494
|
+
expect(models.find(m => m.id === 'gpt-4-turbo')).toBeDefined();
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
describe('checkHealth()', () => {
|
|
499
|
+
it('should return healthy status when API is accessible', async () => {
|
|
500
|
+
mockClient.models.retrieve.mockResolvedValue({ id: 'gpt-3.5-turbo' } as any);
|
|
501
|
+
|
|
502
|
+
const health = await provider.checkHealth();
|
|
503
|
+
|
|
504
|
+
expect(health.status).toBe('healthy');
|
|
505
|
+
expect(health.latency).toBeGreaterThanOrEqual(0);
|
|
506
|
+
expect(health.lastChecked).toBeInstanceOf(Date);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('should return unavailable status when API fails', async () => {
|
|
510
|
+
mockClient.models.retrieve.mockRejectedValue(new Error('Connection failed'));
|
|
511
|
+
|
|
512
|
+
const health = await provider.checkHealth();
|
|
513
|
+
|
|
514
|
+
expect(health.status).toBe('unavailable');
|
|
515
|
+
expect(health.latency).toBeGreaterThanOrEqual(0);
|
|
516
|
+
expect(health.details?.error).toBeDefined();
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe('isAvailable()', () => {
|
|
521
|
+
it('should return true when provider is healthy', async () => {
|
|
522
|
+
mockClient.models.retrieve.mockResolvedValue({ id: 'gpt-3.5-turbo' } as any);
|
|
523
|
+
|
|
524
|
+
const available = await provider.isAvailable();
|
|
525
|
+
|
|
526
|
+
expect(available).toBe(true);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('should return false when provider is unavailable', async () => {
|
|
530
|
+
mockClient.models.retrieve.mockRejectedValue(new Error('API Error'));
|
|
531
|
+
|
|
532
|
+
const available = await provider.isAvailable();
|
|
533
|
+
|
|
534
|
+
expect(available).toBe(false);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
describe('estimateCost()', () => {
|
|
539
|
+
it('should estimate cost for GPT-4 Turbo correctly', () => {
|
|
540
|
+
const cost = provider.estimateCost({
|
|
541
|
+
messages: [
|
|
542
|
+
{ role: 'user', content: 'Hello world, how are you today?' }
|
|
543
|
+
],
|
|
544
|
+
model: 'gpt-4-turbo',
|
|
545
|
+
maxTokens: 1000
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
expect(cost.inputTokens).toBeGreaterThan(0);
|
|
549
|
+
expect(cost.outputTokens).toBe(1000);
|
|
550
|
+
expect(cost.totalTokens).toBe(cost.inputTokens + 1000);
|
|
551
|
+
expect(cost.estimatedCostUSD).toBeGreaterThan(0);
|
|
552
|
+
expect(cost.currency).toBe('USD');
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('should use default maxTokens if not specified', () => {
|
|
556
|
+
const cost = provider.estimateCost({
|
|
557
|
+
messages: [{ role: 'user', content: 'Test' }],
|
|
558
|
+
model: 'gpt-4-turbo'
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
expect(cost.outputTokens).toBe(1000);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('should fall back to default pricing for unknown models', () => {
|
|
565
|
+
const cost = provider.estimateCost({
|
|
566
|
+
messages: [{ role: 'user', content: 'Test' }],
|
|
567
|
+
model: 'unknown-model',
|
|
568
|
+
maxTokens: 500
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
expect(cost.estimatedCostUSD).toBeGreaterThan(0);
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
});
|