UNPKG

@ai-sdk/google

Version:
211 lines (186 loc) • 7.17 kB
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); } }