@ai-sdk/google-vertex 5.0.65 → 5.0.67
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 +15 -0
- package/dist/anthropic/edge/index.js +1 -1
- package/dist/edge/index.d.ts +7 -1
- package/dist/edge/index.js +571 -45
- package/dist/edge/index.js.map +1 -1
- package/dist/index.d.ts +67 -3
- package/dist/index.js +572 -45
- package/dist/index.js.map +1 -1
- package/dist/maas/edge/index.js +1 -1
- package/dist/xai/edge/index.js +1 -1
- package/package.json +2 -2
- package/src/gemini-transcription/google-vertex-gemini-transcription-model-options.ts +51 -0
- package/src/gemini-transcription/google-vertex-gemini-transcription-model.ts +709 -0
- package/src/google-vertex-provider-base.ts +25 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InvalidArgumentError,
|
|
3
|
+
type Experimental_TranscriptionModelV4StreamOptions as TranscriptionModelV4StreamOptions,
|
|
4
|
+
type Experimental_TranscriptionModelV4StreamPart as TranscriptionModelV4StreamPart,
|
|
5
|
+
type JSONObject,
|
|
6
|
+
type SharedV4Warning,
|
|
7
|
+
type TranscriptionModelV4,
|
|
8
|
+
} from '@ai-sdk/provider';
|
|
9
|
+
import {
|
|
10
|
+
combineHeaders,
|
|
11
|
+
connectToWebSocket,
|
|
12
|
+
convertToBase64,
|
|
13
|
+
createJsonResponseHandler,
|
|
14
|
+
parseProviderOptions,
|
|
15
|
+
postJsonToApi,
|
|
16
|
+
resolve,
|
|
17
|
+
safeParseJSON,
|
|
18
|
+
serializeModelOptions,
|
|
19
|
+
waitForWebSocketBufferDrain,
|
|
20
|
+
WORKFLOW_DESERIALIZE,
|
|
21
|
+
WORKFLOW_SERIALIZE,
|
|
22
|
+
type FetchFunction,
|
|
23
|
+
type Resolvable,
|
|
24
|
+
type WebSocketConnection,
|
|
25
|
+
type WebSocketConstructor,
|
|
26
|
+
type WebSocketLike,
|
|
27
|
+
} from '@ai-sdk/provider-utils';
|
|
28
|
+
import { z } from 'zod/v4';
|
|
29
|
+
import { googleVertexFailedResponseHandler } from '../google-vertex-error';
|
|
30
|
+
import {
|
|
31
|
+
googleVertexGeminiTranscriptionModelOptions,
|
|
32
|
+
type GoogleVertexGeminiTranscriptionModelId,
|
|
33
|
+
type GoogleVertexTranscriptionModelGeminiOptions,
|
|
34
|
+
} from './google-vertex-gemini-transcription-model-options';
|
|
35
|
+
|
|
36
|
+
const liveWebSocketPath =
|
|
37
|
+
'google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* After the input audio has ended, finish when no terminal signal
|
|
41
|
+
* (`turnComplete` / idle `interactionStatus`) arrives within this window.
|
|
42
|
+
* Trailing transcripts reset the timer.
|
|
43
|
+
*/
|
|
44
|
+
const defaultFinishGraceMs = 3000;
|
|
45
|
+
|
|
46
|
+
/** Live transcription is only supported by `*-live` model variants. */
|
|
47
|
+
function isLiveTranscriptionModelId(modelId: string): boolean {
|
|
48
|
+
return modelId.includes('-live');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Regional Vertex hostname (mirrors the provider's base-URL host rules). */
|
|
52
|
+
function vertexHost(location: string): string {
|
|
53
|
+
if (location === 'global') return 'aiplatform.googleapis.com';
|
|
54
|
+
if (location === 'eu' || location === 'us') {
|
|
55
|
+
return `aiplatform.${location}.rep.googleapis.com`;
|
|
56
|
+
}
|
|
57
|
+
return `${location}-aiplatform.googleapis.com`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type GoogleLiveWordInfo = {
|
|
61
|
+
text?: string;
|
|
62
|
+
word?: string;
|
|
63
|
+
startOffset?: string;
|
|
64
|
+
endOffset?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type GoogleLiveTranscription = {
|
|
68
|
+
text?: string;
|
|
69
|
+
finished?: boolean;
|
|
70
|
+
languageCode?: string;
|
|
71
|
+
speakerLabel?: string;
|
|
72
|
+
words?: GoogleLiveWordInfo[];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type GoogleLiveServerMessage = {
|
|
76
|
+
setupComplete?: unknown;
|
|
77
|
+
serverContent?: {
|
|
78
|
+
inputTranscription?: GoogleLiveTranscription;
|
|
79
|
+
interimInputTranscription?: GoogleLiveTranscription;
|
|
80
|
+
turnComplete?: boolean;
|
|
81
|
+
generationComplete?: boolean;
|
|
82
|
+
interactionStatus?: string;
|
|
83
|
+
};
|
|
84
|
+
inputTranscription?: GoogleLiveTranscription;
|
|
85
|
+
usageMetadata?: JSONObject;
|
|
86
|
+
error?: { message?: string };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
interface GoogleVertexGeminiTranscriptionModelConfig {
|
|
90
|
+
provider: string;
|
|
91
|
+
/** Regional base URL ending in `/publishers/google`. */
|
|
92
|
+
baseURL: string;
|
|
93
|
+
headers?: Resolvable<Record<string, string | undefined>>;
|
|
94
|
+
fetch?: FetchFunction;
|
|
95
|
+
webSocket?: WebSocketConstructor;
|
|
96
|
+
project: string;
|
|
97
|
+
location: string;
|
|
98
|
+
_internal?: {
|
|
99
|
+
currentDate?: () => Date;
|
|
100
|
+
finishGraceMs?: number;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Gemini transcription on Vertex AI. Unary variants transcribe via
|
|
106
|
+
* `generateContent`; live variants stream over the Vertex Live API WebSocket
|
|
107
|
+
* (`LlmBidiService/BidiGenerateContent`) with OAuth Bearer authentication
|
|
108
|
+
* from the provider's resolved headers.
|
|
109
|
+
*/
|
|
110
|
+
export class GoogleVertexGeminiTranscriptionModel implements TranscriptionModelV4 {
|
|
111
|
+
readonly specificationVersion = 'v4';
|
|
112
|
+
|
|
113
|
+
static [WORKFLOW_SERIALIZE](model: GoogleVertexGeminiTranscriptionModel) {
|
|
114
|
+
return serializeModelOptions({
|
|
115
|
+
modelId: model.modelId,
|
|
116
|
+
config: model.config,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
static [WORKFLOW_DESERIALIZE](options: {
|
|
121
|
+
modelId: GoogleVertexGeminiTranscriptionModelId;
|
|
122
|
+
config: GoogleVertexGeminiTranscriptionModelConfig;
|
|
123
|
+
}) {
|
|
124
|
+
return new GoogleVertexGeminiTranscriptionModel(
|
|
125
|
+
options.modelId,
|
|
126
|
+
options.config,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get provider(): string {
|
|
131
|
+
return this.config.provider;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
constructor(
|
|
135
|
+
readonly modelId: GoogleVertexGeminiTranscriptionModelId,
|
|
136
|
+
private readonly config: GoogleVertexGeminiTranscriptionModelConfig,
|
|
137
|
+
) {}
|
|
138
|
+
|
|
139
|
+
private async parseOptions(
|
|
140
|
+
providerOptions: Record<string, unknown> | undefined,
|
|
141
|
+
): Promise<GoogleVertexTranscriptionModelGeminiOptions | undefined> {
|
|
142
|
+
// The Vertex provider exposes options under `googleVertex`/`vertex`;
|
|
143
|
+
// accept `google` as a cross-namespace fallback (e.g. via the AI Gateway).
|
|
144
|
+
for (const provider of ['googleVertex', 'vertex', 'google'] as const) {
|
|
145
|
+
const parsed = await parseProviderOptions({
|
|
146
|
+
provider,
|
|
147
|
+
providerOptions,
|
|
148
|
+
schema: googleVertexGeminiTranscriptionModelOptions,
|
|
149
|
+
});
|
|
150
|
+
if (parsed != null) return parsed;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async doGenerate(
|
|
156
|
+
options: Parameters<TranscriptionModelV4['doGenerate']>[0],
|
|
157
|
+
): Promise<Awaited<ReturnType<TranscriptionModelV4['doGenerate']>>> {
|
|
158
|
+
if (isLiveTranscriptionModelId(this.modelId)) {
|
|
159
|
+
throw new InvalidArgumentError({
|
|
160
|
+
argument: 'modelId',
|
|
161
|
+
message:
|
|
162
|
+
`Model '${this.modelId}' only supports streaming transcription. ` +
|
|
163
|
+
`Use experimental_streamTranscribe, or a unary model such as 'gemini-3.5-transcribe'.`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
168
|
+
const warnings: SharedV4Warning[] = [];
|
|
169
|
+
const googleOptions = await this.parseOptions(options.providerOptions);
|
|
170
|
+
const audioTranscriptionConfig =
|
|
171
|
+
buildAudioTranscriptionConfig(googleOptions);
|
|
172
|
+
|
|
173
|
+
const requestBody = {
|
|
174
|
+
contents: [
|
|
175
|
+
{
|
|
176
|
+
role: 'user',
|
|
177
|
+
parts: [
|
|
178
|
+
{
|
|
179
|
+
inlineData: {
|
|
180
|
+
mimeType: options.mediaType,
|
|
181
|
+
data: convertToBase64(options.audio),
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
...(audioTranscriptionConfig != null
|
|
188
|
+
? { generationConfig: { audioTranscriptionConfig } }
|
|
189
|
+
: {}),
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const {
|
|
193
|
+
value: response,
|
|
194
|
+
responseHeaders,
|
|
195
|
+
rawValue: rawResponse,
|
|
196
|
+
} = await postJsonToApi({
|
|
197
|
+
url: `${this.config.baseURL}/models/${this.modelId}:generateContent`,
|
|
198
|
+
headers: combineHeaders(
|
|
199
|
+
this.config.headers ? await resolve(this.config.headers) : undefined,
|
|
200
|
+
options.headers,
|
|
201
|
+
),
|
|
202
|
+
body: requestBody,
|
|
203
|
+
failedResponseHandler: googleVertexFailedResponseHandler,
|
|
204
|
+
successfulResponseHandler: createJsonResponseHandler(
|
|
205
|
+
googleVertexGeminiTranscriptionResponseSchema,
|
|
206
|
+
),
|
|
207
|
+
abortSignal: options.abortSignal,
|
|
208
|
+
fetch: this.config.fetch,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const parts = response.candidates?.[0]?.content?.parts ?? [];
|
|
212
|
+
const plainText = parts.map(part => part.text ?? '').join('');
|
|
213
|
+
const transcriptionText = parts
|
|
214
|
+
.map(part => part.audioTranscription?.text ?? '')
|
|
215
|
+
.join('');
|
|
216
|
+
const text = plainText !== '' ? plainText : transcriptionText;
|
|
217
|
+
|
|
218
|
+
let language: string | undefined;
|
|
219
|
+
const segments: Array<{
|
|
220
|
+
text: string;
|
|
221
|
+
startSecond: number;
|
|
222
|
+
endSecond: number;
|
|
223
|
+
}> = [];
|
|
224
|
+
for (const part of parts) {
|
|
225
|
+
const transcription = part.audioTranscription;
|
|
226
|
+
if (transcription == null) continue;
|
|
227
|
+
language ??= transcription.languageCode ?? undefined;
|
|
228
|
+
for (const word of transcription.words ?? []) {
|
|
229
|
+
const startSecond = parseOffsetSeconds(word.startOffset);
|
|
230
|
+
const endSecond = parseOffsetSeconds(word.endOffset);
|
|
231
|
+
if (word.word == null || startSecond == null || endSecond == null) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
segments.push({ text: word.word, startSecond, endSecond });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
text,
|
|
240
|
+
segments,
|
|
241
|
+
language,
|
|
242
|
+
durationInSeconds: undefined,
|
|
243
|
+
warnings,
|
|
244
|
+
response: {
|
|
245
|
+
timestamp: currentDate,
|
|
246
|
+
modelId: this.modelId,
|
|
247
|
+
headers: responseHeaders,
|
|
248
|
+
body: rawResponse,
|
|
249
|
+
},
|
|
250
|
+
...(response.usageMetadata != null
|
|
251
|
+
? {
|
|
252
|
+
providerMetadata: {
|
|
253
|
+
google: { usageMetadata: response.usageMetadata as JSONObject },
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
: {}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async doStream(
|
|
261
|
+
options: TranscriptionModelV4StreamOptions,
|
|
262
|
+
): Promise<
|
|
263
|
+
Awaited<ReturnType<NonNullable<TranscriptionModelV4['doStream']>>>
|
|
264
|
+
> {
|
|
265
|
+
if (!isLiveTranscriptionModelId(this.modelId)) {
|
|
266
|
+
throw new InvalidArgumentError({
|
|
267
|
+
argument: 'modelId',
|
|
268
|
+
message:
|
|
269
|
+
`Model '${this.modelId}' does not support streaming transcription. ` +
|
|
270
|
+
`Use a live model such as 'gemini-3.5-transcribe-live'.`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
|
|
275
|
+
const warnings: SharedV4Warning[] = [];
|
|
276
|
+
const googleOptions = await this.parseOptions(options.providerOptions);
|
|
277
|
+
|
|
278
|
+
validateLiveInputAudioFormat(options.inputAudioFormat);
|
|
279
|
+
|
|
280
|
+
// Vertex Live authenticates with the same OAuth Bearer header the HTTP
|
|
281
|
+
// surface uses; a header-capable WebSocket implementation (e.g. `ws`) is
|
|
282
|
+
// required to send it.
|
|
283
|
+
const headers = combineHeaders(
|
|
284
|
+
this.config.headers ? await resolve(this.config.headers) : undefined,
|
|
285
|
+
options.headers,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const { project, location } = this.config;
|
|
289
|
+
const modelResource = `projects/${project}/locations/${location}/publishers/google/models/${this.modelId}`;
|
|
290
|
+
const url = new URL(
|
|
291
|
+
`wss://${vertexHost(location)}/ws/${liveWebSocketPath}`,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// NOTE: mirrors the Developer API model — Google's documented setup shape
|
|
295
|
+
// includes `generationConfig.responseModalities: ['TEXT']`, but sending it
|
|
296
|
+
// suppresses the final `inputTranscription` segments; omit generationConfig.
|
|
297
|
+
const setup = {
|
|
298
|
+
model: modelResource,
|
|
299
|
+
inputAudioTranscription:
|
|
300
|
+
buildAudioTranscriptionConfig(googleOptions) ?? {},
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
request: { body: setup },
|
|
305
|
+
response: {
|
|
306
|
+
timestamp: currentDate,
|
|
307
|
+
modelId: this.modelId,
|
|
308
|
+
},
|
|
309
|
+
stream: createVertexLiveTranscriptionStream({
|
|
310
|
+
webSocket: this.config.webSocket,
|
|
311
|
+
url,
|
|
312
|
+
headers,
|
|
313
|
+
setup,
|
|
314
|
+
inputAudioRate: options.inputAudioFormat.rate ?? 16000,
|
|
315
|
+
finishGraceMs:
|
|
316
|
+
this.config._internal?.finishGraceMs ?? defaultFinishGraceMs,
|
|
317
|
+
warnings,
|
|
318
|
+
audio: options.audio,
|
|
319
|
+
abortSignal: options.abortSignal,
|
|
320
|
+
includeRawChunks: options.includeRawChunks,
|
|
321
|
+
}),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function createVertexLiveTranscriptionStream({
|
|
327
|
+
webSocket,
|
|
328
|
+
url,
|
|
329
|
+
headers,
|
|
330
|
+
setup,
|
|
331
|
+
inputAudioRate,
|
|
332
|
+
finishGraceMs,
|
|
333
|
+
warnings,
|
|
334
|
+
audio,
|
|
335
|
+
abortSignal,
|
|
336
|
+
includeRawChunks,
|
|
337
|
+
}: {
|
|
338
|
+
webSocket: WebSocketConstructor | undefined;
|
|
339
|
+
url: URL;
|
|
340
|
+
headers: Record<string, string | undefined>;
|
|
341
|
+
setup: unknown;
|
|
342
|
+
inputAudioRate: number;
|
|
343
|
+
finishGraceMs: number;
|
|
344
|
+
warnings: SharedV4Warning[];
|
|
345
|
+
audio: ReadableStream<Uint8Array | string>;
|
|
346
|
+
abortSignal: AbortSignal | undefined;
|
|
347
|
+
includeRawChunks: boolean | undefined;
|
|
348
|
+
}) {
|
|
349
|
+
let finished = false;
|
|
350
|
+
let cleanup: (closeCode?: number) => void = () => {};
|
|
351
|
+
|
|
352
|
+
return new ReadableStream<TranscriptionModelV4StreamPart>({
|
|
353
|
+
start: controller => {
|
|
354
|
+
let audioReader:
|
|
355
|
+
| ReadableStreamDefaultReader<Uint8Array | string>
|
|
356
|
+
| undefined;
|
|
357
|
+
let connection: WebSocketConnection | undefined;
|
|
358
|
+
|
|
359
|
+
// The Live API contract requires waiting for the `setupComplete`
|
|
360
|
+
// server message before sending realtime input: the audio send loop
|
|
361
|
+
// is gated on this promise.
|
|
362
|
+
let resolveSetupComplete!: () => void;
|
|
363
|
+
const setupComplete = new Promise<void>(resolvePromise => {
|
|
364
|
+
resolveSetupComplete = resolvePromise;
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Google Live messages carry no response/item IDs; a segment counter
|
|
368
|
+
// generates consistent synthetic IDs. Transcription fragments arrive
|
|
369
|
+
// incrementally and are accumulated per segment; a `finished: true`
|
|
370
|
+
// transcription or `turnComplete` finalizes the current segment.
|
|
371
|
+
let segmentCounter = 0;
|
|
372
|
+
let segmentBuffer = '';
|
|
373
|
+
let fullText = '';
|
|
374
|
+
// Latest revisable interim text: the fallback final when the server
|
|
375
|
+
// never delivers a finished `inputTranscription` segment.
|
|
376
|
+
let latestInterim = '';
|
|
377
|
+
let language: string | undefined;
|
|
378
|
+
let audioEnded = false;
|
|
379
|
+
let usageMetadata: JSONObject | undefined;
|
|
380
|
+
let finishTimer: ReturnType<typeof setTimeout> | undefined;
|
|
381
|
+
|
|
382
|
+
const segmentId = () => `google-segment-${segmentCounter}`;
|
|
383
|
+
|
|
384
|
+
const cancelPendingFinish = () => {
|
|
385
|
+
if (finishTimer != null) {
|
|
386
|
+
clearTimeout(finishTimer);
|
|
387
|
+
finishTimer = undefined;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
// Trailing transcripts can arrive after audioStreamEnd; without a
|
|
392
|
+
// terminal signal, finish after a quiet grace window. Transcript
|
|
393
|
+
// activity reschedules the timer.
|
|
394
|
+
const schedulePendingFinish = () => {
|
|
395
|
+
if (finished || !audioEnded) return;
|
|
396
|
+
cancelPendingFinish();
|
|
397
|
+
finishTimer = setTimeout(() => {
|
|
398
|
+
finishTimer = undefined;
|
|
399
|
+
finish();
|
|
400
|
+
}, finishGraceMs);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
cleanup = (closeCode?: number) => {
|
|
404
|
+
cancelPendingFinish();
|
|
405
|
+
if (audioReader != null) {
|
|
406
|
+
void audioReader.cancel().catch(() => {});
|
|
407
|
+
} else {
|
|
408
|
+
// pre-open failure or abort: cancel the caller's audio stream so an
|
|
409
|
+
// upstream producer piping into it does not hang:
|
|
410
|
+
void audio.cancel().catch(() => {});
|
|
411
|
+
}
|
|
412
|
+
connection?.close(closeCode);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const finishWithError = (error: unknown) => {
|
|
416
|
+
if (finished) return;
|
|
417
|
+
finished = true;
|
|
418
|
+
cleanup();
|
|
419
|
+
controller.error(error);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const completeSegment = () => {
|
|
423
|
+
// A finished segment supersedes any interim text it revises.
|
|
424
|
+
if (segmentBuffer === '') {
|
|
425
|
+
if (latestInterim === '') return;
|
|
426
|
+
segmentBuffer = latestInterim;
|
|
427
|
+
}
|
|
428
|
+
latestInterim = '';
|
|
429
|
+
controller.enqueue({
|
|
430
|
+
type: 'transcript-final',
|
|
431
|
+
id: segmentId(),
|
|
432
|
+
text: segmentBuffer,
|
|
433
|
+
});
|
|
434
|
+
fullText += fullText === '' ? segmentBuffer : ` ${segmentBuffer}`;
|
|
435
|
+
segmentBuffer = '';
|
|
436
|
+
segmentCounter++;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const finish = () => {
|
|
440
|
+
if (finished) return;
|
|
441
|
+
completeSegment();
|
|
442
|
+
finished = true;
|
|
443
|
+
controller.enqueue({
|
|
444
|
+
type: 'finish',
|
|
445
|
+
text: fullText,
|
|
446
|
+
segments: [],
|
|
447
|
+
language,
|
|
448
|
+
durationInSeconds: undefined,
|
|
449
|
+
...(usageMetadata != null
|
|
450
|
+
? { providerMetadata: { google: { usageMetadata } } }
|
|
451
|
+
: {}),
|
|
452
|
+
});
|
|
453
|
+
controller.close();
|
|
454
|
+
cleanup(1000);
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const sendAudio = async (socket: WebSocketLike) => {
|
|
458
|
+
audioReader = audio.getReader();
|
|
459
|
+
try {
|
|
460
|
+
while (true) {
|
|
461
|
+
const { done, value } = await audioReader.read();
|
|
462
|
+
if (done || finished) break;
|
|
463
|
+
socket.send(
|
|
464
|
+
JSON.stringify({
|
|
465
|
+
realtimeInput: {
|
|
466
|
+
audio: {
|
|
467
|
+
data: convertToBase64(value),
|
|
468
|
+
mimeType: `audio/pcm;rate=${inputAudioRate}`,
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
// backpressure: pause reads while the socket buffer is full
|
|
474
|
+
await waitForWebSocketBufferDrain(socket);
|
|
475
|
+
}
|
|
476
|
+
} finally {
|
|
477
|
+
audioReader.releaseLock();
|
|
478
|
+
// unlocked again: cleanup must cancel `audio`, not the reader
|
|
479
|
+
audioReader = undefined;
|
|
480
|
+
}
|
|
481
|
+
if (!finished) {
|
|
482
|
+
socket.send(
|
|
483
|
+
JSON.stringify({ realtimeInput: { audioStreamEnd: true } }),
|
|
484
|
+
);
|
|
485
|
+
audioEnded = true;
|
|
486
|
+
schedulePendingFinish();
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
connection = connectToWebSocket({
|
|
491
|
+
url,
|
|
492
|
+
headers,
|
|
493
|
+
webSocket,
|
|
494
|
+
abortSignal,
|
|
495
|
+
onAbort: finishWithError,
|
|
496
|
+
onProcessingError: finishWithError,
|
|
497
|
+
onOpen: socket => {
|
|
498
|
+
controller.enqueue({ type: 'stream-start', warnings });
|
|
499
|
+
socket.send(JSON.stringify({ setup }));
|
|
500
|
+
// audio may only be sent after the server acknowledged the setup:
|
|
501
|
+
void setupComplete
|
|
502
|
+
.then(() => (finished ? undefined : sendAudio(socket)))
|
|
503
|
+
.catch(finishWithError);
|
|
504
|
+
},
|
|
505
|
+
onMessageText: async text => {
|
|
506
|
+
if (finished) return;
|
|
507
|
+
const parsed = await safeParseJSON({ text });
|
|
508
|
+
if (!parsed.success) return;
|
|
509
|
+
const message = parsed.value as GoogleLiveServerMessage;
|
|
510
|
+
|
|
511
|
+
if (includeRawChunks) {
|
|
512
|
+
controller.enqueue({ type: 'raw', rawValue: message });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (message.setupComplete != null) {
|
|
516
|
+
resolveSetupComplete();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (message.usageMetadata != null) {
|
|
520
|
+
usageMetadata = message.usageMetadata;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (message.error != null) {
|
|
524
|
+
finishWithError(
|
|
525
|
+
new Error(message.error.message ?? 'Vertex Live API error'),
|
|
526
|
+
);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const serverContent = message.serverContent;
|
|
531
|
+
|
|
532
|
+
// Low-latency revisable transcription while the user is speaking.
|
|
533
|
+
const interim = serverContent?.interimInputTranscription;
|
|
534
|
+
if (interim?.text) {
|
|
535
|
+
schedulePendingFinish();
|
|
536
|
+
latestInterim = interim.text;
|
|
537
|
+
controller.enqueue({
|
|
538
|
+
type: 'transcript-partial',
|
|
539
|
+
id: segmentId(),
|
|
540
|
+
text: interim.text,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const transcription =
|
|
545
|
+
serverContent?.inputTranscription ?? message.inputTranscription;
|
|
546
|
+
if (transcription != null) {
|
|
547
|
+
if (transcription.languageCode != null) {
|
|
548
|
+
language = transcription.languageCode;
|
|
549
|
+
}
|
|
550
|
+
if (transcription.text) {
|
|
551
|
+
schedulePendingFinish();
|
|
552
|
+
// A real transcription delta supersedes interim fallback text.
|
|
553
|
+
latestInterim = '';
|
|
554
|
+
segmentBuffer += transcription.text;
|
|
555
|
+
controller.enqueue({
|
|
556
|
+
type: 'transcript-delta',
|
|
557
|
+
id: segmentId(),
|
|
558
|
+
delta: transcription.text,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
if (transcription.finished === true) {
|
|
562
|
+
completeSegment();
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (serverContent?.turnComplete) {
|
|
567
|
+
completeSegment();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// `interactionStatus` idle (REQUIRES_ACTION in the EAP builds) is
|
|
571
|
+
// the definitive all-processing-complete signal: finish as soon as
|
|
572
|
+
// the input audio has ended.
|
|
573
|
+
const interactionStatus = serverContent?.interactionStatus;
|
|
574
|
+
if (
|
|
575
|
+
audioEnded &&
|
|
576
|
+
(interactionStatus === 'IDLE' ||
|
|
577
|
+
interactionStatus === 'REQUIRES_ACTION' ||
|
|
578
|
+
(serverContent?.turnComplete === true &&
|
|
579
|
+
interactionStatus == null))
|
|
580
|
+
) {
|
|
581
|
+
finish();
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
onSocketError: () => {
|
|
585
|
+
finishWithError(
|
|
586
|
+
new Error(
|
|
587
|
+
'Vertex Live transcription error.' +
|
|
588
|
+
(webSocket == null
|
|
589
|
+
? ' Note: the native WebSocket implementation cannot send' +
|
|
590
|
+
' the Authorization header required by Vertex. Pass a' +
|
|
591
|
+
" header-capable WebSocket implementation (e.g. the 'ws'" +
|
|
592
|
+
' package) via createVertex({ webSocket }).'
|
|
593
|
+
: ''),
|
|
594
|
+
),
|
|
595
|
+
);
|
|
596
|
+
},
|
|
597
|
+
onClose: ({ code, reason }) => {
|
|
598
|
+
if (finished) return;
|
|
599
|
+
// a close after the input audio ended means the server delivered
|
|
600
|
+
// everything it will deliver: finish with the accumulated text
|
|
601
|
+
if (audioEnded) {
|
|
602
|
+
finish();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
finishWithError(
|
|
606
|
+
new Error(
|
|
607
|
+
`Vertex Live transcription WebSocket closed unexpectedly before finishing` +
|
|
608
|
+
` (code ${code ?? 'unknown'}${reason ? `, reason: ${reason}` : ''}).`,
|
|
609
|
+
),
|
|
610
|
+
);
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
cancel: () => {
|
|
616
|
+
if (finished) return;
|
|
617
|
+
finished = true;
|
|
618
|
+
cleanup();
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Builds Google's `AudioTranscriptionConfig` from provider options; returns
|
|
625
|
+
* undefined when no options are set.
|
|
626
|
+
*/
|
|
627
|
+
function buildAudioTranscriptionConfig(
|
|
628
|
+
options: GoogleVertexTranscriptionModelGeminiOptions | undefined,
|
|
629
|
+
): Record<string, unknown> | undefined {
|
|
630
|
+
if (options == null) return undefined;
|
|
631
|
+
const config: Record<string, unknown> = {};
|
|
632
|
+
if (options.languageCodes != null) {
|
|
633
|
+
config.languageCodes = options.languageCodes;
|
|
634
|
+
}
|
|
635
|
+
if (options.customVocabulary != null) {
|
|
636
|
+
config.customVocabulary = options.customVocabulary;
|
|
637
|
+
}
|
|
638
|
+
if (options.wordTimestamp != null) {
|
|
639
|
+
config.wordTimestamp = options.wordTimestamp;
|
|
640
|
+
}
|
|
641
|
+
if (options.diarization != null) {
|
|
642
|
+
config.diarization = options.diarization;
|
|
643
|
+
}
|
|
644
|
+
if (options.mode != null) {
|
|
645
|
+
config.mode = options.mode;
|
|
646
|
+
}
|
|
647
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function validateLiveInputAudioFormat(
|
|
651
|
+
inputAudioFormat: TranscriptionModelV4StreamOptions['inputAudioFormat'],
|
|
652
|
+
) {
|
|
653
|
+
if (
|
|
654
|
+
inputAudioFormat.type !== 'audio/pcm' ||
|
|
655
|
+
(inputAudioFormat.rate != null && inputAudioFormat.rate !== 16000)
|
|
656
|
+
) {
|
|
657
|
+
throw new InvalidArgumentError({
|
|
658
|
+
argument: 'inputAudioFormat',
|
|
659
|
+
message:
|
|
660
|
+
'The Gemini Live transcription API only supports 16kHz 16-bit PCM input audio.',
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Parses a Google duration offset such as `"1s"` or `"9.400s"` to seconds. */
|
|
666
|
+
function parseOffsetSeconds(
|
|
667
|
+
offset: string | undefined | null,
|
|
668
|
+
): number | undefined {
|
|
669
|
+
if (offset == null) return undefined;
|
|
670
|
+
const parsed = Number.parseFloat(offset);
|
|
671
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const googleVertexGeminiTranscriptionWordSchema = z.object({
|
|
675
|
+
word: z.string().nullish(),
|
|
676
|
+
startOffset: z.string().nullish(),
|
|
677
|
+
endOffset: z.string().nullish(),
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const googleVertexGeminiTranscriptionResponseSchema = z.object({
|
|
681
|
+
candidates: z
|
|
682
|
+
.array(
|
|
683
|
+
z.object({
|
|
684
|
+
content: z
|
|
685
|
+
.object({
|
|
686
|
+
parts: z
|
|
687
|
+
.array(
|
|
688
|
+
z.object({
|
|
689
|
+
text: z.string().nullish(),
|
|
690
|
+
audioTranscription: z
|
|
691
|
+
.object({
|
|
692
|
+
text: z.string().nullish(),
|
|
693
|
+
languageCode: z.string().nullish(),
|
|
694
|
+
speakerLabel: z.string().nullish(),
|
|
695
|
+
words: z
|
|
696
|
+
.array(googleVertexGeminiTranscriptionWordSchema)
|
|
697
|
+
.nullish(),
|
|
698
|
+
})
|
|
699
|
+
.nullish(),
|
|
700
|
+
}),
|
|
701
|
+
)
|
|
702
|
+
.nullish(),
|
|
703
|
+
})
|
|
704
|
+
.nullish(),
|
|
705
|
+
}),
|
|
706
|
+
)
|
|
707
|
+
.nullish(),
|
|
708
|
+
usageMetadata: z.record(z.string(), z.unknown()).nullish(),
|
|
709
|
+
});
|