@aj-archipelago/cortex 1.4.32 → 1.4.33

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.
@@ -274,6 +274,14 @@ class PathwayResolver {
274
274
 
275
275
  async handleStream(response) {
276
276
  let streamErrorOccurred = false;
277
+ let streamErrorMessage = null;
278
+ let completionSent = false;
279
+ let receivedSSEData = false; // Track if we actually received SSE events
280
+ let receivedAnyData = false; // Track if we received ANY data from the stream
281
+ let toolCallbackInvoked = false; // Track if a tool callback was invoked (stream close is expected)
282
+ // Accumulate streamed content for continuity memory
283
+ this.streamedContent = '';
284
+ const requestId = this.rootRequestId || this.requestId;
277
285
 
278
286
  if (response && typeof response.on === 'function') {
279
287
  try {
@@ -282,7 +290,7 @@ class PathwayResolver {
282
290
 
283
291
  const onParse = (event) => {
284
292
  let requestProgress = {
285
- requestId: this.rootRequestId || this.requestId
293
+ requestId
286
294
  };
287
295
 
288
296
  logger.debug(`Received event: ${event.type}`);
@@ -292,15 +300,34 @@ class PathwayResolver {
292
300
  logger.debug(`id: ${event.id || '<none>'}`)
293
301
  logger.debug(`name: ${event.name || '<none>'}`)
294
302
  logger.debug(`data: ${event.data}`)
303
+
304
+ receivedSSEData = true; // Only mark SSE data when we get actual 'event' type
305
+
306
+ // Check for error events in the stream data
307
+ try {
308
+ const eventData = JSON.parse(event.data);
309
+ if (eventData.error) {
310
+ streamErrorOccurred = true;
311
+ streamErrorMessage = eventData.error.message || JSON.stringify(eventData.error);
312
+ logger.error(`Stream contained error event: ${streamErrorMessage}`);
313
+ }
314
+ } catch {
315
+ // Not JSON or no error field, continue normal processing
316
+ }
295
317
  } else if (event.type === 'reconnect-interval') {
296
318
  logger.debug(`We should set reconnect interval to ${event.value} milliseconds`)
297
319
  }
298
320
 
299
321
  try {
300
322
  requestProgress = this.modelExecutor.plugin.processStreamEvent(event, requestProgress);
323
+ // Check if plugin signaled a tool callback was invoked
324
+ if (requestProgress.toolCallbackInvoked) {
325
+ toolCallbackInvoked = true;
326
+ }
301
327
  } catch (error) {
302
328
  streamErrorOccurred = true;
303
- logger.error(`Stream error: ${error instanceof Error ? error.stack || error.message : JSON.stringify(error)}`);
329
+ streamErrorMessage = error instanceof Error ? error.message : String(error);
330
+ logger.error(`Stream processing error: ${error instanceof Error ? error.stack || error.message : JSON.stringify(error)}`);
304
331
  incomingMessage.off('data', processStream);
305
332
  return;
306
333
  }
@@ -309,6 +336,9 @@ class PathwayResolver {
309
336
  if (!streamEnded && requestProgress.data) {
310
337
  this.publishNestedRequestProgress(requestProgress);
311
338
  streamEnded = requestProgress.progress === 1;
339
+ if (streamEnded) {
340
+ completionSent = true;
341
+ }
312
342
  }
313
343
  } catch (error) {
314
344
  logger.error(`Could not publish the stream message: "${event.data}", ${error instanceof Error ? error.stack || error.message : JSON.stringify(error)}`);
@@ -319,7 +349,7 @@ class PathwayResolver {
319
349
  const sseParser = createParser(onParse);
320
350
 
321
351
  const processStream = (data) => {
322
- //logger.warn(`RECEIVED DATA: ${JSON.stringify(data.toString())}`);
352
+ receivedAnyData = true; // Track that we got data from the stream
323
353
  sseParser.feed(data.toString());
324
354
  }
325
355
 
@@ -327,27 +357,71 @@ class PathwayResolver {
327
357
  await new Promise((resolve, reject) => {
328
358
  incomingMessage.on('data', processStream);
329
359
  incomingMessage.on('end', resolve);
330
- incomingMessage.on('error', reject);
360
+ incomingMessage.on('error', (err) => {
361
+ streamErrorOccurred = true;
362
+ streamErrorMessage = err instanceof Error ? err.message : String(err);
363
+ reject(err);
364
+ });
365
+ incomingMessage.on('close', () => {
366
+ // Stream closed - detect various incomplete states
367
+ if (!receivedAnyData && !streamErrorOccurred && !toolCallbackInvoked) {
368
+ // Stream opened but closed with NO data at all - this is likely a provider issue
369
+ logger.warn('Stream closed with no data received (empty stream)');
370
+ } else if (receivedSSEData && !completionSent && !streamErrorOccurred && !toolCallbackInvoked) {
371
+ // Got SSE data but no completion signal
372
+ logger.warn('Stream closed without completion signal');
373
+ }
374
+ resolve();
375
+ });
331
376
  });
332
377
  }
333
378
 
334
379
  } catch (error) {
380
+ streamErrorOccurred = true;
381
+ if (!streamErrorMessage) {
382
+ streamErrorMessage = error instanceof Error ? error.message : String(error);
383
+ }
335
384
  logger.error(`Could not subscribe to stream: ${error instanceof Error ? error.stack || error.message : JSON.stringify(error)}`);
336
385
  }
337
386
 
338
- if (streamErrorOccurred) {
339
- logger.error(`Stream read failed. Finishing stream...`);
387
+ // Detect empty stream (opened but closed with no data) - this should be retried
388
+ const emptyStream = !receivedAnyData && !streamErrorOccurred && !toolCallbackInvoked;
389
+
390
+ // Ensure completion is sent if not already done
391
+ // Send completion if:
392
+ // 1. Stream error occurred (always notify client of errors)
393
+ // 2. OR we received SSE data but no completion was sent (and no tool callback)
394
+ // 3. OR empty stream (will only happen if retry logic exhausted - see executePathway)
395
+ // Don't send completion if a tool callback was invoked (stream will resume)
396
+ const shouldSendCompletion = !toolCallbackInvoked && !completionSent &&
397
+ (streamErrorOccurred || receivedSSEData);
398
+
399
+ if (shouldSendCompletion) {
400
+ if (streamErrorOccurred) {
401
+ logger.error(`Stream read failed: ${streamErrorMessage}`);
402
+ }
403
+ const errorMessage = streamErrorOccurred
404
+ ? (streamErrorMessage || this.errors.join(', ') || 'Stream read failed')
405
+ : '';
340
406
  publishRequestProgress({
341
- requestId: this.requestId,
407
+ requestId,
342
408
  progress: 1,
343
409
  data: '',
344
- info: '',
345
- error: 'Stream read failed'
410
+ info: JSON.stringify(this.pathwayResultData || {}),
411
+ error: errorMessage
346
412
  });
347
- } else {
348
- return;
349
413
  }
414
+
415
+ // Return stream result for retry logic
416
+ return {
417
+ success: completionSent || toolCallbackInvoked,
418
+ emptyStream,
419
+ error: streamErrorOccurred ? streamErrorMessage : null
420
+ };
350
421
  }
422
+
423
+ // Non-stream response
424
+ return { success: true, emptyStream: false, error: null };
351
425
  }
352
426
 
353
427
  async resolve(args) {
@@ -479,7 +553,23 @@ class PathwayResolver {
479
553
 
480
554
  // if data is a stream, handle it
481
555
  if (data && typeof data.on === 'function') {
482
- await this.handleStream(data);
556
+ const streamResult = await this.handleStream(data);
557
+ // Check if stream was empty (opened but closed with no data) - retry if so
558
+ if (streamResult?.emptyStream) {
559
+ logger.warn(`Empty stream received - retrying. Attempt ${retries + 1} of ${MAX_RETRIES}`);
560
+ if (retries === MAX_RETRIES - 1) {
561
+ // Last retry - send error completion so client doesn't hang
562
+ logger.error('All stream retries exhausted - empty stream from provider');
563
+ publishRequestProgress({
564
+ requestId: this.rootRequestId || this.requestId,
565
+ progress: 1,
566
+ data: '',
567
+ info: JSON.stringify(this.pathwayResultData || {}),
568
+ error: 'Provider returned empty stream - please try again'
569
+ });
570
+ }
571
+ continue; // Retry
572
+ }
483
573
  return data;
484
574
  }
485
575
 
@@ -0,0 +1,84 @@
1
+ import Claude4VertexPlugin from "./claude4VertexPlugin.js";
2
+
3
+ /**
4
+ * Plugin for direct Anthropic API access (api.anthropic.com)
5
+ *
6
+ * This plugin extends Claude4VertexPlugin and reuses all the message/content
7
+ * conversion logic, but uses direct Anthropic API authentication and endpoints
8
+ * instead of Google Vertex AI.
9
+ *
10
+ * Key differences from Vertex AI:
11
+ * - Uses x-api-key header instead of Bearer token
12
+ * - Uses https://api.anthropic.com/v1/messages endpoint
13
+ * - Model specified in request body, not URL
14
+ * - anthropic-version specified in header, not body
15
+ * - Streaming via stream:true in body, not URL suffix
16
+ */
17
+ class ClaudeAnthropicPlugin extends Claude4VertexPlugin {
18
+
19
+ constructor(pathway, model) {
20
+ super(pathway, model);
21
+ }
22
+
23
+ async getRequestParameters(text, parameters, prompt) {
24
+ // Get base request parameters from parent (includes message conversion)
25
+ const requestParameters = await super.getRequestParameters(
26
+ text,
27
+ parameters,
28
+ prompt
29
+ );
30
+
31
+ // Remove Vertex-specific anthropic_version from body
32
+ delete requestParameters.anthropic_version;
33
+
34
+ // Add model to request body (required for direct Anthropic API)
35
+ // The model name should come from the endpoint params or model config
36
+ const modelName = this.model.params?.model ||
37
+ this.model.endpoints?.[0]?.params?.model ||
38
+ this.modelName;
39
+ requestParameters.model = modelName;
40
+
41
+ return requestParameters;
42
+ }
43
+
44
+ async execute(text, parameters, prompt, cortexRequest) {
45
+ const requestParameters = await this.getRequestParameters(
46
+ text,
47
+ parameters,
48
+ prompt,
49
+ cortexRequest
50
+ );
51
+ const { stream } = parameters;
52
+
53
+ // Add stream parameter to request body for Anthropic API
54
+ if (stream) {
55
+ requestParameters.stream = true;
56
+ }
57
+
58
+ cortexRequest.data = {
59
+ ...(cortexRequest.data || {}),
60
+ ...requestParameters,
61
+ };
62
+ cortexRequest.params = {}; // query params
63
+ cortexRequest.stream = stream;
64
+
65
+ // Direct Anthropic API doesn't use URL suffix for streaming
66
+ cortexRequest.urlSuffix = "";
67
+
68
+ // Set Anthropic-specific headers
69
+ // The x-api-key should already be in the model config headers
70
+ // but we need to add the anthropic-version header
71
+ cortexRequest.headers = {
72
+ ...(cortexRequest.headers || {}),
73
+ "anthropic-version": "2023-06-01"
74
+ };
75
+
76
+ // For direct Anthropic API, authentication is handled via headers in config
77
+ // (x-api-key: {{CLAUDE_API_KEY}})
78
+ // No need for GCP auth token
79
+
80
+ return this.executeRequest(cortexRequest);
81
+ }
82
+ }
83
+
84
+ export default ClaudeAnthropicPlugin;
@@ -178,6 +178,16 @@ class Gemini15ChatPlugin extends ModelPlugin {
178
178
  const { content, finishReason, safetyRatings } = data.candidates[0];
179
179
  if (finishReason === 'STOP' || finishReason === 'MAX_TOKENS') {
180
180
  return content?.parts?.[0]?.text ?? '';
181
+ } else if (finishReason === 'MALFORMED_FUNCTION_CALL') {
182
+ // Model attempted a function call but generated invalid JSON
183
+ // Return any partial text content if available, otherwise return an error message
184
+ const textContent = content?.parts?.[0]?.text;
185
+ if (textContent) {
186
+ logger.warn(`Gemini returned MALFORMED_FUNCTION_CALL but had text content, returning text`);
187
+ return textContent;
188
+ }
189
+ logger.warn(`Gemini returned MALFORMED_FUNCTION_CALL with no text content`);
190
+ return 'I encountered an issue processing that request. Please try rephrasing your question.';
181
191
  } else {
182
192
  const returnString = `Response was not completed. Finish reason: ${finishReason}, Safety ratings: ${JSON.stringify(safetyRatings, null, 2)}`;
183
193
  throw new Error(returnString);
@@ -259,6 +269,13 @@ class Gemini15ChatPlugin extends ModelPlugin {
259
269
  // Only send DONE if there was no content in this message
260
270
  requestProgress.data = '[DONE]';
261
271
  requestProgress.progress = 1;
272
+ } else if (eventData.candidates?.[0]?.finishReason === "MALFORMED_FUNCTION_CALL") {
273
+ // Model attempted a function call but generated invalid JSON
274
+ logger.warn(`Gemini streaming returned MALFORMED_FUNCTION_CALL`);
275
+ requestProgress.data = JSON.stringify(createChunk({
276
+ content: '\n\nI encountered an issue processing that request. Please try rephrasing your question.'
277
+ }));
278
+ requestProgress.progress = 1;
262
279
  }
263
280
 
264
281
  // Handle safety blocks
@@ -2,6 +2,7 @@ import Gemini15ChatPlugin from './gemini15ChatPlugin.js';
2
2
  import CortexResponse from '../../lib/cortexResponse.js';
3
3
  import { requestState } from '../requestState.js';
4
4
  import { addCitationsToResolver } from '../../lib/pathwayTools.js';
5
+ import logger from '../../lib/logger.js';
5
6
  import mime from 'mime-types';
6
7
 
7
8
  class Gemini15VisionPlugin extends Gemini15ChatPlugin {
@@ -414,6 +415,18 @@ class Gemini15VisionPlugin extends Gemini15ChatPlugin {
414
415
 
415
416
  return cortexResponse;
416
417
  }
418
+
419
+ // Handle MALFORMED_FUNCTION_CALL - model tried to call a function but generated invalid JSON
420
+ if (finishReason === 'MALFORMED_FUNCTION_CALL') {
421
+ const textContent = content?.parts?.[0]?.text || '';
422
+ logger.warn(`Gemini returned MALFORMED_FUNCTION_CALL, returning graceful response`);
423
+ return new CortexResponse({
424
+ output_text: textContent || 'I encountered an issue processing that request. Please try rephrasing your question.',
425
+ finishReason: "stop",
426
+ usage: data.usageMetadata || null,
427
+ metadata: { model: this.modelName }
428
+ });
429
+ }
417
430
  }
418
431
 
419
432
  // Fallback to parent implementation
@@ -500,20 +513,38 @@ class Gemini15VisionPlugin extends Gemini15ChatPlugin {
500
513
  const finishReason = this.hadToolCalls ? "tool_calls" : "stop";
501
514
 
502
515
  // Check if there's any remaining content in the final chunk that needs to be published
516
+ let sentFinalChunk = false;
503
517
  if (eventData.candidates?.[0]?.content?.parts) {
504
518
  const parts = eventData.candidates[0].content.parts;
505
519
  for (const part of parts) {
506
520
  if (part.text && part.text.trim()) {
507
521
  // Send the final content chunk with finish reason
508
- requestProgress.data = JSON.stringify(createChunk({
509
- content: part.text
522
+ requestProgress.data = JSON.stringify(createChunk({
523
+ content: part.text
510
524
  }, finishReason));
525
+ sentFinalChunk = true;
511
526
  break; // Only process the first text part
512
527
  }
513
528
  }
514
- } else {
515
- // No content, just send finish chunk
516
- requestProgress.data = JSON.stringify(createChunk({}, finishReason));
529
+ }
530
+ if (!sentFinalChunk) {
531
+ // If we have tool calls, include them in the finish chunk
532
+ // (Gemini often sends functionCall and finishReason in the same event)
533
+ // Filter out undefined elements before mapping
534
+ const validToolCallsForChunk = this.toolCallsBuffer.filter(tc => tc && tc.function);
535
+ if (this.hadToolCalls && validToolCallsForChunk.length > 0) {
536
+ requestProgress.data = JSON.stringify(createChunk({
537
+ tool_calls: validToolCallsForChunk.map((tc, index) => ({
538
+ index,
539
+ id: tc.id,
540
+ type: tc.type,
541
+ function: tc.function
542
+ }))
543
+ }, finishReason));
544
+ } else {
545
+ // No final text content, just send finish chunk
546
+ requestProgress.data = JSON.stringify(createChunk({}, finishReason));
547
+ }
517
548
  }
518
549
 
519
550
  const pathwayResolver = requestState[this.requestId]?.pathwayResolver;
@@ -528,6 +559,8 @@ class Gemini15VisionPlugin extends Gemini15ChatPlugin {
528
559
  tool_calls: validToolCalls,
529
560
  };
530
561
  this.pathwayToolCallback(pathwayResolver?.args, toolMessage, pathwayResolver);
562
+ // Signal to pathwayResolver that stream close is expected (tool callback invoked)
563
+ requestProgress.toolCallbackInvoked = true;
531
564
  // Clear tool buffer after processing; keep content for citations/continuations
532
565
  this.toolCallsBuffer = [];
533
566
  } else {
@@ -539,6 +572,19 @@ class Gemini15VisionPlugin extends Gemini15ChatPlugin {
539
572
  }
540
573
  }
541
574
 
575
+ // Handle MALFORMED_FUNCTION_CALL - model tried to call a function but generated invalid JSON
576
+ if (eventData.candidates?.[0]?.finishReason === "MALFORMED_FUNCTION_CALL") {
577
+ logger.warn(`Gemini streaming returned MALFORMED_FUNCTION_CALL`);
578
+ requestProgress.data = JSON.stringify(createChunk({
579
+ content: '\n\nI encountered an issue processing that request. Please try rephrasing your question.'
580
+ }, "stop"));
581
+ requestProgress.progress = 1;
582
+ // Clear buffers
583
+ this.toolCallsBuffer = [];
584
+ this.contentBuffer = '';
585
+ return requestProgress;
586
+ }
587
+
542
588
  // Handle safety blocks
543
589
  if (eventData.candidates?.[0]?.safetyRatings?.some(rating => rating.blocked)) {
544
590
  requestProgress.data = JSON.stringify(createChunk({
@@ -642,6 +642,8 @@ class GrokResponsesPlugin extends OpenAIVisionPlugin {
642
642
  tool_calls: validToolCalls,
643
643
  };
644
644
  this.pathwayToolCallback(pathwayResolver?.args, toolMessage, pathwayResolver);
645
+ // Signal to pathwayResolver that tool callback was invoked - prevents [DONE] from ending stream
646
+ requestProgress.toolCallbackInvoked = true;
645
647
  }
646
648
  this.toolCallsBuffer = [];
647
649
  break;
@@ -374,14 +374,16 @@ class OpenAIVisionPlugin extends OpenAIChatPlugin {
374
374
  const validToolCalls = this.toolCallsBuffer.filter(tc => tc && tc.function && tc.function.name);
375
375
  const toolMessage = {
376
376
  role: 'assistant',
377
- content: delta?.content || '',
377
+ content: delta?.content || '',
378
378
  tool_calls: validToolCalls,
379
379
  };
380
380
  this.pathwayToolCallback(pathwayResolver?.args, toolMessage, pathwayResolver);
381
+ // Signal to pathwayResolver that tool callback was invoked - prevents [DONE] from ending stream
382
+ requestProgress.toolCallbackInvoked = true;
381
383
  }
382
384
  // Don't set progress to 1 for tool calls to keep stream open
383
385
  // Clear tool buffer after processing, but keep content buffer
384
- this.toolCallsBuffer = [];
386
+ this.toolCallsBuffer = [];
385
387
  break;
386
388
  case 'safety':
387
389
  const safetyRatings = JSON.stringify(parsedMessage?.candidates?.[0]?.safetyRatings) || '';