@llumiverse/drivers 1.0.0-dev.20260224.234313Z → 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.
Files changed (68) hide show
  1. package/lib/cjs/bedrock/converse.js +86 -12
  2. package/lib/cjs/bedrock/converse.js.map +1 -1
  3. package/lib/cjs/bedrock/index.js +208 -1
  4. package/lib/cjs/bedrock/index.js.map +1 -1
  5. package/lib/cjs/groq/index.js +7 -4
  6. package/lib/cjs/groq/index.js.map +1 -1
  7. package/lib/cjs/openai/index.js +457 -26
  8. package/lib/cjs/openai/index.js.map +1 -1
  9. package/lib/cjs/openai/openai_compatible.js +1 -0
  10. package/lib/cjs/openai/openai_compatible.js.map +1 -1
  11. package/lib/cjs/vertexai/index.js +42 -0
  12. package/lib/cjs/vertexai/index.js.map +1 -1
  13. package/lib/cjs/vertexai/models/claude.js +230 -2
  14. package/lib/cjs/vertexai/models/claude.js.map +1 -1
  15. package/lib/cjs/vertexai/models/gemini.js +261 -41
  16. package/lib/cjs/vertexai/models/gemini.js.map +1 -1
  17. package/lib/cjs/vertexai/models.js +1 -1
  18. package/lib/cjs/vertexai/models.js.map +1 -1
  19. package/lib/esm/bedrock/converse.js +80 -6
  20. package/lib/esm/bedrock/converse.js.map +1 -1
  21. package/lib/esm/bedrock/index.js +207 -2
  22. package/lib/esm/bedrock/index.js.map +1 -1
  23. package/lib/esm/groq/index.js +7 -4
  24. package/lib/esm/groq/index.js.map +1 -1
  25. package/lib/esm/openai/index.js +456 -27
  26. package/lib/esm/openai/index.js.map +1 -1
  27. package/lib/esm/openai/openai_compatible.js +1 -0
  28. package/lib/esm/openai/openai_compatible.js.map +1 -1
  29. package/lib/esm/vertexai/index.js +43 -1
  30. package/lib/esm/vertexai/index.js.map +1 -1
  31. package/lib/esm/vertexai/models/claude.js +229 -3
  32. package/lib/esm/vertexai/models/claude.js.map +1 -1
  33. package/lib/esm/vertexai/models/gemini.js +262 -43
  34. package/lib/esm/vertexai/models/gemini.js.map +1 -1
  35. package/lib/esm/vertexai/models.js +1 -1
  36. package/lib/esm/vertexai/models.js.map +1 -1
  37. package/lib/types/bedrock/converse.d.ts +1 -2
  38. package/lib/types/bedrock/converse.d.ts.map +1 -1
  39. package/lib/types/bedrock/index.d.ts +53 -1
  40. package/lib/types/bedrock/index.d.ts.map +1 -1
  41. package/lib/types/openai/index.d.ts +96 -1
  42. package/lib/types/openai/index.d.ts.map +1 -1
  43. package/lib/types/openai/openai_compatible.d.ts +5 -0
  44. package/lib/types/openai/openai_compatible.d.ts.map +1 -1
  45. package/lib/types/openai/openai_format.d.ts +1 -1
  46. package/lib/types/vertexai/index.d.ts +11 -1
  47. package/lib/types/vertexai/index.d.ts.map +1 -1
  48. package/lib/types/vertexai/models/claude.d.ts +64 -1
  49. package/lib/types/vertexai/models/claude.d.ts.map +1 -1
  50. package/lib/types/vertexai/models/gemini.d.ts +61 -1
  51. package/lib/types/vertexai/models/gemini.d.ts.map +1 -1
  52. package/lib/types/vertexai/models.d.ts +6 -1
  53. package/lib/types/vertexai/models.d.ts.map +1 -1
  54. package/package.json +9 -9
  55. package/src/bedrock/converse.ts +85 -10
  56. package/src/bedrock/error-handling.test.ts +352 -0
  57. package/src/bedrock/index.ts +225 -1
  58. package/src/groq/index.ts +9 -4
  59. package/src/openai/error-handling.test.ts +567 -0
  60. package/src/openai/index.ts +505 -29
  61. package/src/openai/openai_compatible.ts +7 -0
  62. package/src/openai/openai_format.ts +1 -1
  63. package/src/vertexai/index.ts +56 -5
  64. package/src/vertexai/models/claude-error-handling.test.ts +432 -0
  65. package/src/vertexai/models/claude.ts +273 -7
  66. package/src/vertexai/models/gemini-error-handling.test.ts +353 -0
  67. package/src/vertexai/models/gemini.ts +304 -48
  68. package/src/vertexai/models.ts +7 -2
@@ -11,7 +11,11 @@ import {
11
11
  ExecutionOptions,
12
12
  ExecutionTokenUsage,
13
13
  JSONSchema,
14
+ LlumiverseError,
15
+ LlumiverseErrorContext,
14
16
  ModelType,
17
+ OpenAiDalleOptions,
18
+ OpenAiGptImageOptions,
15
19
  Providers,
16
20
  ToolDefinition,
17
21
  ToolUse,
@@ -24,11 +28,28 @@ import {
24
28
  incrementConversationTurn,
25
29
  modelModalitiesToArray,
26
30
  stripBase64ImagesFromConversation,
31
+ stripHeartbeatsFromConversation,
27
32
  supportsToolUse,
28
33
  truncateLargeTextInConversation,
29
34
  unwrapConversationArray,
30
35
  } from "@llumiverse/core";
31
36
  import OpenAI, { AzureOpenAI } from "openai";
37
+ import {
38
+ APIConnectionError,
39
+ APIConnectionTimeoutError,
40
+ APIError,
41
+ AuthenticationError,
42
+ BadRequestError,
43
+ ConflictError,
44
+ ContentFilterFinishReasonError,
45
+ InternalServerError,
46
+ LengthFinishReasonError,
47
+ NotFoundError,
48
+ OpenAIError,
49
+ PermissionDeniedError,
50
+ RateLimitError,
51
+ UnprocessableEntityError,
52
+ } from 'openai/error';
32
53
  import { formatOpenAILikeMultimodalPrompt } from "./openai_format.js";
33
54
 
34
55
  // Response API types
@@ -71,15 +92,16 @@ export abstract class BaseOpenAIDriver extends AbstractDriver<
71
92
  const tokenInfo = mapUsage(result.usage);
72
93
 
73
94
  const tools = collectTools(result.output);
74
- const data = extractTextFromResponse(result);
95
+ // Collect all parts in order (text and images)
96
+ const allResults = extractCompletionResults(result.output);
75
97
 
76
- if (!data && !tools) {
98
+ if (allResults.length === 0 && !tools) {
77
99
  this.logger.error({ result }, "[OpenAI] Response is not valid");
78
100
  throw new Error("Response is not valid: no data");
79
101
  }
80
102
 
81
103
  return {
82
- result: textToCompletionResult(data || ''),
104
+ result: allResults,
83
105
  token_usage: tokenInfo,
84
106
  finish_reason: responseFinishReason(result, tools),
85
107
  tool_use: tools,
@@ -92,11 +114,18 @@ export abstract class BaseOpenAIDriver extends AbstractDriver<
92
114
  }
93
115
 
94
116
  // Include conversation history (same as non-streaming)
95
- const conversation = updateConversation(options.conversation, prompt);
117
+ // Fix orphaned function_call items (can occur when agent is stopped mid-tool-execution)
118
+ let conversation = fixOrphanedToolUse(updateConversation(options.conversation, prompt));
96
119
 
97
120
  const toolDefs = getToolDefinitions(options.tools);
98
121
  const useTools: boolean = toolDefs ? supportsToolUse(options.model, this.provider, true) : false;
99
122
 
123
+ // When no tools are provided but conversation contains function_call/function_call_output
124
+ // items (e.g. checkpoint summary calls), convert them to text to avoid API errors
125
+ if (!useTools) {
126
+ conversation = convertOpenAIFunctionItemsToText(conversation);
127
+ }
128
+
100
129
  convertRoles(prompt, options.model);
101
130
 
102
131
  const model_options = options.model_options as any;
@@ -153,7 +182,14 @@ export abstract class BaseOpenAIDriver extends AbstractDriver<
153
182
  const toolDefs = getToolDefinitions(options.tools);
154
183
  const useTools: boolean = toolDefs ? supportsToolUse(options.model, this.provider) : false;
155
184
 
156
- let conversation = updateConversation(options.conversation, prompt);
185
+ // Fix orphaned function_call items (can occur when agent is stopped mid-tool-execution)
186
+ let conversation = fixOrphanedToolUse(updateConversation(options.conversation, prompt));
187
+
188
+ // When no tools are provided but conversation contains function_call/function_call_output
189
+ // items (e.g. checkpoint summary calls), convert them to text to avoid API errors
190
+ if (!useTools) {
191
+ conversation = convertOpenAIFunctionItemsToText(conversation);
192
+ }
157
193
 
158
194
  let parsedSchema: JSONSchema | undefined = undefined;
159
195
  let strictMode = false;
@@ -212,12 +248,25 @@ export abstract class BaseOpenAIDriver extends AbstractDriver<
212
248
  // Truncate large text content if configured
213
249
  processedConversation = truncateLargeTextInConversation(processedConversation, stripOptions);
214
250
 
251
+ // Strip old heartbeat status messages
252
+ processedConversation = stripHeartbeatsFromConversation(processedConversation, {
253
+ keepForTurns: options.stripHeartbeatsAfterTurns ?? 1,
254
+ currentTurn,
255
+ });
256
+
215
257
  completion.conversation = processedConversation;
216
258
 
217
259
  return completion;
218
260
  }
219
261
 
220
262
  protected canStream(_options: ExecutionOptions): Promise<boolean> {
263
+ // Image generation models don't support streaming
264
+ if (_options.model.includes("dall-e")
265
+ || _options.model.includes("gpt-image")
266
+ || _options.model.includes("chatgpt-image")) {
267
+ return Promise.resolve(false);
268
+ }
269
+
221
270
  if (_options.model.includes("o1")
222
271
  && !(_options.model.includes("mini") || _options.model.includes("preview"))) {
223
272
  //o1 full does not support streaming
@@ -277,6 +326,10 @@ export abstract class BaseOpenAIDriver extends AbstractDriver<
277
326
  };
278
327
  let processedConversation = stripBase64ImagesFromConversation(conversation, stripOptions);
279
328
  processedConversation = truncateLargeTextInConversation(processedConversation, stripOptions);
329
+ processedConversation = stripHeartbeatsFromConversation(processedConversation, {
330
+ keepForTurns: options.stripHeartbeatsAfterTurns ?? 1,
331
+ currentTurn,
332
+ });
280
333
 
281
334
  return processedConversation as ResponseInputItem[];
282
335
  }
@@ -341,7 +394,7 @@ export abstract class BaseOpenAIDriver extends AbstractDriver<
341
394
  //Some of these use the completions API instead of the chat completions API.
342
395
  //Others are for non-text input modalities. Therefore common to both.
343
396
  const wordBlacklist = ["embed", "whisper", "transcribe", "audio", "moderation", "tts",
344
- "realtime", "dall-e", "babbage", "davinci", "codex", "o1-pro", "computer-use", "sora"];
397
+ "realtime", "babbage", "davinci", "codex", "o1-pro", "computer-use", "sora"];
345
398
 
346
399
 
347
400
  //OpenAI has very little information, filtering based on name.
@@ -356,14 +409,20 @@ export abstract class BaseOpenAIDriver extends AbstractDriver<
356
409
  if (owner == "system") {
357
410
  owner = "openai";
358
411
  }
412
+
413
+ // Determine model type based on capabilities
414
+ let modelType = ModelType.Text;
415
+ if (m.id.includes("dall-e") || m.id.includes("gpt-image")) {
416
+ modelType = ModelType.Image;
417
+ }
418
+
419
+
359
420
  return {
360
421
  id: m.id,
361
422
  name: m.id,
362
423
  provider: this.provider,
363
424
  owner: owner,
364
- type: m.object === "model" ? ModelType.Text : ModelType.Unknown,
365
- can_stream: true,
366
- is_multimodal: m.id.includes("gpt-4"),
425
+ type: modelType,
367
426
  input_modalities: modelModalitiesToArray(modelCapability.input),
368
427
  output_modalities: modelModalitiesToArray(modelCapability.output),
369
428
  tool_support: modelCapability.tool_support,
@@ -398,6 +457,307 @@ export abstract class BaseOpenAIDriver extends AbstractDriver<
398
457
  return { values: embeddings, model } satisfies EmbeddingsResult;
399
458
  }
400
459
 
460
+ imageModels = ["dall-e", "gpt-image", "chatgpt-image"];
461
+
462
+ /**
463
+ * Determine if a model is specifically an image generation model (not conversational image model)
464
+ */
465
+ isImageModel(model: string): boolean {
466
+ // DALL-E models are standalone image generation
467
+ // gpt-image models can generate images in conversations, not standalone
468
+ return this.imageModels.some(imageModel => model.includes(imageModel));
469
+ }
470
+
471
+ /**
472
+ * Request image generation from standalone Images API
473
+ * Supports: DALL-E 2, DALL-E 3, GPT-image models (for edit/variation)
474
+ */
475
+ async requestImageGeneration(prompt: ResponseInputItem[], options: ExecutionOptions): Promise<Completion> {
476
+ this.logger.debug(`[${this.provider}] Generating image with model ${options.model}`);
477
+
478
+ const model_options = options.model_options as OpenAiDalleOptions | OpenAiGptImageOptions | undefined;
479
+
480
+ // Extract prompt text from ResponseInputItem[]
481
+ let promptText = "";
482
+ for (const item of prompt) {
483
+ if ('content' in item && typeof item.content === 'string') {
484
+ promptText += item.content + "\\n";
485
+ } else if ('content' in item && Array.isArray(item.content)) {
486
+ // Extract text from content array
487
+ for (const part of item.content) {
488
+ if ('type' in part && part.type === 'input_text' && 'text' in part) {
489
+ promptText += part.text + "\\n";
490
+ }
491
+ }
492
+ }
493
+ }
494
+ promptText = promptText.trim();
495
+
496
+ try {
497
+ const generateParams: OpenAI.Images.ImageGenerateParamsNonStreaming = {
498
+ model: options.model,
499
+ prompt: promptText,
500
+ size: model_options?.size || "1024x1024",
501
+ };
502
+
503
+ // Add DALL-E specific options
504
+ if (options.model.includes("dall-e") || model_options?._option_id === "openai-dalle") {
505
+ const dalleOptions = model_options as OpenAiDalleOptions | undefined;
506
+ generateParams.n = dalleOptions?.n || 1;
507
+ generateParams.response_format = dalleOptions?.response_format || "b64_json";
508
+
509
+ if (options.model.includes("dall-e-3")) {
510
+ generateParams.quality = dalleOptions?.image_quality || "standard";
511
+ if (dalleOptions?.style) {
512
+ generateParams.style = dalleOptions.style;
513
+ }
514
+ }
515
+ } else {
516
+ // Default for other models
517
+ generateParams.n = 1;
518
+ }
519
+
520
+ const response = await this.service.images.generate(generateParams);
521
+
522
+ // Convert response to CompletionResults
523
+ const results: CompletionResult[] = [];
524
+
525
+ if (response.data) {
526
+ for (const image of response.data) {
527
+ let imageValue: string;
528
+
529
+ if (image.b64_json) {
530
+ // Base64 format
531
+ imageValue = `data:image/png;base64,${image.b64_json}`;
532
+ } else if (image.url) {
533
+ // URL format
534
+ imageValue = image.url;
535
+ } else {
536
+ continue;
537
+ }
538
+
539
+ results.push({
540
+ type: "image",
541
+ value: imageValue
542
+ });
543
+ }
544
+ }
545
+
546
+ return {
547
+ result: results
548
+ };
549
+
550
+ } catch (error: any) {
551
+ this.logger.error({ error }, `[${this.provider}] Image generation failed`);
552
+ return {
553
+ result: [],
554
+ error: {
555
+ message: error.message,
556
+ code: error.code || 'GENERATION_FAILED'
557
+ }
558
+ };
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Format OpenAI API errors into LlumiverseError with proper status codes and retryability.
564
+ *
565
+ * OpenAI API errors have a specific structure:
566
+ * - APIError.status: HTTP status code (400, 401, 403, 404, 409, 422, 429, 500+)
567
+ * - APIError.error: Error object with type, message, param, code
568
+ * - APIError.requestID: Request ID for support
569
+ * - APIError.code: Error code (e.g., 'invalid_api_key', 'rate_limit_exceeded')
570
+ * - APIError.param: Parameter that caused the error (optional)
571
+ * - APIError.type: Error type (optional)
572
+ *
573
+ * Common error types:
574
+ * - BadRequestError (400): Invalid request parameters
575
+ * - AuthenticationError (401): Invalid API key
576
+ * - PermissionDeniedError (403): Insufficient permissions
577
+ * - NotFoundError (404): Resource not found
578
+ * - ConflictError (409): Resource conflict
579
+ * - UnprocessableEntityError (422): Validation error
580
+ * - RateLimitError (429): Rate limit exceeded
581
+ * - InternalServerError (500+): Server-side errors
582
+ * - APIConnectionError: Connection issues (no status code)
583
+ * - APIConnectionTimeoutError: Request timeout (no status code)
584
+ * - LengthFinishReasonError: Response truncated due to length
585
+ * - ContentFilterFinishReasonError: Content filtered
586
+ *
587
+ * This implementation works for:
588
+ * - OpenAI API
589
+ * - Azure OpenAI
590
+ * - xAI (uses OpenAI-compatible API)
591
+ * - Azure Foundry (OpenAI-compatible)
592
+ * - Other OpenAI-compatible APIs
593
+ *
594
+ * @see https://platform.openai.com/docs/guides/error-codes
595
+ */
596
+ public formatLlumiverseError(
597
+ error: unknown,
598
+ context: LlumiverseErrorContext
599
+ ): LlumiverseError {
600
+ // Check if it's an OpenAI API error
601
+ const isOpenAIError = this.isOpenAIApiError(error);
602
+
603
+ if (!isOpenAIError) {
604
+ // Not an OpenAI API error, use default handling
605
+ throw error;
606
+ }
607
+
608
+ const apiError = error as APIError;
609
+ const httpStatusCode = apiError.status;
610
+
611
+ // Extract error message
612
+ const message = apiError.message || String(error);
613
+
614
+ // Extract additional error details (only available on APIError)
615
+ const errorCode = apiError.code;
616
+ const errorParam = apiError.param;
617
+ const errorType = apiError.type;
618
+
619
+ // Build user-facing message with status code
620
+ let userMessage = message;
621
+
622
+ // Include status code in message (for end-user visibility)
623
+ if (httpStatusCode) {
624
+ userMessage = `[${httpStatusCode}] ${userMessage}`;
625
+ }
626
+
627
+ // Add error code if available and not already in message
628
+ if (errorCode && !userMessage.includes(errorCode)) {
629
+ userMessage += ` (code: ${errorCode})`;
630
+ }
631
+
632
+ // Add parameter info if available and helpful
633
+ if (errorParam && !userMessage.toLowerCase().includes(errorParam.toLowerCase())) {
634
+ userMessage += ` [param: ${errorParam}]`;
635
+ }
636
+
637
+ // Add request ID if available (useful for OpenAI support)
638
+ if (apiError.requestID) {
639
+ userMessage += ` (Request ID: ${apiError.requestID})`;
640
+ }
641
+
642
+ // Determine retryability based on OpenAI error types
643
+ const retryable = this.isOpenAIErrorRetryable(error, httpStatusCode, errorCode, errorType);
644
+
645
+ // Use the error constructor name as the error name
646
+ const errorName = error.constructor?.name || 'OpenAIError';
647
+
648
+ return new LlumiverseError(
649
+ `[${context.provider}] ${userMessage}`,
650
+ retryable,
651
+ context,
652
+ error,
653
+ httpStatusCode,
654
+ errorName
655
+ );
656
+ }
657
+
658
+ /**
659
+ * Type guard to check if error is an OpenAI API error or OpenAI-specific error.
660
+ */
661
+ private isOpenAIApiError(error: unknown): error is APIError | OpenAIError {
662
+ return (
663
+ error !== null &&
664
+ typeof error === 'object' &&
665
+ (error instanceof APIError || error instanceof OpenAIError)
666
+ );
667
+ }
668
+
669
+ /**
670
+ * Determine if an OpenAI API error is retryable.
671
+ *
672
+ * Retryable errors:
673
+ * - RateLimitError (429): Rate limit exceeded, retry with backoff
674
+ * - InternalServerError (500+): Server-side errors
675
+ * - APIConnectionTimeoutError: Request timeout
676
+ * - Error codes: 'timeout', 'server_error', 'service_unavailable'
677
+ * - Status codes: 408, 429, 502, 503, 504, 529, 5xx
678
+ *
679
+ * Non-retryable errors:
680
+ * - BadRequestError (400): Invalid request parameters
681
+ * - AuthenticationError (401): Invalid API key
682
+ * - PermissionDeniedError (403): Insufficient permissions
683
+ * - NotFoundError (404): Resource not found
684
+ * - ConflictError (409): Resource conflict
685
+ * - UnprocessableEntityError (422): Validation error
686
+ * - LengthFinishReasonError: Length limit reached
687
+ * - ContentFilterFinishReasonError: Content filtered
688
+ * - Error codes: 'invalid_api_key', 'invalid_request_error', 'model_not_found'
689
+ * - Other 4xx client errors
690
+ *
691
+ * @param error - The error object
692
+ * @param httpStatusCode - The HTTP status code if available
693
+ * @param errorCode - The error code if available
694
+ * @param errorType - The error type if available
695
+ * @returns True if retryable, false if not retryable, undefined if unknown
696
+ */
697
+ private isOpenAIErrorRetryable(
698
+ error: unknown,
699
+ httpStatusCode: number | undefined,
700
+ errorCode: string | null | undefined,
701
+ errorType: string | undefined
702
+ ): boolean | undefined {
703
+ // Check specific OpenAI error types by class
704
+ if (error instanceof RateLimitError) return true;
705
+ if (error instanceof InternalServerError) return true;
706
+ if (error instanceof APIConnectionTimeoutError) return true;
707
+
708
+ // Non-retryable by error type
709
+ if (error instanceof BadRequestError) return false;
710
+ if (error instanceof AuthenticationError) return false;
711
+ if (error instanceof PermissionDeniedError) return false;
712
+ if (error instanceof NotFoundError) return false;
713
+ if (error instanceof ConflictError) return false;
714
+ if (error instanceof UnprocessableEntityError) return false;
715
+ if (error instanceof LengthFinishReasonError) return false;
716
+ if (error instanceof ContentFilterFinishReasonError) return false;
717
+
718
+ // Check error codes (OpenAI specific)
719
+ if (errorCode) {
720
+ // Retryable error codes
721
+ if (errorCode === 'timeout') return true;
722
+ if (errorCode === 'server_error') return true;
723
+ if (errorCode === 'service_unavailable') return true;
724
+ if (errorCode === 'rate_limit_exceeded') return true;
725
+
726
+ // Non-retryable error codes
727
+ if (errorCode === 'invalid_api_key') return false;
728
+ if (errorCode === 'invalid_request_error') return false;
729
+ if (errorCode === 'model_not_found') return false;
730
+ if (errorCode === 'insufficient_quota') return false;
731
+ if (errorCode === 'invalid_model') return false;
732
+ if (errorCode.includes('invalid_')) return false;
733
+ }
734
+
735
+ // Check error type
736
+ if (errorType === 'invalid_request_error') return false;
737
+ if (errorType === 'authentication_error') return false;
738
+
739
+ // Use HTTP status code
740
+ if (httpStatusCode !== undefined) {
741
+ if (httpStatusCode === 429) return true; // Rate limit
742
+ if (httpStatusCode === 408) return true; // Request timeout
743
+ if (httpStatusCode === 502) return true; // Bad gateway
744
+ if (httpStatusCode === 503) return true; // Service unavailable
745
+ if (httpStatusCode === 504) return true; // Gateway timeout
746
+ if (httpStatusCode === 529) return true; // Overloaded
747
+ if (httpStatusCode >= 500 && httpStatusCode < 600) return true; // Server errors
748
+ if (httpStatusCode >= 400 && httpStatusCode < 500) return false; // Client errors
749
+ }
750
+
751
+ // Connection errors without status codes
752
+ if (error instanceof APIConnectionError && !(error instanceof APIConnectionTimeoutError)) {
753
+ // Generic connection errors might be retryable (network issues)
754
+ return true;
755
+ }
756
+
757
+ // Unknown error type - let consumer decide retry strategy
758
+ return undefined;
759
+ }
760
+
401
761
  }
402
762
 
403
763
 
@@ -606,6 +966,40 @@ function supportsSchema(model: string): boolean {
606
966
  return supportsToolUse(model, "openai");
607
967
  }
608
968
 
969
+ /**
970
+ * Converts function_call and function_call_output items to text messages in OpenAI conversation.
971
+ * Preserves tool call information while removing structured items that require
972
+ * tools to be defined in the API request.
973
+ */
974
+ export function convertOpenAIFunctionItemsToText(items: ResponseInputItem[]): ResponseInputItem[] {
975
+ const hasFunctionItems = items.some(item => {
976
+ const type = (item as any).type;
977
+ return type === 'function_call' || type === 'function_call_output';
978
+ });
979
+ if (!hasFunctionItems) return items;
980
+
981
+ return items.map(item => {
982
+ const typed = item as any;
983
+ if (typed.type === 'function_call') {
984
+ const argsStr = typed.arguments || '';
985
+ const truncated = argsStr.length > 500 ? argsStr.substring(0, 500) + '...' : argsStr;
986
+ return {
987
+ role: 'assistant' as const,
988
+ content: `[Tool call: ${typed.name}(${truncated})]`,
989
+ };
990
+ }
991
+ if (typed.type === 'function_call_output') {
992
+ const output = typed.output || 'No output';
993
+ const truncated = output.length > 500 ? output.substring(0, 500) + '...' : output;
994
+ return {
995
+ role: 'user' as const,
996
+ content: `[Tool result: ${truncated}]`,
997
+ };
998
+ }
999
+ return item;
1000
+ });
1001
+ }
1002
+
609
1003
  function getToolDefinitions(tools: ToolDefinition[] | undefined | null): OpenAI.Responses.Tool[] | undefined {
610
1004
  return tools ? tools.map(getToolDefinition) : undefined;
611
1005
  }
@@ -671,6 +1065,43 @@ export function collectTools(output?: OpenAI.Responses.ResponseOutputItem[]): To
671
1065
  return tools.length > 0 ? tools : undefined;
672
1066
  }
673
1067
 
1068
+ /**
1069
+ * Collect all parts (text and images) from response output in order.
1070
+ * This preserves the original ordering of text and image parts.
1071
+ */
1072
+ function extractCompletionResults(output?: OpenAI.Responses.ResponseOutputItem[]): CompletionResult[] {
1073
+ if (!output) {
1074
+ return [];
1075
+ }
1076
+
1077
+ const results: CompletionResult[] = [];
1078
+ for (const item of output) {
1079
+ if (item.type === 'message') {
1080
+ // Extract text from message content
1081
+ for (const part of item.content) {
1082
+ if (part.type === 'output_text' && part.text) {
1083
+ results.push({
1084
+ type: "text",
1085
+ value: part.text
1086
+ });
1087
+ }
1088
+ }
1089
+ } else if (item.type === 'image_generation_call' && 'result' in item && item.result) {
1090
+ // GPT-image models return base64 encoded images in result field
1091
+ const base64Data = item.result;
1092
+ // Format as data URL for consistency with other image outputs
1093
+ const imageUrl = base64Data.startsWith('data:')
1094
+ ? base64Data
1095
+ : `data:image/png;base64,${base64Data}`;
1096
+ results.push({
1097
+ type: "image",
1098
+ value: imageUrl
1099
+ });
1100
+ }
1101
+ }
1102
+ return results;
1103
+ }
1104
+
674
1105
  //For strict mode false
675
1106
  function limitedSchemaFormat(schema: JSONSchema): JSONSchema {
676
1107
  const formattedSchema = { ...schema };
@@ -761,26 +1192,6 @@ function openAISchemaFormat(schema: JSONSchema, nesting: number = 0): JSONSchema
761
1192
  return formattedSchema
762
1193
  }
763
1194
 
764
- function extractTextFromResponse(response: OpenAI.Responses.Response): string {
765
- if (response.output_text) {
766
- return response.output_text;
767
- }
768
-
769
- const collected: string[] = [];
770
- for (const item of response.output ?? []) {
771
- if (item.type === 'message') {
772
- const text = item.content
773
- .map(part => part.type === 'output_text' ? part.text : '')
774
- .join('');
775
- if (text) {
776
- collected.push(text);
777
- }
778
- }
779
- }
780
-
781
- return collected.join("\n");
782
- }
783
-
784
1195
  function responseFinishReason(response: OpenAI.Responses.Response, tools?: ToolUse[] | undefined): string | undefined {
785
1196
  if (tools && tools.length > 0) {
786
1197
  return "tool_use";
@@ -797,6 +1208,71 @@ function responseFinishReason(response: OpenAI.Responses.Response, tools?: ToolU
797
1208
  return 'stop';
798
1209
  }
799
1210
 
1211
+ /**
1212
+ * Fix orphaned function_call items in the OpenAI Responses API conversation.
1213
+ *
1214
+ * When an agent is stopped mid-tool-execution, the conversation may contain
1215
+ * function_call items without matching function_call_output items. The OpenAI
1216
+ * Responses API requires every function_call to have a matching function_call_output.
1217
+ *
1218
+ * This function detects such cases and injects synthetic function_call_output items
1219
+ * indicating the tools were interrupted, allowing the conversation to continue.
1220
+ */
1221
+ export function fixOrphanedToolUse(items: ResponseInputItem[]): ResponseInputItem[] {
1222
+ if (items.length < 2) return items;
1223
+
1224
+ // First pass: collect all function_call_output call_ids
1225
+ const outputCallIds = new Set<string>();
1226
+ for (const item of items) {
1227
+ if ('type' in item && item.type === 'function_call_output') {
1228
+ outputCallIds.add((item as OpenAI.Responses.ResponseInputItem.FunctionCallOutput).call_id);
1229
+ }
1230
+ }
1231
+
1232
+ // Second pass: build result, injecting synthetic outputs for orphaned function_calls
1233
+ const result: ResponseInputItem[] = [];
1234
+ const pendingCalls = new Map<string, string>(); // call_id -> tool name
1235
+
1236
+ for (const item of items) {
1237
+ if ('type' in item && item.type === 'function_call') {
1238
+ const fc = item as OpenAI.Responses.ResponseFunctionToolCall;
1239
+ // Only track if there's no matching output anywhere in the conversation
1240
+ if (!outputCallIds.has(fc.call_id)) {
1241
+ pendingCalls.set(fc.call_id, fc.name ?? 'unknown');
1242
+ }
1243
+ result.push(item);
1244
+ } else if ('type' in item && item.type === 'function_call_output') {
1245
+ result.push(item);
1246
+ } else {
1247
+ // Before any non-function item, flush pending orphaned calls
1248
+ if (pendingCalls.size > 0) {
1249
+ for (const [callId, toolName] of pendingCalls) {
1250
+ result.push({
1251
+ type: 'function_call_output',
1252
+ call_id: callId,
1253
+ output: `[Tool interrupted: The user stopped the operation before "${toolName}" could execute.]`,
1254
+ });
1255
+ }
1256
+ pendingCalls.clear();
1257
+ }
1258
+ result.push(item);
1259
+ }
1260
+ }
1261
+
1262
+ // Handle trailing orphans at the end of the conversation
1263
+ if (pendingCalls.size > 0) {
1264
+ for (const [callId, toolName] of pendingCalls) {
1265
+ result.push({
1266
+ type: 'function_call_output',
1267
+ call_id: callId,
1268
+ output: `[Tool interrupted: The user stopped the operation before "${toolName}" could execute.]`,
1269
+ });
1270
+ }
1271
+ }
1272
+
1273
+ return result;
1274
+ }
1275
+
800
1276
  function safeJsonParse(value: unknown): any {
801
1277
  if (typeof value !== 'string') {
802
1278
  return value;
@@ -13,6 +13,12 @@ export interface OpenAICompatibleDriverOptions extends DriverOptions {
13
13
  * Example: https://api.example.com/v1
14
14
  */
15
15
  endpoint: string;
16
+
17
+ /**
18
+ * Custom headers to include in every request.
19
+ * Useful for Apigee proxies or custom auth schemes.
20
+ */
21
+ default_headers?: Record<string, string>;
16
22
  }
17
23
 
18
24
  /**
@@ -38,6 +44,7 @@ export class OpenAICompatibleDriver extends BaseOpenAIDriver {
38
44
  this.service = new OpenAI({
39
45
  apiKey: opts.apiKey,
40
46
  baseURL: opts.endpoint,
47
+ defaultHeaders: opts.default_headers,
41
48
  });
42
49
  }
43
50
 
@@ -151,5 +151,5 @@ export async function formatOpenAILikeMultimodalPrompt(segments: PromptSegment[]
151
151
  export interface OpenAIPromptFormatterOptions {
152
152
  multimodal?: boolean
153
153
  useToolForFormatting?: boolean
154
- schema?: Object
154
+ schema?: object
155
155
  }