@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.
- package/CHANGELOG.md +31 -0
- package/dist/index.d.ts +246 -40
- package/dist/index.js +399 -98
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/connect-to-websocket.ts +116 -0
- package/src/fetch-with-validated-redirects.ts +87 -22
- package/src/get-from-api.ts +64 -9
- package/src/index.ts +15 -0
- package/src/sanitize-request-headers.ts +53 -0
- package/src/transcription-stream-envelope.ts +341 -0
- package/src/validate-base-url.ts +12 -0
- package/src/validate-download-url.ts +14 -2
- package/src/websocket.ts +36 -0
|
@@ -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
|
-
//
|
|
120
|
-
if (a
|
|
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
|
+
}
|