@ai-sdk/provider-utils 5.0.66 → 5.0.68

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/provider-utils",
3
- "version": "5.0.66",
3
+ "version": "5.0.68",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -42,7 +42,7 @@
42
42
  }
43
43
  },
44
44
  "dependencies": {
45
- "@ai-sdk/provider": "4.0.32",
45
+ "@ai-sdk/provider": "4.0.34",
46
46
  "@standard-schema/spec": "^1.1.0",
47
47
  "@workflow/serde": "4.1.0",
48
48
  "eventsource-parser": "^3.0.8",
package/src/index.ts CHANGED
@@ -113,6 +113,17 @@ export {
113
113
  export { serializeModelOptions } from './serialize-model-options';
114
114
  export { SerializationError } from './serialization-error';
115
115
  export { secureJsonParse } from './secure-json-parse';
116
+ export {
117
+ SPEECH_STREAM_START_FRAME_TYPE as EXPERIMENTAL_SPEECH_STREAM_START_FRAME_TYPE,
118
+ SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE as EXPERIMENTAL_SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE,
119
+ SPEECH_STREAM_TEXT_DONE_FRAME_TYPE as EXPERIMENTAL_SPEECH_STREAM_TEXT_DONE_FRAME_TYPE,
120
+ parseSpeechStreamClientFrame as experimental_parseSpeechStreamClientFrame,
121
+ parseSpeechStreamPart as experimental_parseSpeechStreamPart,
122
+ serializeSpeechStreamPart as experimental_serializeSpeechStreamPart,
123
+ type SpeechStreamClientFrame as Experimental_SpeechStreamClientFrame,
124
+ type SpeechStreamStartFrame as Experimental_SpeechStreamStartFrame,
125
+ type SpeechStreamTextDeltaFrame as Experimental_SpeechStreamTextDeltaFrame,
126
+ } from './speech-stream-envelope';
116
127
  export {
117
128
  StreamingToolCallTracker,
118
129
  type StreamingToolCallDelta,
@@ -0,0 +1,418 @@
1
+ import type {
2
+ Experimental_SpeechModelV4StreamPart as SpeechModelV4StreamPart,
3
+ JSONObject,
4
+ SharedV4AudioFormat,
5
+ } from '@ai-sdk/provider';
6
+ import { isRecord } from './is-record';
7
+ import { secureJsonParse } from './secure-json-parse';
8
+ import { convertUint8ArrayToBase64 } from './uint8-utils';
9
+
10
+ /**
11
+ * Experimental speech-stream WebSocket envelope (v1): the standard
12
+ * serialization of `Experimental_StreamingSpeechModelV4.doStream` over a WebSocket. Clients (e.g.
13
+ * the `@ai-sdk/gateway` provider) encode with this module and servers (e.g.
14
+ * AI Gateway) decode with it, so the two sides cannot drift.
15
+ *
16
+ * Envelope rules:
17
+ *
18
+ * 1. The client sends exactly one `speech-stream.start` TEXT frame first.
19
+ * 2. Input text rides `speech-stream.text-delta` TEXT frames, in order. A
20
+ * frame may carry its text part's `voice` and `providerOptions`. A long
21
+ * part may span several frames: `voice` is repeated on each, and
22
+ * `providerOptions` appear on the first only, so they must describe an
23
+ * event at the start of the part (e.g. a turn break), not a setting. An
24
+ * empty `delta` is valid and carries only part metadata; it does not end
25
+ * the input.
26
+ * 3. The client signals the end of the input text with the
27
+ * `speech-stream.text-done` TEXT frame; a plain close without it is an
28
+ * abort.
29
+ * 4. Every server→client TEXT frame is one JSON-serialized
30
+ * `SpeechModelV4StreamPart` (flattened, no wrapper). `audio` payloads are
31
+ * base64 strings. `Date` values (`response-metadata.timestamp`) serialize
32
+ * to ISO 8601 strings and are revived by `parseSpeechStreamPart`. `Error`
33
+ * payloads in `error` parts serialize as `{ name, message }`; servers may
34
+ * instead send `{ type, message }`, where `type` names an error class the
35
+ * client can map to a typed error (e.g. `invalid_request_error`).
36
+ * 5. The server closes with code 1000 after the `finish` part. Over this
37
+ * envelope an `error` part is terminal: the server sends it on failure and
38
+ * then closes non-1000. A close without a prior `finish` is an error.
39
+ * 6. Unknown frame/part types are ignored in both directions (forward
40
+ * compatibility).
41
+ * 7. Servers may enforce a maximum frame size (the AI Gateway rejects frames
42
+ * over 256 KiB); clients should keep text-delta frames under 64 KiB.
43
+ * 8. Connection establishment (URL, auth) is transport-specific and out of
44
+ * scope.
45
+ *
46
+ * The envelope validates frame shape only; server policy layers on top. Both
47
+ * parsers use `secureJsonParse`, so frames carrying `__proto__` /
48
+ * `constructor.prototype` keys are rejected rather than parsed.
49
+ */
50
+
51
+ /** Type of the first client TEXT frame. */
52
+ export const SPEECH_STREAM_START_FRAME_TYPE = 'speech-stream.start';
53
+
54
+ /** Type of the client TEXT frame that carries one input text delta. */
55
+ export const SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE = 'speech-stream.text-delta';
56
+
57
+ /** Type of the client TEXT frame that signals the end of the input text. */
58
+ export const SPEECH_STREAM_TEXT_DONE_FRAME_TYPE = 'speech-stream.text-done';
59
+
60
+ /**
61
+ * The client's session start frame. Optional keys are omitted when undefined.
62
+ */
63
+ export type SpeechStreamStartFrame = {
64
+ type: typeof SPEECH_STREAM_START_FRAME_TYPE;
65
+ voice?: string;
66
+ outputFormat?: SharedV4AudioFormat;
67
+ instructions?: string;
68
+ speed?: number;
69
+ language?: string;
70
+
71
+ /** Provider-specific options, passed through verbatim. */
72
+ providerOptions?: Record<string, JSONObject>;
73
+
74
+ /** When true, the server should include `raw` parts in the stream. */
75
+ includeRawChunks?: boolean;
76
+ };
77
+
78
+ /**
79
+ * A client frame carrying one input text delta, i.e. one text part. Optional
80
+ * keys are omitted when undefined.
81
+ */
82
+ export type SpeechStreamTextDeltaFrame = {
83
+ type: typeof SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE;
84
+ delta: string;
85
+ voice?: string;
86
+ providerOptions?: Record<string, JSONObject>;
87
+ };
88
+
89
+ /** Server-side classification of a client TEXT frame. */
90
+ export type SpeechStreamClientFrame =
91
+ | { type: 'start'; frame: SpeechStreamStartFrame }
92
+ | {
93
+ type: 'text-delta';
94
+ delta: string;
95
+ voice?: string;
96
+ providerOptions?: Record<string, JSONObject>;
97
+ }
98
+ | { type: 'text-done' }
99
+ | {
100
+ /** Malformed JSON or a recognized frame with an invalid shape. */
101
+ type: 'invalid';
102
+ message: string;
103
+ }
104
+ | {
105
+ /** Unrecognized frame type; ignore for forward compatibility. */
106
+ type: 'unknown';
107
+ };
108
+
109
+ const optionalStartFrameFields = {
110
+ instructions: 'string',
111
+ language: 'string',
112
+ speed: 'number',
113
+ voice: 'string',
114
+ includeRawChunks: 'boolean',
115
+ } as const;
116
+
117
+ /**
118
+ * Server-side: parse a client TEXT frame. Validates envelope shape only and
119
+ * rejects prototype-pollution payloads. Never throws.
120
+ */
121
+ export function parseSpeechStreamClientFrame(
122
+ text: string,
123
+ ): SpeechStreamClientFrame {
124
+ let value: unknown;
125
+ try {
126
+ value = secureJsonParse(text);
127
+ } catch {
128
+ return { type: 'invalid', message: 'malformed JSON' };
129
+ }
130
+
131
+ if (!isRecord(value)) {
132
+ return { type: 'invalid', message: 'frame must be a JSON object' };
133
+ }
134
+
135
+ if (typeof value.type !== 'string') {
136
+ return { type: 'invalid', message: 'frame type must be a string' };
137
+ }
138
+
139
+ switch (value.type) {
140
+ case SPEECH_STREAM_START_FRAME_TYPE: {
141
+ for (const [field, type] of Object.entries(optionalStartFrameFields)) {
142
+ if (value[field] !== undefined && typeof value[field] !== type) {
143
+ return {
144
+ type: 'invalid',
145
+ message: `${field} must be a ${type} when present`,
146
+ };
147
+ }
148
+ }
149
+ if (
150
+ value.outputFormat !== undefined &&
151
+ !isAudioFormat(value.outputFormat)
152
+ ) {
153
+ return {
154
+ type: 'invalid',
155
+ message:
156
+ 'outputFormat must be an audio format object ({ type, rate? }) when present',
157
+ };
158
+ }
159
+ if (
160
+ value.providerOptions !== undefined &&
161
+ !isProviderOptions(value.providerOptions)
162
+ ) {
163
+ return {
164
+ type: 'invalid',
165
+ message:
166
+ 'providerOptions must be an object of provider objects when present',
167
+ };
168
+ }
169
+ // Only known keys are passed on, so servers never see client extras.
170
+ const frame: SpeechStreamStartFrame = {
171
+ type: SPEECH_STREAM_START_FRAME_TYPE,
172
+ };
173
+ for (const field of Object.keys(optionalStartFrameFields) as Array<
174
+ keyof typeof optionalStartFrameFields
175
+ >) {
176
+ if (value[field] !== undefined) {
177
+ (frame as Record<string, unknown>)[field] = value[field];
178
+ }
179
+ }
180
+ if (value.outputFormat !== undefined) {
181
+ const { type, rate } = value.outputFormat as SharedV4AudioFormat;
182
+ frame.outputFormat = { type, ...(rate !== undefined && { rate }) };
183
+ }
184
+ if (value.providerOptions !== undefined) {
185
+ frame.providerOptions = value.providerOptions as Record<
186
+ string,
187
+ JSONObject
188
+ >;
189
+ }
190
+ return { type: 'start', frame };
191
+ }
192
+
193
+ case SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE: {
194
+ if (typeof value.delta !== 'string') {
195
+ return {
196
+ type: 'invalid',
197
+ message: 'text-delta frame must have a string delta',
198
+ };
199
+ }
200
+ if (value.voice !== undefined && typeof value.voice !== 'string') {
201
+ return {
202
+ type: 'invalid',
203
+ message: 'text-delta voice must be a string when present',
204
+ };
205
+ }
206
+ if (
207
+ value.providerOptions !== undefined &&
208
+ !isProviderOptions(value.providerOptions)
209
+ ) {
210
+ return {
211
+ type: 'invalid',
212
+ message:
213
+ 'text-delta providerOptions must be an object of provider objects when present',
214
+ };
215
+ }
216
+ return {
217
+ type: 'text-delta',
218
+ delta: value.delta,
219
+ ...(value.voice !== undefined && { voice: value.voice }),
220
+ ...(value.providerOptions !== undefined && {
221
+ providerOptions: value.providerOptions as Record<string, JSONObject>,
222
+ }),
223
+ };
224
+ }
225
+
226
+ case SPEECH_STREAM_TEXT_DONE_FRAME_TYPE:
227
+ return { type: 'text-done' };
228
+
229
+ default:
230
+ return { type: 'unknown' };
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Server-side: serialize a speech stream part as one TEXT frame. Binary audio
236
+ * is base64-encoded and `Error` payloads serialize as `{ name, message }`.
237
+ * Returns `undefined` for payloads that are not JSON-serializable; callers
238
+ * drop the frame.
239
+ */
240
+ export function serializeSpeechStreamPart(
241
+ part: SpeechModelV4StreamPart,
242
+ ): string | undefined {
243
+ try {
244
+ if (part.type === 'audio' && part.audio instanceof Uint8Array) {
245
+ return JSON.stringify({
246
+ ...part,
247
+ audio: convertUint8ArrayToBase64(part.audio),
248
+ });
249
+ }
250
+ if (part.type === 'error' && isError(part.error)) {
251
+ return JSON.stringify({
252
+ ...part,
253
+ error: { name: part.error.name, message: part.error.message },
254
+ });
255
+ }
256
+ return JSON.stringify(part);
257
+ } catch {
258
+ return undefined;
259
+ }
260
+ }
261
+
262
+ // `instanceof` misses cross-realm Errors; the brand check does not.
263
+ function isError(value: unknown): value is Error {
264
+ return (
265
+ value instanceof Error ||
266
+ Object.prototype.toString.call(value) === '[object Error]'
267
+ );
268
+ }
269
+
270
+ /**
271
+ * Client-side: parse a server TEXT frame into a speech stream part. Returns
272
+ * `undefined` for malformed or unsafe JSON, unknown part types, and known
273
+ * part types whose fields are missing or mistyped. Revives
274
+ * `response-metadata.timestamp` to a `Date`.
275
+ */
276
+ export function parseSpeechStreamPart(
277
+ text: string,
278
+ ): SpeechModelV4StreamPart | undefined {
279
+ let value: unknown;
280
+ try {
281
+ value = secureJsonParse(text);
282
+ } catch {
283
+ return undefined;
284
+ }
285
+
286
+ if (!isRecord(value)) {
287
+ return undefined;
288
+ }
289
+
290
+ const part = value as SpeechModelV4StreamPart;
291
+
292
+ switch (part.type) {
293
+ case 'stream-start': {
294
+ if (!Array.isArray(part.warnings)) {
295
+ return undefined;
296
+ }
297
+ // Unknown warning types are dropped (forward compatibility); a known
298
+ // type with a malformed shape invalidates the part.
299
+ const warnings = part.warnings.filter(
300
+ warning => !isUnknownWarningType(warning),
301
+ );
302
+ return warnings.every(isWarning) ? { ...part, warnings } : undefined;
303
+ }
304
+
305
+ case 'audio':
306
+ return isString(part.audio) &&
307
+ isAudioFormat(part.audioFormat) &&
308
+ isOptional(part.id, isString) &&
309
+ isOptional(part.providerMetadata, isRecord)
310
+ ? part
311
+ : undefined;
312
+
313
+ case 'finish':
314
+ return isOptional(part.usage, isUsage) &&
315
+ isOptional(part.providerMetadata, isRecord)
316
+ ? part
317
+ : undefined;
318
+
319
+ case 'response-metadata': {
320
+ if (
321
+ !(
322
+ isOptional(part.modelId, isString) &&
323
+ isOptional(part.headers, isRecord)
324
+ )
325
+ ) {
326
+ return undefined;
327
+ }
328
+ const timestamp: unknown = part.timestamp;
329
+ if (timestamp == null) {
330
+ return { ...part, timestamp: undefined };
331
+ }
332
+ if (typeof timestamp !== 'string') {
333
+ return undefined;
334
+ }
335
+ const revived = new Date(timestamp);
336
+ return Number.isNaN(revived.getTime())
337
+ ? undefined
338
+ : { ...part, timestamp: revived };
339
+ }
340
+
341
+ case 'raw':
342
+ return 'rawValue' in part ? part : undefined;
343
+
344
+ case 'error':
345
+ return 'error' in part ? part : undefined;
346
+
347
+ default:
348
+ return undefined;
349
+ }
350
+ }
351
+
352
+ function isString(value: unknown): value is string {
353
+ return typeof value === 'string';
354
+ }
355
+
356
+ function isOptional(
357
+ value: unknown,
358
+ check: (value: unknown) => boolean,
359
+ ): boolean {
360
+ return value === undefined || check(value);
361
+ }
362
+
363
+ const knownWarningTypes = new Set([
364
+ 'compatibility',
365
+ 'deprecated',
366
+ 'other',
367
+ 'unsupported',
368
+ ]);
369
+
370
+ function isUnknownWarningType(value: unknown): boolean {
371
+ return (
372
+ isRecord(value) &&
373
+ isString(value.type) &&
374
+ !knownWarningTypes.has(value.type)
375
+ );
376
+ }
377
+
378
+ function isWarning(value: unknown): boolean {
379
+ if (!isRecord(value)) {
380
+ return false;
381
+ }
382
+ switch (value.type) {
383
+ case 'compatibility':
384
+ case 'unsupported':
385
+ return isString(value.feature) && isOptional(value.details, isString);
386
+ case 'deprecated':
387
+ return isString(value.setting) && isString(value.message);
388
+ case 'other':
389
+ return isString(value.message);
390
+ default:
391
+ return false;
392
+ }
393
+ }
394
+
395
+ function isProviderOptions(value: unknown): boolean {
396
+ return isRecord(value) && Object.values(value).every(isRecord);
397
+ }
398
+
399
+ function isAudioFormat(value: unknown): boolean {
400
+ return (
401
+ isRecord(value) &&
402
+ isString(value.type) &&
403
+ value.type.length > 0 &&
404
+ isOptional(
405
+ value.rate,
406
+ rate => typeof rate === 'number' && Number.isFinite(rate) && rate > 0,
407
+ )
408
+ );
409
+ }
410
+
411
+ function isUsage(value: unknown): boolean {
412
+ return (
413
+ isRecord(value) &&
414
+ ['inputCharacters', 'inputTokens', 'outputTokens'].every(key =>
415
+ isOptional(value[key], count => typeof count === 'number'),
416
+ )
417
+ );
418
+ }