@ai-sdk/openai 4.0.32 → 4.0.34

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.
@@ -0,0 +1,675 @@
1
+ import {
2
+ EmptyResponseBodyError,
3
+ InvalidArgumentError,
4
+ type Experimental_BatchLanguageModelV4 as BatchLanguageModelV4,
5
+ type Experimental_BatchV4StartOptions as BatchV4StartOptions,
6
+ type Experimental_BatchV4StartResult as BatchV4StartResult,
7
+ type Experimental_BatchV4Error as BatchV4Error,
8
+ type Experimental_BatchV4ItemResult as BatchV4ItemResult,
9
+ type Experimental_BatchV4OperationOptions as BatchV4OperationOptions,
10
+ type Experimental_BatchV4Status as BatchV4Status,
11
+ type LanguageModelV4GenerateResult,
12
+ type SharedV4ProviderMetadata,
13
+ type SharedV4Warning,
14
+ } from '@ai-sdk/provider';
15
+ import {
16
+ combineHeaders,
17
+ convertAsyncIteratorToReadableStream,
18
+ createJsonResponseHandler,
19
+ getFromApi,
20
+ lazySchema,
21
+ parseJSON,
22
+ postJsonToApi,
23
+ postToApi,
24
+ safeValidateTypes,
25
+ validateTypes,
26
+ WORKFLOW_DESERIALIZE,
27
+ WORKFLOW_SERIALIZE,
28
+ zodSchema,
29
+ type InferSchema,
30
+ type ResponseHandler,
31
+ } from '@ai-sdk/provider-utils';
32
+ import { z } from 'zod/v4';
33
+ import {
34
+ openaiErrorDataSchema,
35
+ openaiFailedResponseHandler,
36
+ } from './openai-error';
37
+ import type { OpenAIConfig } from './openai-config';
38
+ import { openaiFilesResponseSchema } from './files/openai-files-api';
39
+ import { convertOpenAIResponsesUsage } from './responses/convert-openai-responses-usage';
40
+ import { mapOpenAIResponseFinishReason } from './responses/map-openai-responses-finish-reason';
41
+ import {
42
+ openaiResponsesResponseSchema,
43
+ type OpenAIResponsesLogprobs,
44
+ } from './responses/openai-responses-api';
45
+ import { OpenAIResponsesLanguageModel } from './responses/openai-responses-language-model';
46
+ import type { OpenAIResponsesModelId } from './responses/openai-responses-language-model-options';
47
+
48
+ const openaiBatchEndpoint = '/v1/responses';
49
+ const openaiBatchInputFileExpiresAfterSeconds = 48 * 60 * 60;
50
+
51
+ type OpenAIBatchRequest = Parameters<
52
+ BatchLanguageModelV4['experimental_doStartBatch']
53
+ >[0]['requests'][number];
54
+
55
+ type OpenAIBatchPreparedRequest = {
56
+ body: unknown;
57
+ warnings: SharedV4Warning[];
58
+ };
59
+
60
+ type OpenAIBatchResponseConversion =
61
+ | { success: true; result: LanguageModelV4GenerateResult }
62
+ | { success: false; error: BatchV4Error };
63
+
64
+ const openaiBatchResponseSchema = lazySchema(() =>
65
+ zodSchema(
66
+ z.object({
67
+ id: z.string(),
68
+ status: z.string(),
69
+ output_file_id: z.string().nullish(),
70
+ error_file_id: z.string().nullish(),
71
+ created_at: z.number().nullish(),
72
+ expires_at: z.number().nullish(),
73
+ request_counts: z
74
+ .object({
75
+ total: z.number().nullish(),
76
+ completed: z.number().nullish(),
77
+ failed: z.number().nullish(),
78
+ })
79
+ .nullish(),
80
+ errors: z
81
+ .object({
82
+ data: z
83
+ .array(
84
+ z.object({
85
+ code: z.string().nullish(),
86
+ message: z.string().nullish(),
87
+ }),
88
+ )
89
+ .nullish(),
90
+ })
91
+ .nullish(),
92
+ }),
93
+ ),
94
+ );
95
+
96
+ type OpenAIBatchResponse = InferSchema<typeof openaiBatchResponseSchema>;
97
+
98
+ const openaiBatchResultLineSchema = lazySchema(() =>
99
+ zodSchema(
100
+ z.object({
101
+ custom_id: z.string(),
102
+ response: z
103
+ .object({
104
+ status_code: z.number(),
105
+ request_id: z.string().nullish(),
106
+ body: z.unknown(),
107
+ })
108
+ .nullish(),
109
+ error: z
110
+ .object({
111
+ code: z.string(),
112
+ message: z.string(),
113
+ })
114
+ .nullish(),
115
+ }),
116
+ ),
117
+ );
118
+
119
+ type OpenAIBatchResultLine = InferSchema<typeof openaiBatchResultLineSchema>;
120
+
121
+ class OpenAIResponsesBatch {
122
+ constructor(
123
+ private readonly options: {
124
+ modelId: string;
125
+ config: OpenAIConfig;
126
+ prepareRequest: (
127
+ request: OpenAIBatchRequest,
128
+ ) => PromiseLike<OpenAIBatchPreparedRequest>;
129
+ },
130
+ ) {}
131
+
132
+ async startBatch(
133
+ options: BatchV4StartOptions<OpenAIBatchRequest>,
134
+ ): Promise<BatchV4StartResult> {
135
+ const fileParts: string[] = [];
136
+ const warnings: BatchV4StartResult['warnings'] = [];
137
+
138
+ for (const request of options.requests) {
139
+ const preparedRequest = await this.options.prepareRequest(request);
140
+
141
+ fileParts.push(
142
+ JSON.stringify({
143
+ custom_id: request.id,
144
+ method: 'POST',
145
+ url: openaiBatchEndpoint,
146
+ body: preparedRequest.body,
147
+ }),
148
+ '\n',
149
+ );
150
+
151
+ for (const warning of preparedRequest.warnings) {
152
+ warnings.push({ requestId: request.id, warning });
153
+ }
154
+ }
155
+
156
+ const filename = 'batch.jsonl';
157
+ const file = new Blob(fileParts, {
158
+ type: 'application/jsonl',
159
+ });
160
+ // Blob snapshots the strings, so release the potentially large input array.
161
+ fileParts.length = 0;
162
+ const formData = new FormData();
163
+ formData.append('file', file, filename);
164
+ formData.append('purpose', 'batch');
165
+ formData.append('expires_after[anchor]', 'created_at');
166
+ formData.append(
167
+ 'expires_after[seconds]',
168
+ String(openaiBatchInputFileExpiresAfterSeconds),
169
+ );
170
+
171
+ const { value: uploadedFile } = await postToApi({
172
+ url: this.getUrl('/files'),
173
+ headers: combineHeaders(this.options.config.headers?.(), options.headers),
174
+ body: {
175
+ content: formData,
176
+ values: {
177
+ purpose: 'batch',
178
+ 'expires_after[anchor]': 'created_at',
179
+ 'expires_after[seconds]': String(
180
+ openaiBatchInputFileExpiresAfterSeconds,
181
+ ),
182
+ file: {
183
+ name: filename,
184
+ type: file.type,
185
+ size: file.size,
186
+ },
187
+ },
188
+ },
189
+ failedResponseHandler: openaiFailedResponseHandler,
190
+ successfulResponseHandler: createJsonResponseHandler(
191
+ openaiFilesResponseSchema,
192
+ ),
193
+ abortSignal: options.abortSignal,
194
+ fetch: this.options.config.fetch,
195
+ });
196
+
197
+ const { value: batch } = await postJsonToApi({
198
+ url: this.getUrl('/batches'),
199
+ headers: combineHeaders(this.options.config.headers?.(), options.headers),
200
+ body: {
201
+ input_file_id: uploadedFile.id,
202
+ endpoint: openaiBatchEndpoint,
203
+ completion_window: '24h',
204
+ },
205
+ failedResponseHandler: openaiFailedResponseHandler,
206
+ successfulResponseHandler: createJsonResponseHandler(
207
+ openaiBatchResponseSchema,
208
+ ),
209
+ abortSignal: options.abortSignal,
210
+ fetch: this.options.config.fetch,
211
+ });
212
+
213
+ return {
214
+ batchId: batch.id,
215
+ ...convertOpenAIBatchStatus(batch),
216
+ warnings,
217
+ };
218
+ }
219
+
220
+ async getBatchStatus(
221
+ options: BatchV4OperationOptions,
222
+ ): Promise<BatchV4Status> {
223
+ const batch = await this.retrieveBatch(options);
224
+ return convertOpenAIBatchStatus(batch);
225
+ }
226
+
227
+ async getBatchResults(
228
+ options: BatchV4OperationOptions,
229
+ ): Promise<ReadableStream<BatchV4ItemResult<LanguageModelV4GenerateResult>>> {
230
+ const batch = await this.retrieveBatch(options);
231
+
232
+ if (convertOpenAIBatchStatus(batch).status === 'pending') {
233
+ throw new InvalidArgumentError({
234
+ argument: 'batchId',
235
+ message: `OpenAI batch "${options.batchId}" is not complete.`,
236
+ });
237
+ }
238
+
239
+ const fileIds = [batch.output_file_id, batch.error_file_id].filter(
240
+ (fileId): fileId is string => fileId != null,
241
+ );
242
+ const iterator = this.iterateBatchResults({ fileIds, options });
243
+
244
+ return convertAsyncIteratorToReadableStream(iterator);
245
+ }
246
+
247
+ private async retrieveBatch(
248
+ options: BatchV4OperationOptions,
249
+ ): Promise<OpenAIBatchResponse> {
250
+ const { value: batch } = await getFromApi({
251
+ url: this.getUrl(`/batches/${encodeURIComponent(options.batchId)}`),
252
+ headers: combineHeaders(this.options.config.headers?.(), options.headers),
253
+ failedResponseHandler: openaiFailedResponseHandler,
254
+ successfulResponseHandler: createJsonResponseHandler(
255
+ openaiBatchResponseSchema,
256
+ ),
257
+ abortSignal: options.abortSignal,
258
+ fetch: this.options.config.fetch,
259
+ validateUrl: false,
260
+ });
261
+
262
+ return batch;
263
+ }
264
+
265
+ private async *iterateBatchResults({
266
+ fileIds,
267
+ options,
268
+ }: {
269
+ fileIds: string[];
270
+ options: BatchV4OperationOptions;
271
+ }): AsyncGenerator<BatchV4ItemResult<LanguageModelV4GenerateResult>> {
272
+ for (const fileId of fileIds) {
273
+ const { value: stream } = await getFromApi({
274
+ url: this.getUrl(`/files/${encodeURIComponent(fileId)}/content`),
275
+ headers: combineHeaders(
276
+ this.options.config.headers?.(),
277
+ options.headers,
278
+ ),
279
+ failedResponseHandler: openaiFailedResponseHandler,
280
+ successfulResponseHandler: rawStreamResponseHandler,
281
+ abortSignal: options.abortSignal,
282
+ fetch: this.options.config.fetch,
283
+ validateUrl: false,
284
+ });
285
+
286
+ for await (const line of parseJsonLines(stream)) {
287
+ yield await this.convertResultLine(line);
288
+ }
289
+ }
290
+ }
291
+
292
+ private async convertResultLine(
293
+ line: OpenAIBatchResultLine,
294
+ ): Promise<BatchV4ItemResult<LanguageModelV4GenerateResult>> {
295
+ if (line.error != null) {
296
+ const error = {
297
+ message: line.error.message,
298
+ code: line.error.code,
299
+ };
300
+
301
+ if (line.error.code === 'batch_cancelled') {
302
+ return { id: line.custom_id, status: 'cancelled', error };
303
+ }
304
+
305
+ if (line.error.code === 'batch_expired') {
306
+ return { id: line.custom_id, status: 'expired', error };
307
+ }
308
+
309
+ return { id: line.custom_id, status: 'failed', error };
310
+ }
311
+
312
+ if (line.response == null) {
313
+ return {
314
+ id: line.custom_id,
315
+ status: 'failed',
316
+ error: {
317
+ message:
318
+ 'OpenAI returned a batch result without a response or error.',
319
+ code: 'invalid_batch_result',
320
+ },
321
+ };
322
+ }
323
+
324
+ if (line.response.status_code < 200 || line.response.status_code >= 300) {
325
+ return {
326
+ id: line.custom_id,
327
+ status: 'failed',
328
+ error: await convertOpenAIErrorResponse({
329
+ body: line.response.body,
330
+ statusCode: line.response.status_code,
331
+ }),
332
+ };
333
+ }
334
+
335
+ const conversion = await convertOpenAIResponsesBatchResponse(
336
+ line.response.body,
337
+ );
338
+ if (!conversion.success) {
339
+ return {
340
+ id: line.custom_id,
341
+ status: 'failed',
342
+ error: conversion.error,
343
+ };
344
+ }
345
+
346
+ return {
347
+ id: line.custom_id,
348
+ status: 'succeeded',
349
+ result: conversion.result,
350
+ };
351
+ }
352
+
353
+ private getUrl(path: string) {
354
+ return this.options.config.url({
355
+ modelId: this.options.modelId,
356
+ path,
357
+ });
358
+ }
359
+ }
360
+
361
+ export class OpenAIResponsesBatchLanguageModel
362
+ extends OpenAIResponsesLanguageModel
363
+ implements BatchLanguageModelV4
364
+ {
365
+ private readonly batch: OpenAIResponsesBatch;
366
+
367
+ static [WORKFLOW_SERIALIZE](model: OpenAIResponsesLanguageModel) {
368
+ return OpenAIResponsesLanguageModel[WORKFLOW_SERIALIZE](model);
369
+ }
370
+
371
+ static [WORKFLOW_DESERIALIZE](options: {
372
+ modelId: OpenAIResponsesModelId;
373
+ config: OpenAIConfig;
374
+ }) {
375
+ return new OpenAIResponsesBatchLanguageModel(
376
+ options.modelId,
377
+ options.config,
378
+ );
379
+ }
380
+
381
+ constructor(modelId: OpenAIResponsesModelId, config: OpenAIConfig) {
382
+ super(modelId, config);
383
+ this.batch = new OpenAIResponsesBatch({
384
+ modelId,
385
+ config,
386
+ prepareRequest: async request => {
387
+ const { args: body, warnings } = await this.getArgs(request.options);
388
+
389
+ return { body, warnings };
390
+ },
391
+ });
392
+ }
393
+
394
+ experimental_doStartBatch(
395
+ options: Parameters<BatchLanguageModelV4['experimental_doStartBatch']>[0],
396
+ ) {
397
+ return this.batch.startBatch(options);
398
+ }
399
+
400
+ experimental_doGetBatchStatus(options: BatchV4OperationOptions) {
401
+ return this.batch.getBatchStatus(options);
402
+ }
403
+
404
+ experimental_doGetBatchResults(options: BatchV4OperationOptions) {
405
+ return this.batch.getBatchResults(options);
406
+ }
407
+ }
408
+
409
+ function convertOpenAIBatchStatus(batch: OpenAIBatchResponse): BatchV4Status {
410
+ const status = mapOpenAIBatchStatus(batch.status);
411
+ const firstError = batch.errors?.data?.[0];
412
+ const requestCounts = convertOpenAIRequestCounts(batch.request_counts);
413
+ const createdAt = convertUnixTimestamp(batch.created_at);
414
+ const expiresAt = convertUnixTimestamp(batch.expires_at);
415
+
416
+ return {
417
+ status,
418
+ rawStatus: batch.status,
419
+ ...(requestCounts != null ? { requestCounts } : {}),
420
+ ...(firstError != null
421
+ ? {
422
+ error: {
423
+ message: firstError.message ?? 'OpenAI batch failed.',
424
+ ...(firstError.code != null ? { code: firstError.code } : {}),
425
+ },
426
+ }
427
+ : {}),
428
+ ...(createdAt != null ? { createdAt } : {}),
429
+ ...(expiresAt != null ? { expiresAt } : {}),
430
+ };
431
+ }
432
+
433
+ function mapOpenAIBatchStatus(rawStatus: string): BatchV4Status['status'] {
434
+ switch (rawStatus) {
435
+ case 'completed':
436
+ return 'completed';
437
+ case 'failed':
438
+ case 'expired':
439
+ case 'cancelled':
440
+ return 'failed';
441
+ case 'validating':
442
+ case 'in_progress':
443
+ case 'finalizing':
444
+ case 'cancelling':
445
+ default:
446
+ // Treat unknown provider states conservatively as non-terminal so callers
447
+ // do not attempt to retrieve incomplete result artifacts.
448
+ return 'pending';
449
+ }
450
+ }
451
+
452
+ function convertOpenAIRequestCounts(
453
+ counts: OpenAIBatchResponse['request_counts'],
454
+ ): BatchV4Status['requestCounts'] | undefined {
455
+ const total = counts?.total;
456
+ const completed = counts?.completed;
457
+ const failed = counts?.failed;
458
+
459
+ if (
460
+ total == null ||
461
+ completed == null ||
462
+ failed == null ||
463
+ total < 0 ||
464
+ completed < 0 ||
465
+ failed < 0 ||
466
+ completed + failed > total
467
+ ) {
468
+ return undefined;
469
+ }
470
+
471
+ return {
472
+ total,
473
+ pending: total - completed - failed,
474
+ completed,
475
+ failed,
476
+ };
477
+ }
478
+
479
+ function convertUnixTimestamp(value: number | null | undefined) {
480
+ if (value == null || !Number.isFinite(value)) {
481
+ return undefined;
482
+ }
483
+
484
+ const date = new Date(value * 1000);
485
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
486
+ }
487
+
488
+ async function convertOpenAIErrorResponse({
489
+ body,
490
+ statusCode,
491
+ }: {
492
+ body: unknown;
493
+ statusCode: number;
494
+ }): Promise<BatchV4Error> {
495
+ const result = await safeValidateTypes({
496
+ value: body,
497
+ schema: openaiErrorDataSchema,
498
+ });
499
+
500
+ if (!result.success) {
501
+ return {
502
+ message: `OpenAI batch request failed with status code ${statusCode}.`,
503
+ statusCode,
504
+ };
505
+ }
506
+
507
+ return {
508
+ message: result.value.error.message,
509
+ type: result.value.error.type ?? undefined,
510
+ code:
511
+ result.value.error.code != null
512
+ ? String(result.value.error.code)
513
+ : undefined,
514
+ statusCode,
515
+ };
516
+ }
517
+
518
+ async function convertOpenAIResponsesBatchResponse(
519
+ body: unknown,
520
+ ): Promise<OpenAIBatchResponseConversion> {
521
+ const response = await validateTypes({
522
+ value: body,
523
+ schema: openaiResponsesResponseSchema,
524
+ });
525
+
526
+ if (response.error != null) {
527
+ return {
528
+ success: false,
529
+ error: {
530
+ message: response.error.message,
531
+ type: response.error.type,
532
+ code: response.error.code,
533
+ },
534
+ };
535
+ }
536
+
537
+ if (response.output == null) {
538
+ const detail = response.incomplete_details?.reason;
539
+ return {
540
+ success: false,
541
+ error: {
542
+ message:
543
+ detail != null
544
+ ? `OpenAI Responses returned no output (${detail}).`
545
+ : 'OpenAI Responses returned no output.',
546
+ code: 'invalid_response',
547
+ },
548
+ };
549
+ }
550
+
551
+ const content: LanguageModelV4GenerateResult['content'] = [];
552
+ const logprobs: Array<NonNullable<OpenAIResponsesLogprobs>> = [];
553
+
554
+ for (const part of response.output) {
555
+ if (part.type === 'message') {
556
+ for (const contentPart of part.content) {
557
+ content.push({ type: 'text', text: contentPart.text });
558
+ if (contentPart.logprobs != null) {
559
+ logprobs.push(contentPart.logprobs);
560
+ }
561
+ }
562
+ } else if (
563
+ part.type === 'function_call' ||
564
+ part.type === 'custom_tool_call'
565
+ ) {
566
+ return {
567
+ success: false,
568
+ error: {
569
+ message:
570
+ 'OpenAI returned a tool call, but tool calls are not supported in AI SDK text batches.',
571
+ code: 'unsupported_tool_call',
572
+ },
573
+ };
574
+ }
575
+ }
576
+
577
+ const providerMetadata: SharedV4ProviderMetadata = {
578
+ openai: {
579
+ responseId: response.id,
580
+ ...(logprobs.length > 0 ? { logprobs } : {}),
581
+ ...(typeof response.service_tier === 'string'
582
+ ? { serviceTier: response.service_tier }
583
+ : {}),
584
+ ...(response.reasoning?.context != null
585
+ ? { reasoningContext: response.reasoning.context }
586
+ : {}),
587
+ },
588
+ };
589
+
590
+ return {
591
+ success: true,
592
+ result: {
593
+ content,
594
+ finishReason: {
595
+ unified: mapOpenAIResponseFinishReason({
596
+ finishReason: response.incomplete_details?.reason,
597
+ hasFunctionCall: false,
598
+ }),
599
+ raw: response.incomplete_details?.reason ?? undefined,
600
+ },
601
+ usage: convertOpenAIResponsesUsage(response.usage),
602
+ response: {
603
+ id: response.id,
604
+ timestamp:
605
+ response.created_at != null
606
+ ? new Date(response.created_at * 1000)
607
+ : undefined,
608
+ modelId: response.model,
609
+ },
610
+ providerMetadata,
611
+ warnings: [],
612
+ },
613
+ };
614
+ }
615
+
616
+ const rawStreamResponseHandler: ResponseHandler<
617
+ ReadableStream<Uint8Array>
618
+ > = async ({ response }) => {
619
+ if (response.body == null) {
620
+ throw new EmptyResponseBodyError();
621
+ }
622
+
623
+ return { value: response.body };
624
+ };
625
+
626
+ async function* parseJsonLines(
627
+ stream: ReadableStream<Uint8Array>,
628
+ ): AsyncGenerator<OpenAIBatchResultLine> {
629
+ const reader = stream.getReader();
630
+ const decoder = new TextDecoder();
631
+ let buffer = '';
632
+ let finished = false;
633
+
634
+ try {
635
+ while (true) {
636
+ const { done, value } = await reader.read();
637
+
638
+ if (done) {
639
+ finished = true;
640
+ buffer += decoder.decode();
641
+ break;
642
+ }
643
+
644
+ buffer += decoder.decode(value, { stream: true });
645
+
646
+ let lineEnd = buffer.indexOf('\n');
647
+ while (lineEnd !== -1) {
648
+ const line = buffer.slice(0, lineEnd).replace(/\r$/, '');
649
+ buffer = buffer.slice(lineEnd + 1);
650
+
651
+ if (line.trim().length > 0) {
652
+ yield await parseJSON({
653
+ text: line,
654
+ schema: openaiBatchResultLineSchema,
655
+ });
656
+ }
657
+
658
+ lineEnd = buffer.indexOf('\n');
659
+ }
660
+ }
661
+
662
+ const finalLine = buffer.replace(/\r$/, '');
663
+ if (finalLine.trim().length > 0) {
664
+ yield await parseJSON({
665
+ text: finalLine,
666
+ schema: openaiBatchResultLineSchema,
667
+ });
668
+ }
669
+ } finally {
670
+ if (!finished) {
671
+ await reader.cancel().catch(() => {});
672
+ }
673
+ reader.releaseLock();
674
+ }
675
+ }
@@ -704,6 +704,7 @@ export const openaiResponsesChunkSchema = lazySchema(() =>
704
704
  z.object({
705
705
  type: z.literal('response.output_text.delta'),
706
706
  item_id: z.string(),
707
+ output_index: z.number().nullish(),
707
708
  delta: z.string(),
708
709
  logprobs: z
709
710
  .array(
@@ -1264,17 +1265,20 @@ export const openaiResponsesChunkSchema = lazySchema(() =>
1264
1265
  z.object({
1265
1266
  type: z.literal('response.reasoning_summary_part.added'),
1266
1267
  item_id: z.string(),
1268
+ output_index: z.number().nullish(),
1267
1269
  summary_index: z.number(),
1268
1270
  }),
1269
1271
  z.object({
1270
1272
  type: z.literal('response.reasoning_summary_text.delta'),
1271
1273
  item_id: z.string(),
1274
+ output_index: z.number().nullish(),
1272
1275
  summary_index: z.number(),
1273
1276
  delta: z.string(),
1274
1277
  }),
1275
1278
  z.object({
1276
1279
  type: z.literal('response.reasoning_summary_part.done'),
1277
1280
  item_id: z.string(),
1281
+ output_index: z.number().nullish(),
1278
1282
  summary_index: z.number(),
1279
1283
  }),
1280
1284
  z.object({