@ai-sdk/openai 4.0.11 → 4.0.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/openai",
3
- "version": "4.0.11",
3
+ "version": "4.0.14",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@ai-sdk/provider": "4.0.3",
39
- "@ai-sdk/provider-utils": "5.0.7"
39
+ "@ai-sdk/provider-utils": "5.0.10"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "22.19.19",
@@ -13,6 +13,7 @@ import type {
13
13
  import {
14
14
  loadApiKey,
15
15
  loadOptionalSetting,
16
+ validateBaseURL,
16
17
  withoutTrailingSlash,
17
18
  withUserAgentSuffix,
18
19
  type FetchFunction,
@@ -175,10 +176,12 @@ export function createOpenAI(
175
176
  ): OpenAIProvider {
176
177
  const baseURL =
177
178
  withoutTrailingSlash(
178
- loadOptionalSetting({
179
- settingValue: options.baseURL,
180
- environmentVariableName: 'OPENAI_BASE_URL',
181
- }),
179
+ validateBaseURL(
180
+ loadOptionalSetting({
181
+ settingValue: options.baseURL,
182
+ environmentVariableName: 'OPENAI_BASE_URL',
183
+ }),
184
+ ),
182
185
  ) ?? 'https://api.openai.com/v1';
183
186
 
184
187
  const providerName = options.name ?? 'openai';
@@ -10,16 +10,18 @@ import {
10
10
  convertBase64ToUint8Array,
11
11
  convertToBase64,
12
12
  createJsonResponseHandler,
13
- getWebSocketConstructor,
13
+ connectToWebSocket,
14
14
  mediaTypeToExtension,
15
15
  parseProviderOptions,
16
16
  postFormDataToApi,
17
- readWebSocketMessageText,
18
17
  safeParseJSON,
19
18
  serializeModelOptions,
20
19
  toWebSocketUrl,
21
20
  WORKFLOW_DESERIALIZE,
22
21
  WORKFLOW_SERIALIZE,
22
+ waitForWebSocketBufferDrain,
23
+ type WebSocketConnection,
24
+ type WebSocketLike,
23
25
  } from '@ai-sdk/provider-utils';
24
26
  import type { OpenAIConfig } from '../openai-config';
25
27
  import { openaiFailedResponseHandler } from '../openai-error';
@@ -390,22 +392,21 @@ function createOpenAIRealtimeTranscriptionStream({
390
392
 
391
393
  return new ReadableStream({
392
394
  start: controller => {
393
- const WebSocketConstructor = getWebSocketConstructor(webSocket);
394
- const ws = new WebSocketConstructor(
395
- url,
396
- getOpenAIRealtimeProtocols(headers),
397
- { headers },
398
- );
395
+ const realtimeConnection = getOpenAIRealtimeConnection(headers);
399
396
  let audioReader:
400
397
  | ReadableStreamDefaultReader<Uint8Array | string>
401
398
  | undefined;
399
+ let connection: WebSocketConnection | undefined;
402
400
 
403
401
  cleanup = (closeCode?: number) => {
404
- abortSignal?.removeEventListener('abort', abort);
405
- void audioReader?.cancel().catch(() => {});
406
- try {
407
- ws.close(closeCode);
408
- } catch {}
402
+ if (audioReader != null) {
403
+ void audioReader.cancel().catch(() => {});
404
+ } else {
405
+ // pre-open failure or abort: cancel the caller's audio stream so an
406
+ // upstream producer piping into it does not hang:
407
+ void audio.cancel().catch(() => {});
408
+ }
409
+ connection?.close(closeCode);
409
410
  };
410
411
 
411
412
  const finishWithError = (error: unknown) => {
@@ -431,89 +432,86 @@ function createOpenAIRealtimeTranscriptionStream({
431
432
  cleanup(1000);
432
433
  };
433
434
 
434
- const abort = () => {
435
- finishWithError(abortSignal?.reason ?? new Error('Aborted'));
436
- };
437
- if (abortSignal?.aborted) {
438
- abort();
439
- return;
440
- }
441
- abortSignal?.addEventListener('abort', abort, { once: true });
442
-
443
- const sendAudio = async () => {
435
+ const sendAudio = async (socket: WebSocketLike) => {
444
436
  audioReader = audio.getReader();
445
437
  try {
446
438
  while (true) {
447
439
  const { done, value } = await audioReader.read();
448
440
  if (done || finished) break;
449
- ws.send(
441
+ socket.send(
450
442
  JSON.stringify({
451
443
  type: 'input_audio_buffer.append',
452
444
  audio: convertToBase64(value),
453
445
  }),
454
446
  );
447
+ // backpressure: pause reads while the socket buffer is full
448
+ await waitForWebSocketBufferDrain(socket);
455
449
  }
456
450
  } finally {
457
451
  audioReader.releaseLock();
452
+ // unlocked again: cleanup must cancel `audio`, not the reader
453
+ audioReader = undefined;
458
454
  }
459
455
  if (!finished) {
460
- ws.send(JSON.stringify({ type: 'input_audio_buffer.commit' }));
456
+ socket.send(JSON.stringify({ type: 'input_audio_buffer.commit' }));
461
457
  }
462
458
  };
463
459
 
464
- ws.onopen = () => {
465
- controller.enqueue({ type: 'stream-start', warnings });
466
- ws.send(JSON.stringify(sessionUpdate));
467
- void sendAudio().catch(finishWithError);
468
- };
460
+ connection = connectToWebSocket({
461
+ url,
462
+ protocols: realtimeConnection.protocols,
463
+ headers: realtimeConnection.headers,
464
+ webSocket,
465
+ abortSignal,
466
+ onAbort: finishWithError,
467
+ onProcessingError: finishWithError,
468
+ onOpen: socket => {
469
+ controller.enqueue({ type: 'stream-start', warnings });
470
+ socket.send(JSON.stringify(sessionUpdate));
471
+ void sendAudio(socket).catch(finishWithError);
472
+ },
473
+ onMessageText: async text => {
474
+ const parsed = await safeParseJSON({ text });
475
+ if (!parsed.success) return;
476
+ const raw = parsed.value as OpenAIRealtimeTranscriptionEvent;
469
477
 
470
- ws.onmessage = event => {
471
- void readWebSocketMessageText(event.data)
472
- .then(async text => {
473
- const parsed = await safeParseJSON({ text });
474
- if (!parsed.success) return;
475
- const raw = parsed.value as OpenAIRealtimeTranscriptionEvent;
478
+ if (includeRawChunks) {
479
+ controller.enqueue({ type: 'raw', rawValue: raw });
480
+ }
476
481
 
477
- if (includeRawChunks) {
478
- controller.enqueue({ type: 'raw', rawValue: raw });
482
+ switch (raw.type) {
483
+ case 'conversation.item.input_audio_transcription.delta': {
484
+ controller.enqueue({
485
+ type: 'transcript-delta',
486
+ id: raw.item_id,
487
+ delta: raw.delta ?? '',
488
+ });
489
+ break;
479
490
  }
480
491
 
481
- switch (raw.type) {
482
- case 'conversation.item.input_audio_transcription.delta': {
483
- controller.enqueue({
484
- type: 'transcript-delta',
485
- id: raw.item_id,
486
- delta: raw.delta ?? '',
487
- });
488
- break;
489
- }
490
-
491
- case 'conversation.item.input_audio_transcription.completed': {
492
- finish(raw.transcript ?? '', raw.item_id);
493
- break;
494
- }
495
-
496
- case 'error': {
497
- finishWithError(
498
- new Error(raw.error?.message ?? 'OpenAI realtime error'),
499
- );
500
- break;
501
- }
492
+ case 'conversation.item.input_audio_transcription.completed': {
493
+ finish(raw.transcript ?? '', raw.item_id);
494
+ break;
502
495
  }
503
- })
504
- .catch(finishWithError);
505
- };
506
-
507
- ws.onerror = () => {
508
- finishWithError(new Error('OpenAI realtime transcription error'));
509
- };
510
496
 
511
- ws.onclose = () => {
512
- if (finished) return;
513
- finished = true;
514
- cleanup();
515
- controller.close();
516
- };
497
+ case 'error': {
498
+ finishWithError(
499
+ new Error(raw.error?.message ?? 'OpenAI realtime error'),
500
+ );
501
+ break;
502
+ }
503
+ }
504
+ },
505
+ onSocketError: () => {
506
+ finishWithError(new Error('OpenAI realtime transcription error'));
507
+ },
508
+ onClose: () => {
509
+ if (finished) return;
510
+ finished = true;
511
+ cleanup();
512
+ controller.close();
513
+ },
514
+ });
517
515
  },
518
516
 
519
517
  cancel: () => {
@@ -564,15 +562,36 @@ function buildOpenAIRealtimeTranscriptionSession({
564
562
  };
565
563
  }
566
564
 
567
- function getOpenAIRealtimeProtocols(
565
+ // The bearer token rides the `openai-insecure-api-key` subprotocol (native
566
+ // `WebSocket` cannot send headers) and the Authorization header is stripped:
567
+ // OpenAI rejects handshakes that send both auth channels.
568
+ function getOpenAIRealtimeConnection(
568
569
  headers: Record<string, string | undefined>,
569
- ): string[] {
570
- const authorization = headers.Authorization ?? headers.authorization;
571
- const token = authorization?.startsWith('Bearer ')
572
- ? authorization.slice('Bearer '.length)
573
- : undefined;
574
-
575
- return token == null
576
- ? ['realtime']
577
- : ['realtime', `openai-insecure-api-key.${token}`];
570
+ ): {
571
+ protocols: string[];
572
+ headers: Record<string, string | undefined>;
573
+ } {
574
+ // last case-variant wins: combineHeaders keeps case-distinct keys and
575
+ // spreads per-call headers after configuration headers
576
+ let authorization: string | undefined;
577
+ for (const [key, value] of Object.entries(headers)) {
578
+ if (key.toLowerCase() === 'authorization' && value != null) {
579
+ authorization = value;
580
+ }
581
+ }
582
+ // the HTTP auth scheme is case-insensitive
583
+ const token = authorization?.match(/^bearer\s+(.+)$/i)?.[1];
584
+
585
+ if (token == null) {
586
+ return { protocols: ['realtime'], headers };
587
+ }
588
+
589
+ return {
590
+ protocols: ['realtime', `openai-insecure-api-key.${token}`],
591
+ headers: Object.fromEntries(
592
+ Object.entries(headers).filter(
593
+ ([key]) => key.toLowerCase() !== 'authorization',
594
+ ),
595
+ ),
596
+ };
578
597
  }