@aeye/openai 0.3.9 → 0.4.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 +267 -119
- package/aeye-openai.md +189 -178
- package/dist/convert.d.ts +81 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +108 -0
- package/dist/convert.js.map +1 -0
- package/dist/decisions.d.ts +110 -0
- package/dist/decisions.d.ts.map +1 -0
- package/dist/decisions.js +48 -0
- package/dist/decisions.js.map +1 -0
- package/dist/images.d.ts +47 -0
- package/dist/images.d.ts.map +1 -0
- package/dist/images.js +80 -0
- package/dist/images.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/openai.d.ts +452 -206
- package/dist/openai.d.ts.map +1 -1
- package/dist/openai.js +912 -1424
- package/dist/openai.js.map +1 -1
- package/dist/responses.d.ts +67 -0
- package/dist/responses.d.ts.map +1 -0
- package/dist/responses.js +236 -0
- package/dist/responses.js.map +1 -0
- package/package.json +72 -72
package/dist/openai.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenAI Provider
|
|
3
3
|
*
|
|
4
|
-
* Provider for OpenAI's
|
|
5
|
-
* Also serves as base class for
|
|
4
|
+
* Provider for OpenAI's chat (Chat Completions and Responses), GPT Image,
|
|
5
|
+
* transcription, speech, and embedding models. Also serves as base class for
|
|
6
|
+
* OpenAI-compatible providers.
|
|
6
7
|
*/
|
|
7
|
-
import { detectTier, isModelInfo
|
|
8
|
-
import {
|
|
8
|
+
import { allocateToolDescriptors, buildSchemaBudget, clampReasoningEffort, detectTier, isModelInfo } from '@aeye/ai';
|
|
9
|
+
import { deliverSchema, describeTool, getModel, getResponseFromChunks, schemaRejection, strictify, toBase64, toFile, toJSONSchema, toText, toURL } from '@aeye/core';
|
|
9
10
|
import OpenAI from 'openai';
|
|
11
|
+
import { chatUsage, MODERATION_MODEL, responsesUsage, toChatAudioFormat, toCitation, toModeration, toServiceTier, transcriptionUsage } from './convert';
|
|
12
|
+
import { toDecisionAnswer, toDecisionQuestion } from './decisions';
|
|
13
|
+
import { imageParams, imageUsage, partialImages } from './images';
|
|
14
|
+
import { isResponsesOnly, outputItemChunk, responseChunks, responseEndChunk, textInput, toHostedTool, toInputContent, toReasoningItems } from './responses';
|
|
10
15
|
import { isContextWindowError, parseContextWindowError, withRetry } from './retry';
|
|
11
16
|
import { ContextWindowError, ProviderError } from './types';
|
|
12
17
|
// ============================================================================
|
|
@@ -22,14 +27,20 @@ import { ContextWindowError, ProviderError } from './types';
|
|
|
22
27
|
* return undefined in that case and the caller falls back to LENIENT.
|
|
23
28
|
*/
|
|
24
29
|
function pickModelInfo(metadata, ctx, modelId) {
|
|
25
|
-
|
|
26
|
-
for (const c of candidates) {
|
|
27
|
-
if (c && typeof c === 'object' && isModelInfo(c) && c.id === modelId) {
|
|
28
|
-
return c;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return undefined;
|
|
30
|
+
return [metadata?.model, ctx?.metadata?.model].find((candidate) => !!candidate && isModelInfo(candidate) && candidate.id === modelId);
|
|
32
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Models used when neither the request, the context, nor the config names one.
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_MODELS = {
|
|
36
|
+
imageGenerate: 'gpt-image-2',
|
|
37
|
+
imageEdit: 'gpt-image-2',
|
|
38
|
+
imageAnalyze: 'gpt-5.4-mini',
|
|
39
|
+
transcription: 'gpt-transcribe',
|
|
40
|
+
speech: 'gpt-4o-mini-tts',
|
|
41
|
+
embedding: 'text-embedding-3-small',
|
|
42
|
+
decision: 'gpt-6-luna',
|
|
43
|
+
};
|
|
33
44
|
// ============================================================================
|
|
34
45
|
// OpenAI Provider Class
|
|
35
46
|
// ============================================================================
|
|
@@ -37,15 +48,14 @@ function pickModelInfo(metadata, ctx, modelId) {
|
|
|
37
48
|
* OpenAI provider implementation for the @aeye framework.
|
|
38
49
|
*
|
|
39
50
|
* Supports the full range of OpenAI capabilities including:
|
|
40
|
-
* - Chat
|
|
41
|
-
*
|
|
42
|
-
* -
|
|
43
|
-
* -
|
|
44
|
-
* -
|
|
45
|
-
* -
|
|
51
|
+
* - Chat through Chat Completions, or the Responses API when a model or
|
|
52
|
+
* request needs it (hosted tools, reasoning summaries or reasoning with
|
|
53
|
+
* tools, Responses-only models)
|
|
54
|
+
* - Function calling and structured outputs, streaming, and reasoning
|
|
55
|
+
* - Image, audio, and file input; audio output
|
|
56
|
+
* - Image generation and editing with GPT Image
|
|
57
|
+
* - Transcription (with speaker diarization) and speech synthesis
|
|
46
58
|
* - Text embeddings
|
|
47
|
-
* - Function calling and structured outputs
|
|
48
|
-
* - Streaming responses
|
|
49
59
|
*
|
|
50
60
|
* Can be extended by other OpenAI-compatible providers (e.g., Azure OpenAI, OpenRouter).
|
|
51
61
|
*
|
|
@@ -67,7 +77,7 @@ function pickModelInfo(metadata, ctx, modelId) {
|
|
|
67
77
|
* ]
|
|
68
78
|
* },
|
|
69
79
|
* {},
|
|
70
|
-
* { model: 'gpt-4' }
|
|
80
|
+
* { model: 'gpt-5.4-mini' }
|
|
71
81
|
* );
|
|
72
82
|
* ```
|
|
73
83
|
*
|
|
@@ -92,7 +102,7 @@ function pickModelInfo(metadata, ctx, modelId) {
|
|
|
92
102
|
* toolChoice: 'auto',
|
|
93
103
|
* },
|
|
94
104
|
* {},
|
|
95
|
-
* { model: 'gpt-4' }
|
|
105
|
+
* { model: 'gpt-5.4-mini' }
|
|
96
106
|
* );
|
|
97
107
|
* ```
|
|
98
108
|
*
|
|
@@ -111,59 +121,7 @@ function pickModelInfo(metadata, ctx, modelId) {
|
|
|
111
121
|
* ```
|
|
112
122
|
*
|
|
113
123
|
* @template TConfig Configuration type extending OpenAIConfig
|
|
114
|
-
*
|
|
115
|
-
* Subclasses can optionally override:
|
|
116
|
-
* - `listModels(config)`: Custom model listing logic
|
|
117
|
-
* - `createClient(config)`: Customize client creation
|
|
118
|
-
* - `convertModel(model)`: Customize model conversion
|
|
119
|
-
* - `customizeImageParams(params, config)`: Modify image params
|
|
120
|
-
* - `customizeTranscriptionParams(params, config)`: Modify transcription params
|
|
121
|
-
* - `customizeSpeechParams(params, config)`: Modify speech params
|
|
122
|
-
* - `customizeEmbeddingParams(params, config)`: Modify embedding params
|
|
123
|
-
*/
|
|
124
|
-
/**
|
|
125
|
-
* Helper function to convert OpenAI-style usage to the new structured Usage format
|
|
126
124
|
*/
|
|
127
|
-
function convertOpenAIUsage(openaiUsage) {
|
|
128
|
-
const usage = {};
|
|
129
|
-
// Text tokens (default category)
|
|
130
|
-
// Note: prompt_tokens already includes cached tokens, so we don't subtract them
|
|
131
|
-
// They're just reported separately in the details for pricing purposes
|
|
132
|
-
const textInput = (openaiUsage.prompt_tokens || 0) -
|
|
133
|
-
(openaiUsage.prompt_tokens_details?.audio_tokens || 0);
|
|
134
|
-
const textOutput = (openaiUsage.completion_tokens || 0) -
|
|
135
|
-
(openaiUsage.completion_tokens_details?.reasoning_tokens || 0) -
|
|
136
|
-
(openaiUsage.completion_tokens_details?.audio_tokens || 0);
|
|
137
|
-
if (textInput > 0 || textOutput > 0 || openaiUsage.prompt_tokens_details?.cached_tokens) {
|
|
138
|
-
usage.text = {};
|
|
139
|
-
// Only include non-cached input tokens
|
|
140
|
-
const nonCachedInput = textInput - (openaiUsage.prompt_tokens_details?.cached_tokens || 0);
|
|
141
|
-
if (nonCachedInput > 0)
|
|
142
|
-
usage.text.input = nonCachedInput;
|
|
143
|
-
if (textOutput > 0)
|
|
144
|
-
usage.text.output = textOutput;
|
|
145
|
-
if (openaiUsage.prompt_tokens_details?.cached_tokens) {
|
|
146
|
-
usage.text.cached = openaiUsage.prompt_tokens_details.cached_tokens;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
// Reasoning tokens
|
|
150
|
-
if (openaiUsage.completion_tokens_details?.reasoning_tokens) {
|
|
151
|
-
usage.reasoning = {
|
|
152
|
-
output: openaiUsage.completion_tokens_details.reasoning_tokens
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
// Audio tokens
|
|
156
|
-
const audioInput = openaiUsage.prompt_tokens_details?.audio_tokens;
|
|
157
|
-
const audioOutput = openaiUsage.completion_tokens_details?.audio_tokens;
|
|
158
|
-
if (audioInput || audioOutput) {
|
|
159
|
-
usage.audio = {};
|
|
160
|
-
if (audioInput)
|
|
161
|
-
usage.audio.input = audioInput;
|
|
162
|
-
if (audioOutput)
|
|
163
|
-
usage.audio.output = audioOutput;
|
|
164
|
-
}
|
|
165
|
-
return usage;
|
|
166
|
-
}
|
|
167
125
|
export class OpenAIProvider {
|
|
168
126
|
// Provider name for error messages and identification
|
|
169
127
|
name = 'openai';
|
|
@@ -171,6 +129,8 @@ export class OpenAIProvider {
|
|
|
171
129
|
config;
|
|
172
130
|
// Default metadata to apply to all requests
|
|
173
131
|
defaultMetadata;
|
|
132
|
+
// Models used when neither the request, the context, nor the config names one
|
|
133
|
+
defaults = DEFAULT_MODELS;
|
|
174
134
|
constructor(config) {
|
|
175
135
|
this.config = config;
|
|
176
136
|
}
|
|
@@ -185,16 +145,6 @@ export class OpenAIProvider {
|
|
|
185
145
|
*
|
|
186
146
|
* @param config Provider configuration
|
|
187
147
|
* @returns Configured OpenAI client instance
|
|
188
|
-
*
|
|
189
|
-
* @example
|
|
190
|
-
* ```typescript
|
|
191
|
-
* protected createClient(config: OpenAIConfig): OpenAI {
|
|
192
|
-
* return new OpenAI({
|
|
193
|
-
* apiKey: config.apiKey,
|
|
194
|
-
* baseURL: 'https://custom-endpoint.example.com/v1',
|
|
195
|
-
* });
|
|
196
|
-
* }
|
|
197
|
-
* ```
|
|
198
148
|
*/
|
|
199
149
|
createClient(config) {
|
|
200
150
|
return new OpenAI({
|
|
@@ -214,13 +164,12 @@ export class OpenAIProvider {
|
|
|
214
164
|
* @returns Standardized ModelInfo object
|
|
215
165
|
*/
|
|
216
166
|
convertModel(model) {
|
|
217
|
-
const tier = detectTier(model.id);
|
|
218
167
|
return {
|
|
219
168
|
provider: this.name,
|
|
220
169
|
id: model.id,
|
|
221
170
|
name: model.id,
|
|
222
171
|
capabilities: new Set(['chat', 'streaming']), // Minimal default, enriched by model sources
|
|
223
|
-
tier,
|
|
172
|
+
tier: detectTier(model.id),
|
|
224
173
|
pricing: {},
|
|
225
174
|
contextWindow: 0, // Will be enriched by model sources
|
|
226
175
|
maxOutputTokens: undefined,
|
|
@@ -230,59 +179,6 @@ export class OpenAIProvider {
|
|
|
230
179
|
},
|
|
231
180
|
};
|
|
232
181
|
}
|
|
233
|
-
// ============================================================================
|
|
234
|
-
// Optional Override Methods for Request Customization
|
|
235
|
-
// ============================================================================
|
|
236
|
-
/**
|
|
237
|
-
* Customize image generation params before sending to API.
|
|
238
|
-
*
|
|
239
|
-
* @param params Image generation parameters
|
|
240
|
-
* @param config Provider configuration
|
|
241
|
-
* @returns Modified parameters
|
|
242
|
-
*/
|
|
243
|
-
augmentImageGenerateRequest(params, request, config) {
|
|
244
|
-
// No-op by default
|
|
245
|
-
}
|
|
246
|
-
/**
|
|
247
|
-
* Customize image edit params before sending to API.
|
|
248
|
-
*
|
|
249
|
-
* @param params Image generation parameters
|
|
250
|
-
* @param config Provider configuration
|
|
251
|
-
* @returns Modified parameters
|
|
252
|
-
*/
|
|
253
|
-
augmentImageEditRequest(params, request, config) {
|
|
254
|
-
// No-op by default
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* Customize transcription params before sending to API.
|
|
258
|
-
*
|
|
259
|
-
* @param params Transcription parameters
|
|
260
|
-
* @param config Provider configuration
|
|
261
|
-
* @returns Modified parameters
|
|
262
|
-
*/
|
|
263
|
-
augmentTranscriptionRequest(params, request, config) {
|
|
264
|
-
// No-op by default
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* Customize speech generation params before sending to API.
|
|
268
|
-
*
|
|
269
|
-
* @param params Speech synthesis parameters
|
|
270
|
-
* @param config Provider configuration
|
|
271
|
-
* @returns Modified parameters
|
|
272
|
-
*/
|
|
273
|
-
augmentSpeechRequest(params, request, config) {
|
|
274
|
-
// No-op by default
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Customize embedding params before sending to API.
|
|
278
|
-
*
|
|
279
|
-
* @param params Embedding parameters
|
|
280
|
-
* @param config Provider configuration
|
|
281
|
-
* @returns Modified parameters
|
|
282
|
-
*/
|
|
283
|
-
augmentEmbeddingRequest(params, request, config) {
|
|
284
|
-
// No-op by default
|
|
285
|
-
}
|
|
286
182
|
/**
|
|
287
183
|
* Customize chat completion params before sending to API.
|
|
288
184
|
*
|
|
@@ -290,13 +186,21 @@ export class OpenAIProvider {
|
|
|
290
186
|
* or to add provider-specific features.
|
|
291
187
|
*
|
|
292
188
|
* @param params OpenAI chat completion parameters
|
|
293
|
-
* @param config Provider configuration
|
|
294
189
|
* @param request Original @aeye request
|
|
295
|
-
* @
|
|
190
|
+
* @param config Provider configuration
|
|
296
191
|
*/
|
|
297
192
|
augmentChatRequest(params, request, config) {
|
|
298
193
|
// No-op by default
|
|
299
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Extra HTTP headers for a chat completion request, such as a router's
|
|
197
|
+
* feature flags. None by default.
|
|
198
|
+
*
|
|
199
|
+
* @param params The params about to be sent
|
|
200
|
+
*/
|
|
201
|
+
chatHeaders(params) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
300
204
|
/**
|
|
301
205
|
* Augment chat message with provider-specific data.
|
|
302
206
|
*
|
|
@@ -309,11 +213,6 @@ export class OpenAIProvider {
|
|
|
309
213
|
}
|
|
310
214
|
/**
|
|
311
215
|
* Get augmented chat message with provider-specific data.
|
|
312
|
-
*
|
|
313
|
-
* @param chatMessage - OpenAI chat message
|
|
314
|
-
* @param message - Original @aeye message
|
|
315
|
-
* @param config - Provider configuration
|
|
316
|
-
* @returns
|
|
317
216
|
*/
|
|
318
217
|
getChatMessage(chatMessage, message, config) {
|
|
319
218
|
this.augmentChatMessage(chatMessage, message, config);
|
|
@@ -330,22 +229,20 @@ export class OpenAIProvider {
|
|
|
330
229
|
// No-op by default
|
|
331
230
|
}
|
|
332
231
|
/**
|
|
333
|
-
* Get augmented chat content part with provider-specific data.
|
|
334
|
-
*
|
|
335
|
-
* @param chatContent - OpenAI chat content part
|
|
336
|
-
* @param content - Original @aeye message content
|
|
337
|
-
* @param config - Provider configuration
|
|
338
|
-
* @returns
|
|
232
|
+
* Get augmented chat content part with provider-specific data. A cache
|
|
233
|
+
* breakpoint on the content marks the part.
|
|
339
234
|
*/
|
|
340
235
|
getChatContent(chatContent, content, config) {
|
|
341
|
-
|
|
342
|
-
|
|
236
|
+
const part = content.cacheBreakpoint ? { ...chatContent, prompt_cache_breakpoint: {} } : chatContent;
|
|
237
|
+
this.augmentChatContent(part, content, config);
|
|
238
|
+
return part;
|
|
343
239
|
}
|
|
344
240
|
/**
|
|
345
241
|
* Augment chat chunk with provider-specific data.
|
|
346
242
|
*
|
|
347
|
-
* @param expected
|
|
348
|
-
* @param chunk
|
|
243
|
+
* @param expected - The chunk as received
|
|
244
|
+
* @param chunk - @aeye chunk to augment
|
|
245
|
+
* @param config - Provider configuration
|
|
349
246
|
*/
|
|
350
247
|
augmentChatChunk(expected, chunk, config) {
|
|
351
248
|
// No-op by default
|
|
@@ -353,9 +250,9 @@ export class OpenAIProvider {
|
|
|
353
250
|
/**
|
|
354
251
|
* Augment chat response with provider-specific data.
|
|
355
252
|
*
|
|
356
|
-
* @param
|
|
357
|
-
* @param response @aeye response object to augment
|
|
358
|
-
* @
|
|
253
|
+
* @param expected - The completion as received
|
|
254
|
+
* @param response - @aeye response object to augment
|
|
255
|
+
* @param config - Provider configuration
|
|
359
256
|
*/
|
|
360
257
|
augmentChatResponse(expected, response, config) {
|
|
361
258
|
// No-op by default
|
|
@@ -364,113 +261,76 @@ export class OpenAIProvider {
|
|
|
364
261
|
// Message & Request Conversion (Protected - Reusable)
|
|
365
262
|
// ============================================================================
|
|
366
263
|
/**
|
|
367
|
-
* Convert @aeye MessageContent to OpenAI text content format
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
* @
|
|
264
|
+
* Convert @aeye MessageContent to OpenAI text content format, for roles
|
|
265
|
+
* that only accept text.
|
|
266
|
+
*
|
|
267
|
+
* @param x - Message content
|
|
268
|
+
* @param name - Who sent it, for placeholder text
|
|
269
|
+
* @param config - Provider configuration
|
|
371
270
|
*/
|
|
372
271
|
convertContentText(x, name, config) {
|
|
373
272
|
if (typeof x === 'string') {
|
|
374
273
|
return [{ type: 'text', text: x }];
|
|
375
274
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
return this.getChatContent({ type: 'text', text: String(part.content) }, part, config);
|
|
383
|
-
}
|
|
384
|
-
else if (part.type === 'image') {
|
|
385
|
-
return this.getChatContent({ type: 'text', text: `Image sent by ${name} not included in content.` }, part, config);
|
|
386
|
-
}
|
|
387
|
-
else if (part.type === 'file') {
|
|
388
|
-
return this.getChatContent({ type: 'text', text: `File sent by ${name} not included in content.` }, part, config);
|
|
389
|
-
}
|
|
390
|
-
else if (part.type === 'audio') {
|
|
391
|
-
return this.getChatContent({ type: 'text', text: `Audio sent by ${name} not included in content.` }, part, config);
|
|
392
|
-
}
|
|
393
|
-
return this.getChatContent({ type: 'text', text: `Unsupported content type from ${name}.` }, part, config);
|
|
394
|
-
});
|
|
275
|
+
return x.map((part) => this.getChatContent({
|
|
276
|
+
type: 'text',
|
|
277
|
+
text: part.type === 'text'
|
|
278
|
+
? String(part.content)
|
|
279
|
+
: `${part.type[0].toUpperCase()}${part.type.slice(1)} sent by ${name} not included in content.`,
|
|
280
|
+
}, part, config));
|
|
395
281
|
}
|
|
396
282
|
/**
|
|
397
283
|
* Convert @aeye MessageContent to OpenAI format.
|
|
398
|
-
*
|
|
399
|
-
* @param
|
|
400
|
-
* @
|
|
284
|
+
*
|
|
285
|
+
* @param x - Message content
|
|
286
|
+
* @param name - Who sent it, for placeholder text
|
|
287
|
+
* @param config - Provider configuration
|
|
401
288
|
*/
|
|
402
289
|
async convertContent(x, name, config) {
|
|
403
290
|
if (typeof x === 'string') {
|
|
404
291
|
return [{ type: 'text', text: x }];
|
|
405
292
|
}
|
|
406
293
|
return Promise.all(x.map(async (part) => {
|
|
294
|
+
const note = (text) => this.getChatContent({ type: 'text', text }, part, config);
|
|
407
295
|
switch (part.type) {
|
|
408
296
|
case 'text': {
|
|
409
297
|
const text = await toText(part.content).catch(() => `Text content sent by ${name} could not be converted to text.`);
|
|
410
|
-
return
|
|
411
|
-
type: 'text',
|
|
412
|
-
text,
|
|
413
|
-
}, part, config);
|
|
298
|
+
return note(text);
|
|
414
299
|
}
|
|
415
300
|
case 'image': {
|
|
416
301
|
const url = await toURL(part.content, part.format, 'INVALID').catch(() => 'INVALID');
|
|
417
302
|
if (url === 'INVALID') {
|
|
418
|
-
return
|
|
419
|
-
type: 'text',
|
|
420
|
-
text: `Image content sent by ${name} could not be converted to a URL.`,
|
|
421
|
-
}, part, config);
|
|
303
|
+
return note(`Image content sent by ${name} could not be converted to a URL.`);
|
|
422
304
|
}
|
|
423
|
-
return this.getChatContent({
|
|
424
|
-
type: 'image_url',
|
|
425
|
-
image_url: { url },
|
|
426
|
-
}, part, config);
|
|
305
|
+
return this.getChatContent({ type: 'image_url', image_url: { url, detail: part.detail } }, part, config);
|
|
427
306
|
}
|
|
428
307
|
case 'audio': {
|
|
429
|
-
// Convert audio content to base64 data
|
|
430
308
|
const base64 = await toBase64(part.content, undefined, 'INVALID').catch(() => 'INVALID');
|
|
431
309
|
if (base64 === 'INVALID') {
|
|
432
|
-
return
|
|
433
|
-
type: 'text',
|
|
434
|
-
text: `Audio content sent by ${name} could not be converted to base64.`,
|
|
435
|
-
}, part, config);
|
|
310
|
+
return note(`Audio content sent by ${name} could not be converted to base64.`);
|
|
436
311
|
}
|
|
437
|
-
const [, mimeType, data] = /^data:([^;]+);base64,(.+)$/.exec(base64)
|
|
438
|
-
const format = part.format
|
|
439
|
-
return this.getChatContent({
|
|
440
|
-
type: 'input_audio',
|
|
441
|
-
input_audio: { data, format },
|
|
442
|
-
}, part, config);
|
|
312
|
+
const [, mimeType = '', data = ''] = /^data:([^;]+);base64,(.+)$/.exec(base64) ?? [];
|
|
313
|
+
const format = (part.format ?? mimeType).includes('wav') ? 'wav' : 'mp3';
|
|
314
|
+
return this.getChatContent({ type: 'input_audio', input_audio: { data, format } }, part, config);
|
|
443
315
|
}
|
|
444
316
|
case 'file': {
|
|
445
|
-
// Convert file content to base64 data (file IDs not supported)
|
|
446
317
|
const fileData = await toBase64(part.content, part.format, 'INVALID').catch(() => 'INVALID');
|
|
447
318
|
if (fileData === 'INVALID') {
|
|
448
|
-
return
|
|
449
|
-
type: 'text',
|
|
450
|
-
text: `File content sent by ${name} could not be converted to base64.`,
|
|
451
|
-
}, part, config);
|
|
319
|
+
return note(`File content sent by ${name} could not be converted to base64.`);
|
|
452
320
|
}
|
|
453
321
|
return this.getChatContent({
|
|
454
322
|
type: 'file',
|
|
455
|
-
file: {
|
|
456
|
-
file_data: fileData,
|
|
457
|
-
// TODO add filename
|
|
458
|
-
filename: 'file' + (part.format ? `.${part.format}` : ''),
|
|
459
|
-
},
|
|
323
|
+
file: { file_data: fileData, filename: part.name ?? `file${part.format ? `.${part.format}` : ''}` },
|
|
460
324
|
}, part, config);
|
|
461
325
|
}
|
|
462
326
|
default:
|
|
463
|
-
return
|
|
464
|
-
type: 'text',
|
|
465
|
-
text: `Unsupported content type from ${name}.`,
|
|
466
|
-
}, part, config);
|
|
327
|
+
return note(`${part.type} content sent by ${name} is not supported.`);
|
|
467
328
|
}
|
|
468
329
|
}));
|
|
469
330
|
}
|
|
470
331
|
/**
|
|
471
332
|
* Convert @aeye ToolCall to OpenAI format.
|
|
472
333
|
* @param x ToolCall object
|
|
473
|
-
* @returns
|
|
474
334
|
*/
|
|
475
335
|
convertToolCall(x) {
|
|
476
336
|
return {
|
|
@@ -498,67 +358,85 @@ export class OpenAIProvider {
|
|
|
498
358
|
.substring(0, 64);
|
|
499
359
|
}
|
|
500
360
|
/**
|
|
501
|
-
* Convert @aeye Request messages to
|
|
361
|
+
* Convert @aeye Request messages to Chat Completions messages.
|
|
502
362
|
*
|
|
503
363
|
* Handles conversion of all message types (system, user, assistant, tool)
|
|
504
364
|
* and content types (text, image, audio, file).
|
|
505
365
|
*
|
|
506
366
|
* @param request @aeye request object
|
|
367
|
+
* @param config Provider configuration
|
|
507
368
|
* @returns Array of OpenAI-formatted messages
|
|
508
369
|
*/
|
|
509
370
|
async convertMessages(request, config) {
|
|
510
371
|
return (await Promise.all(request.messages.map(async (msg) => {
|
|
511
372
|
switch (msg.role) {
|
|
512
373
|
case 'system':
|
|
513
|
-
return this.getChatMessage({
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
374
|
+
return [this.getChatMessage({
|
|
375
|
+
role: 'developer',
|
|
376
|
+
name: this.convertName(msg.name),
|
|
377
|
+
content: this.convertContentText(msg.content, msg.name || 'system', config),
|
|
378
|
+
}, msg, config)];
|
|
518
379
|
case 'tool':
|
|
519
|
-
return this.getChatMessage({
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
case '
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
return this.getChatMessage({
|
|
528
|
-
role: msg.role,
|
|
380
|
+
return [this.getChatMessage({
|
|
381
|
+
role: 'tool',
|
|
382
|
+
tool_call_id: msg.toolCallId ?? '',
|
|
383
|
+
content: this.convertContentText(msg.content, msg.name || 'tool', config),
|
|
384
|
+
}, msg, config)];
|
|
385
|
+
case 'user':
|
|
386
|
+
return [this.getChatMessage({
|
|
387
|
+
role: 'user',
|
|
529
388
|
name: this.convertName(msg.name),
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
return
|
|
539
|
-
if (contentPart.type === 'text') {
|
|
540
|
-
return this.getChatMessage({
|
|
541
|
-
role: 'assistant',
|
|
542
|
-
name: this.convertName(msg.name),
|
|
543
|
-
tool_calls: index === firstText ? msg.toolCalls?.map((tc) => this.convertToolCall(tc)) : undefined,
|
|
544
|
-
content: [contentPart],
|
|
545
|
-
}, msg, config);
|
|
546
|
-
}
|
|
547
|
-
else {
|
|
548
|
-
return this.getChatMessage({
|
|
549
|
-
role: 'user',
|
|
550
|
-
name: this.convertName(msg.name),
|
|
551
|
-
content: [contentPart],
|
|
552
|
-
}, msg, config);
|
|
553
|
-
}
|
|
554
|
-
});
|
|
389
|
+
content: await this.convertContent(msg.content, msg.name || 'user', config),
|
|
390
|
+
}, msg, config)];
|
|
391
|
+
case 'assistant': {
|
|
392
|
+
const name = this.convertName(msg.name);
|
|
393
|
+
const toolCalls = msg.toolCalls?.map((tc) => this.convertToolCall(tc));
|
|
394
|
+
// Some upstreams reject empty text, so a turn of only tool calls has no content
|
|
395
|
+
const content = (await this.convertContent(msg.content, msg.name || 'assistant', config)).filter(c => c.type !== 'text' || c.text);
|
|
396
|
+
if (content.every(c => c.type === 'text')) {
|
|
397
|
+
return [this.getChatMessage({ role: 'assistant', name, tool_calls: toolCalls, content: content.length ? content : toolCalls?.length ? null : '' }, msg, config)];
|
|
555
398
|
}
|
|
399
|
+
// The assistant role only takes text, so non-text parts are sent
|
|
400
|
+
// as user messages in place
|
|
401
|
+
const firstText = content.findIndex(c => c.type === 'text');
|
|
402
|
+
return content.map((part, index) => part.type === 'text'
|
|
403
|
+
? this.getChatMessage({ role: 'assistant', name, tool_calls: index === firstText ? toolCalls : undefined, content: [part] }, msg, config)
|
|
404
|
+
: this.getChatMessage({ role: 'user', name, content: [part] }, msg, config));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}))).flat();
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Convert @aeye Request messages to Responses API input items. Assistant
|
|
411
|
+
* turns replay their Responses reasoning so stateless requests keep it, and
|
|
412
|
+
* tool results may carry images and files.
|
|
413
|
+
*
|
|
414
|
+
* @param request @aeye request object
|
|
415
|
+
* @param config Provider configuration
|
|
416
|
+
*/
|
|
417
|
+
async convertResponsesInput(request, config) {
|
|
418
|
+
const content = async (msg) => (await this.convertContent(msg.content, msg.name || msg.role, config)).map(toInputContent);
|
|
419
|
+
// Calls to free-text tools replay as custom tool calls
|
|
420
|
+
const textTools = new Set((request.tools ?? []).flatMap(tool => tool.format ? [tool.name] : []));
|
|
421
|
+
const textCalls = new Set(request.messages.flatMap(msg => msg.toolCalls ?? []).flatMap(tc => textTools.has(tc.name) ? [tc.id] : []));
|
|
422
|
+
return (await Promise.all(request.messages.map(async (msg) => {
|
|
423
|
+
switch (msg.role) {
|
|
424
|
+
case 'system':
|
|
425
|
+
return [{ role: 'developer', content: await content(msg) }];
|
|
556
426
|
case 'user':
|
|
557
|
-
return
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
427
|
+
return [{ role: 'user', content: await content(msg) }];
|
|
428
|
+
case 'tool': {
|
|
429
|
+
const call_id = msg.toolCallId ?? '';
|
|
430
|
+
return [{ type: textCalls.has(call_id) ? 'custom_tool_call_output' : 'function_call_output', call_id, output: await content(msg) }];
|
|
431
|
+
}
|
|
432
|
+
case 'assistant': {
|
|
433
|
+
const text = this.convertContentText(msg.content, msg.name || 'assistant', config).map(part => part.text).join('');
|
|
434
|
+
const message = text ? [{ role: 'assistant', content: text }] : [];
|
|
435
|
+
const calls = (msg.toolCalls ?? []).map((tc) => textCalls.has(tc.id)
|
|
436
|
+
? { type: 'custom_tool_call', call_id: tc.id, name: tc.name, input: textInput(tc.arguments) }
|
|
437
|
+
: { type: 'function_call', call_id: tc.id, name: tc.name, arguments: tc.arguments });
|
|
438
|
+
return [...toReasoningItems(msg.reasoning, this.name), ...message, ...calls];
|
|
439
|
+
}
|
|
562
440
|
}
|
|
563
441
|
}))).flat();
|
|
564
442
|
}
|
|
@@ -578,14 +456,12 @@ export class OpenAIProvider {
|
|
|
578
456
|
*
|
|
579
457
|
* @param request @aeye request (mutated: each tool's `descriptor` field is
|
|
580
458
|
* pinned for the validation roundtrip)
|
|
581
|
-
* @param model the resolved model the request is targeting
|
|
582
459
|
* @param budget shared allocator (also fed by `convertResponseFormat`)
|
|
583
460
|
*/
|
|
584
|
-
convertTools(request,
|
|
461
|
+
convertTools(request, budget) {
|
|
585
462
|
if (!request.tools || request.tools.length === 0)
|
|
586
463
|
return undefined;
|
|
587
|
-
const
|
|
588
|
-
const descriptors = this.allocateToolDescriptors(request, localBudget);
|
|
464
|
+
const descriptors = allocateToolDescriptors(request.tools, budget);
|
|
589
465
|
return request.tools.map((tool, i) => {
|
|
590
466
|
const descriptor = descriptors[i];
|
|
591
467
|
const strictifiedSchema = strictify(tool.parameters, descriptor);
|
|
@@ -593,16 +469,15 @@ export class OpenAIProvider {
|
|
|
593
469
|
// strict-mode-compatible schema. Setting strict:true with a schema
|
|
594
470
|
// that violates OpenAI's strict-mode rules is the failure mode we
|
|
595
471
|
// are trying to avoid.
|
|
596
|
-
|
|
472
|
+
return {
|
|
597
473
|
type: 'function',
|
|
598
474
|
function: {
|
|
599
475
|
name: tool.name,
|
|
600
|
-
description: tool
|
|
476
|
+
description: describeTool(tool),
|
|
601
477
|
parameters: toJSONSchema(strictifiedSchema, descriptor),
|
|
602
478
|
...(descriptor.strict ? { strict: true } : {}),
|
|
603
479
|
},
|
|
604
480
|
};
|
|
605
|
-
return result;
|
|
606
481
|
});
|
|
607
482
|
}
|
|
608
483
|
/**
|
|
@@ -614,70 +489,29 @@ export class OpenAIProvider {
|
|
|
614
489
|
*/
|
|
615
490
|
supportedStrictFamilies = new Set(['openai']);
|
|
616
491
|
/**
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
* across BOTH tool schemas and the response_format schema, so the budget
|
|
620
|
-
* is shared between `convertTools` and `convertResponseFormat`.
|
|
621
|
-
*
|
|
622
|
-
* Strict mode engages only when the model's resolved family is in
|
|
623
|
-
* `supportedStrictFamilies` AND the model has the corresponding
|
|
624
|
-
* capability (`'toolsStrict'` for tools, `'structured'` for output).
|
|
625
|
-
* Mismatched families fall back to LENIENT silently — protects the
|
|
626
|
-
* wire from receiving a dialect the API doesn't understand.
|
|
627
|
-
*/
|
|
628
|
-
buildSchemaBudget(model) {
|
|
629
|
-
const family = model ? resolveStrictFormat(model) : undefined;
|
|
630
|
-
const familySupported = family !== undefined && this.supportedStrictFamilies.has(family);
|
|
631
|
-
const tools = familySupported && model.capabilities.has('toolsStrict')
|
|
632
|
-
? getDescriptor(family, true)
|
|
633
|
-
: LENIENT;
|
|
634
|
-
const out = familySupported && model.capabilities.has('structured')
|
|
635
|
-
? getDescriptor(family, true)
|
|
636
|
-
: LENIENT;
|
|
637
|
-
return new SchemaBudget(strictestOf(tools, out));
|
|
638
|
-
}
|
|
639
|
-
/**
|
|
640
|
-
* Allocate per-tool descriptors in descending-priority order, then return
|
|
641
|
-
* them in original declaration order. Mutates `request.tools[i].descriptor`
|
|
642
|
-
* to record the chosen id for the Prompt's validation roundtrip.
|
|
492
|
+
* The schema budget for a call: from the selected model, or from the model
|
|
493
|
+
* id alone.
|
|
643
494
|
*/
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const order = tools
|
|
647
|
-
.map((t, i) => ({ i, p: strictPriority(t.strict) }))
|
|
648
|
-
.sort((a, b) => b.p - a.p);
|
|
649
|
-
const descriptors = new Array(tools.length);
|
|
650
|
-
for (const { i } of order) {
|
|
651
|
-
const tool = tools[i];
|
|
652
|
-
descriptors[i] = budget.allocateTool(tool.parameters, tool.strict);
|
|
653
|
-
tool.descriptor = descriptors[i].id;
|
|
654
|
-
}
|
|
655
|
-
return descriptors;
|
|
495
|
+
schemaBudget(run) {
|
|
496
|
+
return buildSchemaBudget(run.modelInfo ?? { id: run.model.id, provider: this.name }, this.supportedStrictFamilies);
|
|
656
497
|
}
|
|
657
498
|
/**
|
|
658
499
|
* Convert @aeye tool choice to OpenAI format.
|
|
659
500
|
*
|
|
660
|
-
* Supports auto, required, none, and specific tool selection.
|
|
661
|
-
*
|
|
662
501
|
* @param request @aeye request object
|
|
663
502
|
* @returns OpenAI-formatted tool choice option or undefined
|
|
664
503
|
*/
|
|
665
504
|
convertToolChoice(request) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
return 'auto';
|
|
670
|
-
if (request.toolChoice === 'required')
|
|
671
|
-
return 'required';
|
|
672
|
-
if (request.toolChoice === 'none')
|
|
673
|
-
return 'none';
|
|
674
|
-
if (typeof request.toolChoice === 'object') {
|
|
675
|
-
return {
|
|
676
|
-
type: 'function',
|
|
677
|
-
function: { name: request.toolChoice.tool },
|
|
678
|
-
};
|
|
505
|
+
const choice = request.toolChoice;
|
|
506
|
+
if (typeof choice !== 'object') {
|
|
507
|
+
return choice;
|
|
679
508
|
}
|
|
680
|
-
return
|
|
509
|
+
return 'tool' in choice
|
|
510
|
+
? { type: 'function', function: { name: choice.tool } }
|
|
511
|
+
: {
|
|
512
|
+
type: 'allowed_tools',
|
|
513
|
+
allowed_tools: { mode: choice.required ? 'required' : 'auto', tools: choice.allowed.map(name => ({ type: 'function', function: { name } })) },
|
|
514
|
+
};
|
|
681
515
|
}
|
|
682
516
|
/**
|
|
683
517
|
* Convert @aeye response format to OpenAI format.
|
|
@@ -691,33 +525,27 @@ export class OpenAIProvider {
|
|
|
691
525
|
* matching strictify before parsing model output.
|
|
692
526
|
*
|
|
693
527
|
* @param request @aeye request object (mutated to record descriptor)
|
|
694
|
-
* @param
|
|
528
|
+
* @param budget shared allocator (also fed by `convertTools`)
|
|
695
529
|
* @returns OpenAI-formatted response format or undefined
|
|
696
530
|
*/
|
|
697
|
-
convertResponseFormat(request,
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (typeof request.responseFormat === 'string') {
|
|
701
|
-
if (request.responseFormat === 'text') {
|
|
702
|
-
return { type: 'text' };
|
|
703
|
-
}
|
|
704
|
-
if (request.responseFormat === 'json') {
|
|
705
|
-
return { type: 'json_object' };
|
|
706
|
-
}
|
|
531
|
+
convertResponseFormat(request, budget) {
|
|
532
|
+
const format = request.responseFormat;
|
|
533
|
+
if (!format)
|
|
707
534
|
return undefined;
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
535
|
+
if (format === 'text')
|
|
536
|
+
return { type: 'text' };
|
|
537
|
+
if (format === 'json')
|
|
538
|
+
return { type: 'json_object' };
|
|
539
|
+
const descriptor = budget.allocateOutput(format.type, format.strict ?? 1);
|
|
540
|
+
format.descriptor = descriptor.id;
|
|
541
|
+
return {
|
|
542
|
+
type: 'json_schema',
|
|
543
|
+
json_schema: {
|
|
544
|
+
name: 'response',
|
|
545
|
+
schema: toJSONSchema(strictify(format.type, descriptor), descriptor),
|
|
546
|
+
...(descriptor.strict ? { strict: true } : {}),
|
|
547
|
+
},
|
|
716
548
|
};
|
|
717
|
-
if (descriptor.strict) {
|
|
718
|
-
json_schema.strict = true;
|
|
719
|
-
}
|
|
720
|
-
return { type: 'json_schema', json_schema };
|
|
721
549
|
}
|
|
722
550
|
// ============================================================================
|
|
723
551
|
// Provider Interface Implementation
|
|
@@ -730,15 +558,13 @@ export class OpenAIProvider {
|
|
|
730
558
|
* @throws {ProviderError} If listing models fails
|
|
731
559
|
*/
|
|
732
560
|
async listModels(config) {
|
|
733
|
-
const
|
|
734
|
-
const client = this.createClient(effectiveConfig);
|
|
561
|
+
const client = this.createClient({ ...this.config, ...config });
|
|
735
562
|
try {
|
|
736
563
|
const response = await client.models.list();
|
|
737
|
-
|
|
738
|
-
return models.map((m) => this.convertModel(m));
|
|
564
|
+
return response.data.map((m) => this.convertModel(m));
|
|
739
565
|
}
|
|
740
566
|
catch (error) {
|
|
741
|
-
throw new ProviderError(this.name, 'Failed to list models', error);
|
|
567
|
+
throw new ProviderError(this.name, 'Failed to list models', error instanceof Error ? error : undefined);
|
|
742
568
|
}
|
|
743
569
|
}
|
|
744
570
|
/**
|
|
@@ -749,9 +575,7 @@ export class OpenAIProvider {
|
|
|
749
575
|
*/
|
|
750
576
|
async checkHealth(config) {
|
|
751
577
|
try {
|
|
752
|
-
|
|
753
|
-
const client = this.createClient(effectiveConfig);
|
|
754
|
-
await client.models.list();
|
|
578
|
+
await this.createClient({ ...this.config, ...config }).models.list();
|
|
755
579
|
return true;
|
|
756
580
|
}
|
|
757
581
|
catch {
|
|
@@ -759,20 +583,26 @@ export class OpenAIProvider {
|
|
|
759
583
|
}
|
|
760
584
|
}
|
|
761
585
|
/**
|
|
762
|
-
*
|
|
586
|
+
* Resolve what one API call needs: the effective config and its client,
|
|
587
|
+
* the model (request, then context, then metadata, then configured and
|
|
588
|
+
* built-in defaults), and retries.
|
|
763
589
|
*
|
|
590
|
+
* @param operation - Operation name for retry events and errors
|
|
764
591
|
* @param request - The request
|
|
765
592
|
* @param ctx - The context
|
|
766
|
-
* @param
|
|
767
|
-
* @param
|
|
768
|
-
* @
|
|
593
|
+
* @param metadata - Request metadata, for chat
|
|
594
|
+
* @param config - Per-call config override
|
|
595
|
+
* @param signal - Per-call abort signal
|
|
596
|
+
* @param defaultModel - Which configured default model applies
|
|
769
597
|
*/
|
|
770
|
-
|
|
771
|
-
const
|
|
598
|
+
prepare(operation, request, ctx, metadata, config, signal, defaultModel) {
|
|
599
|
+
const effectiveConfig = { ...this.config, ...config };
|
|
600
|
+
const model = getModel(request.model || ctx.metadata?.model || metadata?.model ||
|
|
601
|
+
effectiveConfig.defaultModels?.[defaultModel] || this.defaults[defaultModel]);
|
|
772
602
|
if (!model) {
|
|
773
603
|
throw new ProviderError(this.name, `Model is required for ${this.name} requests`);
|
|
774
604
|
}
|
|
775
|
-
const retryConfig = { ...effectiveConfig.retry, ...
|
|
605
|
+
const retryConfig = { ...effectiveConfig.retry, ...request.extra?.retry };
|
|
776
606
|
const retryEvents = request.extra?.retryEvents || effectiveConfig.retryEvents || {};
|
|
777
607
|
const retryContext = {
|
|
778
608
|
operation,
|
|
@@ -780,1200 +610,858 @@ export class OpenAIProvider {
|
|
|
780
610
|
provider: this.name,
|
|
781
611
|
startTime: Date.now(),
|
|
782
612
|
};
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
613
|
+
const effectiveSignal = signal || ctx.signal;
|
|
614
|
+
return {
|
|
615
|
+
config: effectiveConfig,
|
|
616
|
+
client: this.createClient(effectiveConfig),
|
|
617
|
+
ctx,
|
|
618
|
+
metadata,
|
|
619
|
+
model,
|
|
620
|
+
modelInfo: pickModelInfo(metadata, ctx, model.id),
|
|
621
|
+
signal: effectiveSignal,
|
|
622
|
+
retry: (call) => withRetry(() => call(effectiveSignal), retryContext, retryConfig, retryEvents, effectiveSignal),
|
|
786
623
|
};
|
|
787
|
-
return { model, retry, signal };
|
|
788
624
|
}
|
|
789
625
|
/**
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
* The executor handles non-streaming chat completions with support for:
|
|
793
|
-
* - Multi-turn conversations
|
|
794
|
-
* - Function calling
|
|
795
|
-
* - Structured outputs
|
|
796
|
-
* - Vision (images)
|
|
797
|
-
* - Audio inputs
|
|
798
|
-
*
|
|
799
|
-
* @param config Optional configuration override
|
|
800
|
-
* @returns Executor function for chat completions
|
|
801
|
-
* @throws {ProviderError} If request fails
|
|
802
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
626
|
+
* Send one API call with retries, around the operation's before and error
|
|
627
|
+
* hooks. A context-window rejection becomes a ContextWindowError.
|
|
803
628
|
*/
|
|
804
|
-
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
// ModelInfo is available (e.g. external callers bypassing BaseAPI).
|
|
813
|
-
const modelInfo = pickModelInfo(metadata, ctx, model.id);
|
|
814
|
-
// One budget shared across tools + structured output. Anthropic-style
|
|
815
|
-
// per-request limits apply across the whole request, so allocations
|
|
816
|
-
// need to cooperate rather than each hold their own counter.
|
|
817
|
-
const budget = this.buildSchemaBudget(modelInfo);
|
|
818
|
-
const messages = await this.convertMessages(request, effectiveConfig);
|
|
819
|
-
const tools = this.convertTools(request, modelInfo, budget);
|
|
820
|
-
const tool_choice = tools?.length ? this.convertToolChoice(request) : undefined;
|
|
821
|
-
const parallel_tool_calls = tools?.length ? !request.toolsOneAtATime : undefined;
|
|
822
|
-
const response_format = this.convertResponseFormat(request, modelInfo, budget);
|
|
823
|
-
const params = {
|
|
824
|
-
model: model.id,
|
|
825
|
-
messages,
|
|
826
|
-
temperature: request.temperature,
|
|
827
|
-
top_p: request.topP,
|
|
828
|
-
max_completion_tokens: request.maxTokens,
|
|
829
|
-
frequency_penalty: request.frequencyPenalty,
|
|
830
|
-
presence_penalty: request.presencePenalty,
|
|
831
|
-
logit_bias: request.logitBias,
|
|
832
|
-
logprobs: request.logProbabilities,
|
|
833
|
-
prompt_cache_key: request.cacheKey,
|
|
834
|
-
safety_identifier: request.userKey,
|
|
835
|
-
store: effectiveConfig.store ?? false,
|
|
836
|
-
stop: request.stop,
|
|
837
|
-
tools,
|
|
838
|
-
tool_choice,
|
|
839
|
-
parallel_tool_calls,
|
|
840
|
-
response_format,
|
|
841
|
-
reasoning_effort: request.reason?.effort,
|
|
842
|
-
...request.extra,
|
|
843
|
-
stream: false,
|
|
844
|
-
};
|
|
845
|
-
this.augmentChatRequest(params, request, effectiveConfig);
|
|
846
|
-
// Call pre-request hook with params
|
|
847
|
-
await effectiveConfig.hooks?.chat?.beforeRequest?.(request, params, ctx, metadata);
|
|
848
|
-
let completion;
|
|
849
|
-
try {
|
|
850
|
-
completion = await retry(async (signal) => {
|
|
851
|
-
const data = await client.chat.completions.create(params, { signal });
|
|
852
|
-
const choice = data.choices?.[0];
|
|
853
|
-
if (!choice) {
|
|
854
|
-
throw new ProviderError(this.name, 'No choices in response');
|
|
855
|
-
}
|
|
856
|
-
return data;
|
|
857
|
-
});
|
|
858
|
-
}
|
|
859
|
-
catch (e) {
|
|
860
|
-
if (isContextWindowError(e)) {
|
|
861
|
-
const details = parseContextWindowError(e);
|
|
862
|
-
const errorResponse = {
|
|
863
|
-
content: '',
|
|
864
|
-
toolCalls: [],
|
|
865
|
-
finishReason: 'length',
|
|
866
|
-
model,
|
|
867
|
-
usage: { text: { input: details?.contextWindow } },
|
|
868
|
-
};
|
|
869
|
-
// Call post-request hook even on error
|
|
870
|
-
await effectiveConfig.hooks?.chat?.afterRequest?.(request, params, errorResponse, ctx, metadata);
|
|
871
|
-
return errorResponse;
|
|
872
|
-
}
|
|
873
|
-
else {
|
|
874
|
-
await effectiveConfig.hooks?.chat?.onError?.(request, params, e, ctx, metadata);
|
|
875
|
-
}
|
|
629
|
+
async send(op, run, request, params, call) {
|
|
630
|
+
const hooks = run.config.hooks?.[op];
|
|
631
|
+
await hooks?.beforeRequest?.(request, params, run.ctx, run.metadata);
|
|
632
|
+
try {
|
|
633
|
+
return await run.retry(call);
|
|
634
|
+
}
|
|
635
|
+
catch (e) {
|
|
636
|
+
if (!(e instanceof Error)) {
|
|
876
637
|
throw e;
|
|
877
638
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
arguments: tc.function.arguments,
|
|
885
|
-
}));
|
|
886
|
-
const response = {
|
|
887
|
-
content: choice.message.content || '',
|
|
888
|
-
toolCalls,
|
|
889
|
-
finishReason: choice.finish_reason === 'function_call' /* deprecated */ ? 'stop' : toolCalls?.length ? 'tool_calls' : choice.finish_reason,
|
|
890
|
-
refusal: choice.finish_reason === 'content_filter' ? choice.message.content || undefined : undefined,
|
|
891
|
-
model,
|
|
892
|
-
usage: completion.usage ? convertOpenAIUsage(completion.usage) : undefined,
|
|
893
|
-
};
|
|
894
|
-
this.augmentChatResponse(completion, response, effectiveConfig);
|
|
895
|
-
// Call post-request hook with params
|
|
896
|
-
await effectiveConfig.hooks?.chat?.afterRequest?.(request, params, response, ctx, metadata);
|
|
897
|
-
return response;
|
|
898
|
-
};
|
|
639
|
+
if (isContextWindowError(e)) {
|
|
640
|
+
throw new ContextWindowError(this.name, `Context window exceeded during ${op}`, parseContextWindowError(e)?.contextWindow, e);
|
|
641
|
+
}
|
|
642
|
+
await hooks?.onError?.(request, params, e, run.ctx, run.metadata);
|
|
643
|
+
throw e;
|
|
644
|
+
}
|
|
899
645
|
}
|
|
900
646
|
/**
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
* The streamer yields chunks as they arrive from OpenAI, enabling
|
|
904
|
-
* real-time response rendering.
|
|
905
|
-
*
|
|
906
|
-
* @param config Optional configuration override
|
|
907
|
-
* @returns Streamer async generator function
|
|
908
|
-
* @throws {ProviderError} If request fails
|
|
909
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
647
|
+
* Report a response to the operation's after hook and return it.
|
|
910
648
|
*/
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
return async function* (request, ctx, metadata, requestSignal) {
|
|
915
|
-
const { model, retry, signal } = this.getRequestData(request, requestSignal, ctx, effectiveConfig, 'streaming chat', metadata?.model || effectiveConfig.defaultModels?.chat);
|
|
916
|
-
const modelInfo = pickModelInfo(metadata, ctx, model.id);
|
|
917
|
-
const budget = this.buildSchemaBudget(modelInfo);
|
|
918
|
-
const messages = await this.convertMessages(request, effectiveConfig);
|
|
919
|
-
const tools = this.convertTools(request, modelInfo, budget);
|
|
920
|
-
const tool_choice = tools?.length ? this.convertToolChoice(request) : undefined;
|
|
921
|
-
const parallel_tool_calls = tools?.length ? !request.toolsOneAtATime : undefined;
|
|
922
|
-
const response_format = this.convertResponseFormat(request, modelInfo, budget);
|
|
923
|
-
const params = {
|
|
924
|
-
model: model.id,
|
|
925
|
-
messages,
|
|
926
|
-
temperature: request.temperature,
|
|
927
|
-
top_p: request.topP,
|
|
928
|
-
max_completion_tokens: request.maxTokens,
|
|
929
|
-
frequency_penalty: request.frequencyPenalty,
|
|
930
|
-
presence_penalty: request.presencePenalty,
|
|
931
|
-
logit_bias: request.logitBias,
|
|
932
|
-
logprobs: request.logProbabilities,
|
|
933
|
-
prompt_cache_key: request.cacheKey,
|
|
934
|
-
safety_identifier: request.userKey,
|
|
935
|
-
store: effectiveConfig.store ?? false,
|
|
936
|
-
stop: request.stop,
|
|
937
|
-
tools,
|
|
938
|
-
tool_choice,
|
|
939
|
-
parallel_tool_calls,
|
|
940
|
-
response_format,
|
|
941
|
-
...request.extra,
|
|
942
|
-
stream: true,
|
|
943
|
-
stream_options: { include_usage: true },
|
|
944
|
-
};
|
|
945
|
-
this.augmentChatRequest(params, request, effectiveConfig);
|
|
946
|
-
// Call pre-request hook with params
|
|
947
|
-
await effectiveConfig.hooks?.chat?.beforeRequest?.(request, params, ctx, metadata);
|
|
948
|
-
let stream;
|
|
949
|
-
try {
|
|
950
|
-
stream = await retry(async (signal) => {
|
|
951
|
-
const { response, data } = await client.chat.completions.create(params, { signal }).withResponse();
|
|
952
|
-
if (!response.ok) {
|
|
953
|
-
const headers = Object.fromEntries(response.headers);
|
|
954
|
-
const body = await response.text().catch(() => '');
|
|
955
|
-
const details = {
|
|
956
|
-
status: response.status,
|
|
957
|
-
statusText: response.statusText,
|
|
958
|
-
headers,
|
|
959
|
-
body,
|
|
960
|
-
};
|
|
961
|
-
throw new ProviderError(this.name, `Chat completion request failed: ${JSON.stringify(details)}`);
|
|
962
|
-
}
|
|
963
|
-
return data;
|
|
964
|
-
});
|
|
965
|
-
}
|
|
966
|
-
catch (e) {
|
|
967
|
-
if (isContextWindowError(e)) {
|
|
968
|
-
const details = parseContextWindowError(e);
|
|
969
|
-
const errorResponse = {
|
|
970
|
-
content: '',
|
|
971
|
-
toolCalls: [],
|
|
972
|
-
finishReason: 'length',
|
|
973
|
-
model,
|
|
974
|
-
usage: { text: { input: details?.contextWindow } },
|
|
975
|
-
};
|
|
976
|
-
// Call post-request hook even on error
|
|
977
|
-
await effectiveConfig.hooks?.chat?.afterRequest?.(request, params, errorResponse, ctx, metadata);
|
|
978
|
-
return errorResponse;
|
|
979
|
-
}
|
|
980
|
-
else {
|
|
981
|
-
await effectiveConfig.hooks?.chat?.onError?.(request, params, e, ctx, metadata);
|
|
982
|
-
}
|
|
983
|
-
throw e;
|
|
984
|
-
}
|
|
985
|
-
const toolCallsMap = new Map();
|
|
986
|
-
const toolCalls = [];
|
|
987
|
-
const chunks = [];
|
|
988
|
-
let maxIndexSeen = -1;
|
|
989
|
-
try {
|
|
990
|
-
for await (const chunk of stream) {
|
|
991
|
-
if (signal?.aborted) {
|
|
992
|
-
throw new Error('Request aborted');
|
|
993
|
-
}
|
|
994
|
-
const choice = chunk?.choices[0];
|
|
995
|
-
const delta = choice?.delta;
|
|
996
|
-
// OpenAI streams `finish_reason: null` on every intermediate chunk
|
|
997
|
-
// and only sets a real value on the last chunk for the choice.
|
|
998
|
-
// Normalize null → undefined so downstream `!== undefined` checks
|
|
999
|
-
// (used by the tool-call finalization logic below) don't treat
|
|
1000
|
-
// every intermediate chunk as "the model is done".
|
|
1001
|
-
const finishReasonRaw = choice?.finish_reason ?? undefined;
|
|
1002
|
-
const yieldChunk = {
|
|
1003
|
-
content: delta?.content || undefined,
|
|
1004
|
-
finishReason: finishReasonRaw,
|
|
1005
|
-
refusal: delta?.refusal ?? undefined,
|
|
1006
|
-
usage: chunk.usage ? convertOpenAIUsage(chunk.usage) : undefined,
|
|
1007
|
-
};
|
|
1008
|
-
// Handle tool calls
|
|
1009
|
-
for (const toolCall of toolCalls) {
|
|
1010
|
-
toolCall.updated = false;
|
|
1011
|
-
}
|
|
1012
|
-
if (delta?.tool_calls) {
|
|
1013
|
-
for (const tc of delta.tool_calls) {
|
|
1014
|
-
const existing = toolCallsMap.get(tc.index);
|
|
1015
|
-
const toolCall = existing || { id: '', name: '', arguments: '', named: false, finished: false, updated: true, index: tc.index };
|
|
1016
|
-
if (tc.id) {
|
|
1017
|
-
toolCall.id = tc.id;
|
|
1018
|
-
toolCall.updated = true;
|
|
1019
|
-
}
|
|
1020
|
-
if (tc.function?.name) {
|
|
1021
|
-
toolCall.name = tc.function.name;
|
|
1022
|
-
toolCall.updated = true;
|
|
1023
|
-
}
|
|
1024
|
-
if (tc.function?.arguments) {
|
|
1025
|
-
toolCall.arguments += tc.function.arguments;
|
|
1026
|
-
toolCall.updated = true;
|
|
1027
|
-
}
|
|
1028
|
-
if (!existing) {
|
|
1029
|
-
toolCallsMap.set(tc.index, toolCall);
|
|
1030
|
-
toolCalls.push(toolCall);
|
|
1031
|
-
}
|
|
1032
|
-
if (typeof tc.index === 'number' && tc.index > maxIndexSeen) {
|
|
1033
|
-
maxIndexSeen = tc.index;
|
|
1034
|
-
}
|
|
1035
|
-
if (toolCall.arguments) {
|
|
1036
|
-
if (!toolCall.named) {
|
|
1037
|
-
yieldChunk.toolCallNamed = toolCall;
|
|
1038
|
-
toolCall.named = true;
|
|
1039
|
-
}
|
|
1040
|
-
else {
|
|
1041
|
-
yieldChunk.toolCallArguments = toolCall;
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
// Finalize tool calls only when we have positive evidence they're done.
|
|
1047
|
-
// The previous "updated == false this chunk" check was wrong: providers
|
|
1048
|
-
// (OpenRouter especially, with stream_options.include_usage) interleave
|
|
1049
|
-
// usage-only / keep-alive chunks that have no `choices`/`delta`, which
|
|
1050
|
-
// would prematurely flip a still-streaming tool_call to finished and
|
|
1051
|
-
// emit it with partial arguments — downstream parse() then rejected
|
|
1052
|
-
// it as "called with NO arguments". Only finalize when:
|
|
1053
|
-
// 1. finish_reason is set on this chunk (the whole response is done), or
|
|
1054
|
-
// 2. a higher tc.index appeared (per OpenAI streaming convention,
|
|
1055
|
-
// tool_calls arrive in index order, so a newer index implies the
|
|
1056
|
-
// older ones are complete).
|
|
1057
|
-
// Anything still unfinished by stream end is handled by the finally block.
|
|
1058
|
-
const hasFinishReason = yieldChunk.finishReason !== undefined;
|
|
1059
|
-
for (const toolCall of toolCalls) {
|
|
1060
|
-
if (!toolCall.finished && (hasFinishReason || toolCall.index < maxIndexSeen)) {
|
|
1061
|
-
toolCall.finished = true;
|
|
1062
|
-
yieldChunk.toolCall = toolCall;
|
|
1063
|
-
if (!toolCall.named) {
|
|
1064
|
-
yieldChunk.toolCallNamed = toolCall;
|
|
1065
|
-
yieldChunk.toolCallArguments = toolCall;
|
|
1066
|
-
toolCall.named = true;
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
// Augment chunk with provider-specific data
|
|
1071
|
-
this.augmentChatChunk(chunk, yieldChunk, effectiveConfig);
|
|
1072
|
-
// Keep track of all chunks for post-processing if needed
|
|
1073
|
-
chunks.push(yieldChunk);
|
|
1074
|
-
// Send it!
|
|
1075
|
-
yield yieldChunk;
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
finally {
|
|
1079
|
-
// All tool calls should've been emitted, but just in case
|
|
1080
|
-
for (const toolCall of toolCalls) {
|
|
1081
|
-
if (!toolCall.finished) {
|
|
1082
|
-
toolCall.finished = true;
|
|
1083
|
-
const chunk = { choices: [], created: 0, id: '', model: '', object: 'chat.completion.chunk' };
|
|
1084
|
-
const yieldChunk = { toolCall };
|
|
1085
|
-
if (!toolCall.named) {
|
|
1086
|
-
yieldChunk.toolCallNamed = toolCall;
|
|
1087
|
-
yieldChunk.toolCallArguments = toolCall;
|
|
1088
|
-
toolCall.named = true;
|
|
1089
|
-
}
|
|
1090
|
-
// Augment chunk with provider-specific data
|
|
1091
|
-
this.augmentChatChunk(chunk, yieldChunk, effectiveConfig);
|
|
1092
|
-
// Keep track of all chunks for post-processing if needed
|
|
1093
|
-
chunks.push(yieldChunk);
|
|
1094
|
-
// Send it!
|
|
1095
|
-
yield yieldChunk;
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
// Call post-request hook with accumulated response
|
|
1099
|
-
if (effectiveConfig.hooks?.chat?.afterRequest) {
|
|
1100
|
-
const finalResponse = getResponseFromChunks(chunks);
|
|
1101
|
-
finalResponse.model = model;
|
|
1102
|
-
await effectiveConfig.hooks.chat.afterRequest(request, params, finalResponse, ctx, metadata);
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
}.bind(this);
|
|
649
|
+
async finish(op, run, request, params, response) {
|
|
650
|
+
await run.config.hooks?.[op]?.afterRequest?.(request, params, response, run.ctx, run.metadata);
|
|
651
|
+
return response;
|
|
1106
652
|
}
|
|
1107
653
|
/**
|
|
1108
|
-
*
|
|
654
|
+
* The schema a bad request rejected, if any. OpenAI names it in the error
|
|
655
|
+
* message; OpenAI-compatible providers can look further.
|
|
1109
656
|
*
|
|
1110
|
-
* @param request -
|
|
1111
|
-
* @param
|
|
1112
|
-
* @returns
|
|
657
|
+
* @param request - The request as sent
|
|
658
|
+
* @param error - The API's 400 or 422 error
|
|
1113
659
|
*/
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
? 'gpt-image'
|
|
1117
|
-
: model.includes('dall-e-2')
|
|
1118
|
-
? 'dall-e-2'
|
|
1119
|
-
: model.includes('dall-e-3')
|
|
1120
|
-
? 'dall-e-3'
|
|
1121
|
-
: 'unknown';
|
|
1122
|
-
const modelParameters = {
|
|
1123
|
-
'gpt-image': {
|
|
1124
|
-
style: {
|
|
1125
|
-
'vivid': undefined,
|
|
1126
|
-
'natural': undefined,
|
|
1127
|
-
},
|
|
1128
|
-
background: {
|
|
1129
|
-
'transparent': 'transparent',
|
|
1130
|
-
'opaque': 'opaque',
|
|
1131
|
-
'auto': 'auto',
|
|
1132
|
-
},
|
|
1133
|
-
quality: {
|
|
1134
|
-
'low': 'low',
|
|
1135
|
-
'medium': 'medium',
|
|
1136
|
-
'high': 'high',
|
|
1137
|
-
},
|
|
1138
|
-
responseFormat: {
|
|
1139
|
-
'url': undefined,
|
|
1140
|
-
'b64_json': undefined,
|
|
1141
|
-
},
|
|
1142
|
-
sizes: [
|
|
1143
|
-
{ width: 1024, height: 1024 },
|
|
1144
|
-
{ width: 1536, height: 1024 },
|
|
1145
|
-
{ width: 1024, height: 1536 },
|
|
1146
|
-
],
|
|
1147
|
-
n: { min: 1, max: 10 },
|
|
1148
|
-
},
|
|
1149
|
-
'dall-e-2': {
|
|
1150
|
-
style: {
|
|
1151
|
-
'vivid': undefined,
|
|
1152
|
-
'natural': undefined,
|
|
1153
|
-
},
|
|
1154
|
-
background: {
|
|
1155
|
-
'transparent': undefined,
|
|
1156
|
-
'opaque': undefined,
|
|
1157
|
-
'auto': undefined,
|
|
1158
|
-
},
|
|
1159
|
-
quality: {
|
|
1160
|
-
'low': 'standard',
|
|
1161
|
-
'medium': 'standard',
|
|
1162
|
-
'high': 'standard',
|
|
1163
|
-
},
|
|
1164
|
-
responseFormat: {
|
|
1165
|
-
'url': 'url',
|
|
1166
|
-
'b64_json': 'b64_json',
|
|
1167
|
-
},
|
|
1168
|
-
sizes: [
|
|
1169
|
-
{ width: 256, height: 256 },
|
|
1170
|
-
{ width: 512, height: 512 },
|
|
1171
|
-
{ width: 1024, height: 1024 },
|
|
1172
|
-
],
|
|
1173
|
-
n: { min: 1, max: 10 },
|
|
1174
|
-
},
|
|
1175
|
-
'dall-e-3': {
|
|
1176
|
-
style: {
|
|
1177
|
-
'vivid': 'vivid',
|
|
1178
|
-
'natural': 'natural',
|
|
1179
|
-
},
|
|
1180
|
-
background: {
|
|
1181
|
-
'transparent': undefined,
|
|
1182
|
-
'opaque': undefined,
|
|
1183
|
-
'auto': undefined,
|
|
1184
|
-
},
|
|
1185
|
-
quality: {
|
|
1186
|
-
'low': 'standard',
|
|
1187
|
-
'medium': 'hd',
|
|
1188
|
-
'high': 'hd',
|
|
1189
|
-
},
|
|
1190
|
-
responseFormat: {
|
|
1191
|
-
'url': 'url',
|
|
1192
|
-
'b64_json': 'b64_json',
|
|
1193
|
-
},
|
|
1194
|
-
sizes: [
|
|
1195
|
-
{ width: 1024, height: 1024 },
|
|
1196
|
-
{ width: 1792, height: 1024 },
|
|
1197
|
-
{ width: 1024, height: 1792 },
|
|
1198
|
-
],
|
|
1199
|
-
n: { min: 1, max: 1 },
|
|
1200
|
-
},
|
|
1201
|
-
'unknown': {
|
|
1202
|
-
style: {
|
|
1203
|
-
'vivid': undefined,
|
|
1204
|
-
'natural': undefined,
|
|
1205
|
-
},
|
|
1206
|
-
background: {
|
|
1207
|
-
'transparent': undefined,
|
|
1208
|
-
'opaque': undefined,
|
|
1209
|
-
'auto': undefined,
|
|
1210
|
-
},
|
|
1211
|
-
quality: {
|
|
1212
|
-
'low': undefined,
|
|
1213
|
-
'medium': undefined,
|
|
1214
|
-
'high': undefined,
|
|
1215
|
-
},
|
|
1216
|
-
responseFormat: {
|
|
1217
|
-
'url': undefined,
|
|
1218
|
-
'b64_json': undefined,
|
|
1219
|
-
},
|
|
1220
|
-
sizes: [],
|
|
1221
|
-
n: { min: 1, max: 1 },
|
|
1222
|
-
},
|
|
1223
|
-
};
|
|
1224
|
-
const sizesAvailable = modelParameters[modelId].sizes;
|
|
1225
|
-
let size = undefined;
|
|
1226
|
-
if (request.size && sizesAvailable.length > 0) {
|
|
1227
|
-
const [widthStr, heightStr] = request.size.split('x');
|
|
1228
|
-
const width = parseInt(widthStr, 10);
|
|
1229
|
-
const height = parseInt(heightStr, 10);
|
|
1230
|
-
const closest = sizesAvailable
|
|
1231
|
-
.map(d => ({
|
|
1232
|
-
...d,
|
|
1233
|
-
ratioDiff: Math.abs(d.width / d.height - width / height),
|
|
1234
|
-
scale: Math.min(d.width / width, d.height / height)
|
|
1235
|
-
}))
|
|
1236
|
-
.sort((a, b) => a.ratioDiff - b.ratioDiff || b.scale - a.scale)[0];
|
|
1237
|
-
size = `${closest.width}x${closest.height}`;
|
|
1238
|
-
}
|
|
1239
|
-
const params = {
|
|
1240
|
-
model,
|
|
1241
|
-
prompt: request.prompt,
|
|
1242
|
-
n: request.n ? Math.max(modelParameters[modelId].n.min, Math.min(modelParameters[modelId].n.max, request.n)) : undefined,
|
|
1243
|
-
size,
|
|
1244
|
-
quality: request.quality ? modelParameters[modelId].quality[request.quality] : undefined,
|
|
1245
|
-
style: request.style ? modelParameters[modelId].style[request.style] : undefined,
|
|
1246
|
-
background: request.background ? modelParameters[modelId].background[request.background] : undefined,
|
|
1247
|
-
response_format: request.responseFormat ? modelParameters[modelId].responseFormat[request.responseFormat] : undefined,
|
|
1248
|
-
};
|
|
1249
|
-
return params;
|
|
660
|
+
rejectedSchema(request, error) {
|
|
661
|
+
return schemaRejection(request, error);
|
|
1250
662
|
}
|
|
1251
663
|
/**
|
|
1252
|
-
*
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1255
|
-
* @param ctx Context object (not used)
|
|
1256
|
-
* @param config Optional configuration override
|
|
1257
|
-
* @returns Generated image URLs or base64 data
|
|
1258
|
-
* @throws {ProviderError} If generation fails
|
|
1259
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
664
|
+
* Send a chat call. A request that overflows the context window ends as
|
|
665
|
+
* `length` rather than failing, so the prompt loop can trim and retry. A
|
|
666
|
+
* rejected schema becomes a SchemaRejectedError.
|
|
1260
667
|
*/
|
|
1261
|
-
|
|
1262
|
-
const effectiveConfig = { ...this.config, ...config };
|
|
1263
|
-
const { model, retry } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'generate image', effectiveConfig.defaultModels?.imageGenerate || 'gpt-image-1');
|
|
1264
|
-
const baseParams = this.getGenerateParameters(request, model.id);
|
|
1265
|
-
const params = {
|
|
1266
|
-
...baseParams,
|
|
1267
|
-
user: request.userIdentifier,
|
|
1268
|
-
...request.extra,
|
|
1269
|
-
stream: false,
|
|
1270
|
-
};
|
|
1271
|
-
// Apply provider-specific customizations
|
|
1272
|
-
this.augmentImageGenerateRequest?.(params, request, effectiveConfig);
|
|
1273
|
-
// Call pre-request hook with params
|
|
1274
|
-
await effectiveConfig.hooks?.imageGenerate?.beforeRequest?.(request, params, ctx);
|
|
1275
|
-
const client = this.createClient(effectiveConfig);
|
|
1276
|
-
let response;
|
|
668
|
+
async sendChat(op, run, request, params, call) {
|
|
1277
669
|
try {
|
|
1278
|
-
|
|
1279
|
-
return await client.images.generate(params, { signal });
|
|
1280
|
-
});
|
|
670
|
+
return { result: await this.send(op, run, request, params, call) };
|
|
1281
671
|
}
|
|
1282
672
|
catch (e) {
|
|
1283
|
-
if (
|
|
1284
|
-
const
|
|
1285
|
-
throw
|
|
1286
|
-
}
|
|
1287
|
-
else {
|
|
1288
|
-
await effectiveConfig.hooks?.imageGenerate?.onError?.(request, params, e, ctx);
|
|
673
|
+
if (!(e instanceof ContextWindowError)) {
|
|
674
|
+
const badRequest = e instanceof Error && 'status' in e && (e.status === 400 || e.status === 422);
|
|
675
|
+
throw (badRequest && this.rejectedSchema(request, e)) || e;
|
|
1289
676
|
}
|
|
1290
|
-
|
|
677
|
+
const overflow = { content: '', toolCalls: [], finishReason: 'length', model: run.model, usage: { text: { input: e.contextWindow } } };
|
|
678
|
+
return { overflow: await this.finish(op, run, request, params, overflow) };
|
|
1291
679
|
}
|
|
1292
|
-
|
|
1293
|
-
images: (response.data || []).map((img) => ({
|
|
1294
|
-
url: img.url || undefined,
|
|
1295
|
-
b64_json: img.b64_json || undefined,
|
|
1296
|
-
revisedPrompt: img.revised_prompt || undefined,
|
|
1297
|
-
})),
|
|
1298
|
-
model,
|
|
1299
|
-
usage: response.usage ? convertOpenAIUsage(response.usage) : undefined,
|
|
1300
|
-
};
|
|
1301
|
-
// Call post-request hook with params
|
|
1302
|
-
await effectiveConfig.hooks?.imageGenerate?.afterRequest?.(request, params, result, ctx);
|
|
1303
|
-
return result;
|
|
1304
|
-
};
|
|
680
|
+
}
|
|
1305
681
|
/**
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
* Note: OpenAI's API doesn't natively support streaming image generation,
|
|
1309
|
-
* so this implementation polls for progress or returns the result immediately.
|
|
1310
|
-
*
|
|
1311
|
-
* @param request Image generation request parameters
|
|
1312
|
-
* @param ctx Context object (not used)
|
|
1313
|
-
* @param config Optional configuration override
|
|
1314
|
-
* @returns Async generator yielding progress chunks
|
|
1315
|
-
* @throws {ProviderError} If generation fails
|
|
1316
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
682
|
+
* Relay streamed chunks, then report the assembled response to the after
|
|
683
|
+
* hook (even when the stream fails part way) and return it.
|
|
1317
684
|
*/
|
|
1318
|
-
|
|
1319
|
-
const
|
|
1320
|
-
const { model, retry, signal } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'generate image stream', effectiveConfig.defaultModels?.imageGenerate || 'gpt-image-1');
|
|
1321
|
-
const baseParams = this.getGenerateParameters(request, model.id);
|
|
1322
|
-
const imageStreams = Math.max(1, Math.min(3, request.streamCount ?? 2));
|
|
1323
|
-
const params = {
|
|
1324
|
-
...baseParams,
|
|
1325
|
-
user: request.userIdentifier,
|
|
1326
|
-
partial_images: imageStreams,
|
|
1327
|
-
...request.extra,
|
|
1328
|
-
stream: true,
|
|
1329
|
-
};
|
|
1330
|
-
this.augmentImageGenerateRequest?.(params, request, effectiveConfig);
|
|
1331
|
-
const client = this.createClient(effectiveConfig);
|
|
685
|
+
async *relay(op, run, request, params, chunks) {
|
|
686
|
+
const received = [];
|
|
1332
687
|
let response;
|
|
1333
688
|
try {
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
const details = parseContextWindowError(e);
|
|
1341
|
-
throw new ContextWindowError(this.name, 'Context window exceeded during image generation', details?.contextWindow, e);
|
|
1342
|
-
}
|
|
1343
|
-
else {
|
|
1344
|
-
await effectiveConfig.hooks?.imageGenerate?.onError?.(request, params, e, ctx);
|
|
689
|
+
for await (const chunk of chunks) {
|
|
690
|
+
if (run.signal?.aborted) {
|
|
691
|
+
throw new Error('Request aborted');
|
|
692
|
+
}
|
|
693
|
+
received.push(chunk);
|
|
694
|
+
yield chunk;
|
|
1345
695
|
}
|
|
1346
|
-
throw e;
|
|
1347
696
|
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
if (signal?.aborted) {
|
|
1352
|
-
throw new Error('Request aborted');
|
|
1353
|
-
}
|
|
1354
|
-
const progress = (imageCount + 1) / (imageStreams + 1);
|
|
1355
|
-
yield {
|
|
1356
|
-
progress,
|
|
1357
|
-
done: img.type === 'image_generation.completed',
|
|
1358
|
-
image: {
|
|
1359
|
-
b64_json: img.b64_json || undefined,
|
|
1360
|
-
},
|
|
1361
|
-
};
|
|
1362
|
-
imageCount++;
|
|
697
|
+
finally {
|
|
698
|
+
response = getResponseFromChunks(received, run.model);
|
|
699
|
+
await this.finish(op, run, request, params, response);
|
|
1363
700
|
}
|
|
1364
|
-
|
|
701
|
+
return response;
|
|
702
|
+
}
|
|
703
|
+
// ============================================================================
|
|
704
|
+
// Chat
|
|
705
|
+
// ============================================================================
|
|
1365
706
|
/**
|
|
1366
|
-
*
|
|
707
|
+
* Whether a chat request goes to the Responses API: always or never when
|
|
708
|
+
* configured, otherwise when the model is Responses-only or the request
|
|
709
|
+
* uses something only Responses offers.
|
|
1367
710
|
*
|
|
1368
|
-
* @param request -
|
|
1369
|
-
* @param
|
|
1370
|
-
* @
|
|
711
|
+
* @param request - The request
|
|
712
|
+
* @param modelId - The model it targets
|
|
713
|
+
* @param config - Provider configuration
|
|
714
|
+
* @param model - The full model, whose registry entry says whether it is
|
|
715
|
+
* Responses-only; the model id decides otherwise
|
|
1371
716
|
*/
|
|
1372
|
-
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
? 'dall-e-2'
|
|
1377
|
-
: model.includes('dall-e-3')
|
|
1378
|
-
? 'dall-e-3'
|
|
1379
|
-
: 'unknown';
|
|
1380
|
-
const modelParameters = {
|
|
1381
|
-
'gpt-image': {
|
|
1382
|
-
responseFormat: {
|
|
1383
|
-
'url': undefined,
|
|
1384
|
-
'b64_json': undefined,
|
|
1385
|
-
},
|
|
1386
|
-
sizes: [
|
|
1387
|
-
{ width: 1024, height: 1024 },
|
|
1388
|
-
{ width: 1536, height: 1024 },
|
|
1389
|
-
{ width: 1024, height: 1536 },
|
|
1390
|
-
],
|
|
1391
|
-
n: { min: 1, max: 10 },
|
|
1392
|
-
},
|
|
1393
|
-
'dall-e-2': {
|
|
1394
|
-
responseFormat: {
|
|
1395
|
-
'url': 'url',
|
|
1396
|
-
'b64_json': 'b64_json',
|
|
1397
|
-
},
|
|
1398
|
-
sizes: [
|
|
1399
|
-
{ width: 256, height: 256 },
|
|
1400
|
-
{ width: 512, height: 512 },
|
|
1401
|
-
{ width: 1024, height: 1024 },
|
|
1402
|
-
],
|
|
1403
|
-
n: { min: 1, max: 10 },
|
|
1404
|
-
},
|
|
1405
|
-
'dall-e-3': {
|
|
1406
|
-
responseFormat: {
|
|
1407
|
-
'url': 'url',
|
|
1408
|
-
'b64_json': 'b64_json',
|
|
1409
|
-
},
|
|
1410
|
-
sizes: [
|
|
1411
|
-
{ width: 1024, height: 1024 },
|
|
1412
|
-
{ width: 1792, height: 1024 },
|
|
1413
|
-
{ width: 1024, height: 1792 },
|
|
1414
|
-
],
|
|
1415
|
-
n: { min: 1, max: 1 },
|
|
1416
|
-
},
|
|
1417
|
-
'unknown': {
|
|
1418
|
-
responseFormat: {
|
|
1419
|
-
'url': undefined,
|
|
1420
|
-
'b64_json': undefined,
|
|
1421
|
-
},
|
|
1422
|
-
sizes: [],
|
|
1423
|
-
n: { min: 1, max: 1 },
|
|
1424
|
-
},
|
|
1425
|
-
};
|
|
1426
|
-
const sizesAvailable = modelParameters[modelId].sizes;
|
|
1427
|
-
let size = undefined;
|
|
1428
|
-
if (request.size && sizesAvailable.length > 0) {
|
|
1429
|
-
const [widthStr, heightStr] = request.size.split('x');
|
|
1430
|
-
const width = parseInt(widthStr, 10);
|
|
1431
|
-
const height = parseInt(heightStr, 10);
|
|
1432
|
-
const closest = sizesAvailable
|
|
1433
|
-
.map(d => ({
|
|
1434
|
-
...d,
|
|
1435
|
-
ratioDiff: Math.abs(d.width / d.height - width / height),
|
|
1436
|
-
scale: Math.min(d.width / width, d.height / height)
|
|
1437
|
-
}))
|
|
1438
|
-
.sort((a, b) => a.ratioDiff - b.ratioDiff || b.scale - a.scale)[0];
|
|
1439
|
-
size = `${closest.width}x${closest.height}`;
|
|
717
|
+
usesResponses(request, modelId, config, model) {
|
|
718
|
+
const api = config.api ?? 'auto';
|
|
719
|
+
if (api !== 'auto') {
|
|
720
|
+
return api === 'responses';
|
|
1440
721
|
}
|
|
1441
|
-
const
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
return params;
|
|
722
|
+
const responsesOnly = model?.metadata?.responsesOnly ?? isResponsesOnly(modelId);
|
|
723
|
+
const { effort, mode, summary, context } = request.reason ?? {};
|
|
724
|
+
const responsesTools = request.tools?.some(tool => tool.format || tool.defer || tool.async);
|
|
725
|
+
// Only Responses carries reasoning across tool calls, and newer models
|
|
726
|
+
// reject function tools with reasoning on Chat Completions
|
|
727
|
+
const reasonsWithTools = !!request.tools?.length && !!effort && effort !== 'none';
|
|
728
|
+
return responsesOnly === true || !!request.hostedTools?.length || !!responsesTools || reasonsWithTools || !!(mode || summary || context);
|
|
1449
729
|
}
|
|
1450
730
|
/**
|
|
1451
|
-
*
|
|
731
|
+
* Chat Completions parameters for a request, before the stream flag.
|
|
1452
732
|
*
|
|
1453
|
-
*
|
|
733
|
+
* @param request - The request
|
|
734
|
+
* @param run - The resolved call
|
|
735
|
+
* @param streaming - Whether the response will stream (audio output
|
|
736
|
+
* streams only as PCM)
|
|
737
|
+
*/
|
|
738
|
+
async buildChatParams(request, run, streaming) {
|
|
739
|
+
const budget = this.schemaBudget(run);
|
|
740
|
+
const tools = this.convertTools(request, budget);
|
|
741
|
+
const audio = request.modalities?.includes('audio') ? request.audio : undefined;
|
|
742
|
+
const audioFormat = outputAudioFormat(request, streaming);
|
|
743
|
+
const store = run.config.store ?? false;
|
|
744
|
+
return {
|
|
745
|
+
model: run.model.id,
|
|
746
|
+
messages: await this.convertMessages(request, run.config),
|
|
747
|
+
temperature: request.temperature,
|
|
748
|
+
top_p: request.topP,
|
|
749
|
+
max_completion_tokens: request.maxTokens,
|
|
750
|
+
frequency_penalty: request.frequencyPenalty,
|
|
751
|
+
presence_penalty: request.presencePenalty,
|
|
752
|
+
logit_bias: request.logitBias,
|
|
753
|
+
logprobs: request.logProbabilities,
|
|
754
|
+
seed: request.seed,
|
|
755
|
+
stop: request.stop,
|
|
756
|
+
tools,
|
|
757
|
+
tool_choice: tools?.length ? this.convertToolChoice(request) : undefined,
|
|
758
|
+
// Parallel calls are the default; reasoning models such as o3 reject the parameter itself
|
|
759
|
+
parallel_tool_calls: tools?.length && request.toolsOneAtATime ? false : undefined,
|
|
760
|
+
response_format: this.convertResponseFormat(request, budget),
|
|
761
|
+
reasoning_effort: request.reason?.effort && clampReasoningEffort(request.reason.effort, run.modelInfo?.reasoningEfforts),
|
|
762
|
+
verbosity: request.verbosity,
|
|
763
|
+
service_tier: request.serviceTier,
|
|
764
|
+
// Chat Completions only takes metadata on stored completions
|
|
765
|
+
metadata: store ? request.metadata : undefined,
|
|
766
|
+
modalities: audio ? ['text', 'audio'] : undefined,
|
|
767
|
+
audio: audio && audioFormat && { voice: audio.voice, format: toChatAudioFormat(audioFormat) },
|
|
768
|
+
moderation: request.moderate ? { model: MODERATION_MODEL } : undefined,
|
|
769
|
+
prompt_cache_key: request.cacheKey,
|
|
770
|
+
safety_identifier: request.userKey,
|
|
771
|
+
store,
|
|
772
|
+
...request.extra,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Responses API parameters for a request, before the stream flag.
|
|
777
|
+
* Reasoning comes back encrypted unless responses are stored, so later
|
|
778
|
+
* turns can replay it.
|
|
1454
779
|
*
|
|
1455
|
-
* @param request
|
|
1456
|
-
* @param
|
|
1457
|
-
* @param config Optional configuration override
|
|
1458
|
-
* @returns Edited image URLs or base64 data
|
|
1459
|
-
* @throws {ProviderError} If editing fails
|
|
1460
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
780
|
+
* @param request - The request
|
|
781
|
+
* @param run - The resolved call
|
|
1461
782
|
*/
|
|
1462
|
-
|
|
1463
|
-
const
|
|
1464
|
-
const
|
|
1465
|
-
const
|
|
1466
|
-
const
|
|
1467
|
-
const
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
783
|
+
async buildResponsesParams(request, run) {
|
|
784
|
+
const budget = this.schemaBudget(run);
|
|
785
|
+
const store = run.config.store ?? false;
|
|
786
|
+
const format = this.convertResponseFormat(request, budget);
|
|
787
|
+
const functions = this.convertTools(request, budget) ?? [];
|
|
788
|
+
const definitions = request.tools ?? [];
|
|
789
|
+
// Deferred tools load through hosted tool search
|
|
790
|
+
const search = definitions.some(tool => tool.defer) ? [{ type: 'tool_search' }] : [];
|
|
791
|
+
const tools = [
|
|
792
|
+
...definitions.map((tool, i) => {
|
|
793
|
+
const shared = { name: tool.name, description: tool.description, defer_loading: tool.defer, async: tool.async };
|
|
794
|
+
return tool.format
|
|
795
|
+
? { type: 'custom', ...shared, format: tool.format }
|
|
796
|
+
: { type: 'function', ...shared, parameters: functions[i].function.parameters ?? null, strict: functions[i].function.strict ?? false };
|
|
797
|
+
}),
|
|
798
|
+
...search,
|
|
799
|
+
...(request.hostedTools ?? []).map(tool => toHostedTool(tool, this.name)),
|
|
800
|
+
];
|
|
801
|
+
const kind = (name) => definitions.find(tool => tool.name === name)?.format ? 'custom' : 'function';
|
|
802
|
+
const choice = request.toolChoice;
|
|
803
|
+
const reason = request.reason;
|
|
804
|
+
return {
|
|
805
|
+
model: run.model.id,
|
|
806
|
+
input: await this.convertResponsesInput(request, run.config),
|
|
807
|
+
tools: tools.length ? tools : undefined,
|
|
808
|
+
tool_choice: !tools.length ? undefined
|
|
809
|
+
: typeof choice !== 'object' ? choice
|
|
810
|
+
: 'tool' in choice ? { type: kind(choice.tool), name: choice.tool }
|
|
811
|
+
: { type: 'allowed_tools', mode: choice.required ? 'required' : 'auto', tools: choice.allowed.map(name => ({ type: kind(name), name })) },
|
|
812
|
+
parallel_tool_calls: tools.length ? !request.toolsOneAtATime : undefined,
|
|
813
|
+
text: {
|
|
814
|
+
// The Responses API treats an unflagged json_schema as strict, so a non-strict one says so
|
|
815
|
+
format: format?.type === 'json_schema'
|
|
816
|
+
? { type: 'json_schema', ...format.json_schema, schema: format.json_schema.schema ?? {}, strict: format.json_schema.strict ?? false }
|
|
817
|
+
: format,
|
|
818
|
+
verbosity: request.verbosity,
|
|
819
|
+
},
|
|
820
|
+
reasoning: reason && {
|
|
821
|
+
effort: reason.effort && clampReasoningEffort(reason.effort, run.modelInfo?.reasoningEfforts),
|
|
822
|
+
summary: reason.summary,
|
|
823
|
+
mode: reason.mode,
|
|
824
|
+
context: reason.context,
|
|
825
|
+
},
|
|
826
|
+
include: store ? undefined : ['reasoning.encrypted_content'],
|
|
827
|
+
max_output_tokens: request.maxTokens,
|
|
828
|
+
temperature: request.temperature,
|
|
829
|
+
top_p: request.topP,
|
|
830
|
+
metadata: request.metadata,
|
|
831
|
+
service_tier: request.serviceTier,
|
|
832
|
+
moderation: request.moderate ? { model: MODERATION_MODEL } : undefined,
|
|
833
|
+
prompt_cache_key: request.cacheKey,
|
|
834
|
+
safety_identifier: request.userKey,
|
|
835
|
+
store,
|
|
1473
836
|
...request.extra,
|
|
1474
|
-
stream: false,
|
|
1475
837
|
};
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Map a Chat Completions result to a @aeye response.
|
|
841
|
+
*/
|
|
842
|
+
chatResponse(completion, run, audioFormat) {
|
|
843
|
+
const { message, finish_reason } = completion.choices[0];
|
|
844
|
+
const toolCalls = message.tool_calls?.flatMap((tc) => tc.type === 'function'
|
|
845
|
+
? [{ id: tc.id, name: tc.function.name, arguments: tc.function.arguments }]
|
|
846
|
+
: []);
|
|
847
|
+
const citations = message.annotations?.map(note => toCitation(note.url_citation));
|
|
848
|
+
return {
|
|
849
|
+
content: message.content || '',
|
|
850
|
+
toolCalls,
|
|
851
|
+
finishReason: toolCalls?.length ? 'tool_calls' : finish_reason === 'function_call' /* deprecated */ ? 'stop' : finish_reason,
|
|
852
|
+
refusal: message.refusal ?? (finish_reason === 'content_filter' ? message.content ?? undefined : undefined),
|
|
853
|
+
citations: citations?.length ? citations : undefined,
|
|
854
|
+
audio: message.audio ? { id: message.audio.id, data: message.audio.data, transcript: message.audio.transcript, format: audioFormat } : undefined,
|
|
855
|
+
moderation: completion.moderation ? toModeration(completion.moderation) : undefined,
|
|
856
|
+
serviceTier: toServiceTier(completion.service_tier),
|
|
857
|
+
model: run.model,
|
|
858
|
+
usage: completion.usage ? chatUsage(completion.usage) : undefined,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Turn a Chat Completions stream into chunks.
|
|
863
|
+
*
|
|
864
|
+
* Tool calls are finalized only on positive evidence they're done: a
|
|
865
|
+
* finish reason on the chunk (the whole response is done), or a higher
|
|
866
|
+
* tool-call index (tool calls arrive in index order). Providers interleave
|
|
867
|
+
* usage-only and keep-alive chunks, so "not updated this chunk" is not
|
|
868
|
+
* evidence. Anything unfinished when the stream completes is finalized
|
|
869
|
+
* then; a stream that fails leaves its calls unfinished.
|
|
870
|
+
*/
|
|
871
|
+
async *chatChunks(stream, request, config) {
|
|
872
|
+
const toolCalls = new Map();
|
|
873
|
+
let maxIndexSeen = -1;
|
|
874
|
+
const audioFormat = outputAudioFormat(request, true);
|
|
875
|
+
// A tool call's own finished chunk, also naming it if it never was
|
|
876
|
+
const finished = (toolCall) => {
|
|
877
|
+
const named = toolCall.named;
|
|
878
|
+
toolCall.finished = toolCall.named = true;
|
|
879
|
+
return named ? { toolCall } : { toolCall, toolCallNamed: toolCall, toolCallArguments: toolCall };
|
|
880
|
+
};
|
|
881
|
+
for await (const raw of stream) {
|
|
882
|
+
const choice = raw?.choices[0];
|
|
883
|
+
const delta = choice?.delta;
|
|
884
|
+
const finish = choice?.finish_reason;
|
|
885
|
+
// OpenAI streams `finish_reason: null` on every intermediate chunk
|
|
886
|
+
const chunk = {
|
|
887
|
+
content: delta?.content || undefined,
|
|
888
|
+
finishReason: finish === 'function_call' /* deprecated */ ? 'stop' : finish ?? undefined,
|
|
889
|
+
refusal: delta?.refusal ?? undefined,
|
|
890
|
+
audio: delta?.audio && { id: delta.audio.id, data: delta.audio.data, transcript: delta.audio.transcript, format: audioFormat },
|
|
891
|
+
moderation: raw.moderation ? toModeration(raw.moderation) : undefined,
|
|
892
|
+
serviceTier: toServiceTier(raw.service_tier),
|
|
893
|
+
usage: raw.usage ? chatUsage(raw.usage) : undefined,
|
|
894
|
+
};
|
|
895
|
+
for (const tc of delta?.tool_calls ?? []) {
|
|
896
|
+
const toolCall = toolCalls.get(tc.index) ?? { id: '', name: '', arguments: '', named: false, finished: false, index: tc.index };
|
|
897
|
+
toolCalls.set(tc.index, toolCall);
|
|
898
|
+
toolCall.id = tc.id || toolCall.id;
|
|
899
|
+
toolCall.name = tc.function?.name || toolCall.name;
|
|
900
|
+
toolCall.arguments += tc.function?.arguments ?? '';
|
|
901
|
+
maxIndexSeen = Math.max(maxIndexSeen, tc.index);
|
|
902
|
+
if (toolCall.arguments) {
|
|
903
|
+
if (toolCall.named) {
|
|
904
|
+
chunk.toolCallArguments = toolCall;
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
chunk.toolCallNamed = toolCall;
|
|
908
|
+
toolCall.named = true;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
this.augmentChatChunk(raw, chunk, config);
|
|
913
|
+
yield chunk;
|
|
914
|
+
for (const toolCall of toolCalls.values()) {
|
|
915
|
+
if (!toolCall.finished && (chunk.finishReason !== undefined || toolCall.index < maxIndexSeen)) {
|
|
916
|
+
yield finished(toolCall);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
1486
919
|
}
|
|
1487
|
-
|
|
1488
|
-
if (
|
|
1489
|
-
|
|
1490
|
-
throw new ContextWindowError(this.name, 'Context window exceeded during image editing', details?.contextWindow, e);
|
|
920
|
+
for (const toolCall of toolCalls.values()) {
|
|
921
|
+
if (!toolCall.finished) {
|
|
922
|
+
yield finished(toolCall);
|
|
1491
923
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Run a chat request through Chat Completions.
|
|
928
|
+
*/
|
|
929
|
+
async complete(request, run) {
|
|
930
|
+
const params = { ...await this.buildChatParams(request, run, false), stream: false };
|
|
931
|
+
this.augmentChatRequest(params, request, run.config);
|
|
932
|
+
const sent = await this.sendChat('chat', run, request, params, async (signal) => {
|
|
933
|
+
const completion = await run.client.chat.completions.create(params, { signal, headers: this.chatHeaders(params) });
|
|
934
|
+
if (!completion.choices?.[0]) {
|
|
935
|
+
throw new ProviderError(this.name, 'No choices in response');
|
|
1494
936
|
}
|
|
1495
|
-
|
|
937
|
+
return completion;
|
|
938
|
+
});
|
|
939
|
+
if ('overflow' in sent) {
|
|
940
|
+
return sent.overflow;
|
|
1496
941
|
}
|
|
1497
|
-
const
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
revisedPrompt: img.revised_prompt || undefined,
|
|
1502
|
-
})),
|
|
1503
|
-
model,
|
|
1504
|
-
usage: response.usage ? convertOpenAIUsage(response.usage) : undefined,
|
|
1505
|
-
};
|
|
1506
|
-
// Call post-request hook with params
|
|
1507
|
-
await effectiveConfig.hooks?.imageEdit?.afterRequest?.(request, params, result, ctx);
|
|
1508
|
-
return result;
|
|
1509
|
-
};
|
|
942
|
+
const response = this.chatResponse(sent.result, run, outputAudioFormat(request, false));
|
|
943
|
+
this.augmentChatResponse(sent.result, response, run.config);
|
|
944
|
+
return this.finish('chat', run, request, params, response);
|
|
945
|
+
}
|
|
1510
946
|
/**
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
* Note: OpenAI's API doesn't natively support streaming image editing,
|
|
1514
|
-
* so this implementation returns the result with progress indicators.
|
|
1515
|
-
*
|
|
1516
|
-
* @param request Image edit request parameters
|
|
1517
|
-
* @param ctx Context object (not used)
|
|
1518
|
-
* @param config Optional configuration override
|
|
1519
|
-
* @returns Async generator yielding progress chunks
|
|
1520
|
-
* @throws {ProviderError} If editing fails
|
|
1521
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
947
|
+
* Stream a chat request through Chat Completions.
|
|
1522
948
|
*/
|
|
1523
|
-
|
|
1524
|
-
const effectiveConfig = { ...this.config, ...config };
|
|
1525
|
-
const { model, retry, signal } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'edit image stream', effectiveConfig.defaultModels?.imageEdit || 'gpt-image-1');
|
|
1526
|
-
const image = await toFile(request.image);
|
|
1527
|
-
const mask = request.mask ? await toFile(request.mask) : undefined;
|
|
1528
|
-
const imageStreams = Math.max(1, Math.min(3, request.streamCount ?? 2));
|
|
1529
|
-
const baseParams = this.getEditParameters(request, model.id);
|
|
949
|
+
async *completeStream(request, run) {
|
|
1530
950
|
const params = {
|
|
1531
|
-
...
|
|
1532
|
-
image,
|
|
1533
|
-
mask,
|
|
1534
|
-
user: request.userIdentifier,
|
|
1535
|
-
partial_images: imageStreams,
|
|
1536
|
-
...request.extra,
|
|
951
|
+
...await this.buildChatParams(request, run, true),
|
|
1537
952
|
stream: true,
|
|
953
|
+
stream_options: { include_usage: true },
|
|
1538
954
|
};
|
|
1539
|
-
this.
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
response = await retry(async (signal) => {
|
|
1544
|
-
return await client.images.edit(params, { signal });
|
|
1545
|
-
});
|
|
955
|
+
this.augmentChatRequest(params, request, run.config);
|
|
956
|
+
const sent = await this.sendChat('chat', run, request, params, (signal) => run.client.chat.completions.create(params, { signal, headers: this.chatHeaders(params) }));
|
|
957
|
+
if ('overflow' in sent) {
|
|
958
|
+
return sent.overflow;
|
|
1546
959
|
}
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
960
|
+
return yield* this.relay('chat', run, request, params, this.chatChunks(sent.result, request, run.config));
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Run a chat request through the Responses API.
|
|
964
|
+
*/
|
|
965
|
+
async respond(request, run) {
|
|
966
|
+
const params = { ...await this.buildResponsesParams(request, run), stream: false };
|
|
967
|
+
const sent = await this.sendChat('responses', run, request, params, (signal) => run.client.responses.create(params, { signal }));
|
|
968
|
+
if ('overflow' in sent) {
|
|
969
|
+
return sent.overflow;
|
|
970
|
+
}
|
|
971
|
+
const chunks = [...sent.result.output.map(item => outputItemChunk(item, false, this.name)), responseEndChunk(sent.result)];
|
|
972
|
+
return this.finish('responses', run, request, params, getResponseFromChunks(chunks, run.model));
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Stream a chat request through the Responses API.
|
|
976
|
+
*/
|
|
977
|
+
async *respondStream(request, run) {
|
|
978
|
+
const params = { ...await this.buildResponsesParams(request, run), stream: true };
|
|
979
|
+
const sent = await this.sendChat('responses', run, request, params, (signal) => run.client.responses.create(params, { signal }));
|
|
980
|
+
if ('overflow' in sent) {
|
|
981
|
+
return sent.overflow;
|
|
1556
982
|
}
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
983
|
+
return yield* this.relay('responses', run, request, params, responseChunks(sent.result, this.name));
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Create an executor for chat requests.
|
|
987
|
+
*
|
|
988
|
+
* @param config Optional configuration override
|
|
989
|
+
* @returns Executor function for chat
|
|
990
|
+
* @throws {ProviderError} If request fails
|
|
991
|
+
*/
|
|
992
|
+
createExecutor(config) {
|
|
993
|
+
return async (request, ctx, metadata, signal) => {
|
|
994
|
+
const run = this.prepare('chat', request, ctx, metadata, config, signal, 'chat');
|
|
995
|
+
const sent = deliverSchema(request);
|
|
996
|
+
return this.usesResponses(sent, run.model.id, run.config, run.modelInfo)
|
|
997
|
+
? this.respond(sent, run)
|
|
998
|
+
: this.complete(sent, run);
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Create a streamer for streaming chat requests.
|
|
1003
|
+
*
|
|
1004
|
+
* @param config Optional configuration override
|
|
1005
|
+
* @returns Streamer async generator function
|
|
1006
|
+
* @throws {ProviderError} If request fails
|
|
1007
|
+
*/
|
|
1008
|
+
createStreamer(config) {
|
|
1009
|
+
const provider = this;
|
|
1010
|
+
return async function* (request, ctx, metadata, signal) {
|
|
1011
|
+
const run = provider.prepare('streaming chat', request, ctx, metadata, config, signal, 'chat');
|
|
1012
|
+
const sent = deliverSchema(request);
|
|
1013
|
+
return yield* provider.usesResponses(sent, run.model.id, run.config, run.modelInfo)
|
|
1014
|
+
? provider.respondStream(sent, run)
|
|
1015
|
+
: provider.completeStream(sent, run);
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
// ============================================================================
|
|
1019
|
+
// Images
|
|
1020
|
+
// ============================================================================
|
|
1021
|
+
/**
|
|
1022
|
+
* Relay image generation or edit progress, then report the final images
|
|
1023
|
+
* to the after hook.
|
|
1024
|
+
*/
|
|
1025
|
+
async *imageProgress(op, run, request, params, events, partials) {
|
|
1026
|
+
const images = [];
|
|
1027
|
+
let received = 0;
|
|
1028
|
+
for await (const event of events) {
|
|
1029
|
+
if (run.signal?.aborted) {
|
|
1561
1030
|
throw new Error('Request aborted');
|
|
1562
1031
|
}
|
|
1563
|
-
const
|
|
1032
|
+
const image = { b64_json: event.b64_json };
|
|
1033
|
+
const done = event.type === 'image_generation.completed' || event.type === 'image_edit.completed';
|
|
1034
|
+
if (done) {
|
|
1035
|
+
images.push(image);
|
|
1036
|
+
}
|
|
1564
1037
|
yield {
|
|
1565
|
-
progress,
|
|
1566
|
-
done
|
|
1567
|
-
image
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
model,
|
|
1038
|
+
progress: ++received / ((partials + 1) * (request.n ?? 1)),
|
|
1039
|
+
done,
|
|
1040
|
+
image,
|
|
1041
|
+
model: run.model,
|
|
1042
|
+
usage: done ? imageUsage(event, 1) : undefined,
|
|
1571
1043
|
};
|
|
1572
|
-
imageCount++;
|
|
1573
1044
|
}
|
|
1574
|
-
|
|
1045
|
+
await this.finish(op, run, request, params, { images, model: run.model });
|
|
1046
|
+
}
|
|
1575
1047
|
/**
|
|
1576
|
-
*
|
|
1577
|
-
*
|
|
1578
|
-
* Supports multiple audio formats and optional timestamps.
|
|
1048
|
+
* Generate images.
|
|
1579
1049
|
*
|
|
1580
|
-
* @param request
|
|
1581
|
-
* @param ctx Context object
|
|
1050
|
+
* @param request Image generation request parameters
|
|
1051
|
+
* @param ctx Context object
|
|
1582
1052
|
* @param config Optional configuration override
|
|
1583
|
-
* @returns
|
|
1584
|
-
* @throws {ProviderError} If
|
|
1585
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
1053
|
+
* @returns Generated images
|
|
1054
|
+
* @throws {ProviderError} If generation fails
|
|
1586
1055
|
*/
|
|
1587
|
-
|
|
1588
|
-
const
|
|
1589
|
-
const { model, retry, signal } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'transcribe', effectiveConfig.defaultModels?.transcription || 'whisper-1');
|
|
1590
|
-
const file = await toFile(request.audio);
|
|
1056
|
+
generateImage = async (request, ctx, config) => {
|
|
1057
|
+
const run = this.prepare('generate image', request, ctx, undefined, config, undefined, 'imageGenerate');
|
|
1591
1058
|
const params = {
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
language: request.language,
|
|
1595
|
-
prompt: request.prompt,
|
|
1596
|
-
temperature: request.temperature,
|
|
1597
|
-
response_format: request.responseFormat,
|
|
1598
|
-
timestamp_granularities: request.timestampGranularities,
|
|
1059
|
+
...imageParams(request, run.model.id, run.modelInfo),
|
|
1060
|
+
moderation: request.moderation,
|
|
1599
1061
|
...request.extra,
|
|
1600
1062
|
stream: false,
|
|
1601
1063
|
};
|
|
1602
|
-
|
|
1603
|
-
this.
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
model,
|
|
1626
|
-
usage: response.usage?.type === 'tokens' ? convertOpenAIUsage(response.usage) :
|
|
1627
|
-
response.usage?.seconds ? { audio: { seconds: response.usage.seconds } } : undefined,
|
|
1064
|
+
this.augmentImageGenerateRequest?.(params, request, run.config);
|
|
1065
|
+
const result = await this.send('imageGenerate', run, request, params, (signal) => run.client.images.generate(params, { signal }));
|
|
1066
|
+
const images = (result.data ?? []).map(toGeneratedImage);
|
|
1067
|
+
return this.finish('imageGenerate', run, request, params, { images, model: run.model, usage: imageUsage(result, images.length) });
|
|
1068
|
+
};
|
|
1069
|
+
/**
|
|
1070
|
+
* Generate images with streaming partial images.
|
|
1071
|
+
*
|
|
1072
|
+
* @param request Image generation request parameters
|
|
1073
|
+
* @param ctx Context object
|
|
1074
|
+
* @param config Optional configuration override
|
|
1075
|
+
* @returns Async generator yielding progress chunks
|
|
1076
|
+
* @throws {ProviderError} If generation fails
|
|
1077
|
+
*/
|
|
1078
|
+
generateImageStream = async function* (request, ctx, config) {
|
|
1079
|
+
const run = this.prepare('generate image stream', request, ctx, undefined, config, undefined, 'imageGenerate');
|
|
1080
|
+
const partials = partialImages(request);
|
|
1081
|
+
const params = {
|
|
1082
|
+
...imageParams(request, run.model.id, run.modelInfo),
|
|
1083
|
+
moderation: request.moderation,
|
|
1084
|
+
partial_images: partials,
|
|
1085
|
+
...request.extra,
|
|
1086
|
+
stream: true,
|
|
1628
1087
|
};
|
|
1629
|
-
|
|
1630
|
-
await
|
|
1631
|
-
|
|
1088
|
+
this.augmentImageGenerateRequest?.(params, request, run.config);
|
|
1089
|
+
const events = await this.send('imageGenerate', run, request, params, (signal) => run.client.images.generate(params, { signal }));
|
|
1090
|
+
yield* this.imageProgress('imageGenerate', run, request, params, events, partials);
|
|
1632
1091
|
};
|
|
1633
1092
|
/**
|
|
1634
|
-
*
|
|
1093
|
+
* Image edit parameters shared by the streaming and non-streaming paths.
|
|
1094
|
+
*/
|
|
1095
|
+
async editParams(request, run) {
|
|
1096
|
+
return {
|
|
1097
|
+
...imageParams(request, run.model.id, run.modelInfo),
|
|
1098
|
+
image: await Promise.all([request.image, ...request.references ?? []].map((image) => toFile(image))),
|
|
1099
|
+
mask: request.mask ? await toFile(request.mask) : undefined,
|
|
1100
|
+
input_fidelity: request.inputFidelity,
|
|
1101
|
+
...request.extra,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Edit or combine images with a text prompt and an optional mask.
|
|
1635
1106
|
*
|
|
1636
|
-
*
|
|
1637
|
-
*
|
|
1107
|
+
* @param request Image edit request parameters
|
|
1108
|
+
* @param ctx Context object
|
|
1109
|
+
* @param config Optional configuration override
|
|
1110
|
+
* @returns Edited images
|
|
1111
|
+
* @throws {ProviderError} If editing fails
|
|
1112
|
+
*/
|
|
1113
|
+
editImage = async (request, ctx, config) => {
|
|
1114
|
+
const run = this.prepare('edit image', request, ctx, undefined, config, undefined, 'imageEdit');
|
|
1115
|
+
const params = { ...await this.editParams(request, run), stream: false };
|
|
1116
|
+
this.augmentImageEditRequest?.(params, request, run.config);
|
|
1117
|
+
const result = await this.send('imageEdit', run, request, params, (signal) => run.client.images.edit(params, { signal }));
|
|
1118
|
+
const images = (result.data ?? []).map(toGeneratedImage);
|
|
1119
|
+
return this.finish('imageEdit', run, request, params, { images, model: run.model, usage: imageUsage(result, images.length) });
|
|
1120
|
+
};
|
|
1121
|
+
/**
|
|
1122
|
+
* Edit images with streaming partial images.
|
|
1638
1123
|
*
|
|
1639
|
-
* @param request
|
|
1640
|
-
* @param ctx Context object
|
|
1124
|
+
* @param request Image edit request parameters
|
|
1125
|
+
* @param ctx Context object
|
|
1641
1126
|
* @param config Optional configuration override
|
|
1642
|
-
* @returns Async generator yielding
|
|
1643
|
-
* @throws {ProviderError} If
|
|
1644
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
1127
|
+
* @returns Async generator yielding progress chunks
|
|
1128
|
+
* @throws {ProviderError} If editing fails
|
|
1645
1129
|
*/
|
|
1646
|
-
|
|
1647
|
-
const
|
|
1648
|
-
const
|
|
1649
|
-
const
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1130
|
+
editImageStream = async function* (request, ctx, config) {
|
|
1131
|
+
const run = this.prepare('edit image stream', request, ctx, undefined, config, undefined, 'imageEdit');
|
|
1132
|
+
const partials = partialImages(request);
|
|
1133
|
+
const params = { ...await this.editParams(request, run), partial_images: partials, stream: true };
|
|
1134
|
+
this.augmentImageEditRequest?.(params, request, run.config);
|
|
1135
|
+
const events = await this.send('imageEdit', run, request, params, (signal) => run.client.images.edit(params, { signal }));
|
|
1136
|
+
yield* this.imageProgress('imageEdit', run, request, params, events, partials);
|
|
1137
|
+
};
|
|
1138
|
+
// ============================================================================
|
|
1139
|
+
// Audio
|
|
1140
|
+
// ============================================================================
|
|
1141
|
+
/**
|
|
1142
|
+
* Transcription parameters shared by the streaming and non-streaming
|
|
1143
|
+
* paths. Diarization needs chunking, and known speakers go as data URLs.
|
|
1144
|
+
*/
|
|
1145
|
+
async transcriptionParams(request, run) {
|
|
1146
|
+
const speakers = request.knownSpeakers;
|
|
1147
|
+
return {
|
|
1148
|
+
model: run.model.id,
|
|
1149
|
+
file: await toFile(request.audio),
|
|
1653
1150
|
language: request.language,
|
|
1654
1151
|
prompt: request.prompt,
|
|
1655
1152
|
temperature: request.temperature,
|
|
1656
1153
|
response_format: request.responseFormat,
|
|
1657
1154
|
timestamp_granularities: request.timestampGranularities,
|
|
1155
|
+
chunking_strategy: request.responseFormat === 'diarized_json' ? 'auto' : undefined,
|
|
1156
|
+
known_speaker_names: speakers?.map((speaker) => speaker.name),
|
|
1157
|
+
known_speaker_references: speakers && await Promise.all(speakers.map((speaker) => toURL(speaker.audio))),
|
|
1658
1158
|
...request.extra,
|
|
1659
|
-
stream: true,
|
|
1660
1159
|
};
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Transcribe audio to text, with timed and speaker-labeled segments when
|
|
1163
|
+
* the response format provides them.
|
|
1164
|
+
*
|
|
1165
|
+
* @param request Transcription request parameters
|
|
1166
|
+
* @param ctx Context object
|
|
1167
|
+
* @param config Optional configuration override
|
|
1168
|
+
* @returns Transcription text and segments
|
|
1169
|
+
* @throws {ProviderError} If transcription fails
|
|
1170
|
+
*/
|
|
1171
|
+
transcribe = async (request, ctx, config) => {
|
|
1172
|
+
const run = this.prepare('transcribe', request, ctx, undefined, config, undefined, 'transcription');
|
|
1173
|
+
const params = { ...await this.transcriptionParams(request, run), stream: false };
|
|
1174
|
+
this.augmentTranscriptionRequest?.(params, request, run.config);
|
|
1175
|
+
// Text, SRT, and VTT formats come back as a bare string
|
|
1176
|
+
const result = await this.send('transcribe', run, request, params, (signal) => run.client.audio.transcriptions.create(params, { signal }));
|
|
1177
|
+
return this.finish('transcribe', run, request, params, typeof result === 'string'
|
|
1178
|
+
? { text: result, model: run.model }
|
|
1179
|
+
: {
|
|
1180
|
+
text: result.text,
|
|
1181
|
+
segments: 'segments' in result ? result.segments?.map(toSegment) : undefined,
|
|
1182
|
+
model: run.model,
|
|
1183
|
+
usage: result.usage && transcriptionUsage(result.usage),
|
|
1668
1184
|
});
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1185
|
+
};
|
|
1186
|
+
/**
|
|
1187
|
+
* Transcribe audio with streaming text and segments.
|
|
1188
|
+
*
|
|
1189
|
+
* @param request Transcription request parameters
|
|
1190
|
+
* @param ctx Context object
|
|
1191
|
+
* @param config Optional configuration override
|
|
1192
|
+
* @returns Async generator yielding transcription chunks
|
|
1193
|
+
* @throws {ProviderError} If transcription fails
|
|
1194
|
+
*/
|
|
1195
|
+
transcribeStream = async function* (request, ctx, config) {
|
|
1196
|
+
const run = this.prepare('transcribe stream', request, ctx, undefined, config, undefined, 'transcription');
|
|
1197
|
+
const params = { ...await this.transcriptionParams(request, run), stream: true };
|
|
1198
|
+
this.augmentTranscriptionRequest?.(params, request, run.config);
|
|
1199
|
+
const events = await this.send('transcribe', run, request, params, (signal) => run.client.audio.transcriptions.create(params, { signal }));
|
|
1200
|
+
for await (const event of events) {
|
|
1201
|
+
if (run.signal?.aborted) {
|
|
1682
1202
|
throw new Error('Request aborted');
|
|
1683
1203
|
}
|
|
1684
|
-
switch (
|
|
1204
|
+
switch (event.type) {
|
|
1685
1205
|
case 'transcript.text.delta':
|
|
1686
|
-
yield {
|
|
1687
|
-
delta: chunk.delta,
|
|
1688
|
-
};
|
|
1206
|
+
yield { delta: event.delta };
|
|
1689
1207
|
break;
|
|
1690
1208
|
case 'transcript.text.segment':
|
|
1691
|
-
yield {
|
|
1692
|
-
segment: {
|
|
1693
|
-
start: chunk.start,
|
|
1694
|
-
end: chunk.end,
|
|
1695
|
-
speaker: chunk.speaker,
|
|
1696
|
-
text: chunk.text,
|
|
1697
|
-
id: chunk.id,
|
|
1698
|
-
},
|
|
1699
|
-
};
|
|
1209
|
+
yield { segment: toSegment(event) };
|
|
1700
1210
|
break;
|
|
1701
1211
|
case 'transcript.text.done':
|
|
1702
|
-
yield {
|
|
1703
|
-
text: chunk.text,
|
|
1704
|
-
usage: chunk.usage ? convertOpenAIUsage(chunk.usage) : undefined,
|
|
1705
|
-
model,
|
|
1706
|
-
};
|
|
1212
|
+
yield { text: event.text, usage: event.usage && transcriptionUsage(event.usage), model: run.model };
|
|
1707
1213
|
break;
|
|
1708
1214
|
}
|
|
1709
1215
|
}
|
|
1710
1216
|
};
|
|
1711
1217
|
/**
|
|
1712
|
-
* Generate speech from text
|
|
1713
|
-
*
|
|
1714
|
-
* Supports multiple voices and audio formats.
|
|
1218
|
+
* Generate speech from text.
|
|
1715
1219
|
*
|
|
1716
1220
|
* @param request Speech synthesis request parameters
|
|
1717
|
-
* @param ctx Context object
|
|
1221
|
+
* @param ctx Context object
|
|
1718
1222
|
* @param config Optional configuration override
|
|
1719
|
-
* @returns Audio
|
|
1223
|
+
* @returns Audio stream and its format
|
|
1720
1224
|
* @throws {ProviderError} If speech generation fails
|
|
1721
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
1722
1225
|
*/
|
|
1723
1226
|
speech = async (request, ctx, config) => {
|
|
1724
|
-
const
|
|
1725
|
-
const { model, retry, signal } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'speech', effectiveConfig.defaultModels?.speech || 'tts-1');
|
|
1726
|
-
const { text: input, instructions, speed } = request;
|
|
1727
|
-
const voiceDefault = 'alloy';
|
|
1728
|
-
const voiceValid = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'sage', 'shimmer', 'verse', 'marin', 'cedar'];
|
|
1729
|
-
const voiceRequest = request.voice || voiceDefault;
|
|
1730
|
-
const voice = voiceValid.includes(voiceRequest) ? voiceRequest : voiceDefault;
|
|
1731
|
-
const responseFormat = request.responseFormat || 'opus';
|
|
1227
|
+
const run = this.prepare('speech', request, ctx, undefined, config, undefined, 'speech');
|
|
1732
1228
|
const params = {
|
|
1733
|
-
model: model.id,
|
|
1734
|
-
input,
|
|
1735
|
-
instructions,
|
|
1736
|
-
voice,
|
|
1737
|
-
speed,
|
|
1738
|
-
response_format: responseFormat,
|
|
1229
|
+
model: run.model.id,
|
|
1230
|
+
input: request.text,
|
|
1231
|
+
instructions: request.instructions,
|
|
1232
|
+
voice: request.voice ?? 'alloy',
|
|
1233
|
+
speed: request.speed,
|
|
1234
|
+
response_format: request.responseFormat ?? 'opus',
|
|
1739
1235
|
...request.extra,
|
|
1740
1236
|
};
|
|
1741
|
-
|
|
1742
|
-
this.
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
const client = this.createClient(effectiveConfig);
|
|
1746
|
-
let response;
|
|
1747
|
-
try {
|
|
1748
|
-
response = await retry(async (signal) => {
|
|
1749
|
-
return await client.audio.speech.create(params, { signal });
|
|
1750
|
-
});
|
|
1237
|
+
this.augmentSpeechRequest?.(params, request, run.config);
|
|
1238
|
+
const result = await this.send('speech', run, request, params, (signal) => run.client.audio.speech.create(params, { signal }));
|
|
1239
|
+
if (!result.body) {
|
|
1240
|
+
throw new ProviderError(this.name, 'Speech response had no audio');
|
|
1751
1241
|
}
|
|
1752
|
-
|
|
1753
|
-
if (isContextWindowError(e)) {
|
|
1754
|
-
const details = parseContextWindowError(e);
|
|
1755
|
-
throw new ContextWindowError(this.name, 'Context window exceeded during speech generation', details?.contextWindow, e);
|
|
1756
|
-
}
|
|
1757
|
-
else {
|
|
1758
|
-
await effectiveConfig.hooks?.speech?.onError?.(request, params, e, ctx);
|
|
1759
|
-
}
|
|
1760
|
-
throw e;
|
|
1761
|
-
}
|
|
1762
|
-
const result = {
|
|
1763
|
-
model,
|
|
1764
|
-
audio: response.body,
|
|
1765
|
-
responseFormat,
|
|
1766
|
-
};
|
|
1767
|
-
// Call post-request hook with params
|
|
1768
|
-
await effectiveConfig.hooks?.speech?.afterRequest?.(request, params, result, ctx);
|
|
1769
|
-
return result;
|
|
1242
|
+
return this.finish('speech', run, request, params, { model: run.model, audio: result.body, responseFormat: params.response_format });
|
|
1770
1243
|
};
|
|
1771
1244
|
/**
|
|
1772
1245
|
* Generate text embeddings for semantic search and similarity.
|
|
1773
1246
|
*
|
|
1774
|
-
* Supports text-embedding-3-small, text-embedding-3-large, and ada-002 models.
|
|
1775
|
-
*
|
|
1776
1247
|
* @param request Embedding request parameters
|
|
1777
|
-
* @param ctx Context object
|
|
1248
|
+
* @param ctx Context object
|
|
1778
1249
|
* @param config Optional configuration override
|
|
1779
1250
|
* @returns Array of embeddings with usage information
|
|
1780
1251
|
* @throws {ProviderError} If embedding generation fails
|
|
1781
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
1782
1252
|
*/
|
|
1783
1253
|
embed = async (request, ctx, config) => {
|
|
1784
|
-
const
|
|
1785
|
-
const { model, retry } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'embed', effectiveConfig.defaultModels?.embedding || 'text-embedding-3-small');
|
|
1254
|
+
const run = this.prepare('embed', request, ctx, undefined, config, undefined, 'embedding');
|
|
1786
1255
|
const params = {
|
|
1787
|
-
model: model.id,
|
|
1256
|
+
model: run.model.id,
|
|
1788
1257
|
input: request.texts,
|
|
1789
1258
|
dimensions: request.dimensions,
|
|
1790
1259
|
encoding_format: request.encodingFormat,
|
|
1791
1260
|
user: request.userIdentifier,
|
|
1792
1261
|
...request.extra,
|
|
1793
1262
|
};
|
|
1794
|
-
|
|
1795
|
-
this.
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
response = await retry(async (signal) => {
|
|
1802
|
-
return await client.embeddings.create(params, { signal });
|
|
1803
|
-
});
|
|
1804
|
-
}
|
|
1805
|
-
catch (e) {
|
|
1806
|
-
if (isContextWindowError(e)) {
|
|
1807
|
-
const details = parseContextWindowError(e);
|
|
1808
|
-
throw new ContextWindowError(this.name, 'Context window exceeded during embedding generation', details?.contextWindow, e);
|
|
1809
|
-
}
|
|
1810
|
-
else {
|
|
1811
|
-
await effectiveConfig.hooks?.embed?.onError?.(request, params, e, ctx);
|
|
1812
|
-
}
|
|
1813
|
-
throw e;
|
|
1814
|
-
}
|
|
1815
|
-
const result = {
|
|
1816
|
-
embeddings: response.data.map((item) => ({
|
|
1817
|
-
embedding: item.embedding,
|
|
1818
|
-
index: item.index,
|
|
1819
|
-
})),
|
|
1820
|
-
model,
|
|
1821
|
-
usage: response.usage
|
|
1822
|
-
? {
|
|
1823
|
-
embeddings: {
|
|
1824
|
-
count: response.data.length,
|
|
1825
|
-
tokens: response.usage.prompt_tokens
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
: undefined,
|
|
1829
|
-
};
|
|
1830
|
-
// Call post-request hook with params
|
|
1831
|
-
await effectiveConfig.hooks?.embed?.afterRequest?.(request, params, result, ctx);
|
|
1832
|
-
return result;
|
|
1263
|
+
this.augmentEmbeddingRequest?.(params, request, run.config);
|
|
1264
|
+
const result = await this.send('embed', run, request, params, (signal) => run.client.embeddings.create(params, { signal }));
|
|
1265
|
+
return this.finish('embed', run, request, params, {
|
|
1266
|
+
embeddings: result.data.map(({ embedding, index }) => ({ embedding, index })),
|
|
1267
|
+
model: run.model,
|
|
1268
|
+
usage: { embeddings: { count: result.data.length, tokens: result.usage.prompt_tokens } },
|
|
1269
|
+
});
|
|
1833
1270
|
};
|
|
1271
|
+
// ============================================================================
|
|
1272
|
+
// Decisions
|
|
1273
|
+
// ============================================================================
|
|
1834
1274
|
/**
|
|
1835
|
-
*
|
|
1275
|
+
* Typed answers about an input. A conversation goes in as Responses input.
|
|
1836
1276
|
*
|
|
1837
|
-
*
|
|
1838
|
-
*
|
|
1839
|
-
*
|
|
1840
|
-
* @param request Image analysis request parameters
|
|
1841
|
-
* @param ctx Context object (not used)
|
|
1277
|
+
* @param request Decision request parameters
|
|
1278
|
+
* @param ctx Context object
|
|
1842
1279
|
* @param config Optional configuration override
|
|
1843
|
-
* @returns
|
|
1844
|
-
* @throws {ProviderError} If analysis fails
|
|
1845
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
1280
|
+
* @returns Answers by question name
|
|
1846
1281
|
*/
|
|
1847
|
-
|
|
1848
|
-
const
|
|
1849
|
-
// Convert ImageAnalyzeRequest to chat Request
|
|
1850
|
-
const chatRequest = {
|
|
1851
|
-
messages: [
|
|
1852
|
-
{
|
|
1853
|
-
role: 'user',
|
|
1854
|
-
content: [
|
|
1855
|
-
{ type: 'text', content: request.prompt },
|
|
1856
|
-
...request.images.map((image) => ({
|
|
1857
|
-
type: 'image',
|
|
1858
|
-
content: image,
|
|
1859
|
-
})),
|
|
1860
|
-
],
|
|
1861
|
-
},
|
|
1862
|
-
],
|
|
1863
|
-
temperature: request.temperature,
|
|
1864
|
-
maxTokens: request.maxTokens,
|
|
1865
|
-
};
|
|
1866
|
-
// Construct the params that would be sent to OpenAI
|
|
1867
|
-
const { model } = this.getRequestData(chatRequest, undefined, ctx, effectiveConfig, 'analyze image', effectiveConfig.defaultModels?.imageAnalyze || 'gpt-4-vision-preview');
|
|
1868
|
-
const messages = await this.convertMessages(chatRequest, effectiveConfig);
|
|
1282
|
+
decide = async (request, ctx, config) => {
|
|
1283
|
+
const run = this.prepare('decide', request, ctx, undefined, config, undefined, 'decision');
|
|
1869
1284
|
const params = {
|
|
1870
|
-
model: model.id,
|
|
1871
|
-
messages,
|
|
1872
|
-
|
|
1873
|
-
|
|
1285
|
+
model: run.model.id,
|
|
1286
|
+
input: typeof request.input === 'string' ? request.input : await this.convertResponsesInput({ messages: request.input }, run.config),
|
|
1287
|
+
questions: Object.entries(request.questions).map(([name, question]) => toDecisionQuestion(name, question)),
|
|
1288
|
+
...request.extra,
|
|
1874
1289
|
};
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
response = await executor(chatRequest, ctx, {}, undefined);
|
|
1882
|
-
// Call post-request hook with params
|
|
1883
|
-
await effectiveConfig.hooks?.imageAnalyze?.afterRequest?.(request, params, response, ctx);
|
|
1884
|
-
}
|
|
1885
|
-
catch (e) {
|
|
1886
|
-
await effectiveConfig.hooks?.imageAnalyze?.onError?.(request, params, e, ctx);
|
|
1887
|
-
throw e;
|
|
1888
|
-
}
|
|
1889
|
-
return response;
|
|
1290
|
+
const result = await this.send('decide', run, request, params, (signal) => run.client.post('/decisions', { body: params, signal }));
|
|
1291
|
+
return this.finish('decide', run, request, params, {
|
|
1292
|
+
answers: Object.fromEntries(result.answers.map(answer => [answer.name, toDecisionAnswer(answer)])),
|
|
1293
|
+
model: run.model,
|
|
1294
|
+
usage: responsesUsage(result.usage),
|
|
1295
|
+
});
|
|
1890
1296
|
};
|
|
1297
|
+
// ============================================================================
|
|
1298
|
+
// Batch
|
|
1299
|
+
// ============================================================================
|
|
1891
1300
|
/**
|
|
1892
|
-
*
|
|
1301
|
+
* Run chat requests as one batch on one model. Each request becomes the
|
|
1302
|
+
* same call a direct request would make, through Responses when any of
|
|
1303
|
+
* them needs it.
|
|
1893
1304
|
*
|
|
1894
|
-
*
|
|
1895
|
-
*
|
|
1305
|
+
* @param requests Chat requests, whose results come back in this order
|
|
1306
|
+
* @param ctx Context object
|
|
1307
|
+
* @param config Optional configuration override
|
|
1308
|
+
* @returns The submitted batch
|
|
1309
|
+
*/
|
|
1310
|
+
submitBatch = async (requests, ctx, config) => {
|
|
1311
|
+
const [first] = requests;
|
|
1312
|
+
if (!first) {
|
|
1313
|
+
throw new ProviderError(this.name, 'A batch needs at least one request');
|
|
1314
|
+
}
|
|
1315
|
+
const run = this.prepare('submit batch', first, ctx, undefined, config, undefined, 'chat');
|
|
1316
|
+
const responses = requests.some(request => this.usesResponses(request, run.model.id, run.config, run.modelInfo));
|
|
1317
|
+
const lines = await Promise.all(requests.map(async (request, index) => {
|
|
1318
|
+
if (responses) {
|
|
1319
|
+
return { custom_id: String(index), body: await this.buildResponsesParams(request, run) };
|
|
1320
|
+
}
|
|
1321
|
+
const params = await this.buildChatParams(request, run, false);
|
|
1322
|
+
this.augmentChatRequest(params, request, run.config);
|
|
1323
|
+
return { custom_id: String(index), body: params };
|
|
1324
|
+
}));
|
|
1325
|
+
return this.createBatch({ endpoint: responses ? '/v1/responses' : '/v1/chat/completions', model: run.model.id, lines }, run);
|
|
1326
|
+
};
|
|
1327
|
+
/**
|
|
1328
|
+
* A batch as it stands now, with its results once it completes.
|
|
1896
1329
|
*
|
|
1897
|
-
* @param
|
|
1898
|
-
* @param ctx Context object
|
|
1330
|
+
* @param job The batch from submitBatch
|
|
1331
|
+
* @param ctx Context object
|
|
1899
1332
|
* @param config Optional configuration override
|
|
1900
|
-
* @returns Async generator yielding response chunks
|
|
1901
|
-
* @throws {ProviderError} If analysis fails
|
|
1902
|
-
* @throws {RateLimitError} If rate limit is exceeded
|
|
1903
1333
|
*/
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1334
|
+
getBatch = (job, ctx, config) => this.retrieveBatch(job, this.prepare('get batch', { model: job.model }, ctx, undefined, config, undefined, 'chat'));
|
|
1335
|
+
/**
|
|
1336
|
+
* Start a batch: OpenAI takes the requests as an uploaded JSONL file.
|
|
1337
|
+
*
|
|
1338
|
+
* @param batch The requests
|
|
1339
|
+
* @param run The resolved call
|
|
1340
|
+
*/
|
|
1341
|
+
async createBatch(batch, run) {
|
|
1342
|
+
const jsonl = batch.lines.map(line => JSON.stringify({ ...line, method: 'POST', url: batch.endpoint })).join('\n');
|
|
1343
|
+
const file = await toFile(new TextEncoder().encode(jsonl), 'application/jsonl', 'batch.jsonl');
|
|
1344
|
+
const input = await run.retry((signal) => run.client.files.create({ file, purpose: 'batch' }, { signal }));
|
|
1345
|
+
const created = await run.retry((signal) => run.client.batches.create({ input_file_id: input.id, endpoint: batch.endpoint, completion_window: '24h' }, { signal }));
|
|
1346
|
+
return this.batchJob(created, run);
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Read a batch: OpenAI writes results, and failed requests, to files.
|
|
1350
|
+
*
|
|
1351
|
+
* @param job The batch
|
|
1352
|
+
* @param run The resolved call
|
|
1353
|
+
*/
|
|
1354
|
+
async retrieveBatch(job, run) {
|
|
1355
|
+
const batch = await run.retry((signal) => run.client.batches.retrieve(job.id, { signal }));
|
|
1356
|
+
if (batch.status !== 'completed') {
|
|
1357
|
+
return this.batchJob(batch, run);
|
|
1358
|
+
}
|
|
1359
|
+
const files = [batch.output_file_id, batch.error_file_id].filter((id) => !!id);
|
|
1360
|
+
const lines = await Promise.all(files.map(async (id) => {
|
|
1361
|
+
const content = await run.retry(async (signal) => (await run.client.files.content(id, { signal })).text());
|
|
1362
|
+
return content.split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
1363
|
+
}));
|
|
1364
|
+
return { ...this.batchJob(batch, run), results: this.batchResults(lines.flat(), batch.request_counts?.total ?? 0, run) };
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* A batch as @aeye reports it.
|
|
1368
|
+
*/
|
|
1369
|
+
batchJob(batch, run) {
|
|
1370
|
+
return {
|
|
1371
|
+
id: batch.id,
|
|
1372
|
+
provider: this.name,
|
|
1373
|
+
model: run.model.id,
|
|
1374
|
+
status: batch.status,
|
|
1375
|
+
counts: batch.request_counts,
|
|
1376
|
+
error: batch.errors?.data?.[0]?.message,
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Results in request order, each mapped as its direct call would be.
|
|
1381
|
+
*
|
|
1382
|
+
* @param lines The results, in any order
|
|
1383
|
+
* @param total How many requests the batch had
|
|
1384
|
+
* @param run The resolved call
|
|
1385
|
+
*/
|
|
1386
|
+
batchResults(lines, total, run) {
|
|
1387
|
+
const results = Array.from({ length: Math.max(total, lines.length) }, () => ({ error: 'No result' }));
|
|
1388
|
+
for (const line of lines) {
|
|
1389
|
+
const body = line.response?.body;
|
|
1390
|
+
results[Number(line.custom_id)] = body && 'object' in body && !line.error
|
|
1391
|
+
? { response: this.batchResponse(body, run) }
|
|
1392
|
+
: { error: line.error?.message ?? (body && !('object' in body) ? body.error.message : `Request failed with status ${line.response?.status_code}`) };
|
|
1393
|
+
}
|
|
1394
|
+
return results;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* One batch result body as a @aeye response.
|
|
1398
|
+
*/
|
|
1399
|
+
batchResponse(body, run) {
|
|
1400
|
+
if (body.object === 'response') {
|
|
1401
|
+
return getResponseFromChunks([...body.output.map(item => outputItemChunk(item, false, this.name)), responseEndChunk(body)], run.model);
|
|
1402
|
+
}
|
|
1403
|
+
const response = this.chatResponse(body, run);
|
|
1404
|
+
this.augmentChatResponse(body, response, run.config);
|
|
1405
|
+
return response;
|
|
1406
|
+
}
|
|
1407
|
+
// ============================================================================
|
|
1408
|
+
// Image Analysis
|
|
1409
|
+
// ============================================================================
|
|
1410
|
+
/**
|
|
1411
|
+
* An image analysis request as a chat request, and the metadata naming
|
|
1412
|
+
* the configured analysis model as the fallback.
|
|
1413
|
+
*/
|
|
1414
|
+
analysisChat(request, config) {
|
|
1415
|
+
const chat = {
|
|
1416
|
+
model: request.model,
|
|
1417
|
+
messages: [{
|
|
1910
1418
|
role: 'user',
|
|
1911
1419
|
content: [
|
|
1912
1420
|
{ type: 'text', content: request.prompt },
|
|
1913
|
-
...request.images.map((image) => ({
|
|
1914
|
-
type: 'image',
|
|
1915
|
-
content: image,
|
|
1916
|
-
})),
|
|
1421
|
+
...request.images.map((image) => ({ type: 'image', content: image })),
|
|
1917
1422
|
],
|
|
1918
|
-
},
|
|
1919
|
-
],
|
|
1423
|
+
}],
|
|
1920
1424
|
temperature: request.temperature,
|
|
1921
1425
|
maxTokens: request.maxTokens,
|
|
1922
1426
|
};
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
let finalUsage = undefined;
|
|
1942
|
-
let finishReason = undefined;
|
|
1943
|
-
const toolCalls = [];
|
|
1944
|
-
try {
|
|
1945
|
-
for await (const chunk of stream) {
|
|
1946
|
-
if (chunk.content) {
|
|
1947
|
-
accumulatedContent += chunk.content;
|
|
1948
|
-
}
|
|
1949
|
-
if (chunk.usage) {
|
|
1950
|
-
finalUsage = chunk.usage;
|
|
1951
|
-
}
|
|
1952
|
-
if (chunk.finishReason) {
|
|
1953
|
-
finishReason = chunk.finishReason;
|
|
1954
|
-
}
|
|
1955
|
-
if (chunk.toolCall) {
|
|
1956
|
-
toolCalls.push(chunk.toolCall);
|
|
1957
|
-
}
|
|
1958
|
-
yield chunk;
|
|
1959
|
-
}
|
|
1960
|
-
}
|
|
1961
|
-
finally {
|
|
1962
|
-
// Call post-request hook with params
|
|
1963
|
-
const finalResponse = {
|
|
1964
|
-
content: accumulatedContent,
|
|
1965
|
-
toolCalls,
|
|
1966
|
-
finishReason: toolCalls.length ? 'tool_calls' : finishReason || 'stop',
|
|
1967
|
-
model,
|
|
1968
|
-
usage: finalUsage,
|
|
1969
|
-
};
|
|
1970
|
-
await effectiveConfig.hooks?.imageAnalyze?.afterRequest?.(request, params, finalResponse, ctx);
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
catch (e) {
|
|
1974
|
-
await effectiveConfig.hooks?.imageAnalyze?.onError?.(request, params, e, ctx);
|
|
1975
|
-
throw e;
|
|
1976
|
-
}
|
|
1427
|
+
const model = config?.defaultModels?.imageAnalyze ?? this.config.defaultModels?.imageAnalyze ?? this.defaults.imageAnalyze;
|
|
1428
|
+
return [chat, { model }];
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Analyze images with a vision model. Runs as a chat request, reported
|
|
1432
|
+
* through the chat hooks.
|
|
1433
|
+
*/
|
|
1434
|
+
analyzeImage = (request, ctx, config) => {
|
|
1435
|
+
const [chat, metadata] = this.analysisChat(request, config);
|
|
1436
|
+
return this.createExecutor(config)(chat, ctx, metadata);
|
|
1437
|
+
};
|
|
1438
|
+
/**
|
|
1439
|
+
* Analyze images with a vision model, streaming the answer. Runs as a
|
|
1440
|
+
* chat request, reported through the chat hooks.
|
|
1441
|
+
*/
|
|
1442
|
+
analyzeImageStream = (request, ctx, config) => {
|
|
1443
|
+
const [chat, metadata] = this.analysisChat(request, config);
|
|
1444
|
+
return this.createStreamer(config)(chat, ctx, metadata);
|
|
1977
1445
|
};
|
|
1978
1446
|
}
|
|
1447
|
+
/**
|
|
1448
|
+
* The encoding of audio a chat request asks for: as requested, or the
|
|
1449
|
+
* default for how it is delivered (audio streams only as PCM).
|
|
1450
|
+
*/
|
|
1451
|
+
function outputAudioFormat(request, streaming) {
|
|
1452
|
+
return request.modalities?.includes('audio') ? request.audio?.format ?? (streaming ? 'pcm' : 'mp3') : undefined;
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* A generated image as returned by the Images API.
|
|
1456
|
+
*/
|
|
1457
|
+
function toGeneratedImage(image) {
|
|
1458
|
+
return { url: image.url, b64_json: image.b64_json, revisedPrompt: image.revised_prompt };
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* A transcription segment from a verbose or diarized transcription, or a
|
|
1462
|
+
* streamed segment event.
|
|
1463
|
+
*/
|
|
1464
|
+
function toSegment(segment) {
|
|
1465
|
+
return { id: String(segment.id), start: segment.start, end: segment.end, text: segment.text, speaker: segment.speaker };
|
|
1466
|
+
}
|
|
1979
1467
|
//# sourceMappingURL=openai.js.map
|