@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/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
- const pathwayName = openAICompletionModels[modelName] || openAICompletionModels['*'];
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
- const pathwayName = openAIChatModels[modelName] || openAIChatModels['*'];
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
- const models = Object.entries(openAIModels)
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
+ });
@@ -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-3.5-turbo',
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: 'claude-3.5-sonnet',
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-3.5-turbo',
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: 'claude-3.5-sonnet',
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-3.5-turbo',
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-3.5-turbo',
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: 'claude-3.5-sonnet',
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: 'claude-3.5-sonnet',
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-3.5-turbo',
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-3.5-turbo',
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-3.5-turbo',
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
- // Missing required model field
449
- messages: [{ role: 'user', content: 'Hello!' }],
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 handle token limits', async (t) => {
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-3.5-turbo',
464
- messages: [{
465
- role: 'user',
466
- content: 'Hello!'.repeat(5000) // Very long message
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
- t.truthy(response.body.choices[0].message.content);
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
+ });