@aj-archipelago/cortex 1.3.22 → 1.3.24
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 +64 -0
- package/config.js +26 -1
- package/helper-apps/cortex-realtime-voice-server/src/realtime/client.ts +9 -4
- package/helper-apps/cortex-realtime-voice-server/src/realtime/realtimeTypes.ts +1 -0
- package/lib/util.js +4 -24
- package/package.json +5 -2
- package/pathways/system/entity/sys_generator_memory.js +3 -3
- package/pathways/system/rest_streaming/sys_ollama_chat.js +21 -0
- package/pathways/system/rest_streaming/sys_ollama_completion.js +14 -0
- package/pathways/system/rest_streaming/sys_openai_chat.js +2 -2
- package/pathways/transcribe_gemini.js +181 -53
- package/server/modelExecutor.js +8 -0
- package/server/pathwayResolver.js +15 -6
- package/server/plugins/claude3VertexPlugin.js +51 -16
- package/server/plugins/gemini15ChatPlugin.js +94 -1
- package/server/plugins/gemini15VisionPlugin.js +9 -3
- package/server/plugins/modelPlugin.js +11 -8
- package/server/plugins/ollamaChatPlugin.js +158 -0
- package/server/plugins/ollamaCompletionPlugin.js +147 -0
- package/server/rest.js +46 -5
- package/tests/multimodal_conversion.test.js +169 -0
- package/tests/openai_api.test.js +43 -23
- package/tests/streaming.test.js +197 -0
- package/tests/transcribe_gemini.test.js +217 -0
package/server/rest.js
CHANGED
|
@@ -6,6 +6,22 @@ import { requestState } from './requestState.js';
|
|
|
6
6
|
import { v4 as uuidv4 } from 'uuid';
|
|
7
7
|
import logger from '../lib/logger.js';
|
|
8
8
|
import { getSingleTokenChunks } from './chunker.js';
|
|
9
|
+
import axios from 'axios';
|
|
10
|
+
|
|
11
|
+
const getOllamaModels = async (ollamaUrl) => {
|
|
12
|
+
try {
|
|
13
|
+
const response = await axios.get(`${ollamaUrl}/api/tags`);
|
|
14
|
+
return response.data.models.map(model => ({
|
|
15
|
+
id: `ollama-${model.name}`,
|
|
16
|
+
object: 'model',
|
|
17
|
+
owned_by: 'ollama',
|
|
18
|
+
permission: ''
|
|
19
|
+
}));
|
|
20
|
+
} catch (error) {
|
|
21
|
+
logger.error(`Error fetching Ollama models: ${error.message}`);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
};
|
|
9
25
|
|
|
10
26
|
const chunkTextIntoTokens = (() => {
|
|
11
27
|
let partialToken = '';
|
|
@@ -282,7 +298,14 @@ function buildRestEndpoints(pathways, app, server, config) {
|
|
|
282
298
|
// Create OpenAI compatible endpoints
|
|
283
299
|
app.post('/v1/completions', async (req, res) => {
|
|
284
300
|
const modelName = req.body.model || 'gpt-3.5-turbo';
|
|
285
|
-
|
|
301
|
+
let pathwayName;
|
|
302
|
+
|
|
303
|
+
if (modelName.startsWith('ollama-')) {
|
|
304
|
+
pathwayName = 'sys_ollama_completion';
|
|
305
|
+
req.body.ollamaModel = modelName.replace('ollama-', '');
|
|
306
|
+
} else {
|
|
307
|
+
pathwayName = openAICompletionModels[modelName] || openAICompletionModels['*'];
|
|
308
|
+
}
|
|
286
309
|
|
|
287
310
|
if (!pathwayName) {
|
|
288
311
|
res.status(404).json({
|
|
@@ -318,7 +341,6 @@ function buildRestEndpoints(pathways, app, server, config) {
|
|
|
318
341
|
if (Boolean(req.body.stream)) {
|
|
319
342
|
jsonResponse.id = `cmpl-${resultText}`;
|
|
320
343
|
jsonResponse.choices[0].finish_reason = null;
|
|
321
|
-
//jsonResponse.object = "text_completion.chunk";
|
|
322
344
|
|
|
323
345
|
processIncomingStream(resultText, res, jsonResponse, pathway);
|
|
324
346
|
} else {
|
|
@@ -330,7 +352,14 @@ function buildRestEndpoints(pathways, app, server, config) {
|
|
|
330
352
|
|
|
331
353
|
app.post('/v1/chat/completions', async (req, res) => {
|
|
332
354
|
const modelName = req.body.model || 'gpt-3.5-turbo';
|
|
333
|
-
|
|
355
|
+
let pathwayName;
|
|
356
|
+
|
|
357
|
+
if (modelName.startsWith('ollama-')) {
|
|
358
|
+
pathwayName = 'sys_ollama_chat';
|
|
359
|
+
req.body.ollamaModel = modelName.replace('ollama-', '');
|
|
360
|
+
} else {
|
|
361
|
+
pathwayName = openAIChatModels[modelName] || openAIChatModels['*'];
|
|
362
|
+
}
|
|
334
363
|
|
|
335
364
|
if (!pathwayName) {
|
|
336
365
|
res.status(404).json({
|
|
@@ -385,8 +414,11 @@ function buildRestEndpoints(pathways, app, server, config) {
|
|
|
385
414
|
app.get('/v1/models', async (req, res) => {
|
|
386
415
|
const openAIModels = { ...openAIChatModels, ...openAICompletionModels };
|
|
387
416
|
const defaultModelId = 'gpt-3.5-turbo';
|
|
417
|
+
let models = [];
|
|
388
418
|
|
|
389
|
-
|
|
419
|
+
// Get standard OpenAI-compatible models, filtering out our internal pathway models
|
|
420
|
+
models = Object.entries(openAIModels)
|
|
421
|
+
.filter(([modelId]) => !['ollama-chat', 'ollama-completion'].includes(modelId))
|
|
390
422
|
.map(([modelId]) => {
|
|
391
423
|
if (modelId.includes('*')) {
|
|
392
424
|
modelId = defaultModelId;
|
|
@@ -397,7 +429,16 @@ function buildRestEndpoints(pathways, app, server, config) {
|
|
|
397
429
|
owned_by: 'openai',
|
|
398
430
|
permission: '',
|
|
399
431
|
};
|
|
400
|
-
})
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Get Ollama models if configured
|
|
435
|
+
if (config.get('ollamaUrl')) {
|
|
436
|
+
const ollamaModels = await getOllamaModels(config.get('ollamaUrl'));
|
|
437
|
+
models = [...models, ...ollamaModels];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Filter out duplicates and sort
|
|
441
|
+
models = models
|
|
401
442
|
.filter((model, index, self) => {
|
|
402
443
|
return index === self.findIndex((m) => m.id === model.id);
|
|
403
444
|
})
|
|
@@ -271,12 +271,18 @@ test('Pathological cases', async (t) => {
|
|
|
271
271
|
|
|
272
272
|
t.is(geminiSystem15.parts[0].text, 'You are a helpful assistant.');
|
|
273
273
|
t.is(geminiSystem15.parts[1].text, 'You are also very knowledgeable.');
|
|
274
|
+
|
|
274
275
|
t.is(geminiMessages15.length, 3);
|
|
276
|
+
// First user message combines "Hello" and "Another greeting"
|
|
275
277
|
t.is(geminiMessages15[0].role, 'user');
|
|
276
278
|
t.is(geminiMessages15[0].parts[0].text, 'Hello');
|
|
277
279
|
t.is(geminiMessages15[0].parts[1].text, 'Another greeting');
|
|
280
|
+
|
|
281
|
+
// Assistant message "Hi there!"
|
|
278
282
|
t.is(geminiMessages15[1].role, 'assistant');
|
|
279
283
|
t.is(geminiMessages15[1].parts[0].text, 'Hi there!');
|
|
284
|
+
|
|
285
|
+
// Final user message combines "How are you?", image content, and "Another question"
|
|
280
286
|
t.is(geminiMessages15[2].role, 'user');
|
|
281
287
|
t.is(geminiMessages15[2].parts[0].text, 'How are you?');
|
|
282
288
|
t.is(geminiMessages15[2].parts[1].text, 'What\'s this?');
|
|
@@ -310,6 +316,79 @@ test('Empty message array', async (t) => {
|
|
|
310
316
|
t.is(geminiMessages15.length, 0);
|
|
311
317
|
});
|
|
312
318
|
|
|
319
|
+
// Test simple string array content
|
|
320
|
+
test('Simple string array content', async (t) => {
|
|
321
|
+
const { gemini15 } = createPlugins();
|
|
322
|
+
|
|
323
|
+
const messages = [
|
|
324
|
+
{ role: 'user', content: "Initial message" },
|
|
325
|
+
{ role: 'assistant', content: [
|
|
326
|
+
"\"Searchin' for my lost shaker of salt...\"\n",
|
|
327
|
+
]},
|
|
328
|
+
{ role: 'user', content: [
|
|
329
|
+
"Here's another simple string in an array",
|
|
330
|
+
]}
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
const { modifiedMessages } = gemini15.convertMessagesToGemini(messages);
|
|
334
|
+
|
|
335
|
+
t.is(modifiedMessages.length, 3);
|
|
336
|
+
t.is(modifiedMessages[0].role, 'user');
|
|
337
|
+
t.is(modifiedMessages[0].parts.length, 1);
|
|
338
|
+
t.is(modifiedMessages[0].parts[0].text, "Initial message");
|
|
339
|
+
t.is(modifiedMessages[1].role, 'assistant');
|
|
340
|
+
t.is(modifiedMessages[1].parts.length, 1);
|
|
341
|
+
t.is(modifiedMessages[1].parts[0].text, "\"Searchin' for my lost shaker of salt...\"\n");
|
|
342
|
+
t.is(modifiedMessages[2].role, 'user');
|
|
343
|
+
t.is(modifiedMessages[2].parts.length, 1);
|
|
344
|
+
t.is(modifiedMessages[2].parts[0].text, "Here's another simple string in an array");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Test string-encoded multimodal content
|
|
348
|
+
test('String-encoded multimodal content', async (t) => {
|
|
349
|
+
const { gemini15 } = createPlugins();
|
|
350
|
+
|
|
351
|
+
const messages = [
|
|
352
|
+
{ role: 'user', content: [
|
|
353
|
+
JSON.stringify({
|
|
354
|
+
type: 'text',
|
|
355
|
+
text: 'What is in this image?'
|
|
356
|
+
}),
|
|
357
|
+
JSON.stringify({
|
|
358
|
+
type: 'image_url',
|
|
359
|
+
image_url: { url: 'gs://my-bucket/image.jpg' }
|
|
360
|
+
})
|
|
361
|
+
]},
|
|
362
|
+
{ role: 'assistant', content: [
|
|
363
|
+
JSON.stringify({
|
|
364
|
+
type: 'text',
|
|
365
|
+
text: 'I see a cat.'
|
|
366
|
+
})
|
|
367
|
+
]},
|
|
368
|
+
{ role: 'user', content: [
|
|
369
|
+
JSON.stringify({
|
|
370
|
+
type: 'text',
|
|
371
|
+
text: 'Is it a big cat?'
|
|
372
|
+
})
|
|
373
|
+
]}
|
|
374
|
+
];
|
|
375
|
+
|
|
376
|
+
const { modifiedMessages } = gemini15.convertMessagesToGemini(messages);
|
|
377
|
+
|
|
378
|
+
t.is(modifiedMessages.length, 3);
|
|
379
|
+
t.is(modifiedMessages[0].role, 'user');
|
|
380
|
+
t.is(modifiedMessages[0].parts.length, 2);
|
|
381
|
+
t.is(modifiedMessages[0].parts[0].text, 'What is in this image?');
|
|
382
|
+
t.true('fileData' in modifiedMessages[0].parts[1]);
|
|
383
|
+
t.is(modifiedMessages[0].parts[1].fileData.fileUri, 'gs://my-bucket/image.jpg');
|
|
384
|
+
t.is(modifiedMessages[1].role, 'assistant');
|
|
385
|
+
t.is(modifiedMessages[1].parts.length, 1);
|
|
386
|
+
t.is(modifiedMessages[1].parts[0].text, 'I see a cat.');
|
|
387
|
+
t.is(modifiedMessages[2].role, 'user');
|
|
388
|
+
t.is(modifiedMessages[2].parts.length, 1);
|
|
389
|
+
t.is(modifiedMessages[2].parts[0].text, 'Is it a big cat?');
|
|
390
|
+
});
|
|
391
|
+
|
|
313
392
|
// Test messages with only system messages
|
|
314
393
|
test('Only system messages', async (t) => {
|
|
315
394
|
const { openai, claude, gemini, gemini15 } = createPlugins();
|
|
@@ -417,3 +496,93 @@ test('Gemini 1.5 image URL edge cases', t => {
|
|
|
417
496
|
// Verify we only have one part (the text)
|
|
418
497
|
t.is(modifiedMessages[0].parts.length, 1, 'Should only have the text part');
|
|
419
498
|
});
|
|
499
|
+
|
|
500
|
+
// Test multiple images in single message for Claude
|
|
501
|
+
test('Multiple images in single Claude message', async (t) => {
|
|
502
|
+
const { claude } = createPlugins();
|
|
503
|
+
|
|
504
|
+
const multiImageMessage = [
|
|
505
|
+
{ role: 'user', content: [
|
|
506
|
+
{ type: 'text', text: 'Compare these images:' },
|
|
507
|
+
{ type: 'image_url', image_url: { url: sampleBase64Image } },
|
|
508
|
+
{ type: 'text', text: 'with this one:' },
|
|
509
|
+
{ type: 'image_url', image_url: { url: sampleBase64Image } },
|
|
510
|
+
{ type: 'image_url', gcs: 'gs://cortex-bucket/image.jpg' }
|
|
511
|
+
]}
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
const { modifiedMessages } = await claude.convertMessagesToClaudeVertex(multiImageMessage);
|
|
515
|
+
|
|
516
|
+
t.is(modifiedMessages.length, 1);
|
|
517
|
+
t.is(modifiedMessages[0].role, 'user');
|
|
518
|
+
t.is(modifiedMessages[0].content.length, 4);
|
|
519
|
+
t.is(modifiedMessages[0].content[0].text, 'Compare these images:');
|
|
520
|
+
t.true(modifiedMessages[0].content[1].source.type === 'base64');
|
|
521
|
+
t.is(modifiedMessages[0].content[2].text, 'with this one:');
|
|
522
|
+
t.true(modifiedMessages[0].content[3].source.type === 'base64');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Test conversation history with mixed image types
|
|
526
|
+
test('Conversation history with mixed image types', async (t) => {
|
|
527
|
+
const { claude, gemini15 } = createPlugins();
|
|
528
|
+
|
|
529
|
+
const conversationHistory = [
|
|
530
|
+
{ role: 'system', content: 'You are a visual analysis assistant.' },
|
|
531
|
+
{ role: 'user', content: [
|
|
532
|
+
{ type: 'text', text: 'What\'s in this image?' },
|
|
533
|
+
{ type: 'image_url', image_url: { url: sampleBase64Image } }
|
|
534
|
+
]},
|
|
535
|
+
{ role: 'assistant', content: 'I see a landscape.' },
|
|
536
|
+
{ role: 'user', content: [
|
|
537
|
+
{ type: 'text', text: 'Compare it with this:' },
|
|
538
|
+
{ type: 'image_url', gcs: 'gs://cortex-bucket/image2.jpg' }
|
|
539
|
+
]},
|
|
540
|
+
{ role: 'assistant', content: 'The second image shows a different scene.' },
|
|
541
|
+
{ role: 'user', content: 'Which one do you prefer?' }
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
// Test Claude conversion
|
|
545
|
+
const { system: claudeSystem, modifiedMessages: claudeMessages } = await claude.convertMessagesToClaudeVertex(conversationHistory);
|
|
546
|
+
|
|
547
|
+
t.is(claudeSystem, 'You are a visual analysis assistant.');
|
|
548
|
+
t.is(claudeMessages.length, 5);
|
|
549
|
+
t.is(claudeMessages[1].content[0].text, 'I see a landscape.');
|
|
550
|
+
t.is(claudeMessages[3].content[0].text, 'The second image shows a different scene.');
|
|
551
|
+
t.is(claudeMessages[4].content[0].text, 'Which one do you prefer?');
|
|
552
|
+
|
|
553
|
+
// Test Gemini 1.5 conversion
|
|
554
|
+
const { system: geminiSystem15, modifiedMessages: geminiMessages15 } = gemini15.convertMessagesToGemini(conversationHistory);
|
|
555
|
+
|
|
556
|
+
t.is(geminiSystem15.parts[0].text, 'You are a visual analysis assistant.');
|
|
557
|
+
t.is(geminiMessages15.length, 5);
|
|
558
|
+
t.true('inlineData' in geminiMessages15[0].parts[1]);
|
|
559
|
+
t.is(geminiMessages15[1].parts[0].text, 'I see a landscape.');
|
|
560
|
+
t.true('fileData' in geminiMessages15[2].parts[1]);
|
|
561
|
+
t.is(geminiMessages15[2].parts[1].fileData.fileUri, 'gs://cortex-bucket/image2.jpg');
|
|
562
|
+
t.is(geminiMessages15[3].parts[0].text, 'The second image shows a different scene.');
|
|
563
|
+
t.is(geminiMessages15[4].parts[0].text, 'Which one do you prefer?');
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// Test handling of large images
|
|
567
|
+
test('Large image handling', async (t) => {
|
|
568
|
+
const { claude, gemini15 } = createPlugins();
|
|
569
|
+
|
|
570
|
+
// Create a large base64 string (>10MB)
|
|
571
|
+
const largeSampleImage = 'data:image/jpeg;base64,' + 'A'.repeat(10 * 1024 * 1024);
|
|
572
|
+
|
|
573
|
+
const largeImageMessage = [
|
|
574
|
+
{ role: 'user', content: [
|
|
575
|
+
{ type: 'text', text: 'Check this large image:' },
|
|
576
|
+
{ type: 'image_url', image_url: { url: largeSampleImage } }
|
|
577
|
+
]}
|
|
578
|
+
];
|
|
579
|
+
|
|
580
|
+
// Both Claude and Gemini should handle or reject oversized images gracefully
|
|
581
|
+
const { modifiedMessages: claudeMessages } = await claude.convertMessagesToClaudeVertex(largeImageMessage);
|
|
582
|
+
const { modifiedMessages: geminiMessages } = gemini15.convertMessagesToGemini(largeImageMessage);
|
|
583
|
+
|
|
584
|
+
// Verify both models handle the oversized image appropriately
|
|
585
|
+
// (The exact behavior - rejection vs. compression - should match the model's specifications)
|
|
586
|
+
t.is(claudeMessages[0].content[0].text, 'Check this large image:');
|
|
587
|
+
t.is(geminiMessages[0].parts[0].text, 'Check this large image:');
|
|
588
|
+
});
|
package/tests/openai_api.test.js
CHANGED
|
@@ -48,7 +48,7 @@ test('POST /completions', async (t) => {
|
|
|
48
48
|
test('POST /chat/completions', async (t) => {
|
|
49
49
|
const response = await got.post(`${API_BASE}/chat/completions`, {
|
|
50
50
|
json: {
|
|
51
|
-
model: 'gpt-
|
|
51
|
+
model: 'gpt-4o',
|
|
52
52
|
messages: [{ role: 'user', content: 'Hello!' }],
|
|
53
53
|
stream: false,
|
|
54
54
|
},
|
|
@@ -63,7 +63,7 @@ test('POST /chat/completions', async (t) => {
|
|
|
63
63
|
test('POST /chat/completions with multimodal content', async (t) => {
|
|
64
64
|
const response = await got.post(`${API_BASE}/chat/completions`, {
|
|
65
65
|
json: {
|
|
66
|
-
model: '
|
|
66
|
+
model: 'gpt-4o',
|
|
67
67
|
messages: [{
|
|
68
68
|
role: 'user',
|
|
69
69
|
content: [
|
|
@@ -153,7 +153,7 @@ test('POST SSE: /v1/completions should send a series of events and a [DONE] even
|
|
|
153
153
|
|
|
154
154
|
test('POST SSE: /v1/chat/completions should send a series of events and a [DONE] event', async (t) => {
|
|
155
155
|
const payload = {
|
|
156
|
-
model: 'gpt-
|
|
156
|
+
model: 'gpt-4o',
|
|
157
157
|
messages: [
|
|
158
158
|
{
|
|
159
159
|
role: 'user',
|
|
@@ -177,7 +177,7 @@ test('POST SSE: /v1/chat/completions should send a series of events and a [DONE]
|
|
|
177
177
|
|
|
178
178
|
test('POST SSE: /v1/chat/completions with multimodal content should send a series of events and a [DONE] event', async (t) => {
|
|
179
179
|
const payload = {
|
|
180
|
-
model: '
|
|
180
|
+
model: 'gpt-4o',
|
|
181
181
|
messages: [{
|
|
182
182
|
role: 'user',
|
|
183
183
|
content: [
|
|
@@ -213,7 +213,7 @@ test('POST SSE: /v1/chat/completions with multimodal content should send a serie
|
|
|
213
213
|
test('POST /chat/completions should handle multimodal content for non-multimodal model', async (t) => {
|
|
214
214
|
const response = await got.post(`${API_BASE}/chat/completions`, {
|
|
215
215
|
json: {
|
|
216
|
-
model: 'gpt-
|
|
216
|
+
model: 'gpt-4o',
|
|
217
217
|
messages: [{
|
|
218
218
|
role: 'user',
|
|
219
219
|
content: [
|
|
@@ -242,7 +242,7 @@ test('POST /chat/completions should handle multimodal content for non-multimodal
|
|
|
242
242
|
|
|
243
243
|
test('POST SSE: /v1/chat/completions should handle streaming multimodal content for non-multimodal model', async (t) => {
|
|
244
244
|
const payload = {
|
|
245
|
-
model: 'gpt-
|
|
245
|
+
model: 'gpt-4o',
|
|
246
246
|
messages: [{
|
|
247
247
|
role: 'user',
|
|
248
248
|
content: [
|
|
@@ -282,7 +282,7 @@ test('POST SSE: /v1/chat/completions should handle streaming multimodal content
|
|
|
282
282
|
test('POST /chat/completions should handle malformed multimodal content', async (t) => {
|
|
283
283
|
const response = await got.post(`${API_BASE}/chat/completions`, {
|
|
284
284
|
json: {
|
|
285
|
-
model: '
|
|
285
|
+
model: 'gpt-4o',
|
|
286
286
|
messages: [{
|
|
287
287
|
role: 'user',
|
|
288
288
|
content: [
|
|
@@ -310,7 +310,7 @@ test('POST /chat/completions should handle malformed multimodal content', async
|
|
|
310
310
|
test('POST /chat/completions should handle invalid image data', async (t) => {
|
|
311
311
|
const response = await got.post(`${API_BASE}/chat/completions`, {
|
|
312
312
|
json: {
|
|
313
|
-
model: '
|
|
313
|
+
model: 'gpt-4o',
|
|
314
314
|
messages: [{
|
|
315
315
|
role: 'user',
|
|
316
316
|
content: [
|
|
@@ -361,7 +361,7 @@ test('POST /completions should handle model parameters', async (t) => {
|
|
|
361
361
|
test('POST /chat/completions should handle function calling', async (t) => {
|
|
362
362
|
const response = await got.post(`${API_BASE}/chat/completions`, {
|
|
363
363
|
json: {
|
|
364
|
-
model: 'gpt-
|
|
364
|
+
model: 'gpt-4o',
|
|
365
365
|
messages: [{ role: 'user', content: 'What is the weather in Boston?' }],
|
|
366
366
|
functions: [{
|
|
367
367
|
name: 'get_weather',
|
|
@@ -401,7 +401,7 @@ test('POST /chat/completions should handle function calling', async (t) => {
|
|
|
401
401
|
test('POST /chat/completions should validate response format', async (t) => {
|
|
402
402
|
const response = await got.post(`${API_BASE}/chat/completions`, {
|
|
403
403
|
json: {
|
|
404
|
-
model: 'gpt-
|
|
404
|
+
model: 'gpt-4o',
|
|
405
405
|
messages: [{ role: 'user', content: 'Hello!' }],
|
|
406
406
|
stream: false,
|
|
407
407
|
},
|
|
@@ -426,7 +426,7 @@ test('POST /chat/completions should validate response format', async (t) => {
|
|
|
426
426
|
test('POST /chat/completions should handle system messages', async (t) => {
|
|
427
427
|
const response = await got.post(`${API_BASE}/chat/completions`, {
|
|
428
428
|
json: {
|
|
429
|
-
model: 'gpt-
|
|
429
|
+
model: 'gpt-4o',
|
|
430
430
|
messages: [
|
|
431
431
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
432
432
|
{ role: 'user', content: 'Hello!' }
|
|
@@ -443,10 +443,29 @@ test('POST /chat/completions should handle system messages', async (t) => {
|
|
|
443
443
|
});
|
|
444
444
|
|
|
445
445
|
test('POST /chat/completions should handle errors gracefully', async (t) => {
|
|
446
|
+
const error = await t.throwsAsync(
|
|
447
|
+
() => got.post(`${API_BASE}/chat/completions`, {
|
|
448
|
+
json: {
|
|
449
|
+
// Missing required model field
|
|
450
|
+
messages: [{ role: 'user', content: 'Hello!' }],
|
|
451
|
+
},
|
|
452
|
+
responseType: 'json',
|
|
453
|
+
})
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
t.is(error.response.statusCode, 404);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('POST /chat/completions should handle token limits', async (t) => {
|
|
446
460
|
const response = await got.post(`${API_BASE}/chat/completions`, {
|
|
447
461
|
json: {
|
|
448
|
-
|
|
449
|
-
messages: [{
|
|
462
|
+
model: 'gpt-4o',
|
|
463
|
+
messages: [{
|
|
464
|
+
role: 'user',
|
|
465
|
+
content: 'Hello!'.repeat(5000) // Very long message
|
|
466
|
+
}],
|
|
467
|
+
max_tokens: 100,
|
|
468
|
+
stream: false,
|
|
450
469
|
},
|
|
451
470
|
responseType: 'json',
|
|
452
471
|
});
|
|
@@ -455,17 +474,16 @@ test('POST /chat/completions should handle errors gracefully', async (t) => {
|
|
|
455
474
|
t.is(response.body.object, 'chat.completion');
|
|
456
475
|
t.true(Array.isArray(response.body.choices));
|
|
457
476
|
t.truthy(response.body.choices[0].message.content);
|
|
458
|
-
});
|
|
477
|
+
});
|
|
459
478
|
|
|
460
|
-
test('POST /chat/completions should
|
|
479
|
+
test('POST /chat/completions should return complete responses from gpt-4o', async (t) => {
|
|
461
480
|
const response = await got.post(`${API_BASE}/chat/completions`, {
|
|
462
481
|
json: {
|
|
463
|
-
model: 'gpt-
|
|
464
|
-
messages: [
|
|
465
|
-
role: '
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
max_tokens: 100,
|
|
482
|
+
model: 'gpt-4o',
|
|
483
|
+
messages: [
|
|
484
|
+
{ role: 'system', content: 'You are a helpful assistant. Always end your response with the exact string "END_MARKER_XYZ".' },
|
|
485
|
+
{ role: 'user', content: 'Say hello and explain why complete responses matter.' }
|
|
486
|
+
],
|
|
469
487
|
stream: false,
|
|
470
488
|
},
|
|
471
489
|
responseType: 'json',
|
|
@@ -474,6 +492,8 @@ test('POST /chat/completions should handle token limits', async (t) => {
|
|
|
474
492
|
t.is(response.statusCode, 200);
|
|
475
493
|
t.is(response.body.object, 'chat.completion');
|
|
476
494
|
t.true(Array.isArray(response.body.choices));
|
|
477
|
-
|
|
478
|
-
|
|
495
|
+
console.log('GPT-4o Response:', JSON.stringify(response.body.choices[0].message.content));
|
|
496
|
+
const content = response.body.choices[0].message.content;
|
|
497
|
+
t.regex(content, /END_MARKER_XYZ$/);
|
|
498
|
+
});
|
|
479
499
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import test from 'ava';
|
|
2
|
+
import serverFactory from '../index.js';
|
|
3
|
+
import { PathwayResolver } from '../server/pathwayResolver.js';
|
|
4
|
+
import OpenAIChatPlugin from '../server/plugins/openAiChatPlugin.js';
|
|
5
|
+
import GeminiChatPlugin from '../server/plugins/geminiChatPlugin.js';
|
|
6
|
+
import Gemini15ChatPlugin from '../server/plugins/gemini15ChatPlugin.js';
|
|
7
|
+
import Claude3VertexPlugin from '../server/plugins/claude3VertexPlugin.js';
|
|
8
|
+
import { config } from '../config.js';
|
|
9
|
+
|
|
10
|
+
let testServer;
|
|
11
|
+
|
|
12
|
+
test.before(async () => {
|
|
13
|
+
process.env.CORTEX_ENABLE_REST = 'true';
|
|
14
|
+
const { server, startServer } = await serverFactory();
|
|
15
|
+
startServer && await startServer();
|
|
16
|
+
testServer = server;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test.after.always('cleanup', async () => {
|
|
20
|
+
if (testServer) {
|
|
21
|
+
await testServer.stop();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Helper function to create a PathwayResolver with a specific plugin
|
|
26
|
+
function createResolverWithPlugin(pluginClass, modelName = 'test-model') {
|
|
27
|
+
// Map plugin classes to their corresponding model types
|
|
28
|
+
const pluginToModelType = {
|
|
29
|
+
OpenAIChatPlugin: 'OPENAI-VISION',
|
|
30
|
+
GeminiChatPlugin: 'GEMINI-VISION',
|
|
31
|
+
Gemini15ChatPlugin: 'GEMINI-1.5-VISION',
|
|
32
|
+
Claude3VertexPlugin: 'CLAUDE-3-VERTEX'
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const modelType = pluginToModelType[pluginClass.name];
|
|
36
|
+
if (!modelType) {
|
|
37
|
+
throw new Error(`Unknown plugin class: ${pluginClass.name}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pathway = {
|
|
41
|
+
name: 'test-pathway',
|
|
42
|
+
model: modelName,
|
|
43
|
+
prompt: 'test prompt'
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const model = {
|
|
47
|
+
name: modelName,
|
|
48
|
+
type: modelType
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const resolver = new PathwayResolver({
|
|
52
|
+
config,
|
|
53
|
+
pathway,
|
|
54
|
+
args: {},
|
|
55
|
+
endpoints: { [modelName]: model }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
resolver.modelExecutor.plugin = new pluginClass(pathway, model);
|
|
59
|
+
return resolver;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Test OpenAI Chat Plugin Streaming
|
|
63
|
+
test('OpenAI Chat Plugin - processStreamEvent handles content chunks correctly', async t => {
|
|
64
|
+
const resolver = createResolverWithPlugin(OpenAIChatPlugin);
|
|
65
|
+
const plugin = resolver.modelExecutor.plugin;
|
|
66
|
+
|
|
67
|
+
// Test regular content chunk
|
|
68
|
+
const contentEvent = {
|
|
69
|
+
data: JSON.stringify({
|
|
70
|
+
id: 'test-id',
|
|
71
|
+
choices: [{
|
|
72
|
+
delta: { content: 'test content' },
|
|
73
|
+
finish_reason: null
|
|
74
|
+
}]
|
|
75
|
+
})
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let progress = plugin.processStreamEvent(contentEvent, {});
|
|
79
|
+
t.is(progress.data, contentEvent.data);
|
|
80
|
+
t.falsy(progress.progress);
|
|
81
|
+
|
|
82
|
+
// Test stream end
|
|
83
|
+
const endEvent = {
|
|
84
|
+
data: JSON.stringify({
|
|
85
|
+
id: 'test-id',
|
|
86
|
+
choices: [{
|
|
87
|
+
delta: {},
|
|
88
|
+
finish_reason: 'stop'
|
|
89
|
+
}]
|
|
90
|
+
})
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
progress = plugin.processStreamEvent(endEvent, {});
|
|
94
|
+
t.is(progress.progress, 1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Test Gemini Chat Plugin Streaming
|
|
98
|
+
test('Gemini Chat Plugin - processStreamEvent handles content chunks correctly', async t => {
|
|
99
|
+
const resolver = createResolverWithPlugin(GeminiChatPlugin);
|
|
100
|
+
const plugin = resolver.modelExecutor.plugin;
|
|
101
|
+
|
|
102
|
+
// Test regular content chunk
|
|
103
|
+
const contentEvent = {
|
|
104
|
+
data: JSON.stringify({
|
|
105
|
+
candidates: [{
|
|
106
|
+
content: {
|
|
107
|
+
parts: [{ text: 'test content' }]
|
|
108
|
+
},
|
|
109
|
+
finishReason: null
|
|
110
|
+
}]
|
|
111
|
+
})
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
let progress = plugin.processStreamEvent(contentEvent, {});
|
|
115
|
+
t.truthy(progress.data, 'Should have data');
|
|
116
|
+
const parsedData = JSON.parse(progress.data);
|
|
117
|
+
t.truthy(parsedData.candidates, 'Should have candidates array');
|
|
118
|
+
t.truthy(parsedData.candidates[0].content, 'Should have content object');
|
|
119
|
+
t.truthy(parsedData.candidates[0].content.parts, 'Should have parts array');
|
|
120
|
+
t.is(parsedData.candidates[0].content.parts[0].text, 'test content', 'Content should match');
|
|
121
|
+
t.falsy(progress.progress);
|
|
122
|
+
|
|
123
|
+
// Test stream end with STOP
|
|
124
|
+
const endEvent = {
|
|
125
|
+
data: JSON.stringify({
|
|
126
|
+
candidates: [{
|
|
127
|
+
content: {
|
|
128
|
+
parts: [{ text: '' }]
|
|
129
|
+
},
|
|
130
|
+
finishReason: 'STOP'
|
|
131
|
+
}]
|
|
132
|
+
})
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
progress = plugin.processStreamEvent(endEvent, {});
|
|
136
|
+
t.is(progress.progress, 1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Test Gemini 15 Chat Plugin Streaming
|
|
140
|
+
test('Gemini 15 Chat Plugin - processStreamEvent handles safety blocks', async t => {
|
|
141
|
+
const resolver = createResolverWithPlugin(Gemini15ChatPlugin);
|
|
142
|
+
const plugin = resolver.modelExecutor.plugin;
|
|
143
|
+
|
|
144
|
+
// Test safety block
|
|
145
|
+
const safetyEvent = {
|
|
146
|
+
data: JSON.stringify({
|
|
147
|
+
candidates: [{
|
|
148
|
+
safetyRatings: [{ blocked: true }]
|
|
149
|
+
}]
|
|
150
|
+
})
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const progress = plugin.processStreamEvent(safetyEvent, {});
|
|
154
|
+
t.true(progress.data.includes('Response blocked'));
|
|
155
|
+
t.is(progress.progress, 1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Test Claude 3 Vertex Plugin Streaming
|
|
159
|
+
test('Claude 3 Vertex Plugin - processStreamEvent handles message types', async t => {
|
|
160
|
+
const resolver = createResolverWithPlugin(Claude3VertexPlugin);
|
|
161
|
+
const plugin = resolver.modelExecutor.plugin;
|
|
162
|
+
|
|
163
|
+
// Test message start
|
|
164
|
+
const startEvent = {
|
|
165
|
+
data: JSON.stringify({
|
|
166
|
+
type: 'message_start',
|
|
167
|
+
message: { id: 'test-id' }
|
|
168
|
+
})
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
let progress = plugin.processStreamEvent(startEvent, {});
|
|
172
|
+
t.true(JSON.parse(progress.data).choices[0].delta.role === 'assistant');
|
|
173
|
+
|
|
174
|
+
// Test content block
|
|
175
|
+
const contentEvent = {
|
|
176
|
+
data: JSON.stringify({
|
|
177
|
+
type: 'content_block_delta',
|
|
178
|
+
delta: {
|
|
179
|
+
type: 'text_delta',
|
|
180
|
+
text: 'test content'
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
progress = plugin.processStreamEvent(contentEvent, {});
|
|
186
|
+
t.true(JSON.parse(progress.data).choices[0].delta.content === 'test content');
|
|
187
|
+
|
|
188
|
+
// Test message stop
|
|
189
|
+
const stopEvent = {
|
|
190
|
+
data: JSON.stringify({
|
|
191
|
+
type: 'message_stop'
|
|
192
|
+
})
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
progress = plugin.processStreamEvent(stopEvent, {});
|
|
196
|
+
t.is(progress.progress, 1);
|
|
197
|
+
});
|