@ai-sdk/gateway 4.0.122 → 4.0.123
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 +13 -0
- package/dist/index.d.ts +52 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +335 -129
- package/dist/index.js.map +1 -1
- package/docs/00-ai-gateway.mdx +63 -0
- package/package.json +3 -3
- package/src/gateway-provider.ts +71 -7
- package/src/gateway-realtime-auth.ts +21 -3
- package/src/gateway-speech-model.ts +305 -1
- package/src/gateway-stream-errors.ts +71 -0
- package/src/gateway-transcription-model.ts +18 -72
- package/src/index.ts +2 -0
package/docs/00-ai-gateway.mdx
CHANGED
|
@@ -464,6 +464,69 @@ console.log(ranking);
|
|
|
464
464
|
|
|
465
465
|
Reranking models are useful for improving search results in retrieval-augmented generation (RAG) pipelines by re-scoring candidate documents after an initial retrieval step.
|
|
466
466
|
|
|
467
|
+
## Streaming Speech Models
|
|
468
|
+
|
|
469
|
+
<Note type="warning">
|
|
470
|
+
Streaming speech is experimental and the API may change in patch releases.
|
|
471
|
+
</Note>
|
|
472
|
+
|
|
473
|
+
Speech models that support streaming can be used with
|
|
474
|
+
`experimental_streamSpeech`. The gateway provider connects to AI Gateway over a
|
|
475
|
+
WebSocket, forwards text deltas as they arrive, and streams audio chunks back.
|
|
476
|
+
Gateway WebSocket auth is carried in the subprotocols, so no header-capable
|
|
477
|
+
WebSocket implementation is needed.
|
|
478
|
+
|
|
479
|
+
Streaming speech works only with models that the
|
|
480
|
+
[AI Gateway model catalog](https://vercel.com/ai-gateway/models) lists with
|
|
481
|
+
streaming speech support. The model ID below is illustrative; check the catalog
|
|
482
|
+
for current availability.
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
import { experimental_streamSpeech as streamSpeech } from 'ai';
|
|
486
|
+
|
|
487
|
+
const result = streamSpeech({
|
|
488
|
+
model: 'elevenlabs/eleven-flash-v2.5',
|
|
489
|
+
text: textStream, // a string, or an array or stream of strings and/or text parts
|
|
490
|
+
outputFormat: { type: 'audio/pcm', rate: 24000 },
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
for await (const chunk of result.audioStream) {
|
|
494
|
+
// Uint8Array audio bytes
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
console.log(await result.usage); // { inputCharacters: ... }
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### Streaming Speech in the Browser
|
|
501
|
+
|
|
502
|
+
To stream speech from browser code without exposing your Gateway credential,
|
|
503
|
+
mint a short-lived client secret on your server with
|
|
504
|
+
`gateway.experimental_speech.getToken()` and pass the token to the browser.
|
|
505
|
+
The token is bound to the streaming speech surface and the requested model.
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
// Server
|
|
509
|
+
import { gateway } from '@ai-sdk/gateway';
|
|
510
|
+
|
|
511
|
+
const { token } = await gateway.experimental_speech.getToken({
|
|
512
|
+
model: 'elevenlabs/eleven-flash-v2.5',
|
|
513
|
+
expiresAfterSeconds: 60,
|
|
514
|
+
});
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
// Browser
|
|
519
|
+
import { createGateway } from '@ai-sdk/gateway';
|
|
520
|
+
import { experimental_streamSpeech as streamSpeech } from 'ai';
|
|
521
|
+
|
|
522
|
+
const gateway = createGateway({ apiKey: token });
|
|
523
|
+
|
|
524
|
+
const result = streamSpeech({
|
|
525
|
+
model: gateway.experimental_speech('elevenlabs/eleven-flash-v2.5'),
|
|
526
|
+
text: textStream,
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
467
530
|
## Realtime Models
|
|
468
531
|
|
|
469
532
|
<Note type="warning">
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/gateway",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "4.0.
|
|
4
|
+
"version": "4.0.123",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"sideEffects": false,
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@ai-sdk/provider": "4.0.
|
|
34
|
-
"@ai-sdk/provider-utils": "5.0.
|
|
33
|
+
"@ai-sdk/provider": "4.0.33",
|
|
34
|
+
"@ai-sdk/provider-utils": "5.0.67",
|
|
35
35
|
"@vercel/oidc": "3.2.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
package/src/gateway-provider.ts
CHANGED
|
@@ -40,7 +40,7 @@ import { GatewayImageModel } from './gateway-image-model';
|
|
|
40
40
|
import { GatewayVideoModel } from './gateway-video-model';
|
|
41
41
|
import { GatewayDecisionModel } from './gateway-decision-model';
|
|
42
42
|
import { GatewayRerankingModel } from './gateway-reranking-model';
|
|
43
|
-
import { GatewaySpeechModel } from './gateway-speech-model';
|
|
43
|
+
import { GatewaySpeechModel, toGatewaySpeechUrl } from './gateway-speech-model';
|
|
44
44
|
import {
|
|
45
45
|
GatewayTranscriptionModel,
|
|
46
46
|
toGatewayTranscriptionUrl,
|
|
@@ -66,6 +66,7 @@ import type {
|
|
|
66
66
|
TranscriptionModelV4,
|
|
67
67
|
Experimental_VideoModelV4,
|
|
68
68
|
Experimental_DecisionModelV4,
|
|
69
|
+
Experimental_StreamingSpeechModelV4,
|
|
69
70
|
Experimental_RealtimeFactoryV4 as RealtimeFactoryV4,
|
|
70
71
|
Experimental_RealtimeFactoryV4GetTokenOptions as RealtimeFactoryV4GetTokenOptions,
|
|
71
72
|
ProviderV4,
|
|
@@ -216,6 +217,14 @@ export interface GatewayProvider extends ProviderV4 {
|
|
|
216
217
|
*/
|
|
217
218
|
experimental_transcription: GatewayTranscriptionFactory;
|
|
218
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Experimental streaming-speech entry point. Callable like
|
|
222
|
+
* `speech(modelId)`, plus `getToken` for minting a short-lived client
|
|
223
|
+
* secret (`vcst_`) a browser can use to open the streaming speech
|
|
224
|
+
* WebSocket without holding the Gateway credential.
|
|
225
|
+
*/
|
|
226
|
+
experimental_speech: GatewaySpeechFactory;
|
|
227
|
+
|
|
219
228
|
/**
|
|
220
229
|
* Gateway-specific tools executed server-side.
|
|
221
230
|
*/
|
|
@@ -252,6 +261,36 @@ export interface GatewayTranscriptionFactory {
|
|
|
252
261
|
): Promise<GatewayTranscriptionFactoryGetTokenResult>;
|
|
253
262
|
}
|
|
254
263
|
|
|
264
|
+
export type GatewaySpeechFactoryGetTokenOptions = {
|
|
265
|
+
model: GatewaySpeechModelId;
|
|
266
|
+
/** Token lifetime in seconds. Gateway default is 60s (max 300s). */
|
|
267
|
+
expiresAfterSeconds?: number;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
export type GatewaySpeechFactoryGetTokenResult = {
|
|
271
|
+
/** The minted `vcst_` client secret. */
|
|
272
|
+
token: string;
|
|
273
|
+
/** WebSocket URL of the streaming speech surface for this model. */
|
|
274
|
+
url: string;
|
|
275
|
+
/** Token expiry, epoch seconds. */
|
|
276
|
+
expiresAt?: number;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Streaming-speech factory: callable like `speech(modelId)`, plus a
|
|
281
|
+
* server-side `getToken` that mints a speech-bound short-lived client secret
|
|
282
|
+
* (`vcst_`). The browser connects with
|
|
283
|
+
* `createGateway({ apiKey: token }).speech(modelId)`; the token rides the
|
|
284
|
+
* same auth subprotocol an API key does, without exposing the credential.
|
|
285
|
+
*/
|
|
286
|
+
export interface GatewaySpeechFactory {
|
|
287
|
+
(modelId: GatewaySpeechModelId): Experimental_StreamingSpeechModelV4;
|
|
288
|
+
|
|
289
|
+
getToken(
|
|
290
|
+
options: GatewaySpeechFactoryGetTokenOptions,
|
|
291
|
+
): Promise<GatewaySpeechFactoryGetTokenResult>;
|
|
292
|
+
}
|
|
293
|
+
|
|
255
294
|
export interface GatewayProviderSettings {
|
|
256
295
|
/** Settings for downloading JSON Lines batch results. */
|
|
257
296
|
batchResultDownloads?: {
|
|
@@ -288,10 +327,10 @@ export interface GatewayProviderSettings {
|
|
|
288
327
|
fetch?: FetchFunction;
|
|
289
328
|
|
|
290
329
|
/**
|
|
291
|
-
* Custom WebSocket implementation used for streaming transcription
|
|
292
|
-
* useful for testing or for runtimes without a
|
|
293
|
-
* header-capable implementation is not required —
|
|
294
|
-
* carried in the subprotocols.
|
|
330
|
+
* Custom WebSocket implementation used for streaming transcription and
|
|
331
|
+
* streaming speech. This is useful for testing or for runtimes without a
|
|
332
|
+
* global WebSocket. A header-capable implementation is not required —
|
|
333
|
+
* Gateway WebSocket auth is carried in the subprotocols.
|
|
295
334
|
*/
|
|
296
335
|
webSocket?: WebSocketConstructor;
|
|
297
336
|
|
|
@@ -391,7 +430,7 @@ export function createGateway(
|
|
|
391
430
|
const mintClientSecret = async (params: {
|
|
392
431
|
modelId: string;
|
|
393
432
|
expiresAfterSeconds?: number;
|
|
394
|
-
routeKind?: 'transcription';
|
|
433
|
+
routeKind?: 'speech' | 'transcription';
|
|
395
434
|
}): Promise<{ token: string; expiresAt?: number }> => {
|
|
396
435
|
assertGatewayClientSecretServerEnvironment();
|
|
397
436
|
const auth = await getRealtimeAuthToken();
|
|
@@ -624,10 +663,35 @@ export function createGateway(
|
|
|
624
663
|
headers: getHeaders,
|
|
625
664
|
fetch: options.fetch,
|
|
626
665
|
o11yHeaders: createO11yHeaders(),
|
|
666
|
+
webSocket: options.webSocket,
|
|
627
667
|
});
|
|
628
668
|
};
|
|
629
669
|
provider.speechModel = createSpeechModel;
|
|
630
670
|
provider.speech = createSpeechModel;
|
|
671
|
+
// Callable like `speech(modelId)`; `getToken` mints a speech-bound client
|
|
672
|
+
// secret (server-side only) the browser uses to connect via
|
|
673
|
+
// `createGateway({ apiKey: token }).speech(modelId)`.
|
|
674
|
+
provider.experimental_speech = Object.assign(
|
|
675
|
+
(modelId: GatewaySpeechModelId) => createSpeechModel(modelId),
|
|
676
|
+
{
|
|
677
|
+
getToken: async (
|
|
678
|
+
tokenOptions: GatewaySpeechFactoryGetTokenOptions,
|
|
679
|
+
): Promise<GatewaySpeechFactoryGetTokenResult> => {
|
|
680
|
+
const secret = await mintClientSecret({
|
|
681
|
+
modelId: tokenOptions.model,
|
|
682
|
+
routeKind: 'speech',
|
|
683
|
+
...(tokenOptions.expiresAfterSeconds != null && {
|
|
684
|
+
expiresAfterSeconds: tokenOptions.expiresAfterSeconds,
|
|
685
|
+
}),
|
|
686
|
+
});
|
|
687
|
+
return {
|
|
688
|
+
token: secret.token,
|
|
689
|
+
url: toGatewaySpeechUrl(baseURL, tokenOptions.model),
|
|
690
|
+
...(secret.expiresAt != null && { expiresAt: secret.expiresAt }),
|
|
691
|
+
};
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
) as GatewaySpeechFactory;
|
|
631
695
|
const createTranscriptionModel = (modelId: GatewayTranscriptionModelId) => {
|
|
632
696
|
return new GatewayTranscriptionModel(modelId, {
|
|
633
697
|
provider: 'gateway',
|
|
@@ -728,7 +792,7 @@ export async function getGatewayAuthToken(
|
|
|
728
792
|
function assertGatewayClientSecretServerEnvironment(): void {
|
|
729
793
|
if (typeof globalThis.window !== 'undefined') {
|
|
730
794
|
throw new Error(
|
|
731
|
-
'AI Gateway client secrets must be minted server-side: minting needs your Gateway credential, which must never reach the browser. Call gateway.experimental_realtime.getToken() or gateway.
|
|
795
|
+
'AI Gateway client secrets must be minted server-side: minting needs your Gateway credential, which must never reach the browser. Call gateway.experimental_realtime.getToken(), gateway.experimental_transcription.getToken(), or gateway.experimental_speech.getToken() from your server and pass the returned token to the client.',
|
|
732
796
|
);
|
|
733
797
|
}
|
|
734
798
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared WebSocket subprotocol contract for AI Gateway realtime
|
|
3
|
-
* transcription auth.
|
|
2
|
+
* Shared WebSocket subprotocol contract for AI Gateway realtime, streaming
|
|
3
|
+
* transcription, and streaming speech auth.
|
|
4
4
|
*
|
|
5
5
|
* The browser `WebSocket` API cannot set request headers, so the Gateway auth
|
|
6
6
|
* (bearer) token is carried through the `Sec-WebSocket-Protocol` handshake
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* This module is the single source of truth for that contract so the client and
|
|
11
11
|
* the Gateway server can't drift: the client encodes values with
|
|
12
|
-
* `getGatewayRealtimeProtocols` / `getGatewayTranscriptionProtocols
|
|
12
|
+
* `getGatewayRealtimeProtocols` / `getGatewayTranscriptionProtocols` /
|
|
13
|
+
* `getGatewaySpeechProtocols`, and the
|
|
13
14
|
* Gateway server decodes them with `getGatewayRealtimeAuthToken` /
|
|
14
15
|
* `getGatewayRealtimeTeamIdOrSlug`.
|
|
15
16
|
*
|
|
@@ -33,6 +34,12 @@ export const GATEWAY_REALTIME_SUBPROTOCOL = 'ai-gateway-realtime.v1';
|
|
|
33
34
|
*/
|
|
34
35
|
export const GATEWAY_TRANSCRIPTION_SUBPROTOCOL = 'ai-gateway-transcription.v1';
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Marker subprotocol for streaming speech handshakes (same negotiation
|
|
39
|
+
* purpose as `GATEWAY_REALTIME_SUBPROTOCOL`).
|
|
40
|
+
*/
|
|
41
|
+
export const GATEWAY_SPEECH_SUBPROTOCOL = 'ai-gateway-speech.v1';
|
|
42
|
+
|
|
36
43
|
/** Subprotocol prefix that carries the Gateway auth (bearer) token. */
|
|
37
44
|
export const GATEWAY_AUTH_SUBPROTOCOL_PREFIX = 'ai-gateway-auth.';
|
|
38
45
|
|
|
@@ -65,6 +72,17 @@ export function getGatewayTranscriptionProtocols(
|
|
|
65
72
|
);
|
|
66
73
|
}
|
|
67
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Client-side: `getGatewayRealtimeProtocols`, but with the streaming speech
|
|
77
|
+
* marker subprotocol.
|
|
78
|
+
*/
|
|
79
|
+
export function getGatewaySpeechProtocols(
|
|
80
|
+
token: string,
|
|
81
|
+
options?: { teamIdOrSlug?: string },
|
|
82
|
+
): string[] {
|
|
83
|
+
return buildGatewayProtocols(GATEWAY_SPEECH_SUBPROTOCOL, token, options);
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
function buildGatewayProtocols(
|
|
69
87
|
marker: string,
|
|
70
88
|
token: string,
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
Experimental_SpeechModelV4StreamOptions as SpeechModelV4StreamOptions,
|
|
3
|
+
Experimental_SpeechModelV4StreamPart as SpeechModelV4StreamPart,
|
|
4
|
+
Experimental_SpeechModelV4StreamResult as SpeechModelV4StreamResult,
|
|
5
|
+
Experimental_SpeechModelV4StreamTextPart as SpeechModelV4StreamTextPart,
|
|
6
|
+
Experimental_StreamingSpeechModelV4 as StreamingSpeechModelV4,
|
|
2
7
|
SharedV4ProviderMetadata,
|
|
3
8
|
SharedV4Warning,
|
|
4
9
|
SpeechModelV4,
|
|
@@ -7,19 +12,41 @@ import {
|
|
|
7
12
|
lazySchema,
|
|
8
13
|
zodSchema,
|
|
9
14
|
combineHeaders,
|
|
15
|
+
connectToWebSocket,
|
|
10
16
|
createJsonErrorResponseHandler,
|
|
11
17
|
createJsonResponseHandler,
|
|
12
18
|
getErrorMessage,
|
|
19
|
+
experimental_parseSpeechStreamPart as parseSpeechStreamPart,
|
|
13
20
|
postJsonToApi,
|
|
14
21
|
resolve,
|
|
22
|
+
EXPERIMENTAL_SPEECH_STREAM_START_FRAME_TYPE as SPEECH_STREAM_START_FRAME_TYPE,
|
|
23
|
+
EXPERIMENTAL_SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE as SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE,
|
|
24
|
+
EXPERIMENTAL_SPEECH_STREAM_TEXT_DONE_FRAME_TYPE as SPEECH_STREAM_TEXT_DONE_FRAME_TYPE,
|
|
25
|
+
waitForWebSocketBufferDrain,
|
|
26
|
+
type Experimental_SpeechStreamStartFrame,
|
|
27
|
+
type Experimental_SpeechStreamTextDeltaFrame,
|
|
15
28
|
type Resolvable,
|
|
29
|
+
type WebSocketConnection,
|
|
30
|
+
type WebSocketConstructor,
|
|
31
|
+
type WebSocketLike,
|
|
16
32
|
} from '@ai-sdk/provider-utils';
|
|
17
33
|
import { z } from './zod';
|
|
18
34
|
import { asGatewayError } from './errors';
|
|
19
35
|
import { parseAuthMethod } from './errors/parse-auth-method';
|
|
20
36
|
import type { GatewayConfig } from './gateway-config';
|
|
37
|
+
import {
|
|
38
|
+
GATEWAY_SPEECH_SUBPROTOCOL,
|
|
39
|
+
getGatewaySpeechProtocols,
|
|
40
|
+
} from './gateway-realtime-auth';
|
|
41
|
+
import {
|
|
42
|
+
createErrorFromServerErrorPart,
|
|
43
|
+
errorControllerWithGatewayError,
|
|
44
|
+
} from './gateway-stream-errors';
|
|
45
|
+
import { getProtocolsFromHeaders } from './gateway-transcription-model';
|
|
21
46
|
|
|
22
|
-
export class GatewaySpeechModel
|
|
47
|
+
export class GatewaySpeechModel
|
|
48
|
+
implements SpeechModelV4, StreamingSpeechModelV4
|
|
49
|
+
{
|
|
23
50
|
readonly specificationVersion = 'v4' as const;
|
|
24
51
|
|
|
25
52
|
constructor(
|
|
@@ -27,6 +54,9 @@ export class GatewaySpeechModel implements SpeechModelV4 {
|
|
|
27
54
|
private readonly config: GatewayConfig & {
|
|
28
55
|
provider: string;
|
|
29
56
|
o11yHeaders: Resolvable<Record<string, string>>;
|
|
57
|
+
_internal?: {
|
|
58
|
+
currentDate?: () => Date;
|
|
59
|
+
};
|
|
30
60
|
},
|
|
31
61
|
) {}
|
|
32
62
|
|
|
@@ -104,6 +134,59 @@ export class GatewaySpeechModel implements SpeechModelV4 {
|
|
|
104
134
|
}
|
|
105
135
|
}
|
|
106
136
|
|
|
137
|
+
async doStream(
|
|
138
|
+
options: SpeechModelV4StreamOptions,
|
|
139
|
+
): Promise<SpeechModelV4StreamResult> {
|
|
140
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
141
|
+
|
|
142
|
+
const headers = combineHeaders(
|
|
143
|
+
await resolve(this.config.headers ?? {}),
|
|
144
|
+
options.headers ?? {},
|
|
145
|
+
this.getModelConfigHeaders(),
|
|
146
|
+
await resolve(this.config.o11yHeaders),
|
|
147
|
+
);
|
|
148
|
+
const authMethod = await parseAuthMethod(headers);
|
|
149
|
+
|
|
150
|
+
// First frame after the WebSocket opens, per the speech-stream envelope.
|
|
151
|
+
// Optional keys are omitted so the frame stays minimal.
|
|
152
|
+
const startFrame: Experimental_SpeechStreamStartFrame = {
|
|
153
|
+
type: SPEECH_STREAM_START_FRAME_TYPE,
|
|
154
|
+
...(options.voice != null && { voice: options.voice }),
|
|
155
|
+
...(options.outputFormat != null && {
|
|
156
|
+
outputFormat: options.outputFormat,
|
|
157
|
+
}),
|
|
158
|
+
...(options.instructions != null && {
|
|
159
|
+
instructions: options.instructions,
|
|
160
|
+
}),
|
|
161
|
+
...(options.speed != null && { speed: options.speed }),
|
|
162
|
+
...(options.language != null && { language: options.language }),
|
|
163
|
+
...(options.providerOptions != null && {
|
|
164
|
+
providerOptions: options.providerOptions,
|
|
165
|
+
}),
|
|
166
|
+
...(options.includeRawChunks != null && {
|
|
167
|
+
includeRawChunks: options.includeRawChunks,
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
stream: createGatewaySpeechStream({
|
|
173
|
+
webSocket: this.config.webSocket,
|
|
174
|
+
url: toGatewaySpeechUrl(this.config.baseURL, this.modelId),
|
|
175
|
+
protocols: getProtocolsFromHeaders(headers, {
|
|
176
|
+
marker: GATEWAY_SPEECH_SUBPROTOCOL,
|
|
177
|
+
getProtocols: getGatewaySpeechProtocols,
|
|
178
|
+
}),
|
|
179
|
+
headers,
|
|
180
|
+
startFrame,
|
|
181
|
+
text: options.text,
|
|
182
|
+
abortSignal: options.abortSignal,
|
|
183
|
+
authMethod,
|
|
184
|
+
}),
|
|
185
|
+
request: { body: startFrame },
|
|
186
|
+
response: { timestamp: currentDate, modelId: this.modelId },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
107
190
|
private getUrl() {
|
|
108
191
|
return `${this.config.baseURL}/speech-model`;
|
|
109
192
|
}
|
|
@@ -116,6 +199,227 @@ export class GatewaySpeechModel implements SpeechModelV4 {
|
|
|
116
199
|
}
|
|
117
200
|
}
|
|
118
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Gateway streaming speech WebSocket URL: HTTP(S) base upgraded to WS(S),
|
|
204
|
+
* model id in `?ai-model-id=` (browser `WebSocket` cannot set headers).
|
|
205
|
+
*/
|
|
206
|
+
export function toGatewaySpeechUrl(baseURL: string, modelId: string): string {
|
|
207
|
+
const url = new URL(`${baseURL.replace(/^http/, 'ws')}/speech-model`);
|
|
208
|
+
url.searchParams.set('ai-model-id', modelId);
|
|
209
|
+
return url.toString();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Keeps text-delta frames under 64 KiB (envelope rule 7) even when JSON
|
|
214
|
+
* escapes every code unit as `\uXXXX`.
|
|
215
|
+
*/
|
|
216
|
+
const MAX_TEXT_DELTA_CODE_UNITS = 8 * 1024;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Splits a long text delta into pieces of at most `MAX_TEXT_DELTA_CODE_UNITS`
|
|
220
|
+
* UTF-16 code units without separating a surrogate pair.
|
|
221
|
+
*/
|
|
222
|
+
export function splitTextDelta(delta: string): string[] {
|
|
223
|
+
const pieces: string[] = [];
|
|
224
|
+
let offset = 0;
|
|
225
|
+
while (offset < delta.length) {
|
|
226
|
+
let end = Math.min(offset + MAX_TEXT_DELTA_CODE_UNITS, delta.length);
|
|
227
|
+
const lastCodeUnit = delta.charCodeAt(end - 1);
|
|
228
|
+
if (
|
|
229
|
+
end < delta.length &&
|
|
230
|
+
lastCodeUnit >= 0xd800 &&
|
|
231
|
+
lastCodeUnit <= 0xdbff
|
|
232
|
+
) {
|
|
233
|
+
end--;
|
|
234
|
+
}
|
|
235
|
+
pieces.push(delta.slice(offset, end));
|
|
236
|
+
offset = end;
|
|
237
|
+
}
|
|
238
|
+
return pieces;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function createGatewaySpeechStream({
|
|
242
|
+
webSocket,
|
|
243
|
+
url,
|
|
244
|
+
protocols,
|
|
245
|
+
headers,
|
|
246
|
+
startFrame,
|
|
247
|
+
text,
|
|
248
|
+
abortSignal,
|
|
249
|
+
authMethod,
|
|
250
|
+
}: {
|
|
251
|
+
webSocket: WebSocketConstructor | undefined;
|
|
252
|
+
url: string;
|
|
253
|
+
protocols: string[];
|
|
254
|
+
headers: Record<string, string | undefined>;
|
|
255
|
+
startFrame: Experimental_SpeechStreamStartFrame;
|
|
256
|
+
text: ReadableStream<SpeechModelV4StreamTextPart>;
|
|
257
|
+
abortSignal: AbortSignal | undefined;
|
|
258
|
+
authMethod: 'api-key' | 'oidc' | undefined;
|
|
259
|
+
}): ReadableStream<SpeechModelV4StreamPart> {
|
|
260
|
+
let finished = false;
|
|
261
|
+
let cleanup: (closeCode?: number) => void = () => {};
|
|
262
|
+
|
|
263
|
+
return new ReadableStream<SpeechModelV4StreamPart>({
|
|
264
|
+
start: controller => {
|
|
265
|
+
let textReader:
|
|
266
|
+
| ReadableStreamDefaultReader<SpeechModelV4StreamTextPart>
|
|
267
|
+
| undefined;
|
|
268
|
+
let hasServerErrorPart = false;
|
|
269
|
+
let lastServerError: unknown;
|
|
270
|
+
let textStopped = false;
|
|
271
|
+
let connection: WebSocketConnection | undefined;
|
|
272
|
+
|
|
273
|
+
const cancelText = () => {
|
|
274
|
+
if (textReader != null) {
|
|
275
|
+
void textReader.cancel().catch(() => {});
|
|
276
|
+
textReader = undefined;
|
|
277
|
+
} else {
|
|
278
|
+
void text.cancel().catch(() => {});
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
cleanup = (closeCode?: number) => {
|
|
283
|
+
cancelText();
|
|
284
|
+
connection?.close(closeCode);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const finishWithError = (error: unknown) => {
|
|
288
|
+
if (finished) return;
|
|
289
|
+
finished = true;
|
|
290
|
+
cleanup();
|
|
291
|
+
void errorControllerWithGatewayError(controller, error, authMethod);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const sendText = async (socket: WebSocketLike) => {
|
|
295
|
+
const reader = text.getReader();
|
|
296
|
+
textReader = reader;
|
|
297
|
+
try {
|
|
298
|
+
while (true) {
|
|
299
|
+
const { done, value } = await reader.read();
|
|
300
|
+
if (done || finished || textStopped) break;
|
|
301
|
+
let first = true;
|
|
302
|
+
// An empty part still carries its voice or provider options.
|
|
303
|
+
const pieces = splitTextDelta(value.text);
|
|
304
|
+
if (
|
|
305
|
+
pieces.length === 0 &&
|
|
306
|
+
(value.voice != null || value.providerOptions != null)
|
|
307
|
+
) {
|
|
308
|
+
pieces.push('');
|
|
309
|
+
}
|
|
310
|
+
for (const delta of pieces) {
|
|
311
|
+
if (finished || textStopped) break;
|
|
312
|
+
// The voice applies to every frame of the part; provider
|
|
313
|
+
// options (e.g. a turn break) only to its first frame.
|
|
314
|
+
const frame: Experimental_SpeechStreamTextDeltaFrame = {
|
|
315
|
+
type: SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE,
|
|
316
|
+
delta,
|
|
317
|
+
...(value.voice != null && { voice: value.voice }),
|
|
318
|
+
...(first &&
|
|
319
|
+
value.providerOptions != null && {
|
|
320
|
+
providerOptions: value.providerOptions,
|
|
321
|
+
}),
|
|
322
|
+
};
|
|
323
|
+
first = false;
|
|
324
|
+
socket.send(JSON.stringify(frame));
|
|
325
|
+
await waitForWebSocketBufferDrain(socket);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} finally {
|
|
329
|
+
reader.releaseLock();
|
|
330
|
+
if (textReader === reader) {
|
|
331
|
+
textReader = undefined;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (!finished && !textStopped) {
|
|
335
|
+
socket.send(
|
|
336
|
+
JSON.stringify({ type: SPEECH_STREAM_TEXT_DONE_FRAME_TYPE }),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
connection = connectToWebSocket({
|
|
342
|
+
url,
|
|
343
|
+
protocols,
|
|
344
|
+
headers,
|
|
345
|
+
webSocket,
|
|
346
|
+
abortSignal,
|
|
347
|
+
onAbort: reason => {
|
|
348
|
+
if (finished) return;
|
|
349
|
+
finished = true;
|
|
350
|
+
cleanup();
|
|
351
|
+
controller.error(reason);
|
|
352
|
+
},
|
|
353
|
+
onProcessingError: finishWithError,
|
|
354
|
+
onOpen: socket => {
|
|
355
|
+
socket.send(JSON.stringify(startFrame));
|
|
356
|
+
void sendText(socket).catch(finishWithError);
|
|
357
|
+
},
|
|
358
|
+
onMessageText: async messageText => {
|
|
359
|
+
if (finished) return;
|
|
360
|
+
const part = parseSpeechStreamPart(messageText);
|
|
361
|
+
if (part == null) return;
|
|
362
|
+
if (part.type === 'finish') {
|
|
363
|
+
finished = true;
|
|
364
|
+
controller.enqueue(part);
|
|
365
|
+
controller.close();
|
|
366
|
+
cleanup(1000);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (part.type === 'error') {
|
|
371
|
+
// Error parts are terminal (envelope rule 5): stop sending text
|
|
372
|
+
// and surface the server's message when the socket closes.
|
|
373
|
+
hasServerErrorPart = true;
|
|
374
|
+
lastServerError = part.error;
|
|
375
|
+
textStopped = true;
|
|
376
|
+
cancelText();
|
|
377
|
+
part.error = await createErrorFromServerErrorPart(
|
|
378
|
+
part.error,
|
|
379
|
+
authMethod,
|
|
380
|
+
'speech',
|
|
381
|
+
);
|
|
382
|
+
if (finished) return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
controller.enqueue(part);
|
|
386
|
+
},
|
|
387
|
+
onSocketError: () => {
|
|
388
|
+
finishWithError(
|
|
389
|
+
new Error('Connection error on AI Gateway speech stream'),
|
|
390
|
+
);
|
|
391
|
+
},
|
|
392
|
+
onClose: ({ code, reason }) => {
|
|
393
|
+
if (hasServerErrorPart) {
|
|
394
|
+
if (finished) return;
|
|
395
|
+
void createErrorFromServerErrorPart(
|
|
396
|
+
lastServerError,
|
|
397
|
+
authMethod,
|
|
398
|
+
'speech',
|
|
399
|
+
).then(finishWithError);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
finishWithError(
|
|
403
|
+
new Error(
|
|
404
|
+
'AI Gateway speech stream closed before a finish part was received' +
|
|
405
|
+
(code != null
|
|
406
|
+
? ` (code ${code}${reason ? `: ${reason}` : ''})`
|
|
407
|
+
: '') +
|
|
408
|
+
'.',
|
|
409
|
+
),
|
|
410
|
+
);
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
cancel: () => {
|
|
416
|
+
if (finished) return;
|
|
417
|
+
finished = true;
|
|
418
|
+
cleanup();
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
119
423
|
const providerMetadataEntrySchema = () => z.object({}).catchall(z.unknown());
|
|
120
424
|
|
|
121
425
|
const gatewaySpeechWarningSchema = () =>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { getErrorMessage } from '@ai-sdk/provider';
|
|
2
|
+
import { asGatewayError, createGatewayErrorFromResponse } from './errors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `asGatewayError` is async while the WebSocket event handlers are
|
|
6
|
+
* synchronous, hence this helper.
|
|
7
|
+
*/
|
|
8
|
+
export async function errorControllerWithGatewayError<PART>(
|
|
9
|
+
controller: ReadableStreamDefaultController<PART>,
|
|
10
|
+
error: unknown,
|
|
11
|
+
authMethod: 'api-key' | 'oidc' | undefined,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
controller.error(await asGatewayError(error, authMethod));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract a human-readable message from an `error` stream part's payload for
|
|
18
|
+
* the terminal close error.
|
|
19
|
+
*/
|
|
20
|
+
function getServerErrorMessage(error: unknown): string {
|
|
21
|
+
if (
|
|
22
|
+
error != null &&
|
|
23
|
+
typeof error === 'object' &&
|
|
24
|
+
'message' in error &&
|
|
25
|
+
typeof error.message === 'string'
|
|
26
|
+
) {
|
|
27
|
+
return error.message;
|
|
28
|
+
}
|
|
29
|
+
// JSON-stringifies object payloads (`String` would yield '[object Object]').
|
|
30
|
+
return getErrorMessage(error);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Canonical status codes for server error-part types (no HTTP status exists on the WebSocket). */
|
|
34
|
+
const SERVER_ERROR_STATUS_CODES: Record<string, number> = {
|
|
35
|
+
authentication_error: 401,
|
|
36
|
+
failed_dependency: 424,
|
|
37
|
+
forbidden: 403,
|
|
38
|
+
internal_server_error: 500,
|
|
39
|
+
invalid_request_error: 400,
|
|
40
|
+
model_not_found: 404,
|
|
41
|
+
rate_limit_exceeded: 429,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Maps a server error-part payload (`{ message, type }`) to the public
|
|
46
|
+
* Gateway error class for its type; unknown shapes keep the generic message.
|
|
47
|
+
*/
|
|
48
|
+
export async function createErrorFromServerErrorPart(
|
|
49
|
+
error: unknown,
|
|
50
|
+
authMethod: 'api-key' | 'oidc' | undefined,
|
|
51
|
+
streamLabel = 'transcription',
|
|
52
|
+
): Promise<unknown> {
|
|
53
|
+
if (
|
|
54
|
+
typeof error === 'object' &&
|
|
55
|
+
error != null &&
|
|
56
|
+
'message' in error &&
|
|
57
|
+
typeof error.message === 'string' &&
|
|
58
|
+
'type' in error &&
|
|
59
|
+
typeof error.type === 'string' &&
|
|
60
|
+
error.type in SERVER_ERROR_STATUS_CODES
|
|
61
|
+
) {
|
|
62
|
+
return createGatewayErrorFromResponse({
|
|
63
|
+
response: { error: { message: error.message, type: error.type } },
|
|
64
|
+
statusCode: SERVER_ERROR_STATUS_CODES[error.type],
|
|
65
|
+
authMethod,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return new Error(
|
|
69
|
+
`AI Gateway ${streamLabel} stream failed: ${getServerErrorMessage(error)}`,
|
|
70
|
+
);
|
|
71
|
+
}
|