@ai-sdk/google
Version:
211 lines (186 loc) • 7.17 kB
text/typescript
import type {
Experimental_RealtimeModelV4 as RealtimeModelV4,
Experimental_RealtimeModelV4ClientEvent as RealtimeModelV4ClientEvent,
Experimental_RealtimeModelV4ClientSecretOptions as RealtimeModelV4ClientSecretOptions,
Experimental_RealtimeModelV4ClientSecretResult as RealtimeModelV4ClientSecretResult,
Experimental_RealtimeModelV4ServerConnection as RealtimeModelV4ServerConnection,
Experimental_RealtimeModelV4ServerEvent as RealtimeModelV4ServerEvent,
Experimental_RealtimeModelV4SessionConfig as RealtimeModelV4SessionConfig,
Experimental_RealtimeModelV4SessionIntent as RealtimeModelV4SessionIntent,
} from '@ai-sdk/provider';
import {
resolve,
resolveWith,
removeUndefinedEntries,
type Resolvable,
type FetchFunction,
} from '@ai-sdk/provider-utils';
import {
getRealtimeBaseURL,
getRealtimeWebSocketURL,
} from '../get-realtime-base-url';
import {
GoogleRealtimeEventMapper,
buildGoogleSessionConfig,
} from './google-realtime-event-mapper';
const realtimeWebSocketPath =
'google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained';
// Server-side connections use the unconstrained Bidi endpoint: a trusted server
// holds the API key and sends the session `setup` over the socket, rather than
// baking it into an ephemeral token at mint time (which the constrained
// endpoint requires).
const realtimeServerWebSocketPath =
'google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent';
function getAuthTokensURL(baseURL: string): string {
const url = getRealtimeBaseURL(baseURL);
url.pathname = `${url.pathname.replace(/\/$/, '')}/v1alpha/auth_tokens`;
return url.toString();
}
function getWebSocketURL(baseURL: string): string {
return getRealtimeWebSocketURL(baseURL, realtimeWebSocketPath).toString();
}
function getServerWebSocketURL(baseURL: string): string {
const url = getRealtimeBaseURL(baseURL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = `${url.pathname.replace(/\/$/, '')}/ws/${realtimeServerWebSocketPath}`;
return url.toString();
}
export type GoogleRealtimeModelConfig = {
provider: string;
baseURL: string;
headers: Resolvable<Record<string, string | undefined>>;
fetch?: FetchFunction;
};
export class GoogleRealtimeModel implements RealtimeModelV4 {
readonly specificationVersion = 'v4' as const;
readonly capabilities = {
conversation: 'turn-based',
transports: ['websocket'],
connections: ['client-secret-websocket', 'server-websocket'],
} as const;
readonly provider: string;
readonly modelId: string;
private readonly config: GoogleRealtimeModelConfig;
private readonly mapper = new GoogleRealtimeEventMapper();
constructor(modelId: string, config: GoogleRealtimeModelConfig) {
this.modelId = modelId;
this.provider = config.provider;
this.config = config;
}
async doCreateClientSecret(
options: RealtimeModelV4ClientSecretOptions,
): Promise<RealtimeModelV4ClientSecretResult> {
const fetchFn = this.config.fetch ?? fetch;
const headers = await resolve(this.config.headers);
const apiKey = headers['x-goog-api-key'];
if (!apiKey) {
throw new Error(
'Google Generative AI API key is required for realtime token creation.',
);
}
// `newSessionExpireTime` controls how long the token can be used to *open*
// a session — the window callers actually care about — so map
// `expiresAfterSeconds` to it (Google otherwise defaults it to ~60s).
// `expireTime` is the overall token lifetime and must be >=
// `newSessionExpireTime`, so extend it to leave room for the opened session
// to run.
const now = Date.now();
const openWindowMs = (options.expiresAfterSeconds ?? 60) * 1000;
const newSessionExpireTime = new Date(now + openWindowMs).toISOString();
const expireTime = new Date(
now + openWindowMs + 30 * 60 * 1000,
).toISOString();
const setupPayload = buildGoogleSessionConfig(
options.sessionConfig,
this.modelId,
);
const response = await fetchFn(
`${getAuthTokensURL(this.config.baseURL)}?key=${encodeURIComponent(apiKey)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// `uses: 0` means no limit is applied to how many times the token can
// start a session (per the AuthToken spec). An unset value would
// default to 1, which breaks WebSocket reconnects within the session.
uses: 0,
expireTime,
newSessionExpireTime,
bidiGenerateContentSetup: setupPayload,
}),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(
`Google realtime auth token request failed: ${response.status} ${text}`,
);
}
const data = (await response.json()) as {
name: string;
expireTime?: string;
};
return {
token: data.name,
url: getWebSocketURL(this.config.baseURL),
expiresAt: data.expireTime
? Math.floor(new Date(data.expireTime).getTime() / 1000)
: undefined,
};
}
getWebSocketConfig(options: { token: string; url: string }): {
url: string;
protocols?: string[];
} {
return {
url: `${options.url}?access_token=${encodeURIComponent(options.token)}`,
};
}
getServerWebSocketConfig(options?: {
intent?: RealtimeModelV4SessionIntent;
}):
| RealtimeModelV4ServerConnection
| PromiseLike<RealtimeModelV4ServerConnection> {
// Gemini Live exposes only the conversational endpoint; fail fast instead
// of silently downgrading unsupported intents.
this.assertConversationIntent(options);
return resolveWith(this.config.headers, headers => ({
url: getServerWebSocketURL(this.config.baseURL),
headers: removeUndefinedEntries(headers),
}));
}
private assertConversationIntent(options?: {
intent?: RealtimeModelV4SessionIntent;
}): void {
if (options?.intent != null && options.intent !== 'conversation') {
throw new Error(
`Google realtime does not support the '${options.intent}' session intent.`,
);
}
}
parseServerEvent(
raw: unknown,
): RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[] {
return this.mapper.parseServerEvent(raw);
}
createServerEventParser(): ReturnType<
NonNullable<RealtimeModelV4['createServerEventParser']>
> {
const mapper = new GoogleRealtimeEventMapper();
return raw => mapper.parseServerEvent(raw);
}
serializeClientEvent(
event: RealtimeModelV4ClientEvent,
options?: { intent?: RealtimeModelV4SessionIntent },
): ReturnType<RealtimeModelV4['serializeClientEvent']> {
this.assertConversationIntent(options);
return this.mapper.serializeClientEvent(event, this.modelId);
}
buildSessionConfig(
config: RealtimeModelV4SessionConfig,
options?: { intent?: RealtimeModelV4SessionIntent },
): Record<string, unknown> {
this.assertConversationIntent(options);
return buildGoogleSessionConfig(config, this.modelId);
}
}