@ai-sdk/gateway 4.0.17 → 4.0.19
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 +27 -0
- package/dist/index.d.ts +30 -9
- package/dist/index.js +255 -8
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/gateway-config.ts +6 -1
- package/src/gateway-fetch-metadata.ts +2 -0
- package/src/gateway-generation-info.ts +1 -0
- package/src/gateway-provider.ts +10 -0
- package/src/gateway-realtime-auth.ts +38 -10
- package/src/gateway-spend-report.ts +1 -0
- package/src/gateway-transcription-model-settings.ts +1 -0
- package/src/gateway-transcription-model.ts +354 -5
- package/src/index.ts +2 -0
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.19",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"sideEffects": false,
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@vercel/oidc": "3.2.0",
|
|
34
34
|
"@ai-sdk/provider": "4.0.3",
|
|
35
|
-
"@ai-sdk/provider-utils": "5.0.
|
|
35
|
+
"@ai-sdk/provider-utils": "5.0.9"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/node": "22.19.19",
|
package/src/gateway-config.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
FetchFunction,
|
|
3
|
+
Resolvable,
|
|
4
|
+
WebSocketConstructor,
|
|
5
|
+
} from '@ai-sdk/provider-utils';
|
|
2
6
|
|
|
3
7
|
export type GatewayConfig = {
|
|
4
8
|
baseURL: string;
|
|
5
9
|
headers?: Resolvable<Record<string, string | undefined>>;
|
|
6
10
|
fetch?: FetchFunction;
|
|
11
|
+
webSocket?: WebSocketConstructor;
|
|
7
12
|
};
|
|
@@ -34,6 +34,7 @@ export class GatewayFetchMetadata {
|
|
|
34
34
|
try {
|
|
35
35
|
const { value } = await getFromApi({
|
|
36
36
|
url: `${this.config.baseURL}/config`,
|
|
37
|
+
validateUrl: false,
|
|
37
38
|
headers: this.config.headers
|
|
38
39
|
? await resolve(this.config.headers)
|
|
39
40
|
: undefined,
|
|
@@ -59,6 +60,7 @@ export class GatewayFetchMetadata {
|
|
|
59
60
|
|
|
60
61
|
const { value } = await getFromApi({
|
|
61
62
|
url: `${baseUrl.origin}/v1/credits`,
|
|
63
|
+
validateUrl: false,
|
|
62
64
|
headers: this.config.headers
|
|
63
65
|
? await resolve(this.config.headers)
|
|
64
66
|
: undefined,
|
|
@@ -65,6 +65,7 @@ export class GatewayGenerationInfoFetcher {
|
|
|
65
65
|
|
|
66
66
|
const { value } = await getFromApi({
|
|
67
67
|
url: `${baseUrl.origin}/v1/generation?id=${encodeURIComponent(params.id)}`,
|
|
68
|
+
validateUrl: false,
|
|
68
69
|
headers: this.config.headers
|
|
69
70
|
? await resolve(this.config.headers)
|
|
70
71
|
: undefined,
|
package/src/gateway-provider.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
withoutTrailingSlash,
|
|
7
7
|
withUserAgentSuffix,
|
|
8
8
|
type FetchFunction,
|
|
9
|
+
type WebSocketConstructor,
|
|
9
10
|
} from '@ai-sdk/provider-utils';
|
|
10
11
|
import { z } from 'zod/v4';
|
|
11
12
|
import { asGatewayError, GatewayAuthenticationError } from './errors';
|
|
@@ -208,6 +209,14 @@ export interface GatewayProviderSettings {
|
|
|
208
209
|
*/
|
|
209
210
|
fetch?: FetchFunction;
|
|
210
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Custom WebSocket implementation used for streaming transcription. This is
|
|
214
|
+
* useful for testing or for runtimes without a global WebSocket. A
|
|
215
|
+
* header-capable implementation is not required — Gateway WebSocket auth is
|
|
216
|
+
* carried in the subprotocols.
|
|
217
|
+
*/
|
|
218
|
+
webSocket?: WebSocketConstructor;
|
|
219
|
+
|
|
211
220
|
/**
|
|
212
221
|
* How frequently to refresh the metadata cache in milliseconds.
|
|
213
222
|
*/
|
|
@@ -516,6 +525,7 @@ export function createGateway(
|
|
|
516
525
|
headers: getHeaders,
|
|
517
526
|
fetch: options.fetch,
|
|
518
527
|
o11yHeaders: createO11yHeaders(),
|
|
528
|
+
webSocket: options.webSocket,
|
|
519
529
|
});
|
|
520
530
|
};
|
|
521
531
|
provider.transcriptionModel = createTranscriptionModel;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared WebSocket subprotocol contract for AI Gateway realtime
|
|
2
|
+
* Shared WebSocket subprotocol contract for AI Gateway realtime and streaming
|
|
3
|
+
* transcription auth.
|
|
3
4
|
*
|
|
4
5
|
* The browser `WebSocket` API cannot set request headers, so the Gateway auth
|
|
5
6
|
* (bearer) token is carried through the `Sec-WebSocket-Protocol` handshake
|
|
@@ -8,8 +9,9 @@
|
|
|
8
9
|
*
|
|
9
10
|
* This module is the single source of truth for that contract so the client and
|
|
10
11
|
* the Gateway server can't drift: the client encodes values with
|
|
11
|
-
* `getGatewayRealtimeProtocols`, and the
|
|
12
|
-
* `getGatewayRealtimeAuthToken` /
|
|
12
|
+
* `getGatewayRealtimeProtocols` / `getGatewayTranscriptionProtocols`, and the
|
|
13
|
+
* Gateway server decodes them with `getGatewayRealtimeAuthToken` /
|
|
14
|
+
* `getGatewayRealtimeTeamIdOrSlug`.
|
|
13
15
|
*
|
|
14
16
|
* WebSocket subprotocol values must fit the RFC token grammar. The auth token is
|
|
15
17
|
* sent as-is, so callers must use tokens that are valid subprotocol tokens; the
|
|
@@ -19,12 +21,18 @@
|
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
|
-
* Marker subprotocol offered on every handshake so the Gateway can
|
|
23
|
-
* negotiated subprotocol on the 101 response (some clients require the
|
|
24
|
-
* select one of the offered subprotocols).
|
|
24
|
+
* Marker subprotocol offered on every realtime handshake so the Gateway can
|
|
25
|
+
* echo a negotiated subprotocol on the 101 response (some clients require the
|
|
26
|
+
* server to select one of the offered subprotocols).
|
|
25
27
|
*/
|
|
26
28
|
export const GATEWAY_REALTIME_SUBPROTOCOL = 'ai-gateway-realtime.v1';
|
|
27
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Marker subprotocol for streaming transcription handshakes (same negotiation
|
|
32
|
+
* purpose as `GATEWAY_REALTIME_SUBPROTOCOL`).
|
|
33
|
+
*/
|
|
34
|
+
export const GATEWAY_TRANSCRIPTION_SUBPROTOCOL = 'ai-gateway-transcription.v1';
|
|
35
|
+
|
|
28
36
|
/** Subprotocol prefix that carries the Gateway auth (bearer) token. */
|
|
29
37
|
export const GATEWAY_AUTH_SUBPROTOCOL_PREFIX = 'ai-gateway-auth.';
|
|
30
38
|
|
|
@@ -39,10 +47,30 @@ export function getGatewayRealtimeProtocols(
|
|
|
39
47
|
token: string,
|
|
40
48
|
options?: { teamIdOrSlug?: string },
|
|
41
49
|
): string[] {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
return buildGatewayProtocols(GATEWAY_REALTIME_SUBPROTOCOL, token, options);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Client-side: `getGatewayRealtimeProtocols`, but with the streaming
|
|
55
|
+
* transcription marker subprotocol.
|
|
56
|
+
*/
|
|
57
|
+
export function getGatewayTranscriptionProtocols(
|
|
58
|
+
token: string,
|
|
59
|
+
options?: { teamIdOrSlug?: string },
|
|
60
|
+
): string[] {
|
|
61
|
+
return buildGatewayProtocols(
|
|
62
|
+
GATEWAY_TRANSCRIPTION_SUBPROTOCOL,
|
|
63
|
+
token,
|
|
64
|
+
options,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildGatewayProtocols(
|
|
69
|
+
marker: string,
|
|
70
|
+
token: string,
|
|
71
|
+
options?: { teamIdOrSlug?: string },
|
|
72
|
+
): string[] {
|
|
73
|
+
const protocols = [marker, `${GATEWAY_AUTH_SUBPROTOCOL_PREFIX}${token}`];
|
|
46
74
|
|
|
47
75
|
if (options?.teamIdOrSlug) {
|
|
48
76
|
protocols.push(
|
|
@@ -106,6 +106,7 @@ export class GatewaySpendReport {
|
|
|
106
106
|
|
|
107
107
|
const { value } = await getFromApi({
|
|
108
108
|
url: `${baseUrl.origin}/v1/report?${searchParams.toString()}`,
|
|
109
|
+
validateUrl: false,
|
|
109
110
|
headers: this.config.headers
|
|
110
111
|
? await resolve(this.config.headers)
|
|
111
112
|
: undefined,
|
|
@@ -1,21 +1,41 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import {
|
|
2
|
+
getErrorMessage,
|
|
3
|
+
type Experimental_TranscriptionModelV4StreamOptions as TranscriptionModelV4StreamOptions,
|
|
4
|
+
type Experimental_TranscriptionModelV4StreamPart as TranscriptionModelV4StreamPart,
|
|
5
|
+
type Experimental_TranscriptionModelV4StreamResult as TranscriptionModelV4StreamResult,
|
|
6
|
+
type SharedV4ProviderMetadata,
|
|
7
|
+
type SharedV4Warning,
|
|
8
|
+
type TranscriptionModelV4,
|
|
5
9
|
} from '@ai-sdk/provider';
|
|
6
10
|
import {
|
|
7
11
|
combineHeaders,
|
|
12
|
+
convertBase64ToUint8Array,
|
|
8
13
|
convertUint8ArrayToBase64,
|
|
9
14
|
createJsonErrorResponseHandler,
|
|
10
15
|
createJsonResponseHandler,
|
|
16
|
+
connectToWebSocket,
|
|
17
|
+
normalizeHeaders,
|
|
18
|
+
experimental_parseTranscriptionStreamPart as parseTranscriptionStreamPart,
|
|
11
19
|
postJsonToApi,
|
|
12
20
|
resolve,
|
|
21
|
+
EXPERIMENTAL_TRANSCRIPTION_STREAM_AUDIO_DONE_FRAME_TYPE as TRANSCRIPTION_STREAM_AUDIO_DONE_FRAME_TYPE,
|
|
22
|
+
waitForWebSocketBufferDrain,
|
|
23
|
+
EXPERIMENTAL_TRANSCRIPTION_STREAM_START_FRAME_TYPE as TRANSCRIPTION_STREAM_START_FRAME_TYPE,
|
|
24
|
+
type Experimental_TranscriptionStreamStartFrame,
|
|
13
25
|
type Resolvable,
|
|
26
|
+
type WebSocketConnection,
|
|
27
|
+
type WebSocketConstructor,
|
|
28
|
+
type WebSocketLike,
|
|
14
29
|
} from '@ai-sdk/provider-utils';
|
|
15
30
|
import { z } from 'zod/v4';
|
|
16
|
-
import { asGatewayError } from './errors';
|
|
31
|
+
import { asGatewayError, createGatewayErrorFromResponse } from './errors';
|
|
17
32
|
import { parseAuthMethod } from './errors/parse-auth-method';
|
|
18
33
|
import type { GatewayConfig } from './gateway-config';
|
|
34
|
+
import { VERCEL_AI_GATEWAY_TEAM_HEADER } from './gateway-headers';
|
|
35
|
+
import {
|
|
36
|
+
GATEWAY_TRANSCRIPTION_SUBPROTOCOL,
|
|
37
|
+
getGatewayTranscriptionProtocols,
|
|
38
|
+
} from './gateway-realtime-auth';
|
|
19
39
|
|
|
20
40
|
export class GatewayTranscriptionModel implements TranscriptionModelV4 {
|
|
21
41
|
readonly specificationVersion = 'v4' as const;
|
|
@@ -25,6 +45,9 @@ export class GatewayTranscriptionModel implements TranscriptionModelV4 {
|
|
|
25
45
|
private readonly config: GatewayConfig & {
|
|
26
46
|
provider: string;
|
|
27
47
|
o11yHeaders: Resolvable<Record<string, string>>;
|
|
48
|
+
_internal?: {
|
|
49
|
+
currentDate?: () => Date;
|
|
50
|
+
};
|
|
28
51
|
},
|
|
29
52
|
) {}
|
|
30
53
|
|
|
@@ -99,6 +122,48 @@ export class GatewayTranscriptionModel implements TranscriptionModelV4 {
|
|
|
99
122
|
}
|
|
100
123
|
}
|
|
101
124
|
|
|
125
|
+
async doStream(
|
|
126
|
+
options: TranscriptionModelV4StreamOptions,
|
|
127
|
+
): Promise<TranscriptionModelV4StreamResult> {
|
|
128
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
129
|
+
|
|
130
|
+
const headers = combineHeaders(
|
|
131
|
+
await resolve(this.config.headers ?? {}),
|
|
132
|
+
options.headers ?? {},
|
|
133
|
+
this.getModelConfigHeaders(),
|
|
134
|
+
await resolve(this.config.o11yHeaders),
|
|
135
|
+
);
|
|
136
|
+
const authMethod = await parseAuthMethod(headers);
|
|
137
|
+
|
|
138
|
+
// First frame after the WebSocket opens, per the transcription-stream
|
|
139
|
+
// envelope. Optional keys are omitted so the frame stays minimal.
|
|
140
|
+
const startFrame: Experimental_TranscriptionStreamStartFrame = {
|
|
141
|
+
type: TRANSCRIPTION_STREAM_START_FRAME_TYPE,
|
|
142
|
+
inputAudioFormat: options.inputAudioFormat,
|
|
143
|
+
...(options.providerOptions != null && {
|
|
144
|
+
providerOptions: options.providerOptions,
|
|
145
|
+
}),
|
|
146
|
+
...(options.includeRawChunks != null && {
|
|
147
|
+
includeRawChunks: options.includeRawChunks,
|
|
148
|
+
}),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
stream: createGatewayTranscriptionStream({
|
|
153
|
+
webSocket: this.config.webSocket,
|
|
154
|
+
url: toGatewayTranscriptionUrl(this.config.baseURL, this.modelId),
|
|
155
|
+
protocols: getProtocolsFromHeaders(headers),
|
|
156
|
+
headers,
|
|
157
|
+
startFrame,
|
|
158
|
+
audio: options.audio,
|
|
159
|
+
abortSignal: options.abortSignal,
|
|
160
|
+
authMethod,
|
|
161
|
+
}),
|
|
162
|
+
request: { body: startFrame },
|
|
163
|
+
response: { timestamp: currentDate, modelId: this.modelId },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
102
167
|
private getUrl() {
|
|
103
168
|
return `${this.config.baseURL}/transcription-model`;
|
|
104
169
|
}
|
|
@@ -111,6 +176,222 @@ export class GatewayTranscriptionModel implements TranscriptionModelV4 {
|
|
|
111
176
|
}
|
|
112
177
|
}
|
|
113
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Gateway streaming transcription WebSocket URL: HTTP(S) base upgraded to
|
|
181
|
+
* WS(S), model id in `?ai-model-id=` (browser `WebSocket` cannot set headers;
|
|
182
|
+
* slash-safe for qualified ids like `openai/gpt-realtime-whisper`).
|
|
183
|
+
*/
|
|
184
|
+
function toGatewayTranscriptionUrl(baseURL: string, modelId: string): string {
|
|
185
|
+
const url = new URL(`${baseURL.replace(/^http/, 'ws')}/transcription-model`);
|
|
186
|
+
url.searchParams.set('ai-model-id', modelId);
|
|
187
|
+
return url.toString();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Auth-carrying subprotocols from the resolved request headers (bearer token
|
|
192
|
+
* + optional team scope). Native `WebSocket` cannot send headers, so auth
|
|
193
|
+
* rides the `Sec-WebSocket-Protocol` handshake. Header lookups are
|
|
194
|
+
* case-insensitive because `combineHeaders` does not normalize casing and
|
|
195
|
+
* callers can pass arbitrary casing via the `headers` option.
|
|
196
|
+
*/
|
|
197
|
+
function getProtocolsFromHeaders(
|
|
198
|
+
headers: Record<string, string | undefined>,
|
|
199
|
+
): string[] {
|
|
200
|
+
const normalizedHeaders = normalizeHeaders(headers);
|
|
201
|
+
const authorization = normalizedHeaders.authorization;
|
|
202
|
+
const token = authorization?.startsWith('Bearer ')
|
|
203
|
+
? authorization.slice('Bearer '.length)
|
|
204
|
+
: undefined;
|
|
205
|
+
|
|
206
|
+
return token == null
|
|
207
|
+
? [GATEWAY_TRANSCRIPTION_SUBPROTOCOL]
|
|
208
|
+
: getGatewayTranscriptionProtocols(token, {
|
|
209
|
+
teamIdOrSlug: normalizedHeaders[VERCEL_AI_GATEWAY_TEAM_HEADER],
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Audio frames stay under server frame-size limits (envelope rule 7). */
|
|
214
|
+
const MAX_AUDIO_FRAME_BYTES = 64 * 1024;
|
|
215
|
+
|
|
216
|
+
function createGatewayTranscriptionStream({
|
|
217
|
+
webSocket,
|
|
218
|
+
url,
|
|
219
|
+
protocols,
|
|
220
|
+
headers,
|
|
221
|
+
startFrame,
|
|
222
|
+
audio,
|
|
223
|
+
abortSignal,
|
|
224
|
+
authMethod,
|
|
225
|
+
}: {
|
|
226
|
+
webSocket: WebSocketConstructor | undefined;
|
|
227
|
+
url: string;
|
|
228
|
+
protocols: string[];
|
|
229
|
+
headers: Record<string, string | undefined>;
|
|
230
|
+
startFrame: Experimental_TranscriptionStreamStartFrame;
|
|
231
|
+
audio: ReadableStream<Uint8Array | string>;
|
|
232
|
+
abortSignal: AbortSignal | undefined;
|
|
233
|
+
authMethod: 'api-key' | 'oidc' | undefined;
|
|
234
|
+
}): ReadableStream<TranscriptionModelV4StreamPart> {
|
|
235
|
+
let finished = false;
|
|
236
|
+
let cleanup: (closeCode?: number) => void = () => {};
|
|
237
|
+
|
|
238
|
+
return new ReadableStream<TranscriptionModelV4StreamPart>({
|
|
239
|
+
start: controller => {
|
|
240
|
+
let audioReader:
|
|
241
|
+
| ReadableStreamDefaultReader<Uint8Array | string>
|
|
242
|
+
| undefined;
|
|
243
|
+
let hasServerErrorPart = false;
|
|
244
|
+
let lastServerError: unknown;
|
|
245
|
+
let audioStopped = false;
|
|
246
|
+
let connection: WebSocketConnection | undefined;
|
|
247
|
+
|
|
248
|
+
cleanup = (closeCode?: number) => {
|
|
249
|
+
if (audioReader != null) {
|
|
250
|
+
void audioReader.cancel().catch(() => {});
|
|
251
|
+
} else {
|
|
252
|
+
// Pre-open failure or abort: `sendAudio` never took a reader, so
|
|
253
|
+
// cancel the caller's audio stream directly — otherwise an upstream
|
|
254
|
+
// producer piping into it hangs.
|
|
255
|
+
void audio.cancel().catch(() => {});
|
|
256
|
+
}
|
|
257
|
+
connection?.close(closeCode);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const stopAudio = () => {
|
|
261
|
+
audioStopped = true;
|
|
262
|
+
if (audioReader != null) {
|
|
263
|
+
void audioReader.cancel().catch(() => {});
|
|
264
|
+
audioReader = undefined;
|
|
265
|
+
} else {
|
|
266
|
+
void audio.cancel().catch(() => {});
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const finishWithError = (error: unknown) => {
|
|
271
|
+
if (finished) return;
|
|
272
|
+
finished = true;
|
|
273
|
+
cleanup();
|
|
274
|
+
void errorControllerWithGatewayError(controller, error, authMethod);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const sendAudio = async (socket: WebSocketLike) => {
|
|
278
|
+
const reader = audio.getReader();
|
|
279
|
+
audioReader = reader;
|
|
280
|
+
try {
|
|
281
|
+
while (true) {
|
|
282
|
+
const { done, value } = await reader.read();
|
|
283
|
+
if (done || finished) break;
|
|
284
|
+
// Binary frames; base64 string chunks are decoded first. Caller
|
|
285
|
+
// chunks are split to stay under server frame-size limits
|
|
286
|
+
// (envelope rule 7), with backpressure between frames.
|
|
287
|
+
const bytes =
|
|
288
|
+
typeof value === 'string'
|
|
289
|
+
? convertBase64ToUint8Array(value)
|
|
290
|
+
: value;
|
|
291
|
+
for (
|
|
292
|
+
let offset = 0;
|
|
293
|
+
offset < bytes.length;
|
|
294
|
+
offset += MAX_AUDIO_FRAME_BYTES
|
|
295
|
+
) {
|
|
296
|
+
if (finished) break;
|
|
297
|
+
socket.send(
|
|
298
|
+
bytes.subarray(offset, offset + MAX_AUDIO_FRAME_BYTES),
|
|
299
|
+
);
|
|
300
|
+
await waitForWebSocketBufferDrain(socket);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} finally {
|
|
304
|
+
reader.releaseLock();
|
|
305
|
+
// unlocked again: cleanup must cancel `audio`, not the reader
|
|
306
|
+
if (audioReader === reader) {
|
|
307
|
+
audioReader = undefined;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (!finished && !audioStopped) {
|
|
311
|
+
socket.send(
|
|
312
|
+
JSON.stringify({
|
|
313
|
+
type: TRANSCRIPTION_STREAM_AUDIO_DONE_FRAME_TYPE,
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
connection = connectToWebSocket({
|
|
320
|
+
url,
|
|
321
|
+
protocols,
|
|
322
|
+
headers,
|
|
323
|
+
webSocket,
|
|
324
|
+
abortSignal,
|
|
325
|
+
onAbort: reason => {
|
|
326
|
+
if (finished) return;
|
|
327
|
+
finished = true;
|
|
328
|
+
cleanup();
|
|
329
|
+
controller.error(reason);
|
|
330
|
+
},
|
|
331
|
+
onProcessingError: finishWithError,
|
|
332
|
+
onOpen: socket => {
|
|
333
|
+
socket.send(JSON.stringify(startFrame));
|
|
334
|
+
void sendAudio(socket).catch(finishWithError);
|
|
335
|
+
},
|
|
336
|
+
// Server frames are envelope-serialized stream parts; the codec
|
|
337
|
+
// handles parsing, unknown-part skipping, and timestamp revival.
|
|
338
|
+
onMessageText: text => {
|
|
339
|
+
if (finished) return;
|
|
340
|
+
const part = parseTranscriptionStreamPart(text);
|
|
341
|
+
if (part == null) return;
|
|
342
|
+
if (part.type === 'finish') {
|
|
343
|
+
finished = true;
|
|
344
|
+
controller.enqueue(part);
|
|
345
|
+
controller.close();
|
|
346
|
+
cleanup(1000);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (part.type === 'error') {
|
|
351
|
+
// Remembered so the terminal close error (the server closes
|
|
352
|
+
// non-1000 after an error part) surfaces the server's message
|
|
353
|
+
// to promise-based consumers.
|
|
354
|
+
hasServerErrorPart = true;
|
|
355
|
+
lastServerError = part.error;
|
|
356
|
+
// envelope rule 5: error parts are terminal — stop sending audio
|
|
357
|
+
// while the server holds the connection open (e.g. for its final
|
|
358
|
+
// billing flush)
|
|
359
|
+
stopAudio();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
controller.enqueue(part);
|
|
363
|
+
},
|
|
364
|
+
onSocketError: () => {
|
|
365
|
+
finishWithError(
|
|
366
|
+
new Error('Connection error on AI Gateway transcription stream'),
|
|
367
|
+
);
|
|
368
|
+
},
|
|
369
|
+
onClose: () => {
|
|
370
|
+
if (hasServerErrorPart) {
|
|
371
|
+
if (finished) return;
|
|
372
|
+
void createErrorFromServerErrorPart(
|
|
373
|
+
lastServerError,
|
|
374
|
+
authMethod,
|
|
375
|
+
).then(finishWithError);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
finishWithError(
|
|
379
|
+
new Error(
|
|
380
|
+
'AI Gateway transcription stream closed before a finish part was received',
|
|
381
|
+
),
|
|
382
|
+
);
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
cancel: () => {
|
|
388
|
+
if (finished) return;
|
|
389
|
+
finished = true;
|
|
390
|
+
cleanup();
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
114
395
|
const providerMetadataEntrySchema = z.object({}).catchall(z.unknown());
|
|
115
396
|
|
|
116
397
|
const gatewayTranscriptionWarningSchema = z.discriminatedUnion('type', [
|
|
@@ -153,3 +434,71 @@ const gatewayTranscriptionResponseSchema = z.object({
|
|
|
153
434
|
.record(z.string(), providerMetadataEntrySchema)
|
|
154
435
|
.optional(),
|
|
155
436
|
});
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* `asGatewayError` is async while the WebSocket event handlers are
|
|
440
|
+
* synchronous, hence this helper.
|
|
441
|
+
*/
|
|
442
|
+
async function errorControllerWithGatewayError(
|
|
443
|
+
controller: ReadableStreamDefaultController<TranscriptionModelV4StreamPart>,
|
|
444
|
+
error: unknown,
|
|
445
|
+
authMethod: 'api-key' | 'oidc' | undefined,
|
|
446
|
+
): Promise<void> {
|
|
447
|
+
controller.error(await asGatewayError(error, authMethod));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Extract a human-readable message from an `error` stream part's payload for
|
|
452
|
+
* the terminal close error.
|
|
453
|
+
*/
|
|
454
|
+
function getServerErrorMessage(error: unknown): string {
|
|
455
|
+
if (
|
|
456
|
+
error != null &&
|
|
457
|
+
typeof error === 'object' &&
|
|
458
|
+
'message' in error &&
|
|
459
|
+
typeof error.message === 'string'
|
|
460
|
+
) {
|
|
461
|
+
return error.message;
|
|
462
|
+
}
|
|
463
|
+
// JSON-stringifies object payloads (`String` would yield '[object Object]').
|
|
464
|
+
return getErrorMessage(error);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Canonical status codes for server error-part types (no HTTP status exists on the WebSocket). */
|
|
468
|
+
const SERVER_ERROR_STATUS_CODES: Record<string, number> = {
|
|
469
|
+
authentication_error: 401,
|
|
470
|
+
failed_dependency: 424,
|
|
471
|
+
forbidden: 403,
|
|
472
|
+
internal_server_error: 500,
|
|
473
|
+
invalid_request_error: 400,
|
|
474
|
+
model_not_found: 404,
|
|
475
|
+
rate_limit_exceeded: 429,
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Maps a server error-part payload (`{ message, type }`) to the public
|
|
480
|
+
* Gateway error class for its type; unknown shapes keep the generic message.
|
|
481
|
+
*/
|
|
482
|
+
async function createErrorFromServerErrorPart(
|
|
483
|
+
error: unknown,
|
|
484
|
+
authMethod: 'api-key' | 'oidc' | undefined,
|
|
485
|
+
): Promise<unknown> {
|
|
486
|
+
if (
|
|
487
|
+
typeof error === 'object' &&
|
|
488
|
+
error != null &&
|
|
489
|
+
'message' in error &&
|
|
490
|
+
typeof error.message === 'string' &&
|
|
491
|
+
'type' in error &&
|
|
492
|
+
typeof error.type === 'string' &&
|
|
493
|
+
error.type in SERVER_ERROR_STATUS_CODES
|
|
494
|
+
) {
|
|
495
|
+
return createGatewayErrorFromResponse({
|
|
496
|
+
response: { error: { message: error.message, type: error.type } },
|
|
497
|
+
statusCode: SERVER_ERROR_STATUS_CODES[error.type],
|
|
498
|
+
authMethod,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return new Error(
|
|
502
|
+
`AI Gateway transcription stream failed: ${getServerErrorMessage(error)}`,
|
|
503
|
+
);
|
|
504
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,9 +3,11 @@ export {
|
|
|
3
3
|
GATEWAY_AUTH_SUBPROTOCOL_PREFIX,
|
|
4
4
|
GATEWAY_REALTIME_SUBPROTOCOL,
|
|
5
5
|
GATEWAY_TEAM_SUBPROTOCOL_PREFIX,
|
|
6
|
+
GATEWAY_TRANSCRIPTION_SUBPROTOCOL,
|
|
6
7
|
getGatewayRealtimeAuthToken,
|
|
7
8
|
getGatewayRealtimeProtocols,
|
|
8
9
|
getGatewayRealtimeTeamIdOrSlug,
|
|
10
|
+
getGatewayTranscriptionProtocols,
|
|
9
11
|
} from './gateway-realtime-auth';
|
|
10
12
|
export type { GatewayRealtimeModelId } from './gateway-realtime-model-settings';
|
|
11
13
|
export type { GatewayRerankingModelId } from './gateway-reranking-model-settings';
|