@ai-sdk/google-vertex
Version:
195 lines (175 loc) • 6.02 kB
text/typescript
import type { SharedV4Warning, SpeechModelV4 } from '@ai-sdk/provider';
import {
lazySchema,
zodSchema,
combineHeaders,
convertBase64ToUint8Array,
createJsonResponseHandler,
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 type { GoogleVertexSpeechModelId } from './google-vertex-speech-model-options';
interface GoogleVertexCloudTTSSpeechModelConfig {
provider: string;
headers?: Resolvable<Record<string, string | undefined>>;
fetch?: FetchFunction;
_internal?: {
currentDate?: () => Date;
};
}
const DEFAULT_VOICE = 'Kore';
const DEFAULT_LANGUAGE = 'en-US';
// Chirp 3: HD voice names are `<locale>-Chirp3-HD-<voice>`,
// e.g. `en-US-Chirp3-HD-Kore`.
// https://cloud.google.com/text-to-speech/docs/chirp3-hd
const CHIRP3_HD_VOICE_INFIX = 'Chirp3-HD';
// Cloud Text-to-Speech uses a single non-regional host for standard
// synthesis (unlike Speech-to-Text and Vertex AI, which are regional).
// https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/synthesize
const CLOUD_TTS_SYNTHESIZE_URL =
'https://texttospeech.googleapis.com/v1/text:synthesize';
/**
* Speech model for Chirp 3: HD voices on the Google Cloud Text-to-Speech API.
*
* Unlike the Gemini TTS models (which go through the Vertex
* `generateContent` endpoint via `GoogleSpeechModel`), Chirp 3: HD voices are
* served by the dedicated Cloud Text-to-Speech `text:synthesize` endpoint,
* reusing the provider's Google Cloud credentials.
*/
export class GoogleVertexCloudTTSSpeechModel implements SpeechModelV4 {
readonly specificationVersion = 'v4';
static [WORKFLOW_SERIALIZE](model: GoogleVertexCloudTTSSpeechModel) {
return serializeModelOptions({
modelId: model.modelId,
config: model.config,
});
}
static [WORKFLOW_DESERIALIZE](options: {
modelId: GoogleVertexSpeechModelId;
config: GoogleVertexCloudTTSSpeechModelConfig;
}) {
return new GoogleVertexCloudTTSSpeechModel(options.modelId, options.config);
}
get provider(): string {
return this.config.provider;
}
constructor(
readonly modelId: GoogleVertexSpeechModelId,
private readonly config: GoogleVertexCloudTTSSpeechModelConfig,
) {}
async doGenerate(
options: Parameters<SpeechModelV4['doGenerate']>[0],
): Promise<Awaited<ReturnType<SpeechModelV4['doGenerate']>>> {
const currentDate = this.config._internal?.currentDate?.() ?? new Date();
const warnings: SharedV4Warning[] = [];
const {
text,
voice = DEFAULT_VOICE,
outputFormat,
instructions,
speed,
language,
} = options;
// Compose the voice name. A fully-qualified Chirp 3: HD voice name
// (e.g. `en-US-Chirp3-HD-Kore`) is passed through verbatim, with its
// locale prefix as the language code; otherwise the name is composed
// from the language (BCP-47 locale, e.g. `en-US`) and the plain voice
// name (e.g. `Kore`).
let voiceName: string;
let languageCode: string;
if (voice.includes(CHIRP3_HD_VOICE_INFIX)) {
voiceName = voice;
// The locale prefix may be missing (e.g. `Chirp3-HD-Kore`), in which
// case the extracted prefix is empty and the default language is used.
const localePrefix = voice
.split(CHIRP3_HD_VOICE_INFIX)[0]
.replace(/-$/, '');
languageCode = language ?? (localePrefix || DEFAULT_LANGUAGE);
} else {
languageCode = language ?? DEFAULT_LANGUAGE;
voiceName = `${languageCode}-${CHIRP3_HD_VOICE_INFIX}-${voice}`;
}
if (instructions != null) {
warnings.push({
type: 'unsupported',
feature: 'instructions',
details:
'Google Cloud Text-to-Speech Chirp 3: HD voices do not support the `instructions` option. It was ignored.',
});
}
// LINEAR16 responses are WAV (RIFF) files, so only `wav` is supported.
if (outputFormat != null && outputFormat !== 'wav') {
warnings.push({
type: 'unsupported',
feature: 'outputFormat',
details: `Unsupported output format: ${outputFormat}. Using wav instead.`,
});
}
const requestBody = {
input: { text },
voice: { languageCode, name: voiceName },
audioConfig: {
audioEncoding: 'LINEAR16',
...(speed != null ? { speakingRate: speed } : {}),
},
};
const {
value: response,
responseHeaders,
rawValue: rawResponse,
} = await postJsonToApi({
url: CLOUD_TTS_SYNTHESIZE_URL,
headers: combineHeaders(
this.config.headers ? await resolve(this.config.headers) : undefined,
options.headers,
),
body: requestBody,
failedResponseHandler: googleVertexFailedResponseHandler,
successfulResponseHandler: createJsonResponseHandler(
googleVertexCloudTTSResponseSchema,
),
abortSignal: options.abortSignal,
fetch: this.config.fetch,
});
// Empty audio is returned as-is so the core layer throws
// NoSpeechGeneratedError.
const audio =
response.audioContent != null
? convertBase64ToUint8Array(response.audioContent)
: new Uint8Array(0);
return {
audio,
warnings,
request: {
body: JSON.stringify(requestBody),
},
response: {
timestamp: currentDate,
modelId: this.modelId,
headers: responseHeaders,
body: rawResponse,
},
providerMetadata: {
google: {
mimeType: 'audio/wav',
},
},
};
}
}
// Minimal schema: only the fields the implementation reads, with `.nullish()`
// so provider API changes don't break parsing.
const googleVertexCloudTTSResponseSchema = lazySchema(() =>
zodSchema(
z.object({
audioContent: z.string().nullish(),
}),
),
);