@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/README.md +921 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/openai.d.ts +444 -0
- package/dist/openai.d.ts.map +1 -0
- package/dist/openai.js +1844 -0
- package/dist/openai.js.map +1 -0
- package/dist/retry.d.ts +117 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +224 -0
- package/dist/retry.js.map +1 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +129 -0
- package/dist/types.js.map +1 -0
- package/package.json +57 -0
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
|