@ai-sdk/google-vertex
Version:
250 lines (228 loc) • 7.52 kB
text/typescript
import {
InvalidArgumentError,
type SharedV4Warning,
type TranscriptionModelV4,
} from '@ai-sdk/provider';
import {
lazySchema,
zodSchema,
combineHeaders,
convertUint8ArrayToBase64,
createJsonResponseHandler,
isValidHostnamePart,
parseProviderOptions,
postJsonToApi,
resolve,
serializeModelOptions,
WORKFLOW_DESERIALIZE,
WORKFLOW_SERIALIZE,
type FetchFunction,
type Resolvable,
} from '@ai-sdk/provider-utils';
import { z } from 'zod/v4';
import { googleVertexFailedResponseHandler } from './google-vertex-error';
import {
googleVertexTranscriptionProviderOptionsSchema,
type GoogleVertexTranscriptionModelId,
type GoogleVertexTranscriptionModelOptions,
} from './google-vertex-transcription-model-options';
interface GoogleVertexTranscriptionModelConfig {
provider: string;
/** Google Cloud project id. */
project: string;
/** Default Speech-to-Text region (overridable via provider options). */
location: string;
headers?: Resolvable<Record<string, string | undefined>>;
fetch?: FetchFunction;
_internal?: {
currentDate?: () => Date;
};
}
// Speech-to-Text Durations are strings like `"1.200s"`; parse to seconds.
function parseDurationSeconds(
value: string | null | undefined,
): number | undefined {
if (value == null) {
return undefined;
}
const seconds = Number.parseFloat(value);
return Number.isFinite(seconds) ? seconds : undefined;
}
function convertBcp47ToIso6391(
value: string | null | undefined,
): string | undefined {
if (value == null) {
return undefined;
}
try {
const language = new Intl.Locale(value).language;
return language.length === 2 ? language : undefined;
} catch {
return undefined;
}
}
export class GoogleVertexTranscriptionModel implements TranscriptionModelV4 {
readonly specificationVersion = 'v4';
static [WORKFLOW_SERIALIZE](model: GoogleVertexTranscriptionModel) {
return serializeModelOptions({
modelId: model.modelId,
config: model.config,
});
}
static [WORKFLOW_DESERIALIZE](options: {
modelId: GoogleVertexTranscriptionModelId;
config: GoogleVertexTranscriptionModelConfig;
}) {
return new GoogleVertexTranscriptionModel(options.modelId, options.config);
}
get provider(): string {
return this.config.provider;
}
constructor(
readonly modelId: GoogleVertexTranscriptionModelId,
private readonly config: GoogleVertexTranscriptionModelConfig,
) {}
async doGenerate(
options: Parameters<TranscriptionModelV4['doGenerate']>[0],
): Promise<Awaited<ReturnType<TranscriptionModelV4['doGenerate']>>> {
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
const warnings: SharedV4Warning[] = [];
// Provider options may be passed under `googleVertex`, `vertex`, or `google`
// (matching the Vertex language + embedding models).
let googleOptions: GoogleVertexTranscriptionModelOptions | undefined;
for (const provider of ['googleVertex', 'vertex', 'google'] as const) {
googleOptions = await parseProviderOptions({
provider,
providerOptions: options.providerOptions,
schema: googleVertexTranscriptionProviderOptionsSchema,
});
if (googleOptions != null) {
break;
}
}
const region = googleOptions?.region ?? this.config.location;
if (!isValidHostnamePart(region)) {
throw new InvalidArgumentError({
argument: googleOptions?.region != null ? 'region' : 'location',
message:
'Invalid Google Cloud Speech-to-Text region. Expected a single DNS label (letters, digits, and hyphens).',
});
}
const languageCodes = googleOptions?.languageCodes ?? ['auto'];
// The recognize API takes base64-encoded audio in the `content` field. A
// string audio input is already base64-encoded per the spec.
const content =
typeof options.audio === 'string'
? options.audio
: convertUint8ArrayToBase64(options.audio);
const requestBody = {
config: {
model: this.modelId,
languageCodes,
// Let Speech-to-Text auto-detect the audio encoding (wav/mp3/flac/…).
autoDecodingConfig: {},
features: {
// Word timing populates `segments`.
enableWordTimeOffsets: googleOptions?.enableWordTimeOffsets ?? true,
enableAutomaticPunctuation:
googleOptions?.enableAutomaticPunctuation ?? true,
},
},
content,
};
const host =
region === 'global'
? 'speech.googleapis.com'
: `${region}-speech.googleapis.com`;
const url =
`https://${host}/v2/projects/` +
`${this.config.project}/locations/${region}/recognizers/_:recognize`;
const {
value: response,
responseHeaders,
rawValue: rawResponse,
} = await postJsonToApi({
url,
headers: combineHeaders(
this.config.headers ? await resolve(this.config.headers) : undefined,
options.headers,
),
body: requestBody,
failedResponseHandler: googleVertexFailedResponseHandler,
successfulResponseHandler: createJsonResponseHandler(
googleVertexTranscriptionResponseSchema,
),
abortSignal: options.abortSignal,
fetch: this.config.fetch,
});
// Results are sequential portions of the audio; concatenate their primary
// alternatives into the full transcript and collect word-level segments.
const results = response.results ?? [];
const text = results
.map(result => result.alternatives?.[0]?.transcript ?? '')
.join(' ')
.trim();
const segments = results.flatMap(
result =>
result.alternatives?.[0]?.words?.flatMap(word => {
const startSecond = parseDurationSeconds(word.startOffset);
const endSecond = parseDurationSeconds(word.endOffset);
return word.word == null || startSecond == null || endSecond == null
? []
: [{ text: word.word, startSecond, endSecond }];
}) ?? [],
);
const language = convertBcp47ToIso6391(results[0]?.languageCode);
return {
text,
segments,
language,
durationInSeconds: parseDurationSeconds(
response.metadata?.totalBilledDuration,
),
warnings,
response: {
timestamp: currentDate,
modelId: this.modelId,
headers: responseHeaders,
body: rawResponse,
},
};
}
}
// Minimal schema: only the fields the implementation reads, with `.nullish()`
// so provider API changes don't break parsing.
const googleVertexTranscriptionResponseSchema = lazySchema(() =>
zodSchema(
z.object({
results: z
.array(
z.object({
alternatives: z
.array(
z.object({
transcript: z.string().nullish(),
words: z
.array(
z.object({
word: z.string().nullish(),
startOffset: z.string().nullish(),
endOffset: z.string().nullish(),
}),
)
.nullish(),
}),
)
.nullish(),
languageCode: z.string().nullish(),
}),
)
.nullish(),
metadata: z
.object({
totalBilledDuration: z.string().nullish(),
})
.nullish(),
}),
),
);