@aeye/openai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/openai.js ADDED
@@ -0,0 +1,1844 @@
1
+ /**
2
+ * OpenAI Provider
3
+ *
4
+ * Provider for OpenAI's GPT models, DALL-E, Whisper, and TTS.
5
+ * Also serves as base class for OpenAI-compatible providers.
6
+ */
7
+ import { detectTier } from '@aeye/ai';
8
+ import { getModel } from '@aeye/core';
9
+ import fs from 'fs';
10
+ import OpenAI from 'openai';
11
+ import z from 'zod';
12
+ import { isContextWindowError, parseContextWindowError, withRetry } from './retry';
13
+ import { ContextWindowError, ProviderError } from './types';
14
+ // ============================================================================
15
+ // OpenAI Provider Class
16
+ // ============================================================================
17
+ /**
18
+ * OpenAI provider implementation for the @aeye framework.
19
+ *
20
+ * Supports the full range of OpenAI capabilities including:
21
+ * - Chat completions with GPT-4, GPT-4 Turbo, GPT-3.5 Turbo
22
+ * - Vision with GPT-4 Vision (GPT-4V)
23
+ * - Reasoning models (o1, o3-mini)
24
+ * - Image generation with DALL-E 2 and DALL-E 3
25
+ * - Speech-to-text with Whisper
26
+ * - Text-to-speech with TTS
27
+ * - Text embeddings
28
+ * - Function calling and structured outputs
29
+ * - Streaming responses
30
+ *
31
+ * Can be extended by other OpenAI-compatible providers (e.g., Azure OpenAI, OpenRouter).
32
+ *
33
+ * @example Basic usage
34
+ * ```typescript
35
+ * import { OpenAIProvider } from '@aeye/openai';
36
+ *
37
+ * const provider = new OpenAIProvider({
38
+ * apiKey: process.env.OPENAI_API_KEY!,
39
+ * });
40
+ *
41
+ * // Create an executor for chat completions
42
+ * const executor = provider.createExecutor();
43
+ *
44
+ * const response = await executor(
45
+ * {
46
+ * messages: [
47
+ * { role: 'user', content: 'Hello!' }
48
+ * ]
49
+ * },
50
+ * {},
51
+ * { model: 'gpt-4' }
52
+ * );
53
+ * ```
54
+ *
55
+ * @example With function calling
56
+ * ```typescript
57
+ * import { z } from 'zod';
58
+ *
59
+ * const response = await executor(
60
+ * {
61
+ * messages: [
62
+ * { role: 'user', content: 'What is the weather in San Francisco?' }
63
+ * ],
64
+ * tools: [
65
+ * {
66
+ * name: 'get_weather',
67
+ * description: 'Get current weather',
68
+ * parameters: z.object({
69
+ * location: z.string(),
70
+ * }),
71
+ * }
72
+ * ],
73
+ * toolChoice: 'auto',
74
+ * },
75
+ * {},
76
+ * { model: 'gpt-4' }
77
+ * );
78
+ * ```
79
+ *
80
+ * @example Extending for custom providers
81
+ * ```typescript
82
+ * class CustomProvider extends OpenAIProvider {
83
+ * readonly name = 'custom';
84
+ *
85
+ * protected createClient(config: OpenAIConfig) {
86
+ * return new OpenAI({
87
+ * apiKey: config.apiKey,
88
+ * baseURL: 'https://custom-api.example.com/v1',
89
+ * });
90
+ * }
91
+ * }
92
+ * ```
93
+ *
94
+ * @template TConfig Configuration type extending OpenAIConfig
95
+ *
96
+ * Subclasses can optionally override:
97
+ * - `listModels(config)`: Custom model listing logic
98
+ * - `createClient(config)`: Customize client creation
99
+ * - `convertModel(model)`: Customize model conversion
100
+ * - `customizeImageParams(params, config)`: Modify image params
101
+ * - `customizeTranscriptionParams(params, config)`: Modify transcription params
102
+ * - `customizeSpeechParams(params, config)`: Modify speech params
103
+ * - `customizeEmbeddingParams(params, config)`: Modify embedding params
104
+ */
105
+ /**
106
+ * Helper function to convert OpenAI-style usage to the new structured Usage format
107
+ */
108
+ function convertOpenAIUsage(openaiUsage) {
109
+ const usage = {};
110
+ // Text tokens (default category)
111
+ // Note: prompt_tokens already includes cached tokens, so we don't subtract them
112
+ // They're just reported separately in the details for pricing purposes
113
+ const textInput = (openaiUsage.prompt_tokens || 0) -
114
+ (openaiUsage.prompt_tokens_details?.audio_tokens || 0);
115
+ const textOutput = (openaiUsage.completion_tokens || 0) -
116
+ (openaiUsage.completion_tokens_details?.reasoning_tokens || 0) -
117
+ (openaiUsage.completion_tokens_details?.audio_tokens || 0);
118
+ if (textInput > 0 || textOutput > 0 || openaiUsage.prompt_tokens_details?.cached_tokens) {
119
+ usage.text = {};
120
+ // Only include non-cached input tokens
121
+ const nonCachedInput = textInput - (openaiUsage.prompt_tokens_details?.cached_tokens || 0);
122
+ if (nonCachedInput > 0)
123
+ usage.text.input = nonCachedInput;
124
+ if (textOutput > 0)
125
+ usage.text.output = textOutput;
126
+ if (openaiUsage.prompt_tokens_details?.cached_tokens) {
127
+ usage.text.cached = openaiUsage.prompt_tokens_details.cached_tokens;
128
+ }
129
+ }
130
+ // Reasoning tokens
131
+ if (openaiUsage.completion_tokens_details?.reasoning_tokens) {
132
+ usage.reasoning = {
133
+ output: openaiUsage.completion_tokens_details.reasoning_tokens
134
+ };
135
+ }
136
+ // Audio tokens
137
+ const audioInput = openaiUsage.prompt_tokens_details?.audio_tokens;
138
+ const audioOutput = openaiUsage.completion_tokens_details?.audio_tokens;
139
+ if (audioInput || audioOutput) {
140
+ usage.audio = {};
141
+ if (audioInput)
142
+ usage.audio.input = audioInput;
143
+ if (audioOutput)
144
+ usage.audio.output = audioOutput;
145
+ }
146
+ return usage;
147
+ }
148
+ export class OpenAIProvider {
149
+ // Provider name for error messages and identification
150
+ name = 'openai';
151
+ // Config stored in the provider instance (used as fallback)
152
+ config;
153
+ // Default metadata to apply to all requests
154
+ defaultMetadata;
155
+ constructor(config) {
156
+ this.config = config;
157
+ }
158
+ // ============================================================================
159
+ // Client Creation (Can Override)
160
+ // ============================================================================
161
+ /**
162
+ * Create OpenAI client from provider config.
163
+ *
164
+ * Subclasses can override this method to customize client initialization,
165
+ * such as setting a custom base URL for OpenAI-compatible APIs.
166
+ *
167
+ * @param config Provider configuration
168
+ * @returns Configured OpenAI client instance
169
+ *
170
+ * @example
171
+ * ```typescript
172
+ * protected createClient(config: OpenAIConfig): OpenAI {
173
+ * return new OpenAI({
174
+ * apiKey: config.apiKey,
175
+ * baseURL: 'https://custom-endpoint.example.com/v1',
176
+ * });
177
+ * }
178
+ * ```
179
+ */
180
+ createClient(config) {
181
+ return new OpenAI({
182
+ apiKey: config.apiKey,
183
+ baseURL: config.baseURL,
184
+ organization: config.organization,
185
+ project: config.project,
186
+ });
187
+ }
188
+ /**
189
+ * Convert OpenAI's model format to @aeye ModelInfo format.
190
+ *
191
+ * Subclasses can override this to enrich model metadata with
192
+ * provider-specific information.
193
+ *
194
+ * @param model OpenAI model object
195
+ * @returns Standardized ModelInfo object
196
+ */
197
+ convertModel(model) {
198
+ const tier = detectTier(model.id);
199
+ return {
200
+ provider: this.name,
201
+ id: model.id,
202
+ name: model.id,
203
+ capabilities: new Set(['chat', 'streaming']), // Minimal default, enriched by model sources
204
+ tier,
205
+ pricing: {},
206
+ contextWindow: 0, // Will be enriched by model sources
207
+ maxOutputTokens: undefined,
208
+ metadata: {
209
+ ownedBy: model.owned_by,
210
+ created: model.created,
211
+ },
212
+ };
213
+ }
214
+ // ============================================================================
215
+ // Optional Override Methods for Request Customization
216
+ // ============================================================================
217
+ /**
218
+ * Customize image generation params before sending to API.
219
+ *
220
+ * @param params Image generation parameters
221
+ * @param config Provider configuration
222
+ * @returns Modified parameters
223
+ */
224
+ augmentImageGenerateRequest(params, request, config) {
225
+ // No-op by default
226
+ }
227
+ /**
228
+ * Customize image edit params before sending to API.
229
+ *
230
+ * @param params Image generation parameters
231
+ * @param config Provider configuration
232
+ * @returns Modified parameters
233
+ */
234
+ augmentImageEditRequest(params, request, config) {
235
+ // No-op by default
236
+ }
237
+ /**
238
+ * Customize transcription params before sending to API.
239
+ *
240
+ * @param params Transcription parameters
241
+ * @param config Provider configuration
242
+ * @returns Modified parameters
243
+ */
244
+ augmentTranscriptionRequest(params, request, config) {
245
+ // No-op by default
246
+ }
247
+ /**
248
+ * Customize speech generation params before sending to API.
249
+ *
250
+ * @param params Speech synthesis parameters
251
+ * @param config Provider configuration
252
+ * @returns Modified parameters
253
+ */
254
+ augmentSpeechRequest(params, request, config) {
255
+ // No-op by default
256
+ }
257
+ /**
258
+ * Customize embedding params before sending to API.
259
+ *
260
+ * @param params Embedding parameters
261
+ * @param config Provider configuration
262
+ * @returns Modified parameters
263
+ */
264
+ augmentEmbeddingRequest(params, request, config) {
265
+ // No-op by default
266
+ }
267
+ /**
268
+ * Customize chat completion params before sending to API.
269
+ *
270
+ * Override this method to modify request parameters for custom providers
271
+ * or to add provider-specific features.
272
+ *
273
+ * @param params OpenAI chat completion parameters
274
+ * @param config Provider configuration
275
+ * @param request Original @aeye request
276
+ * @returns Modified parameters
277
+ */
278
+ augmentChatRequest(params, request, config) {
279
+ // No-op by default
280
+ }
281
+ /**
282
+ * Augment chat chunk with provider-specific data.
283
+ *
284
+ * @param expected
285
+ * @param chunk
286
+ */
287
+ augmentChatChunk(expected, chunk, config) {
288
+ // No-op by default
289
+ }
290
+ /**
291
+ * Augment chat response with provider-specific data.
292
+ *
293
+ * @param openai OpenAI chat completion response
294
+ * @param response @aeye response object to augment
295
+ * @returns
296
+ */
297
+ augmentChatResponse(expected, response, config) {
298
+ // No-op by default
299
+ }
300
+ // ============================================================================
301
+ // Message & Request Conversion (Protected - Reusable)
302
+ // ============================================================================
303
+ /**
304
+ * Convert @aeye MessageContent to OpenAI content string format.
305
+ * @param x
306
+ * @param from
307
+ * @returns
308
+ */
309
+ convertContentString(x, from) {
310
+ if (typeof x === 'string') {
311
+ return x;
312
+ }
313
+ if (x instanceof Blob) {
314
+ return x.toString();
315
+ }
316
+ if (x instanceof URL) {
317
+ return x.toString();
318
+ }
319
+ return '';
320
+ }
321
+ /**
322
+ * Convert @aeye MessageContent to OpenAI text content format.
323
+ * @param x
324
+ * @param name
325
+ * @returns
326
+ */
327
+ convertContentText(x, name) {
328
+ if (typeof x === 'string') {
329
+ return x;
330
+ }
331
+ if (!Array.isArray(x)) {
332
+ console.warn(`[OpenAIProvider] convertContentText: Unsupported content type from ${name}.`);
333
+ return [];
334
+ }
335
+ return x.map((part) => {
336
+ if (part.type === 'text') {
337
+ return { type: 'text', text: String(part.content) };
338
+ }
339
+ else if (part.type === 'image') {
340
+ return { type: 'text', text: `Image sent by ${name} not included in content.` };
341
+ }
342
+ else if (part.type === 'file') {
343
+ return { type: 'text', text: `File sent by ${name} not included in content.` };
344
+ }
345
+ else if (part.type === 'audio') {
346
+ return { type: 'text', text: `Audio sent by ${name} not included in content.` };
347
+ }
348
+ return { type: 'text', text: `Unsupported content type from ${name}.` };
349
+ });
350
+ }
351
+ /**
352
+ * Convert @aeye MessageContent to OpenAI format.
353
+ * @param x
354
+ * @param name
355
+ * @returns
356
+ */
357
+ convertContent(x, name) {
358
+ if (typeof x === 'string') {
359
+ return x;
360
+ }
361
+ return x.map((part) => {
362
+ switch (part.type) {
363
+ case 'text':
364
+ return {
365
+ type: 'text',
366
+ text: String(part.content),
367
+ };
368
+ case 'image': {
369
+ // Convert content to URL or base64 data URL
370
+ let url;
371
+ if (typeof part.content === 'string') {
372
+ // Check if it's already a data URL or regular URL
373
+ if (part.content.startsWith('data:')) {
374
+ url = part.content;
375
+ }
376
+ else if (part.content.startsWith('http://') || part.content.startsWith('https://')) {
377
+ url = part.content;
378
+ }
379
+ else {
380
+ // Assume it's base64 encoded data without the data URL prefix
381
+ const mimeType = part.format || 'image/png';
382
+ url = `data:${mimeType};base64,${part.content}`;
383
+ }
384
+ }
385
+ else if (part.content instanceof URL) {
386
+ url = part.content.toString();
387
+ }
388
+ else if (part.content instanceof Buffer) {
389
+ // Convert Buffer to base64 data URL
390
+ const base64 = part.content.toString('base64');
391
+ const mimeType = part.format || 'image/png';
392
+ url = `data:${mimeType};base64,${base64}`;
393
+ }
394
+ else {
395
+ // ReadableStream - not supported, return text fallback
396
+ return {
397
+ type: 'text',
398
+ text: `Image (stream) sent by ${name} cannot be converted to URL.`,
399
+ };
400
+ }
401
+ return {
402
+ type: 'image_url',
403
+ image_url: { url },
404
+ };
405
+ }
406
+ case 'audio': {
407
+ // Convert audio content to base64 data
408
+ let data;
409
+ let format = 'mp3';
410
+ if (typeof part.content === 'string') {
411
+ // Check if it's a data URL
412
+ if (part.content.startsWith('data:')) {
413
+ const dataUrlMatch = part.content.match(/^data:([^;]+);base64,(.+)$/);
414
+ if (dataUrlMatch) {
415
+ const mimeType = dataUrlMatch[1];
416
+ data = dataUrlMatch[2];
417
+ // Extract format from MIME type
418
+ if (mimeType.includes('wav')) {
419
+ format = 'wav';
420
+ }
421
+ else if (mimeType.includes('mp3') || mimeType.includes('mpeg')) {
422
+ format = 'mp3';
423
+ }
424
+ }
425
+ else {
426
+ // Invalid data URL
427
+ return {
428
+ type: 'text',
429
+ text: `Audio data URL sent by ${name} is not valid base64.`,
430
+ };
431
+ }
432
+ }
433
+ else {
434
+ // Assume it's already base64 encoded without the data URL prefix
435
+ data = part.content;
436
+ }
437
+ }
438
+ else if (part.content instanceof Buffer) {
439
+ data = part.content.toString('base64');
440
+ }
441
+ else {
442
+ // URL or ReadableStream - not supported
443
+ return {
444
+ type: 'text',
445
+ text: `Audio sent by ${name} cannot be converted to base64.`,
446
+ };
447
+ }
448
+ // Override format from part.format if provided
449
+ if (part.format) {
450
+ if (part.format === 'wav' || part.format === 'audio/wav') {
451
+ format = 'wav';
452
+ }
453
+ else if (part.format === 'mp3' || part.format === 'audio/mpeg' || part.format === 'audio/mp3') {
454
+ format = 'mp3';
455
+ }
456
+ }
457
+ return {
458
+ type: 'input_audio',
459
+ input_audio: { data, format },
460
+ };
461
+ }
462
+ case 'file': {
463
+ // Convert file content to base64 data (file IDs not supported)
464
+ let fileData;
465
+ if (typeof part.content === 'string') {
466
+ // Check if it's a data URL
467
+ if (part.content.startsWith('data:')) {
468
+ const dataUrlMatch = part.content.match(/^data:[^;]+;base64,(.+)$/);
469
+ if (dataUrlMatch) {
470
+ fileData = dataUrlMatch[1];
471
+ }
472
+ else {
473
+ // Invalid data URL
474
+ return {
475
+ type: 'text',
476
+ text: `File data URL sent by ${name} is not valid base64.`,
477
+ };
478
+ }
479
+ }
480
+ // Check if it's a URL or file ID (not supported)
481
+ else if (part.content.startsWith('http://') ||
482
+ part.content.startsWith('https://') ||
483
+ part.content.startsWith('file-')) {
484
+ return {
485
+ type: 'text',
486
+ text: `File URL/ID sent by ${name} cannot be used (only base64 data supported).`,
487
+ };
488
+ }
489
+ // Assume it's base64 data
490
+ else {
491
+ fileData = part.content;
492
+ }
493
+ }
494
+ else if (part.content instanceof Buffer) {
495
+ fileData = part.content.toString('base64');
496
+ }
497
+ else if (part.content instanceof URL) {
498
+ return {
499
+ type: 'text',
500
+ text: `File URL sent by ${name} cannot be used (only base64 data supported).`,
501
+ };
502
+ }
503
+ else {
504
+ // ReadableStream - not supported
505
+ return {
506
+ type: 'text',
507
+ text: `File (stream) sent by ${name} cannot be converted.`,
508
+ };
509
+ }
510
+ return {
511
+ type: 'file',
512
+ file: {
513
+ file_data: fileData,
514
+ },
515
+ };
516
+ }
517
+ default:
518
+ return {
519
+ type: 'text',
520
+ text: `Unsupported content type from ${name}.`,
521
+ };
522
+ }
523
+ });
524
+ }
525
+ /**
526
+ * Convert @aeye ToolCall to OpenAI format.
527
+ * @param x ToolCall object
528
+ * @returns
529
+ */
530
+ convertToolCall(x) {
531
+ return {
532
+ id: x.id,
533
+ type: 'function',
534
+ function: {
535
+ name: x.name,
536
+ arguments: x.arguments,
537
+ },
538
+ };
539
+ }
540
+ /**
541
+ * Converts a name on a message to conform to OpenAI's naming rules.
542
+ * Removes invalid characters and truncates to max length.
543
+ *
544
+ * @param name - Original name
545
+ * @returns Converted name or undefined
546
+ */
547
+ convertName(name) {
548
+ if (!name) {
549
+ return undefined;
550
+ }
551
+ return name.replace(/\s+/g, '-')
552
+ .replace(/[^a-zA-Z0-9-_]/g, '')
553
+ .substring(0, 64);
554
+ }
555
+ /**
556
+ * Convert @aeye Request messages to OpenAI format.
557
+ *
558
+ * Handles conversion of all message types (system, user, assistant, tool)
559
+ * and content types (text, image, audio, file).
560
+ *
561
+ * @param request @aeye request object
562
+ * @returns Array of OpenAI-formatted messages
563
+ */
564
+ convertMessages(request) {
565
+ return request.messages.map((msg) => {
566
+ switch (msg.role) {
567
+ case 'system':
568
+ return {
569
+ role: 'developer',
570
+ name: this.convertName(msg.name),
571
+ content: this.convertContentText(msg.content, msg.name || 'system'),
572
+ };
573
+ case 'tool':
574
+ return {
575
+ role: 'tool',
576
+ tool_call_id: msg.toolCallId,
577
+ content: this.convertContentText(msg.content, msg.name || 'tool'),
578
+ };
579
+ case 'assistant':
580
+ const content = this.convertContent(msg.content, msg.name || 'assistant');
581
+ if (!content || typeof content === 'string' || content.every(c => c.type === 'text')) {
582
+ return {
583
+ role: msg.role,
584
+ name: this.convertName(msg.name),
585
+ tool_calls: msg.toolCalls?.map((tc) => this.convertToolCall(tc)),
586
+ content,
587
+ };
588
+ }
589
+ else {
590
+ // Mixed content - need to split into multiple messages where non-text parts are separate messages
591
+ // sent as user messages because assistent role cannot have non-text content
592
+ const firstText = content.findIndex(c => c.type === 'text');
593
+ return content.map((contentPart, index) => {
594
+ if (contentPart.type === 'text') {
595
+ return {
596
+ role: 'assistant',
597
+ name: this.convertName(msg.name),
598
+ tool_calls: index === firstText ? msg.toolCalls?.map((tc) => this.convertToolCall(tc)) : undefined,
599
+ content: [contentPart],
600
+ };
601
+ }
602
+ else {
603
+ return {
604
+ role: 'user',
605
+ name: this.convertName(msg.name),
606
+ content: [contentPart],
607
+ };
608
+ }
609
+ });
610
+ }
611
+ case 'user':
612
+ return {
613
+ role: msg.role,
614
+ name: this.convertName(msg.name),
615
+ content: this.convertContent(msg.content, msg.name || 'user'),
616
+ };
617
+ }
618
+ }).flat();
619
+ }
620
+ /**
621
+ * Convert @aeye tool definitions to OpenAI format.
622
+ *
623
+ * Transforms Zod schemas into JSON Schema format required by OpenAI.
624
+ *
625
+ * @param request @aeye request object
626
+ * @returns Array of OpenAI-formatted tools or undefined
627
+ */
628
+ convertTools(request) {
629
+ if (!request.tools || request.tools.length === 0)
630
+ return undefined;
631
+ return request.tools.map((tool) => ({
632
+ type: 'function',
633
+ function: {
634
+ name: tool.name,
635
+ description: tool.description,
636
+ parameters: z.toJSONSchema(tool.parameters, { target: 'draft-7' }),
637
+ },
638
+ }));
639
+ }
640
+ /**
641
+ * Convert @aeye tool choice to OpenAI format.
642
+ *
643
+ * Supports auto, required, none, and specific tool selection.
644
+ *
645
+ * @param request @aeye request object
646
+ * @returns OpenAI-formatted tool choice option or undefined
647
+ */
648
+ convertToolChoice(request) {
649
+ if (!request.toolChoice)
650
+ return undefined;
651
+ if (request.toolChoice === 'auto')
652
+ return 'auto';
653
+ if (request.toolChoice === 'required')
654
+ return 'required';
655
+ if (request.toolChoice === 'none')
656
+ return 'none';
657
+ if (typeof request.toolChoice === 'object') {
658
+ return {
659
+ type: 'function',
660
+ function: { name: request.toolChoice.tool },
661
+ };
662
+ }
663
+ return undefined;
664
+ }
665
+ /**
666
+ * Convert @aeye response format to OpenAI format.
667
+ *
668
+ * Supports text, JSON object mode, and structured output with Zod schemas.
669
+ *
670
+ * @param request @aeye request object
671
+ * @returns OpenAI-formatted response format or undefined
672
+ */
673
+ convertResponseFormat(request) {
674
+ if (!request.responseFormat)
675
+ return undefined;
676
+ if (typeof request.responseFormat === 'string') {
677
+ if (request.responseFormat === 'text') {
678
+ return { type: 'text' };
679
+ }
680
+ if (request.responseFormat === 'json') {
681
+ return { type: 'json_object' };
682
+ }
683
+ return undefined;
684
+ }
685
+ // Zod schema
686
+ return {
687
+ type: 'json_schema',
688
+ json_schema: {
689
+ name: 'response',
690
+ schema: z.toJSONSchema(request.responseFormat, { target: 'draft-7' }),
691
+ strict: true,
692
+ },
693
+ };
694
+ }
695
+ // ============================================================================
696
+ // Provider Interface Implementation
697
+ // ============================================================================
698
+ /**
699
+ * List available models from OpenAI.
700
+ *
701
+ * @param config Optional configuration override
702
+ * @returns Array of available models with metadata
703
+ * @throws {ProviderError} If listing models fails
704
+ */
705
+ async listModels(config) {
706
+ const effectiveConfig = config || this.config;
707
+ const client = this.createClient(effectiveConfig);
708
+ try {
709
+ const response = await client.models.list();
710
+ const models = response.data;
711
+ return models.map((m) => this.convertModel(m));
712
+ }
713
+ catch (error) {
714
+ throw new ProviderError(this.name, 'Failed to list models', error);
715
+ }
716
+ }
717
+ /**
718
+ * Check if the OpenAI API is accessible with the provided credentials.
719
+ *
720
+ * @param config Optional configuration override
721
+ * @returns true if API is accessible, false otherwise
722
+ */
723
+ async checkHealth(config) {
724
+ try {
725
+ const effectiveConfig = config || this.config;
726
+ const client = this.createClient(effectiveConfig);
727
+ await client.models.list();
728
+ return true;
729
+ }
730
+ catch {
731
+ return false;
732
+ }
733
+ }
734
+ /**
735
+ * Get request data common to all request types.
736
+ *
737
+ * @param request - The request
738
+ * @param ctx - The context
739
+ * @param effectiveConfig - The effective configuration
740
+ * @param defaultModel - Optional default model
741
+ * @returns
742
+ */
743
+ getRequestData(request, requestSignal, ctx, effectiveConfig, operation, defaultModel) {
744
+ const model = getModel(request.model || ctx.metadata?.model || defaultModel);
745
+ if (!model) {
746
+ throw new ProviderError(this.name, `Model is required for ${this.name} requests`);
747
+ }
748
+ const retryConfig = { ...effectiveConfig.retry, ...(request.extra?.retry) };
749
+ const retryEvents = request.extra?.retryEvents || effectiveConfig.retryEvents || {};
750
+ const retryContext = {
751
+ operation,
752
+ model: model.id,
753
+ provider: this.name,
754
+ startTime: Date.now(),
755
+ };
756
+ const signal = requestSignal || ctx.signal;
757
+ const retry = (fn) => {
758
+ return withRetry(() => fn(signal), retryContext, retryConfig, retryEvents, signal);
759
+ };
760
+ return { model, retry, signal };
761
+ }
762
+ /**
763
+ * Create an executor for chat completion requests.
764
+ *
765
+ * The executor handles non-streaming chat completions with support for:
766
+ * - Multi-turn conversations
767
+ * - Function calling
768
+ * - Structured outputs
769
+ * - Vision (images)
770
+ * - Audio inputs
771
+ *
772
+ * @param config Optional configuration override
773
+ * @returns Executor function for chat completions
774
+ * @throws {ProviderError} If request fails
775
+ * @throws {RateLimitError} If rate limit is exceeded
776
+ */
777
+ createExecutor(config) {
778
+ const effectiveConfig = { ...this.config, ...config };
779
+ const client = this.createClient(effectiveConfig);
780
+ return async (request, ctx, metadata, requestSignal) => {
781
+ const { model, retry } = this.getRequestData(request, requestSignal, ctx, effectiveConfig, 'chat', metadata?.model || effectiveConfig.defaultModels?.chat);
782
+ const messages = this.convertMessages(request);
783
+ const tools = this.convertTools(request);
784
+ const tool_choice = tools?.length ? this.convertToolChoice(request) : undefined;
785
+ const parallel_tool_calls = tools?.length ? !request.toolsOneAtATime : undefined;
786
+ const response_format = this.convertResponseFormat(request);
787
+ const params = {
788
+ model: model.id,
789
+ messages,
790
+ temperature: request.temperature,
791
+ top_p: request.topP,
792
+ max_completion_tokens: request.maxTokens,
793
+ frequency_penalty: request.frequencyPenalty,
794
+ presence_penalty: request.presencePenalty,
795
+ logit_bias: request.logitBias,
796
+ logprobs: request.logProbabilities,
797
+ prompt_cache_key: request.cacheKey,
798
+ safety_identifier: request.userKey,
799
+ store: false,
800
+ stop: request.stop,
801
+ tools,
802
+ tool_choice,
803
+ parallel_tool_calls,
804
+ response_format,
805
+ reasoning_effort: request.reason?.effort,
806
+ ...request.extra,
807
+ stream: false,
808
+ };
809
+ this.augmentChatRequest(params, request, effectiveConfig);
810
+ let completion;
811
+ try {
812
+ completion = await retry(async (signal) => {
813
+ const { response, data } = await client.chat.completions.create(params, { signal }).withResponse();
814
+ if (!response.ok) {
815
+ const headers = Object.fromEntries(response.headers);
816
+ const body = await response.text().catch(() => '');
817
+ const details = {
818
+ status: response.status,
819
+ statusText: response.statusText,
820
+ headers,
821
+ body,
822
+ };
823
+ throw new ProviderError(this.name, `Chat completion request failed: ${JSON.stringify(details)}`);
824
+ }
825
+ const choice = data.choices[0];
826
+ if (!choice) {
827
+ throw new ProviderError(this.name, 'No choices in response');
828
+ }
829
+ return data;
830
+ });
831
+ }
832
+ catch (e) {
833
+ if (isContextWindowError(e)) {
834
+ const details = parseContextWindowError(e);
835
+ return {
836
+ content: '',
837
+ toolCalls: [],
838
+ finishReason: 'length',
839
+ model,
840
+ usage: { text: { input: details?.contextWindow } },
841
+ };
842
+ }
843
+ throw e;
844
+ }
845
+ const choice = completion.choices[0];
846
+ const toolCalls = choice.message.tool_calls
847
+ ?.filter((tc) => tc.type === 'function')
848
+ .map((tc) => ({
849
+ id: tc.id,
850
+ name: tc.function.name,
851
+ arguments: tc.function.arguments,
852
+ }));
853
+ const response = {
854
+ content: choice.message.content || '',
855
+ toolCalls,
856
+ finishReason: choice.finish_reason === 'function_call' /* deprecated */ ? 'stop' : choice.finish_reason,
857
+ refusal: choice.finish_reason === 'content_filter' ? choice.message.content || undefined : undefined,
858
+ model,
859
+ usage: completion.usage ? convertOpenAIUsage(completion.usage) : undefined,
860
+ };
861
+ this.augmentChatResponse(completion, response, effectiveConfig);
862
+ return response;
863
+ };
864
+ }
865
+ /**
866
+ * Create a streamer for streaming chat completion requests.
867
+ *
868
+ * The streamer yields chunks as they arrive from OpenAI, enabling
869
+ * real-time response rendering.
870
+ *
871
+ * @param config Optional configuration override
872
+ * @returns Streamer async generator function
873
+ * @throws {ProviderError} If request fails
874
+ * @throws {RateLimitError} If rate limit is exceeded
875
+ */
876
+ createStreamer(config) {
877
+ const effectiveConfig = { ...this.config, ...config };
878
+ const client = this.createClient(effectiveConfig);
879
+ return async function* (request, ctx, metadata, requestSignal) {
880
+ const { model, retry, signal } = this.getRequestData(request, requestSignal, ctx, effectiveConfig, 'streaming chat', metadata?.model || effectiveConfig.defaultModels?.chat);
881
+ const messages = this.convertMessages(request);
882
+ const tools = this.convertTools(request);
883
+ const tool_choice = tools?.length ? this.convertToolChoice(request) : undefined;
884
+ const parallel_tool_calls = tools?.length ? !request.toolsOneAtATime : undefined;
885
+ const response_format = this.convertResponseFormat(request);
886
+ const params = {
887
+ model: model.id,
888
+ messages,
889
+ temperature: request.temperature,
890
+ top_p: request.topP,
891
+ max_completion_tokens: request.maxTokens,
892
+ frequency_penalty: request.frequencyPenalty,
893
+ presence_penalty: request.presencePenalty,
894
+ logit_bias: request.logitBias,
895
+ logprobs: request.logProbabilities,
896
+ prompt_cache_key: request.cacheKey,
897
+ safety_identifier: request.userKey,
898
+ store: false,
899
+ stop: request.stop,
900
+ tools,
901
+ tool_choice,
902
+ parallel_tool_calls,
903
+ response_format,
904
+ ...request.extra,
905
+ stream: true,
906
+ stream_options: { include_usage: true },
907
+ };
908
+ this.augmentChatRequest(params, request, effectiveConfig);
909
+ let stream;
910
+ try {
911
+ stream = await retry(async (signal) => {
912
+ const { response, data } = await client.chat.completions.create(params, { signal }).withResponse();
913
+ if (!response.ok) {
914
+ const headers = Object.fromEntries(response.headers);
915
+ const body = await response.text().catch(() => '');
916
+ const details = {
917
+ status: response.status,
918
+ statusText: response.statusText,
919
+ headers,
920
+ body,
921
+ };
922
+ throw new ProviderError(this.name, `Chat completion request failed: ${JSON.stringify(details)}`);
923
+ }
924
+ return data;
925
+ });
926
+ }
927
+ catch (e) {
928
+ if (isContextWindowError(e)) {
929
+ const details = parseContextWindowError(e);
930
+ return {
931
+ content: '',
932
+ toolCalls: [],
933
+ finishReason: 'length',
934
+ model,
935
+ usage: { text: { input: details?.contextWindow } },
936
+ };
937
+ }
938
+ throw e;
939
+ }
940
+ const toolCallsMap = new Map();
941
+ const toolCalls = [];
942
+ for await (const chunk of stream) {
943
+ if (signal?.aborted) {
944
+ throw new Error('Request aborted');
945
+ }
946
+ const choice = chunk?.choices[0];
947
+ const delta = choice?.delta;
948
+ const yieldChunk = {
949
+ content: delta?.content || undefined,
950
+ finishReason: choice?.finish_reason,
951
+ refusal: delta?.refusal ?? undefined,
952
+ usage: chunk.usage ? convertOpenAIUsage(chunk.usage) : undefined,
953
+ };
954
+ // Handle tool calls
955
+ for (const toolCall of toolCalls) {
956
+ toolCall.updated = false;
957
+ }
958
+ if (delta?.tool_calls) {
959
+ for (const tc of delta.tool_calls) {
960
+ const existing = toolCallsMap.get(tc.index);
961
+ const toolCall = existing || { id: '', name: '', arguments: '', named: false, finished: false, updated: true };
962
+ if (tc.id) {
963
+ toolCall.id = tc.id;
964
+ toolCall.updated = true;
965
+ }
966
+ if (tc.function?.name) {
967
+ toolCall.name = tc.function.name;
968
+ toolCall.updated = true;
969
+ }
970
+ if (tc.function?.arguments) {
971
+ toolCall.arguments += tc.function.arguments;
972
+ toolCall.updated = true;
973
+ }
974
+ if (!existing) {
975
+ toolCallsMap.set(tc.index, toolCall);
976
+ toolCalls.push(toolCall);
977
+ }
978
+ if (toolCall.arguments) {
979
+ if (!toolCall.named) {
980
+ yieldChunk.toolCallNamed = existing;
981
+ toolCall.named = true;
982
+ }
983
+ else {
984
+ yieldChunk.toolCallArguments = existing;
985
+ }
986
+ }
987
+ }
988
+ }
989
+ for (const toolCall of toolCalls) {
990
+ if (!toolCall.updated && !toolCall.finished) {
991
+ toolCall.finished = true;
992
+ yieldChunk.toolCall = toolCall;
993
+ }
994
+ }
995
+ // Augment chunk with provider-specific data
996
+ this.augmentChatChunk(chunk, yieldChunk, effectiveConfig);
997
+ // Send it!
998
+ yield yieldChunk;
999
+ }
1000
+ // All tool calls should've been emitted, but just in case
1001
+ for (const toolCall of toolCalls) {
1002
+ if (!toolCall.finished) {
1003
+ toolCall.finished = true;
1004
+ const chunk = { choices: [], created: 0, id: '', model: '', object: 'chat.completion.chunk' };
1005
+ const yieldChunk = { toolCall };
1006
+ // Augment chunk with provider-specific data
1007
+ this.augmentChatChunk(chunk, yieldChunk, effectiveConfig);
1008
+ // Send it!
1009
+ yield { toolCall };
1010
+ }
1011
+ }
1012
+ }.bind(this);
1013
+ }
1014
+ /**
1015
+ * Get image generation parameters based on model and request.
1016
+ *
1017
+ * @param request - Image generation request
1018
+ * @param model - Model ID
1019
+ * @returns
1020
+ */
1021
+ getGenerateParameters(request, model) {
1022
+ const modelId = model.includes('gpt-image-')
1023
+ ? 'gpt-image'
1024
+ : model.includes('dall-e-2')
1025
+ ? 'dall-e-2'
1026
+ : model.includes('dall-e-3')
1027
+ ? 'dall-e-3'
1028
+ : 'unknown';
1029
+ const modelParameters = {
1030
+ 'gpt-image': {
1031
+ style: {
1032
+ 'vivid': undefined,
1033
+ 'natural': undefined,
1034
+ },
1035
+ background: {
1036
+ 'transparent': 'transparent',
1037
+ 'opaque': 'opaque',
1038
+ 'auto': 'auto',
1039
+ },
1040
+ quality: {
1041
+ 'low': 'low',
1042
+ 'medium': 'medium',
1043
+ 'high': 'high',
1044
+ },
1045
+ responseFormat: {
1046
+ 'url': undefined,
1047
+ 'b64_json': undefined,
1048
+ },
1049
+ sizes: [
1050
+ { width: 1024, height: 1024 },
1051
+ { width: 1536, height: 1024 },
1052
+ { width: 1024, height: 1536 },
1053
+ ],
1054
+ n: { min: 1, max: 10 },
1055
+ },
1056
+ 'dall-e-2': {
1057
+ style: {
1058
+ 'vivid': undefined,
1059
+ 'natural': undefined,
1060
+ },
1061
+ background: {
1062
+ 'transparent': undefined,
1063
+ 'opaque': undefined,
1064
+ 'auto': undefined,
1065
+ },
1066
+ quality: {
1067
+ 'low': 'standard',
1068
+ 'medium': 'standard',
1069
+ 'high': 'standard',
1070
+ },
1071
+ responseFormat: {
1072
+ 'url': 'url',
1073
+ 'b64_json': 'b64_json',
1074
+ },
1075
+ sizes: [
1076
+ { width: 256, height: 256 },
1077
+ { width: 512, height: 512 },
1078
+ { width: 1024, height: 1024 },
1079
+ ],
1080
+ n: { min: 1, max: 10 },
1081
+ },
1082
+ 'dall-e-3': {
1083
+ style: {
1084
+ 'vivid': 'vivid',
1085
+ 'natural': 'natural',
1086
+ },
1087
+ background: {
1088
+ 'transparent': undefined,
1089
+ 'opaque': undefined,
1090
+ 'auto': undefined,
1091
+ },
1092
+ quality: {
1093
+ 'low': 'standard',
1094
+ 'medium': 'hd',
1095
+ 'high': 'hd',
1096
+ },
1097
+ responseFormat: {
1098
+ 'url': 'url',
1099
+ 'b64_json': 'b64_json',
1100
+ },
1101
+ sizes: [
1102
+ { width: 1024, height: 1024 },
1103
+ { width: 1792, height: 1024 },
1104
+ { width: 1024, height: 1792 },
1105
+ ],
1106
+ n: { min: 1, max: 1 },
1107
+ },
1108
+ 'unknown': {
1109
+ style: {
1110
+ 'vivid': undefined,
1111
+ 'natural': undefined,
1112
+ },
1113
+ background: {
1114
+ 'transparent': undefined,
1115
+ 'opaque': undefined,
1116
+ 'auto': undefined,
1117
+ },
1118
+ quality: {
1119
+ 'low': undefined,
1120
+ 'medium': undefined,
1121
+ 'high': undefined,
1122
+ },
1123
+ responseFormat: {
1124
+ 'url': undefined,
1125
+ 'b64_json': undefined,
1126
+ },
1127
+ sizes: [],
1128
+ n: { min: 1, max: 1 },
1129
+ },
1130
+ };
1131
+ const sizesAvailable = modelParameters[modelId].sizes;
1132
+ let size = undefined;
1133
+ if (request.size && sizesAvailable.length > 0) {
1134
+ const [widthStr, heightStr] = request.size.split('x');
1135
+ const width = parseInt(widthStr, 10);
1136
+ const height = parseInt(heightStr, 10);
1137
+ const closest = sizesAvailable
1138
+ .map(d => ({
1139
+ ...d,
1140
+ ratioDiff: Math.abs(d.width / d.height - width / height),
1141
+ scale: Math.min(d.width / width, d.height / height)
1142
+ }))
1143
+ .sort((a, b) => a.ratioDiff - b.ratioDiff || b.scale - a.scale)[0];
1144
+ size = `${closest.width}x${closest.height}`;
1145
+ }
1146
+ const params = {
1147
+ model,
1148
+ prompt: request.prompt,
1149
+ n: request.n ? Math.max(modelParameters[modelId].n.min, Math.min(modelParameters[modelId].n.max, request.n)) : undefined,
1150
+ size,
1151
+ quality: request.quality ? modelParameters[modelId].quality[request.quality] : undefined,
1152
+ style: request.style ? modelParameters[modelId].style[request.style] : undefined,
1153
+ background: request.background ? modelParameters[modelId].background[request.background] : undefined,
1154
+ response_format: request.responseFormat ? modelParameters[modelId].responseFormat[request.responseFormat] : undefined,
1155
+ };
1156
+ return params;
1157
+ }
1158
+ /**
1159
+ * Generate images.
1160
+ *
1161
+ * @param request Image generation request parameters
1162
+ * @param ctx Context object (not used)
1163
+ * @param config Optional configuration override
1164
+ * @returns Generated image URLs or base64 data
1165
+ * @throws {ProviderError} If generation fails
1166
+ * @throws {RateLimitError} If rate limit is exceeded
1167
+ */
1168
+ generateImage = async (request, ctx, config) => {
1169
+ const effectiveConfig = { ...this.config, ...config };
1170
+ const { model, retry } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'generate image', effectiveConfig.defaultModels?.imageGenerate || 'gpt-image-1');
1171
+ const baseParams = this.getGenerateParameters(request, model.id);
1172
+ const params = {
1173
+ ...baseParams,
1174
+ user: request.userIdentifier,
1175
+ ...request.extra,
1176
+ stream: false,
1177
+ };
1178
+ // Apply provider-specific customizations
1179
+ this.augmentImageGenerateRequest?.(params, request, effectiveConfig);
1180
+ const client = this.createClient(effectiveConfig);
1181
+ let response;
1182
+ try {
1183
+ response = await retry(async (signal) => {
1184
+ return await client.images.generate(params, { signal });
1185
+ });
1186
+ }
1187
+ catch (e) {
1188
+ if (isContextWindowError(e)) {
1189
+ const details = parseContextWindowError(e);
1190
+ throw new ContextWindowError(this.name, 'Context window exceeded during image generation', details?.contextWindow, e);
1191
+ }
1192
+ throw e;
1193
+ }
1194
+ return {
1195
+ images: (response.data || []).map((img) => ({
1196
+ url: img.url || undefined,
1197
+ b64_json: img.b64_json || undefined,
1198
+ revisedPrompt: img.revised_prompt || undefined,
1199
+ })),
1200
+ model,
1201
+ usage: response.usage ? convertOpenAIUsage(response.usage) : undefined,
1202
+ };
1203
+ };
1204
+ /**
1205
+ * Generate images with streaming progress updates.
1206
+ *
1207
+ * Note: OpenAI's API doesn't natively support streaming image generation,
1208
+ * so this implementation polls for progress or returns the result immediately.
1209
+ *
1210
+ * @param request Image generation request parameters
1211
+ * @param ctx Context object (not used)
1212
+ * @param config Optional configuration override
1213
+ * @returns Async generator yielding progress chunks
1214
+ * @throws {ProviderError} If generation fails
1215
+ * @throws {RateLimitError} If rate limit is exceeded
1216
+ */
1217
+ generateImageStream = async function* (request, ctx, config) {
1218
+ const effectiveConfig = { ...this.config, ...config };
1219
+ const { model, retry, signal } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'generate image stream', effectiveConfig.defaultModels?.imageGenerate || 'gpt-image-1');
1220
+ const baseParams = this.getGenerateParameters(request, model.id);
1221
+ const imageStreams = Math.max(1, Math.min(3, request.streamCount ?? 2));
1222
+ const params = {
1223
+ ...baseParams,
1224
+ user: request.userIdentifier,
1225
+ partial_images: imageStreams,
1226
+ ...request.extra,
1227
+ stream: true,
1228
+ };
1229
+ this.augmentImageGenerateRequest?.(params, request, effectiveConfig);
1230
+ const client = this.createClient(effectiveConfig);
1231
+ let response;
1232
+ try {
1233
+ response = await retry(async (signal) => {
1234
+ return await client.images.generate(params, { signal });
1235
+ });
1236
+ }
1237
+ catch (e) {
1238
+ if (isContextWindowError(e)) {
1239
+ const details = parseContextWindowError(e);
1240
+ throw new ContextWindowError(this.name, 'Context window exceeded during image generation', details?.contextWindow, e);
1241
+ }
1242
+ throw e;
1243
+ }
1244
+ // Yield completion with results
1245
+ let imageCount = 0;
1246
+ for await (const img of response) {
1247
+ if (signal?.aborted) {
1248
+ throw new Error('Request aborted');
1249
+ }
1250
+ const progress = (imageCount + 1) / (imageStreams + 1);
1251
+ yield {
1252
+ progress,
1253
+ done: img.type === 'image_generation.completed',
1254
+ image: {
1255
+ b64_json: img.b64_json || undefined,
1256
+ },
1257
+ };
1258
+ imageCount++;
1259
+ }
1260
+ };
1261
+ /**
1262
+ * Get image generation parameters based on model and request.
1263
+ *
1264
+ * @param request - Image generation request
1265
+ * @param model - Model ID
1266
+ * @returns
1267
+ */
1268
+ getEditParameters(request, model) {
1269
+ const modelId = model.includes('gpt-image-')
1270
+ ? 'gpt-image'
1271
+ : model.includes('dall-e-2')
1272
+ ? 'dall-e-2'
1273
+ : model.includes('dall-e-3')
1274
+ ? 'dall-e-3'
1275
+ : 'unknown';
1276
+ const modelParameters = {
1277
+ 'gpt-image': {
1278
+ responseFormat: {
1279
+ 'url': undefined,
1280
+ 'b64_json': undefined,
1281
+ },
1282
+ sizes: [
1283
+ { width: 1024, height: 1024 },
1284
+ { width: 1536, height: 1024 },
1285
+ { width: 1024, height: 1536 },
1286
+ ],
1287
+ n: { min: 1, max: 10 },
1288
+ },
1289
+ 'dall-e-2': {
1290
+ responseFormat: {
1291
+ 'url': 'url',
1292
+ 'b64_json': 'b64_json',
1293
+ },
1294
+ sizes: [
1295
+ { width: 256, height: 256 },
1296
+ { width: 512, height: 512 },
1297
+ { width: 1024, height: 1024 },
1298
+ ],
1299
+ n: { min: 1, max: 10 },
1300
+ },
1301
+ 'dall-e-3': {
1302
+ responseFormat: {
1303
+ 'url': 'url',
1304
+ 'b64_json': 'b64_json',
1305
+ },
1306
+ sizes: [
1307
+ { width: 1024, height: 1024 },
1308
+ { width: 1792, height: 1024 },
1309
+ { width: 1024, height: 1792 },
1310
+ ],
1311
+ n: { min: 1, max: 1 },
1312
+ },
1313
+ 'unknown': {
1314
+ responseFormat: {
1315
+ 'url': undefined,
1316
+ 'b64_json': undefined,
1317
+ },
1318
+ sizes: [],
1319
+ n: { min: 1, max: 1 },
1320
+ },
1321
+ };
1322
+ const sizesAvailable = modelParameters[modelId].sizes;
1323
+ let size = undefined;
1324
+ if (request.size && sizesAvailable.length > 0) {
1325
+ const [widthStr, heightStr] = request.size.split('x');
1326
+ const width = parseInt(widthStr, 10);
1327
+ const height = parseInt(heightStr, 10);
1328
+ const closest = sizesAvailable
1329
+ .map(d => ({
1330
+ ...d,
1331
+ ratioDiff: Math.abs(d.width / d.height - width / height),
1332
+ scale: Math.min(d.width / width, d.height / height)
1333
+ }))
1334
+ .sort((a, b) => a.ratioDiff - b.ratioDiff || b.scale - a.scale)[0];
1335
+ size = `${closest.width}x${closest.height}`;
1336
+ }
1337
+ const params = {
1338
+ model,
1339
+ prompt: request.prompt,
1340
+ n: request.n ? Math.max(modelParameters[modelId].n.min, Math.min(modelParameters[modelId].n.max, request.n)) : undefined,
1341
+ size,
1342
+ response_format: request.responseFormat ? modelParameters[modelId].responseFormat[request.responseFormat] : undefined,
1343
+ };
1344
+ return params;
1345
+ }
1346
+ /**
1347
+ * Edit images using DALL-E models.
1348
+ *
1349
+ * Supports image editing with text prompts and optional masks.
1350
+ *
1351
+ * @param request Image edit request parameters
1352
+ * @param ctx Context object (not used)
1353
+ * @param config Optional configuration override
1354
+ * @returns Edited image URLs or base64 data
1355
+ * @throws {ProviderError} If editing fails
1356
+ * @throws {RateLimitError} If rate limit is exceeded
1357
+ */
1358
+ editImage = async (request, ctx, config) => {
1359
+ const effectiveConfig = { ...this.config, ...config };
1360
+ const { model, retry } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'edit image', effectiveConfig.defaultModels?.imageEdit || 'gpt-image-1');
1361
+ const image = this.toUploadableImage(request.image);
1362
+ const mask = request.mask ? this.toUploadableImage(request.mask) : undefined;
1363
+ const baseParams = this.getEditParameters(request, model.id);
1364
+ const params = {
1365
+ ...baseParams,
1366
+ image,
1367
+ mask,
1368
+ user: request.userIdentifier,
1369
+ ...request.extra,
1370
+ stream: false,
1371
+ };
1372
+ // Apply provider-specific customizations
1373
+ this.augmentImageGenerateRequest?.(params, request, effectiveConfig);
1374
+ const client = this.createClient(effectiveConfig);
1375
+ let response;
1376
+ try {
1377
+ response = await retry(async (signal) => {
1378
+ return await client.images.edit(params, { signal });
1379
+ });
1380
+ }
1381
+ catch (e) {
1382
+ if (isContextWindowError(e)) {
1383
+ const details = parseContextWindowError(e);
1384
+ throw new ContextWindowError(this.name, 'Context window exceeded during image editing', details?.contextWindow, e);
1385
+ }
1386
+ throw e;
1387
+ }
1388
+ return {
1389
+ images: (response.data || []).map((img) => ({
1390
+ url: img.url || undefined,
1391
+ b64_json: img.b64_json || undefined,
1392
+ revisedPrompt: img.revised_prompt || undefined,
1393
+ })),
1394
+ model,
1395
+ usage: response.usage ? convertOpenAIUsage(response.usage) : undefined,
1396
+ };
1397
+ };
1398
+ /**
1399
+ * Edit images with streaming progress updates.
1400
+ *
1401
+ * Note: OpenAI's API doesn't natively support streaming image editing,
1402
+ * so this implementation returns the result with progress indicators.
1403
+ *
1404
+ * @param request Image edit request parameters
1405
+ * @param ctx Context object (not used)
1406
+ * @param config Optional configuration override
1407
+ * @returns Async generator yielding progress chunks
1408
+ * @throws {ProviderError} If editing fails
1409
+ * @throws {RateLimitError} If rate limit is exceeded
1410
+ */
1411
+ editImageStream = async function* (request, ctx, config) {
1412
+ const effectiveConfig = { ...this.config, ...config };
1413
+ const { model, retry, signal } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'edit image stream', effectiveConfig.defaultModels?.imageEdit || 'gpt-image-1');
1414
+ const image = this.toUploadableImage(request.image);
1415
+ const mask = request.mask ? this.toUploadableImage(request.mask) : undefined;
1416
+ const imageStreams = Math.max(1, Math.min(3, request.streamCount ?? 2));
1417
+ const baseParams = this.getEditParameters(request, model.id);
1418
+ const params = {
1419
+ ...baseParams,
1420
+ image,
1421
+ mask,
1422
+ user: request.userIdentifier,
1423
+ partial_images: imageStreams,
1424
+ ...request.extra,
1425
+ stream: true,
1426
+ };
1427
+ this.augmentImageEditRequest?.(params, request, effectiveConfig);
1428
+ const client = this.createClient(effectiveConfig);
1429
+ let response;
1430
+ try {
1431
+ response = await retry(async (signal) => {
1432
+ return await client.images.edit(params, { signal });
1433
+ });
1434
+ }
1435
+ catch (e) {
1436
+ if (isContextWindowError(e)) {
1437
+ const details = parseContextWindowError(e);
1438
+ throw new ContextWindowError(this.name, 'Context window exceeded during image editing', details?.contextWindow, e);
1439
+ }
1440
+ throw e;
1441
+ }
1442
+ // Yield completion with results
1443
+ let imageCount = 0;
1444
+ for await (const img of response) {
1445
+ if (signal?.aborted) {
1446
+ throw new Error('Request aborted');
1447
+ }
1448
+ const progress = (imageCount + 1) / (imageStreams + 1);
1449
+ yield {
1450
+ progress,
1451
+ done: img.type === 'image_edit.completed',
1452
+ image: {
1453
+ b64_json: img.b64_json || undefined,
1454
+ },
1455
+ model,
1456
+ };
1457
+ imageCount++;
1458
+ }
1459
+ };
1460
+ /**
1461
+ * Convert various image input types to Uploadable format.
1462
+ *
1463
+ * @param data Image data in various formats
1464
+ * @returns Uploadable image for OpenAI API
1465
+ */
1466
+ toUploadableImage(data) {
1467
+ if (typeof data === 'string') {
1468
+ // URL
1469
+ if (data.startsWith('http://') || data.startsWith('https://')) {
1470
+ return fs.createReadStream(data);
1471
+ }
1472
+ // Data URL
1473
+ if (data.startsWith('data:')) {
1474
+ const match = data.match(/^data:([^;]+);base64,(.+)$/);
1475
+ if (!match) {
1476
+ throw new Error('Invalid data URL');
1477
+ }
1478
+ const base64Data = match[2];
1479
+ const buffer = Buffer.from(base64Data, 'base64');
1480
+ return new File([buffer], 'image', { type: match[1] });
1481
+ }
1482
+ // File path
1483
+ if (data.startsWith('/')) {
1484
+ return fs.createReadStream(data);
1485
+ }
1486
+ // Raw base64
1487
+ const buffer = Buffer.from(data, 'base64');
1488
+ return new File([buffer], 'image', { type: 'image/png' });
1489
+ }
1490
+ else if (Buffer.isBuffer(data)) {
1491
+ return new File([data], 'image', { type: 'image/png' });
1492
+ }
1493
+ else if (data instanceof Uint8Array) {
1494
+ return new File([data], 'image', { type: 'image/png' });
1495
+ }
1496
+ else {
1497
+ return data;
1498
+ }
1499
+ }
1500
+ /**
1501
+ * Convert various audio input types to Uploadable format.
1502
+ *
1503
+ * @param data Audio data in various formats
1504
+ * @returns Uploadable audio for OpenAI API
1505
+ */
1506
+ toUploadableAudio(data) {
1507
+ if (typeof data === 'string') {
1508
+ // URL
1509
+ if (data.startsWith('http://') || data.startsWith('https://')) {
1510
+ return fs.createReadStream(data);
1511
+ }
1512
+ // Data URL
1513
+ if (data.startsWith('data:')) {
1514
+ const match = data.match(/^data:([^;]+);base64,(.+)$/);
1515
+ if (!match) {
1516
+ throw new Error('Invalid data URL');
1517
+ }
1518
+ const base64Data = match[2];
1519
+ const buffer = Buffer.from(base64Data, 'base64');
1520
+ return new File([buffer], 'audio', { type: match[1] });
1521
+ }
1522
+ // File path
1523
+ if (data.startsWith('/')) {
1524
+ return fs.createReadStream(data);
1525
+ }
1526
+ // Raw base64
1527
+ const buffer = Buffer.from(data, 'base64');
1528
+ return new File([buffer], 'audio', {});
1529
+ }
1530
+ else if (Buffer.isBuffer(data)) {
1531
+ return new File([data], 'audio', {});
1532
+ }
1533
+ else {
1534
+ return data;
1535
+ }
1536
+ }
1537
+ /**
1538
+ * Transcribe audio to text using Whisper models.
1539
+ *
1540
+ * Supports multiple audio formats and optional timestamps.
1541
+ *
1542
+ * @param request Transcription request parameters
1543
+ * @param ctx Context object (not used)
1544
+ * @param config Optional configuration override
1545
+ * @returns Transcription text and optional metadata
1546
+ * @throws {ProviderError} If transcription fails
1547
+ * @throws {RateLimitError} If rate limit is exceeded
1548
+ */
1549
+ transcribe = async (request, ctx, config) => {
1550
+ const effectiveConfig = { ...this.config, ...config };
1551
+ const { model, retry, signal } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'transcribe', effectiveConfig.defaultModels?.transcription || 'whisper-1');
1552
+ const file = this.toUploadableAudio(request.audio);
1553
+ const params = {
1554
+ model: model.id,
1555
+ file,
1556
+ language: request.language,
1557
+ prompt: request.prompt,
1558
+ temperature: request.temperature,
1559
+ response_format: request.responseFormat,
1560
+ timestamp_granularities: request.timestampGranularities,
1561
+ ...request.extra,
1562
+ stream: false,
1563
+ };
1564
+ // Apply provider-specific customizations
1565
+ this.augmentTranscriptionRequest?.(params, request, effectiveConfig);
1566
+ const client = this.createClient(effectiveConfig);
1567
+ let response;
1568
+ try {
1569
+ response = await retry(async (signal) => {
1570
+ return await client.audio.transcriptions.create(params, { signal });
1571
+ });
1572
+ }
1573
+ catch (e) {
1574
+ if (isContextWindowError(e)) {
1575
+ const details = parseContextWindowError(e);
1576
+ throw new ContextWindowError(this.name, 'Context window exceeded during image editing', details?.contextWindow, e);
1577
+ }
1578
+ throw e;
1579
+ }
1580
+ return {
1581
+ text: response.text,
1582
+ model,
1583
+ usage: response.usage?.type === 'tokens' ? convertOpenAIUsage(response.usage) :
1584
+ response.usage?.seconds ? { audio: { seconds: response.usage.seconds } } : undefined,
1585
+ };
1586
+ };
1587
+ /**
1588
+ * Transcribe audio with streaming progress updates.
1589
+ *
1590
+ * Note: OpenAI's API doesn't natively support streaming transcription,
1591
+ * so this implementation returns the result with progress indicators.
1592
+ *
1593
+ * @param request Transcription request parameters
1594
+ * @param ctx Context object (not used)
1595
+ * @param config Optional configuration override
1596
+ * @returns Async generator yielding transcription chunks
1597
+ * @throws {ProviderError} If transcription fails
1598
+ * @throws {RateLimitError} If rate limit is exceeded
1599
+ */
1600
+ transcribeStream = async function* (request, ctx, config) {
1601
+ const effectiveConfig = { ...this.config, ...config };
1602
+ const { model, retry, signal } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'transcribe stream', effectiveConfig.defaultModels?.transcription || 'whisper-1');
1603
+ const file = this.toUploadableAudio(request.audio);
1604
+ const params = {
1605
+ model: model.id,
1606
+ file,
1607
+ language: request.language,
1608
+ prompt: request.prompt,
1609
+ temperature: request.temperature,
1610
+ response_format: request.responseFormat,
1611
+ timestamp_granularities: request.timestampGranularities,
1612
+ ...request.extra,
1613
+ stream: true,
1614
+ };
1615
+ this.augmentTranscriptionRequest?.(params, request, effectiveConfig);
1616
+ const client = this.createClient(effectiveConfig);
1617
+ let response;
1618
+ try {
1619
+ response = await retry(async (signal) => {
1620
+ return await client.audio.transcriptions.create(params, { signal });
1621
+ });
1622
+ }
1623
+ catch (e) {
1624
+ if (isContextWindowError(e)) {
1625
+ const details = parseContextWindowError(e);
1626
+ throw new ContextWindowError(this.name, 'Context window exceeded during transcription', details?.contextWindow, e);
1627
+ }
1628
+ throw e;
1629
+ }
1630
+ for await (const chunk of response) {
1631
+ if (signal?.aborted) {
1632
+ throw new Error('Request aborted');
1633
+ }
1634
+ switch (chunk.type) {
1635
+ case 'transcript.text.delta':
1636
+ yield {
1637
+ delta: chunk.delta,
1638
+ };
1639
+ break;
1640
+ case 'transcript.text.segment':
1641
+ yield {
1642
+ segment: {
1643
+ start: chunk.start,
1644
+ end: chunk.end,
1645
+ speaker: chunk.speaker,
1646
+ text: chunk.text,
1647
+ id: chunk.id,
1648
+ },
1649
+ };
1650
+ break;
1651
+ case 'transcript.text.done':
1652
+ yield {
1653
+ text: chunk.text,
1654
+ usage: chunk.usage ? convertOpenAIUsage(chunk.usage) : undefined,
1655
+ model,
1656
+ };
1657
+ break;
1658
+ }
1659
+ }
1660
+ };
1661
+ /**
1662
+ * Generate speech from text using TTS models.
1663
+ *
1664
+ * Supports multiple voices and audio formats.
1665
+ *
1666
+ * @param request Speech synthesis request parameters
1667
+ * @param ctx Context object (not used)
1668
+ * @param config Optional configuration override
1669
+ * @returns Audio buffer and content type
1670
+ * @throws {ProviderError} If speech generation fails
1671
+ * @throws {RateLimitError} If rate limit is exceeded
1672
+ */
1673
+ speech = async (request, ctx, config) => {
1674
+ const effectiveConfig = { ...this.config, ...config };
1675
+ const { model, retry, signal } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'speech', effectiveConfig.defaultModels?.speech || 'tts-1');
1676
+ const { text: input, instructions, speed } = request;
1677
+ const voiceDefault = 'alloy';
1678
+ const voiceValid = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'sage', 'shimmer', 'verse', 'marin', 'cedar'];
1679
+ const voiceRequest = request.voice || voiceDefault;
1680
+ const voice = voiceValid.includes(voiceRequest) ? voiceRequest : voiceDefault;
1681
+ const responseFormat = request.responseFormat || 'opus';
1682
+ const params = {
1683
+ model: model.id,
1684
+ input,
1685
+ instructions,
1686
+ voice,
1687
+ speed,
1688
+ response_format: responseFormat,
1689
+ ...request.extra,
1690
+ };
1691
+ // Apply provider-specific customizations
1692
+ this.augmentSpeechRequest?.(params, request, effectiveConfig);
1693
+ const client = this.createClient(effectiveConfig);
1694
+ let response;
1695
+ try {
1696
+ response = await retry(async (signal) => {
1697
+ return await client.audio.speech.create(params, { signal });
1698
+ });
1699
+ }
1700
+ catch (e) {
1701
+ if (isContextWindowError(e)) {
1702
+ const details = parseContextWindowError(e);
1703
+ throw new ContextWindowError(this.name, 'Context window exceeded during speech generation', details?.contextWindow, e);
1704
+ }
1705
+ throw e;
1706
+ }
1707
+ return {
1708
+ model,
1709
+ audio: response.body,
1710
+ responseFormat,
1711
+ };
1712
+ };
1713
+ /**
1714
+ * Generate text embeddings for semantic search and similarity.
1715
+ *
1716
+ * Supports text-embedding-3-small, text-embedding-3-large, and ada-002 models.
1717
+ *
1718
+ * @param request Embedding request parameters
1719
+ * @param ctx Context object (not used)
1720
+ * @param config Optional configuration override
1721
+ * @returns Array of embeddings with usage information
1722
+ * @throws {ProviderError} If embedding generation fails
1723
+ * @throws {RateLimitError} If rate limit is exceeded
1724
+ */
1725
+ embed = async (request, ctx, config) => {
1726
+ const effectiveConfig = { ...this.config, ...config };
1727
+ const { model, retry } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'embed', effectiveConfig.defaultModels?.embedding || 'text-embedding-3-small');
1728
+ const params = {
1729
+ model: model.id,
1730
+ input: request.texts,
1731
+ dimensions: request.dimensions,
1732
+ encoding_format: request.encodingFormat,
1733
+ user: request.userIdentifier,
1734
+ ...request.extra,
1735
+ };
1736
+ // Apply provider-specific customizations
1737
+ this.augmentEmbeddingRequest?.(params, request, effectiveConfig);
1738
+ const client = this.createClient(effectiveConfig);
1739
+ let response;
1740
+ try {
1741
+ response = await retry(async (signal) => {
1742
+ return await client.embeddings.create(params, { signal });
1743
+ });
1744
+ }
1745
+ catch (e) {
1746
+ if (isContextWindowError(e)) {
1747
+ const details = parseContextWindowError(e);
1748
+ throw new ContextWindowError(this.name, 'Context window exceeded during embedding generation', details?.contextWindow, e);
1749
+ }
1750
+ throw e;
1751
+ }
1752
+ return {
1753
+ embeddings: response.data.map((item) => ({
1754
+ embedding: item.embedding,
1755
+ index: item.index,
1756
+ })),
1757
+ model,
1758
+ usage: response.usage
1759
+ ? {
1760
+ embeddings: {
1761
+ count: response.data.length,
1762
+ tokens: response.usage.prompt_tokens
1763
+ }
1764
+ }
1765
+ : undefined,
1766
+ };
1767
+ };
1768
+ /**
1769
+ * Analyze images using vision-capable models.
1770
+ *
1771
+ * This method converts the image analysis request into a chat request
1772
+ * with image content and calls the chat API internally.
1773
+ *
1774
+ * @param request Image analysis request parameters
1775
+ * @param ctx Context object (not used)
1776
+ * @param config Optional configuration override
1777
+ * @returns Analysis response with text content
1778
+ * @throws {ProviderError} If analysis fails
1779
+ * @throws {RateLimitError} If rate limit is exceeded
1780
+ */
1781
+ analyzeImage = async (request, ctx, config) => {
1782
+ const effectiveConfig = { ...this.config, ...config };
1783
+ const executor = this.createExecutor(effectiveConfig);
1784
+ // Convert ImageAnalyzeRequest to chat Request
1785
+ const chatRequest = {
1786
+ messages: [
1787
+ {
1788
+ role: 'user',
1789
+ content: [
1790
+ { type: 'text', content: request.prompt },
1791
+ ...request.images.map((image) => ({
1792
+ type: 'image',
1793
+ content: image,
1794
+ })),
1795
+ ],
1796
+ },
1797
+ ],
1798
+ temperature: request.temperature,
1799
+ maxTokens: request.maxTokens,
1800
+ };
1801
+ // Use the chat executor to analyze the images
1802
+ const response = await executor(chatRequest, ctx, {}, undefined);
1803
+ return response;
1804
+ };
1805
+ /**
1806
+ * Analyze images with streaming response.
1807
+ *
1808
+ * This method converts the image analysis request into a chat request
1809
+ * with image content and calls the streaming chat API internally.
1810
+ *
1811
+ * @param request Image analysis request parameters
1812
+ * @param ctx Context object (not used)
1813
+ * @param config Optional configuration override
1814
+ * @returns Async generator yielding response chunks
1815
+ * @throws {ProviderError} If analysis fails
1816
+ * @throws {RateLimitError} If rate limit is exceeded
1817
+ */
1818
+ analyzeImageStream = async function* (request, ctx, config) {
1819
+ const effectiveConfig = { ...this.config, ...config };
1820
+ const streamer = this.createStreamer(effectiveConfig);
1821
+ // Convert ImageAnalyzeRequest to chat Request
1822
+ const chatRequest = {
1823
+ messages: [
1824
+ {
1825
+ role: 'user',
1826
+ content: [
1827
+ { type: 'text', content: request.prompt },
1828
+ ...request.images.map((image) => ({
1829
+ type: 'image',
1830
+ content: image,
1831
+ })),
1832
+ ],
1833
+ },
1834
+ ],
1835
+ temperature: request.temperature,
1836
+ maxTokens: request.maxTokens,
1837
+ };
1838
+ // Use the chat streamer to analyze the images
1839
+ for await (const chunk of streamer(chatRequest, ctx, { model: request.model }, undefined)) {
1840
+ yield chunk;
1841
+ }
1842
+ };
1843
+ }
1844
+ //# sourceMappingURL=openai.js.map