@ai-sdk/provider-utils 5.0.6 → 5.0.9

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,341 @@
1
+ import type {
2
+ Experimental_TranscriptionModelV4StreamPart as TranscriptionModelV4StreamPart,
3
+ JSONObject,
4
+ } from '@ai-sdk/provider';
5
+ import { secureJsonParse } from './secure-json-parse';
6
+
7
+ /**
8
+ * Experimental transcription-stream WebSocket envelope (v1): the standard
9
+ * serialization of `TranscriptionModelV4.doStream` over a WebSocket. Clients
10
+ * (e.g. the `@ai-sdk/gateway` provider) encode with this module and servers
11
+ * (e.g. AI Gateway) decode with it, so the two sides cannot drift.
12
+ *
13
+ * Envelope rules:
14
+ *
15
+ * 1. The client sends exactly one `transcription-stream.start` TEXT frame
16
+ * first.
17
+ * 2. Audio rides BINARY frames containing raw bytes in the declared
18
+ * `inputAudioFormat` (base64 string chunks are decoded before sending).
19
+ * 3. The client signals end of audio with the
20
+ * `transcription-stream.audio-done` TEXT frame; a plain close without it
21
+ * is an abort.
22
+ * 4. Every server→client TEXT frame is one JSON-serialized
23
+ * `TranscriptionModelV4StreamPart` (flattened, no wrapper). Payloads must
24
+ * be JSON-serializable. `Date` values (`response-metadata.timestamp`)
25
+ * serialize to ISO 8601 strings and are revived by
26
+ * `parseTranscriptionStreamPart`. `Error` payloads in `error` parts
27
+ * serialize as `{ name, message }`.
28
+ * 5. The server closes with code 1000 after the `finish` part; on failure it
29
+ * sends an `error` part and closes non-1000. A close without a prior
30
+ * `finish` is an error.
31
+ * 6. Unknown frame/part types are ignored in both directions (forward
32
+ * compatibility).
33
+ * 7. Servers may enforce a maximum frame size (the AI Gateway rejects frames
34
+ * over 256 KiB); clients should split audio into frames of at most
35
+ * 64 KiB.
36
+ * 8. Connection establishment (URL, auth) is transport-specific and out of
37
+ * scope.
38
+ *
39
+ * The envelope validates frame shape only; server policy (accepted audio
40
+ * formats, required `rate`, size limits) layers on top. Both parsers use
41
+ * `secureJsonParse`, so frames carrying `__proto__` / `constructor.prototype`
42
+ * keys are rejected (prototype-pollution protection) rather than parsed.
43
+ */
44
+
45
+ /** Type of the first client TEXT frame. */
46
+ export const TRANSCRIPTION_STREAM_START_FRAME_TYPE =
47
+ 'transcription-stream.start';
48
+
49
+ /** Type of the client TEXT frame that signals the end of the audio input. */
50
+ export const TRANSCRIPTION_STREAM_AUDIO_DONE_FRAME_TYPE =
51
+ 'transcription-stream.audio-done';
52
+
53
+ /**
54
+ * The client's session start frame. Optional keys are omitted when undefined.
55
+ */
56
+ export type TranscriptionStreamStartFrame = {
57
+ type: typeof TRANSCRIPTION_STREAM_START_FRAME_TYPE;
58
+
59
+ /** Audio format of the binary audio frames, e.g. `{ type: 'audio/pcm', rate: 16000 }`. */
60
+ inputAudioFormat: {
61
+ type: string;
62
+ rate?: number;
63
+ };
64
+
65
+ /** Provider-specific options, passed through verbatim. */
66
+ providerOptions?: Record<string, JSONObject>;
67
+
68
+ /** When true, the server should include `raw` parts in the stream. */
69
+ includeRawChunks?: boolean;
70
+ };
71
+
72
+ /** Server-side classification of a client TEXT frame. */
73
+ export type TranscriptionStreamClientFrame =
74
+ | {
75
+ type: 'start';
76
+ frame: TranscriptionStreamStartFrame;
77
+ }
78
+ | {
79
+ type: 'audio-done';
80
+ }
81
+ | {
82
+ /** Malformed JSON or a recognized frame with an invalid shape. */
83
+ type: 'invalid';
84
+ message: string;
85
+ }
86
+ | {
87
+ /** Unrecognized frame type; ignore for forward compatibility. */
88
+ type: 'unknown';
89
+ };
90
+
91
+ /**
92
+ * Server-side: parse a client TEXT frame. Validates envelope shape only and
93
+ * rejects prototype-pollution payloads (parsed with `secureJsonParse`). Never
94
+ * throws.
95
+ */
96
+ export function parseTranscriptionStreamClientFrame(
97
+ text: string,
98
+ ): TranscriptionStreamClientFrame {
99
+ let value: unknown;
100
+ try {
101
+ value = secureJsonParse(text);
102
+ } catch {
103
+ return { type: 'invalid', message: 'malformed JSON' };
104
+ }
105
+
106
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
107
+ return { type: 'invalid', message: 'frame must be a JSON object' };
108
+ }
109
+
110
+ const frame = value as Record<string, unknown>;
111
+
112
+ if (typeof frame.type !== 'string') {
113
+ return { type: 'invalid', message: 'frame type must be a string' };
114
+ }
115
+
116
+ switch (frame.type) {
117
+ case TRANSCRIPTION_STREAM_START_FRAME_TYPE: {
118
+ const inputAudioFormat = frame.inputAudioFormat as
119
+ | Record<string, unknown>
120
+ | null
121
+ | undefined;
122
+ if (
123
+ inputAudioFormat == null ||
124
+ typeof inputAudioFormat !== 'object' ||
125
+ Array.isArray(inputAudioFormat) ||
126
+ typeof inputAudioFormat.type !== 'string'
127
+ ) {
128
+ return {
129
+ type: 'invalid',
130
+ message:
131
+ 'start frame must have an inputAudioFormat object with a string type',
132
+ };
133
+ }
134
+ if (
135
+ inputAudioFormat.rate !== undefined &&
136
+ typeof inputAudioFormat.rate !== 'number'
137
+ ) {
138
+ return {
139
+ type: 'invalid',
140
+ message: 'inputAudioFormat.rate must be a number when present',
141
+ };
142
+ }
143
+ if (
144
+ frame.providerOptions !== undefined &&
145
+ (frame.providerOptions == null ||
146
+ typeof frame.providerOptions !== 'object' ||
147
+ Array.isArray(frame.providerOptions))
148
+ ) {
149
+ return {
150
+ type: 'invalid',
151
+ message: 'providerOptions must be an object when present',
152
+ };
153
+ }
154
+ if (
155
+ frame.includeRawChunks !== undefined &&
156
+ typeof frame.includeRawChunks !== 'boolean'
157
+ ) {
158
+ return {
159
+ type: 'invalid',
160
+ message: 'includeRawChunks must be a boolean when present',
161
+ };
162
+ }
163
+ return {
164
+ type: 'start',
165
+ frame: frame as TranscriptionStreamStartFrame,
166
+ };
167
+ }
168
+
169
+ case TRANSCRIPTION_STREAM_AUDIO_DONE_FRAME_TYPE:
170
+ return { type: 'audio-done' };
171
+
172
+ default:
173
+ return { type: 'unknown' };
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Server-side: serialize a transcription stream part as one TEXT frame.
179
+ * `Error` payloads in `error` parts serialize as `{ name, message }` —
180
+ * `Error` properties are non-enumerable, so a plain `JSON.stringify` would
181
+ * serialize them to `{}` and lose the message end-to-end. Returns
182
+ * `undefined` for payloads that are not JSON-serializable (envelope rule 4,
183
+ * e.g. bigint or cyclic values); callers drop the frame.
184
+ */
185
+ export function serializeTranscriptionStreamPart(
186
+ part: TranscriptionModelV4StreamPart,
187
+ ): string | undefined {
188
+ try {
189
+ if (part.type === 'error' && isError(part.error)) {
190
+ return JSON.stringify({
191
+ ...part,
192
+ error: { name: part.error.name, message: part.error.message },
193
+ });
194
+ }
195
+ return JSON.stringify(part);
196
+ } catch {
197
+ return undefined;
198
+ }
199
+ }
200
+
201
+ // `instanceof` misses cross-realm Errors; the brand check does not.
202
+ function isError(value: unknown): value is Error {
203
+ return (
204
+ value instanceof Error ||
205
+ Object.prototype.toString.call(value) === '[object Error]'
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Client-side: parse a server TEXT frame into a transcription stream part.
211
+ * Returns `undefined` for malformed or unsafe (prototype-polluting) JSON
212
+ * (parsed with `secureJsonParse`), unknown part types, and known part types
213
+ * whose required or optional fields are missing or mistyped (including
214
+ * warning and segment elements) — downstream SDK code dereferences those
215
+ * fields, so a drifted server must not crash or pollute the stream.
216
+ * Revives `response-metadata.timestamp` to a `Date`.
217
+ */
218
+ export function parseTranscriptionStreamPart(
219
+ text: string,
220
+ ): TranscriptionModelV4StreamPart | undefined {
221
+ let value: unknown;
222
+ try {
223
+ value = secureJsonParse(text);
224
+ } catch {
225
+ return undefined;
226
+ }
227
+
228
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
229
+ return undefined;
230
+ }
231
+
232
+ const part = value as TranscriptionModelV4StreamPart;
233
+
234
+ switch (part.type) {
235
+ case 'stream-start':
236
+ return Array.isArray(part.warnings) && part.warnings.every(isWarning)
237
+ ? part
238
+ : undefined;
239
+
240
+ case 'transcript-delta':
241
+ return isString(part.delta) &&
242
+ isOptional(part.id, isString) &&
243
+ isOptional(part.providerMetadata, isPlainObject)
244
+ ? part
245
+ : undefined;
246
+
247
+ case 'transcript-partial':
248
+ return isString(part.text) &&
249
+ isOptional(part.id, isString) &&
250
+ isOptional(part.startSecond, isNumber) &&
251
+ isOptional(part.durationInSeconds, isNumber) &&
252
+ isOptional(part.channelIndex, isNumber) &&
253
+ isOptional(part.providerMetadata, isPlainObject)
254
+ ? part
255
+ : undefined;
256
+
257
+ case 'transcript-final':
258
+ return isString(part.text) &&
259
+ isOptional(part.id, isString) &&
260
+ isOptional(part.startSecond, isNumber) &&
261
+ isOptional(part.endSecond, isNumber) &&
262
+ isOptional(part.channelIndex, isNumber) &&
263
+ isOptional(part.providerMetadata, isPlainObject)
264
+ ? part
265
+ : undefined;
266
+
267
+ case 'finish':
268
+ return isString(part.text) &&
269
+ Array.isArray(part.segments) &&
270
+ part.segments.every(isSegment) &&
271
+ isOptional(part.language, isString) &&
272
+ isOptional(part.durationInSeconds, isNumber) &&
273
+ isOptional(part.providerMetadata, isPlainObject)
274
+ ? part
275
+ : undefined;
276
+
277
+ case 'response-metadata': {
278
+ if (
279
+ !(
280
+ isOptional(part.modelId, isString) &&
281
+ isOptional(part.headers, isPlainObject)
282
+ )
283
+ ) {
284
+ return undefined;
285
+ }
286
+ // Envelope rule 4: timestamps ride as ISO 8601 strings.
287
+ const timestamp: unknown = part.timestamp;
288
+ if (timestamp == null) {
289
+ return { ...part, timestamp: undefined };
290
+ }
291
+ if (typeof timestamp !== 'string') {
292
+ return undefined;
293
+ }
294
+ const revived = new Date(timestamp);
295
+ return Number.isNaN(revived.getTime())
296
+ ? undefined
297
+ : { ...part, timestamp: revived };
298
+ }
299
+
300
+ case 'raw':
301
+ return 'rawValue' in part ? part : undefined;
302
+
303
+ case 'error':
304
+ return 'error' in part ? part : undefined;
305
+
306
+ default:
307
+ return undefined;
308
+ }
309
+ }
310
+
311
+ function isString(value: unknown): value is string {
312
+ return typeof value === 'string';
313
+ }
314
+
315
+ function isNumber(value: unknown): value is number {
316
+ return typeof value === 'number';
317
+ }
318
+
319
+ function isOptional(
320
+ value: unknown,
321
+ check: (value: unknown) => boolean,
322
+ ): boolean {
323
+ return value === undefined || check(value);
324
+ }
325
+
326
+ function isPlainObject(value: unknown): boolean {
327
+ return typeof value === 'object' && value != null && !Array.isArray(value);
328
+ }
329
+
330
+ function isWarning(value: unknown): boolean {
331
+ return isPlainObject(value) && isString((value as { type?: unknown }).type);
332
+ }
333
+
334
+ function isSegment(value: unknown): boolean {
335
+ return (
336
+ isPlainObject(value) &&
337
+ isString((value as { text?: unknown }).text) &&
338
+ isNumber((value as { startSecond?: unknown }).startSecond) &&
339
+ isNumber((value as { endSecond?: unknown }).endSecond)
340
+ );
341
+ }
@@ -0,0 +1,12 @@
1
+ import { InvalidArgumentError } from '@ai-sdk/provider';
2
+
3
+ export function validateBaseURL(baseURL: string | undefined) {
4
+ if (baseURL?.trim() === '') {
5
+ throw new InvalidArgumentError({
6
+ argument: 'baseURL',
7
+ message: 'baseURL must be a non-empty string.',
8
+ });
9
+ }
10
+
11
+ return baseURL;
12
+ }
@@ -112,12 +112,18 @@ function isPrivateIPv4(ip: string): boolean {
112
112
  if (a === 172 && b >= 16 && b <= 31) return true;
113
113
  // 192.0.0.0/24 (IETF protocol assignments)
114
114
  if (a === 192 && b === 0 && c === 0) return true;
115
+ // 192.0.2.0/24 (TEST-NET-1, documentation)
116
+ if (a === 192 && b === 0 && c === 2) return true;
115
117
  // 192.168.0.0/16
116
118
  if (a === 192 && b === 168) return true;
117
119
  // 198.18.0.0/15 (benchmarking)
118
120
  if (a === 198 && (b === 18 || b === 19)) return true;
119
- // 240.0.0.0/4 (reserved, includes 255.255.255.255 broadcast)
120
- if (a >= 240) return true;
121
+ // 198.51.100.0/24 (TEST-NET-2, documentation)
122
+ if (a === 198 && b === 51 && c === 100) return true;
123
+ // 203.0.113.0/24 (TEST-NET-3, documentation)
124
+ if (a === 203 && b === 0 && c === 113) return true;
125
+ // 224.0.0.0/4 (multicast) and 240.0.0.0/4 (reserved, incl. 255.255.255.255 broadcast)
126
+ if (a >= 224) return true;
121
127
 
122
128
  return false;
123
129
  }
@@ -198,6 +204,12 @@ function isPrivateIPv6(ip: string): boolean {
198
204
  // ff00::/8 (multicast)
199
205
  if ((groups[0] & 0xff00) === 0xff00) return true;
200
206
 
207
+ // 2001:db8::/32 (documentation range, not globally routable)
208
+ if (groups[0] === 0x2001 && groups[1] === 0x0db8) return true;
209
+
210
+ // 3fff::/20 (documentation range, RFC 9637)
211
+ if (groups[0] === 0x3fff && (groups[1] & 0xf000) === 0x0000) return true;
212
+
201
213
  // Addresses that embed an IPv4 address in their last 32 bits. For these we
202
214
  // extract the embedded IPv4 and reuse the IPv4 private-range checks, so that
203
215
  // e.g. ::ffff:127.0.0.1 or 64:ff9b::169.254.169.254 are blocked.
package/src/websocket.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import { delay } from './delay';
2
+
1
3
  export type WebSocketLike = {
2
4
  readyState: number;
5
+ /** Bytes queued by `send` but not yet transmitted (native + `ws`). */
6
+ readonly bufferedAmount?: number;
3
7
  send(data: string | Uint8Array | ArrayBuffer): void;
4
8
  close(code?: number, reason?: string): void;
5
9
  onopen: ((event: unknown) => void) | null;
@@ -59,3 +63,35 @@ export async function readWebSocketMessageText(data: unknown): Promise<string> {
59
63
  }
60
64
  return String(data);
61
65
  }
66
+
67
+ const WEBSOCKET_OPEN_STATE = 1;
68
+
69
+ /**
70
+ * Waits until the socket's send buffer drains below `highWaterMark` bytes.
71
+ * No-op for implementations that do not expose `bufferedAmount`. There is no
72
+ * portable drain event, so this polls. Returns as soon as the socket is no
73
+ * longer open or the signal aborts — `bufferedAmount` never drains on a
74
+ * closed socket, so waiting on would poll forever.
75
+ */
76
+ export async function waitForWebSocketBufferDrain(
77
+ socket: WebSocketLike,
78
+ {
79
+ highWaterMark = 1024 * 1024,
80
+ pollIntervalMs = 20,
81
+ abortSignal,
82
+ }: {
83
+ highWaterMark?: number;
84
+ pollIntervalMs?: number;
85
+ abortSignal?: AbortSignal;
86
+ } = {},
87
+ ): Promise<void> {
88
+ while (
89
+ socket.readyState === WEBSOCKET_OPEN_STATE &&
90
+ (socket.bufferedAmount ?? 0) > highWaterMark
91
+ ) {
92
+ if (abortSignal?.aborted === true) {
93
+ return;
94
+ }
95
+ await delay(pollIntervalMs);
96
+ }
97
+ }