@aeye/openai 0.3.10 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/openai.js CHANGED
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * OpenAI Provider
3
3
  *
4
- * Provider for OpenAI's GPT models, DALL-E, Whisper, and TTS.
5
- * Also serves as base class for OpenAI-compatible providers.
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, resolveStrictFormat } from '@aeye/ai';
8
- import { toFile, getDescriptor, getModel, LENIENT, SchemaBudget, strictestOf, strictify, strictPriority, toBase64, toJSONSchema, toText, toURL, getResponseFromChunks } from '@aeye/core';
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
- const candidates = [metadata?.model, ctx?.metadata?.model];
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 completions with GPT-4, GPT-4 Turbo, GPT-3.5 Turbo
41
- * - Vision with GPT-4 Vision (GPT-4V)
42
- * - Reasoning models (o1, o3-mini)
43
- * - Image generation with DALL-E 2 and DALL-E 3
44
- * - Speech-to-text with Whisper
45
- * - Text-to-speech with TTS
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
- * @returns Modified parameters
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
- this.augmentChatContent(chatContent, content, config);
342
- return chatContent;
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 openai OpenAI chat completion response
357
- * @param response @aeye response object to augment
358
- * @returns
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
- * @param x
369
- * @param name
370
- * @returns
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
- if (!Array.isArray(x)) {
377
- console.warn(`[OpenAIProvider] convertContentText: Unsupported content type from ${name}.`);
378
- return [];
379
- }
380
- return x.map((part) => {
381
- if (part.type === 'text') {
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
- * @param x
399
- * @param name
400
- * @returns
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 this.getChatContent({
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 this.getChatContent({
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 this.getChatContent({
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) || [null, '', ''];
438
- const format = part.format || mimeType && mimeType.includes('wav') ? 'wav' : 'mp3';
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 this.getChatContent({
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 this.getChatContent({
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 OpenAI format.
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
- role: 'developer',
515
- name: this.convertName(msg.name),
516
- content: this.convertContentText(msg.content, msg.name || 'system', config),
517
- }, msg, config);
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
- role: 'tool',
521
- tool_call_id: msg.toolCallId,
522
- content: this.convertContentText(msg.content, msg.name || 'tool', config),
523
- }, msg, config);
524
- case 'assistant':
525
- const content = await this.convertContent(msg.content, msg.name || 'assistant', config);
526
- if (!content || typeof content === 'string' || content.every(c => c.type === 'text')) {
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
- tool_calls: msg.toolCalls?.map((tc) => this.convertToolCall(tc)),
531
- content,
532
- }, msg, config);
533
- }
534
- else {
535
- // Mixed content - need to split into multiple messages where non-text parts are separate messages
536
- // sent as user messages because assistent role cannot have non-text content
537
- const firstText = content.findIndex(c => c.type === 'text');
538
- return content.map((contentPart, index) => {
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 this.getChatMessage({
558
- role: msg.role,
559
- name: this.convertName(msg.name),
560
- content: await this.convertContent(msg.content, msg.name || 'user', config),
561
- }, msg, config);
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, model, budget) {
461
+ convertTools(request, budget) {
585
462
  if (!request.tools || request.tools.length === 0)
586
463
  return undefined;
587
- const localBudget = budget ?? this.buildSchemaBudget(model);
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
- const result = {
472
+ return {
597
473
  type: 'function',
598
474
  function: {
599
475
  name: tool.name,
600
- description: tool.description,
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
- * Build a SchemaBudget tuned to the strictest of the model's tool /
618
- * structured-output families. Anthropic-style per-request limits apply
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
- allocateToolDescriptors(request, budget) {
645
- const tools = request.tools;
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
- if (!request.toolChoice)
667
- return undefined;
668
- if (request.toolChoice === 'auto')
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 undefined;
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 model the resolved model the request is targeting
528
+ * @param budget shared allocator (also fed by `convertTools`)
695
529
  * @returns OpenAI-formatted response format or undefined
696
530
  */
697
- convertResponseFormat(request, model, budget) {
698
- if (!request.responseFormat)
699
- return undefined;
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
- const localBudget = budget ?? this.buildSchemaBudget(model);
710
- const descriptor = localBudget.allocateOutput(request.responseFormat.type, request.responseFormat.strict ?? 1);
711
- request.responseFormat.descriptor = descriptor.id;
712
- const strictifiedSchema = strictify(request.responseFormat.type, descriptor);
713
- const json_schema = {
714
- name: 'response',
715
- schema: toJSONSchema(strictifiedSchema, descriptor),
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 effectiveConfig = config || this.config;
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
- const models = response.data;
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
- const effectiveConfig = config || this.config;
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
- * Get request data common to all request types.
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 effectiveConfig - The effective configuration
767
- * @param defaultModel - Optional default model
768
- * @returns
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
- getRequestData(request, requestSignal, ctx, effectiveConfig, operation, defaultModel) {
771
- const model = getModel(request.model || ctx.metadata?.model || defaultModel);
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, ...(request.extra?.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 signal = requestSignal || ctx.signal;
784
- const retry = (fn) => {
785
- return withRetry(() => fn(signal), retryContext, retryConfig, retryEvents, signal);
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
- * Create an executor for chat completion requests.
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
- createExecutor(config) {
805
- const effectiveConfig = { ...this.config, ...config };
806
- const client = this.createClient(effectiveConfig);
807
- return async (request, ctx, metadata, requestSignal) => {
808
- const { model, retry } = this.getRequestData(request, requestSignal, ctx, effectiveConfig, 'chat', metadata?.model || effectiveConfig.defaultModels?.chat);
809
- // Resolve full ModelInfo for strict-format decisions. BaseAPI injects
810
- // the selected ModelInfo into ctx.metadata.model after model selection;
811
- // fall back to the lightweight model from getRequestData when no
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
- const choice = completion.choices[0];
879
- const toolCalls = choice.message.tool_calls
880
- ?.filter((tc) => tc.type === 'function')
881
- .map((tc) => ({
882
- id: tc.id,
883
- name: tc.function.name,
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
- * Create a streamer for streaming chat completion requests.
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
- createStreamer(config) {
912
- const effectiveConfig = { ...this.config, ...config };
913
- const client = this.createClient(effectiveConfig);
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
- * Get image generation parameters based on model and request.
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 - Image generation request
1111
- * @param model - Model ID
1112
- * @returns
657
+ * @param request - The request as sent
658
+ * @param error - The API's 400 or 422 error
1113
659
  */
1114
- getGenerateParameters(request, model) {
1115
- const modelId = model.includes('gpt-image-')
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
- * Generate images.
1253
- *
1254
- * @param request Image generation request parameters
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
- generateImage = async (request, ctx, config) => {
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
- response = await retry(async (signal) => {
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 (isContextWindowError(e)) {
1284
- const details = parseContextWindowError(e);
1285
- throw new ContextWindowError(this.name, 'Context window exceeded during image generation', details?.contextWindow, e);
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
- throw e;
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
- const result = {
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
- * Generate images with streaming progress updates.
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
- generateImageStream = async function* (request, ctx, config) {
1319
- const effectiveConfig = { ...this.config, ...config };
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
- response = await retry(async (signal) => {
1335
- return await client.images.generate(params, { signal });
1336
- });
1337
- }
1338
- catch (e) {
1339
- if (isContextWindowError(e)) {
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
- // Yield completion with results
1349
- let imageCount = 0;
1350
- for await (const img of response) {
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
- * Get image generation parameters based on model and request.
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 - Image generation request
1369
- * @param model - Model ID
1370
- * @returns
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
- getEditParameters(request, model) {
1373
- const modelId = model.includes('gpt-image-')
1374
- ? 'gpt-image'
1375
- : model.includes('dall-e-2')
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 params = {
1442
- model,
1443
- prompt: request.prompt,
1444
- n: request.n ? Math.max(modelParameters[modelId].n.min, Math.min(modelParameters[modelId].n.max, request.n)) : undefined,
1445
- size,
1446
- response_format: request.responseFormat ? modelParameters[modelId].responseFormat[request.responseFormat] : undefined,
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
- * Edit images using DALL-E models.
731
+ * Chat Completions parameters for a request, before the stream flag.
1452
732
  *
1453
- * Supports image editing with text prompts and optional masks.
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 Image edit request parameters
1456
- * @param ctx Context object (not used)
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
- editImage = async (request, ctx, config) => {
1463
- const effectiveConfig = { ...this.config, ...config };
1464
- const { model, retry } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'edit image', effectiveConfig.defaultModels?.imageEdit || 'gpt-image-1');
1465
- const image = await toFile(request.image);
1466
- const mask = request.mask ? await toFile(request.mask) : undefined;
1467
- const baseParams = this.getEditParameters(request, model.id);
1468
- const params = {
1469
- ...baseParams,
1470
- image,
1471
- mask,
1472
- user: request.userIdentifier,
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
- // Apply provider-specific customizations
1477
- this.augmentImageGenerateRequest?.(params, request, effectiveConfig);
1478
- // Call pre-request hook with params
1479
- await effectiveConfig.hooks?.imageEdit?.beforeRequest?.(request, params, ctx);
1480
- const client = this.createClient(effectiveConfig);
1481
- let response;
1482
- try {
1483
- response = await retry(async (signal) => {
1484
- return await client.images.edit(params, { signal });
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
- catch (e) {
1488
- if (isContextWindowError(e)) {
1489
- const details = parseContextWindowError(e);
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
- else {
1493
- await effectiveConfig.hooks?.imageEdit?.onError?.(request, params, e, ctx);
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
- throw e;
937
+ return completion;
938
+ });
939
+ if ('overflow' in sent) {
940
+ return sent.overflow;
1496
941
  }
1497
- const result = {
1498
- images: (response.data || []).map((img) => ({
1499
- url: img.url || undefined,
1500
- b64_json: img.b64_json || undefined,
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
- * Edit images with streaming progress updates.
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
- editImageStream = async function* (request, ctx, config) {
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
- ...baseParams,
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.augmentImageEditRequest?.(params, request, effectiveConfig);
1540
- const client = this.createClient(effectiveConfig);
1541
- let response;
1542
- try {
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
- catch (e) {
1548
- if (isContextWindowError(e)) {
1549
- const details = parseContextWindowError(e);
1550
- throw new ContextWindowError(this.name, 'Context window exceeded during image editing', details?.contextWindow, e);
1551
- }
1552
- else {
1553
- await effectiveConfig.hooks?.imageEdit?.onError?.(request, params, e, ctx);
1554
- }
1555
- throw e;
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
- // Yield completion with results
1558
- let imageCount = 0;
1559
- for await (const img of response) {
1560
- if (signal?.aborted) {
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 progress = (imageCount + 1) / (imageStreams + 1);
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: img.type === 'image_edit.completed',
1567
- image: {
1568
- b64_json: img.b64_json || undefined,
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
- * Transcribe audio to text using Whisper models.
1577
- *
1578
- * Supports multiple audio formats and optional timestamps.
1048
+ * Generate images.
1579
1049
  *
1580
- * @param request Transcription request parameters
1581
- * @param ctx Context object (not used)
1050
+ * @param request Image generation request parameters
1051
+ * @param ctx Context object
1582
1052
  * @param config Optional configuration override
1583
- * @returns Transcription text and optional metadata
1584
- * @throws {ProviderError} If transcription fails
1585
- * @throws {RateLimitError} If rate limit is exceeded
1053
+ * @returns Generated images
1054
+ * @throws {ProviderError} If generation fails
1586
1055
  */
1587
- transcribe = async (request, ctx, config) => {
1588
- const effectiveConfig = { ...this.config, ...config };
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
- model: model.id,
1593
- file,
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
- // Apply provider-specific customizations
1603
- this.augmentTranscriptionRequest?.(params, request, effectiveConfig);
1604
- // Call pre-request hook with params
1605
- await effectiveConfig.hooks?.transcribe?.beforeRequest?.(request, params, ctx);
1606
- const client = this.createClient(effectiveConfig);
1607
- let response;
1608
- try {
1609
- response = await retry(async (signal) => {
1610
- return await client.audio.transcriptions.create(params, { signal });
1611
- });
1612
- }
1613
- catch (e) {
1614
- if (isContextWindowError(e)) {
1615
- const details = parseContextWindowError(e);
1616
- throw new ContextWindowError(this.name, 'Context window exceeded during image editing', details?.contextWindow, e);
1617
- }
1618
- else {
1619
- await effectiveConfig.hooks?.transcribe?.onError?.(request, params, e, ctx);
1620
- }
1621
- throw e;
1622
- }
1623
- const result = {
1624
- text: response.text,
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
- // Call post-request hook with params
1630
- await effectiveConfig.hooks?.transcribe?.afterRequest?.(request, params, result, ctx);
1631
- return result;
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
- * Transcribe audio with streaming progress updates.
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
- * Note: OpenAI's API doesn't natively support streaming transcription,
1637
- * so this implementation returns the result with progress indicators.
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 Transcription request parameters
1640
- * @param ctx Context object (not used)
1124
+ * @param request Image edit request parameters
1125
+ * @param ctx Context object
1641
1126
  * @param config Optional configuration override
1642
- * @returns Async generator yielding transcription chunks
1643
- * @throws {ProviderError} If transcription fails
1644
- * @throws {RateLimitError} If rate limit is exceeded
1127
+ * @returns Async generator yielding progress chunks
1128
+ * @throws {ProviderError} If editing fails
1645
1129
  */
1646
- transcribeStream = async function* (request, ctx, config) {
1647
- const effectiveConfig = { ...this.config, ...config };
1648
- const { model, retry, signal } = this.getRequestData(request, undefined, ctx, effectiveConfig, 'transcribe stream', effectiveConfig.defaultModels?.transcription || 'whisper-1');
1649
- const file = await toFile(request.audio);
1650
- const params = {
1651
- model: model.id,
1652
- file,
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
- this.augmentTranscriptionRequest?.(params, request, effectiveConfig);
1662
- await effectiveConfig.hooks?.transcribe?.beforeRequest?.(request, params, ctx);
1663
- const client = this.createClient(effectiveConfig);
1664
- let response;
1665
- try {
1666
- response = await retry(async (signal) => {
1667
- return await client.audio.transcriptions.create(params, { signal });
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
- catch (e) {
1671
- if (isContextWindowError(e)) {
1672
- const details = parseContextWindowError(e);
1673
- throw new ContextWindowError(this.name, 'Context window exceeded during transcription', details?.contextWindow, e);
1674
- }
1675
- else {
1676
- await effectiveConfig.hooks?.transcribe?.onError?.(request, params, e, ctx);
1677
- }
1678
- throw e;
1679
- }
1680
- for await (const chunk of response) {
1681
- if (signal?.aborted) {
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 (chunk.type) {
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 using TTS models.
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 (not used)
1221
+ * @param ctx Context object
1718
1222
  * @param config Optional configuration override
1719
- * @returns Audio buffer and content type
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 effectiveConfig = { ...this.config, ...config };
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
- // Apply provider-specific customizations
1742
- this.augmentSpeechRequest?.(params, request, effectiveConfig);
1743
- // Call pre-request hook with params
1744
- await effectiveConfig.hooks?.speech?.beforeRequest?.(request, params, ctx);
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
- catch (e) {
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 (not used)
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 effectiveConfig = { ...this.config, ...config };
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
- // Apply provider-specific customizations
1795
- this.augmentEmbeddingRequest?.(params, request, effectiveConfig);
1796
- // Call pre-request hook with params
1797
- await effectiveConfig.hooks?.embed?.beforeRequest?.(request, params, ctx);
1798
- const client = this.createClient(effectiveConfig);
1799
- let response;
1800
- try {
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
- * Analyze images using vision-capable models.
1275
+ * Typed answers about an input. A conversation goes in as Responses input.
1836
1276
  *
1837
- * This method converts the image analysis request into a chat request
1838
- * with image content and calls the chat API internally.
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 Analysis response with text content
1844
- * @throws {ProviderError} If analysis fails
1845
- * @throws {RateLimitError} If rate limit is exceeded
1280
+ * @returns Answers by question name
1846
1281
  */
1847
- analyzeImage = async (request, ctx, config) => {
1848
- const effectiveConfig = { ...this.config, ...config };
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
- temperature: chatRequest.temperature,
1873
- max_completion_tokens: chatRequest.maxTokens,
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
- // Call pre-request hook with params
1876
- await effectiveConfig.hooks?.imageAnalyze?.beforeRequest?.(request, params, ctx);
1877
- const executor = this.createExecutor(effectiveConfig);
1878
- let response;
1879
- try {
1880
- // Use the chat executor to analyze the images (this will also call chat hooks)
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
- * Analyze images with streaming response.
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
- * This method converts the image analysis request into a chat request
1895
- * with image content and calls the streaming chat API internally.
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 request Image analysis request parameters
1898
- * @param ctx Context object (not used)
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
- analyzeImageStream = async function* (request, ctx, config) {
1905
- const effectiveConfig = { ...this.config, ...config };
1906
- // Convert ImageAnalyzeRequest to chat Request
1907
- const chatRequest = {
1908
- messages: [
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
- // Construct the params that would be sent to OpenAI
1924
- const { model } = this.getRequestData(chatRequest, undefined, ctx, effectiveConfig, 'analyze image stream', effectiveConfig.defaultModels?.imageAnalyze || 'gpt-4-vision-preview');
1925
- const messages = await this.convertMessages(chatRequest, effectiveConfig);
1926
- const params = {
1927
- model: model.id,
1928
- messages,
1929
- temperature: chatRequest.temperature,
1930
- max_completion_tokens: chatRequest.maxTokens,
1931
- stream: true,
1932
- };
1933
- // Call pre-request hook with params
1934
- await effectiveConfig.hooks?.imageAnalyze?.beforeRequest?.(request, params, ctx);
1935
- const streamer = this.createStreamer(effectiveConfig);
1936
- try {
1937
- // Use the chat streamer to analyze the images (this will also call chat hooks)
1938
- const stream = streamer(chatRequest, ctx, { model: request.model }, undefined);
1939
- // Track accumulated response for post-hook
1940
- let accumulatedContent = '';
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