Skip to main content

ferrin_google/
realtime.rs

1//! Live API realtime model: ephemeral auth tokens, session setup and the
2//! mapping between Live API messages and the specification events.
3
4use std::sync::Mutex;
5
6use base64::Engine;
7use bytes::Bytes;
8use chrono::Duration;
9use chrono::Utc;
10use ferrin_provider_util::http::ResponseHandlers;
11use ferrin_provider_util::http::json_response_handler;
12use ferrin_provider_util::http::post_json;
13use ferrin_spec::Headers;
14use ferrin_spec::JsonObject;
15use ferrin_spec::JsonValue;
16use ferrin_spec::ModelId;
17use ferrin_spec::ProviderId;
18use ferrin_spec::RealtimeModelRef;
19use ferrin_spec::error::NoSuchModelError;
20use ferrin_spec::error::ProviderError;
21use ferrin_spec::realtime_model::ClientSecret;
22use ferrin_spec::realtime_model::ClientSecretOptions;
23use ferrin_spec::realtime_model::ConversationItem;
24use ferrin_spec::realtime_model::GetTokenOptions;
25use ferrin_spec::realtime_model::RealtimeClientEvent;
26use ferrin_spec::realtime_model::RealtimeFactory;
27use ferrin_spec::realtime_model::RealtimeModel;
28use ferrin_spec::realtime_model::RealtimeServerEvent;
29use ferrin_spec::realtime_model::RealtimeSessionConfig;
30use ferrin_spec::realtime_model::WebSocketConfig;
31use secrecy::ExposeSecret;
32use serde::Deserialize;
33use serde_json::json;
34use tokio_util::sync::CancellationToken;
35use url::Url;
36
37use crate::config::AUTH_TOKENS_PATH;
38use crate::config::CANONICAL_OPTIONS_KEY;
39use crate::config::GoogleConfig;
40use crate::config::SharedConfig;
41use crate::error::failed_response_handler;
42use crate::json_schema::convert_json_schema_to_openapi_schema;
43
44/// Provider id family.
45pub const FAMILY: &str = "realtime";
46
47/// Service path of the constrained (token-authenticated) bidi endpoint.
48pub const WEBSOCKET_SERVICE_PATH: &str =
49    "google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained";
50
51/// Default window in which a token may open a session.
52pub const DEFAULT_EXPIRES_AFTER_SECONDS: u64 = 60;
53
54/// Default input audio sample rate advertised in audio blobs.
55pub const DEFAULT_INPUT_AUDIO_RATE: u32 = 16_000;
56
57#[derive(Debug, Deserialize)]
58struct AuthTokenResponse {
59    name: String,
60    #[serde(default, rename = "expireTime")]
61    expire_time: Option<String>,
62}
63
64/// Builds the `setup` message (`bidiGenerateContentSetup`) for a session.
65///
66/// # Errors
67///
68/// Returns [`ProviderError::UnsupportedFunctionality`] when a tool schema
69/// cannot be converted.
70pub fn build_session_config(
71    config: Option<&RealtimeSessionConfig>,
72    model_id: &str,
73) -> Result<JsonValue, ProviderError> {
74    let mut setup = JsonObject::new();
75    setup.insert(
76        "model".to_owned(),
77        JsonValue::from(GoogleConfig::model_path(model_id)),
78    );
79    let mut generation = JsonObject::new();
80    let modalities: Vec<JsonValue> =
81        match config.and_then(|config| config.output_modalities.as_ref()) {
82            Some(modalities) => modalities
83                .iter()
84                .map(|modality| {
85                    let text = serde_json::to_value(modality)
86                        .ok()
87                        .and_then(|value| value.as_str().map(str::to_ascii_uppercase))
88                        .unwrap_or_else(|| "AUDIO".to_owned());
89                    JsonValue::from(text)
90                })
91                .collect(),
92            None => vec![JsonValue::from("AUDIO")],
93        };
94    generation.insert(
95        "responseModalities".to_owned(),
96        JsonValue::Array(modalities),
97    );
98    if let Some(voice) = config.and_then(|config| config.voice.as_deref()) {
99        generation.insert(
100            "speechConfig".to_owned(),
101            json!({"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}}),
102        );
103    }
104    setup.insert("generationConfig".to_owned(), JsonValue::Object(generation));
105    let Some(config) = config else {
106        return Ok(JsonValue::Object(setup));
107    };
108    if let Some(instructions) = &config.instructions {
109        setup.insert(
110            "systemInstruction".to_owned(),
111            json!({"parts": [{"text": instructions}]}),
112        );
113    }
114    if !config.tools.is_empty() {
115        let mut declarations = Vec::new();
116        for tool in &config.tools {
117            let mut declaration = JsonObject::new();
118            declaration.insert("name".to_owned(), JsonValue::from(tool.name.as_str()));
119            if let Some(description) = &tool.description {
120                declaration.insert(
121                    "description".to_owned(),
122                    JsonValue::from(description.as_str()),
123                );
124            }
125            if let Some(parameters) = convert_json_schema_to_openapi_schema(&tool.parameters)? {
126                declaration.insert("parameters".to_owned(), parameters);
127            }
128            declarations.push(JsonValue::Object(declaration));
129        }
130        setup.insert(
131            "tools".to_owned(),
132            json!([{"functionDeclarations": declarations}]),
133        );
134    }
135    if config.input_audio_transcription.is_some() {
136        setup.insert("inputAudioTranscription".to_owned(), json!({}));
137    }
138    if config.output_audio_transcription.is_some() {
139        setup.insert("outputAudioTranscription".to_owned(), json!({}));
140    }
141    if let Some(provider_options) = &config.provider_options {
142        let mut google: Option<JsonObject> = None;
143        for (key, value) in provider_options {
144            if key == CANONICAL_OPTIONS_KEY {
145                if let JsonValue::Object(object) = value {
146                    google = Some(object.clone());
147                }
148            } else {
149                setup.insert(key.clone(), value.clone());
150            }
151        }
152        if let Some(translation) = google
153            .as_ref()
154            .and_then(|google| google.get("translationConfig"))
155        {
156            let target = match setup.get_mut("generationConfig") {
157                Some(JsonValue::Object(generation)) => generation,
158                _ => {
159                    setup.insert("generationConfig".to_owned(), json!({}));
160                    match setup.get_mut("generationConfig") {
161                        Some(JsonValue::Object(generation)) => generation,
162                        _ => return Ok(JsonValue::Object(setup)),
163                    }
164                }
165            };
166            target.insert("translationConfig".to_owned(), translation.clone());
167        }
168    }
169    Ok(JsonValue::Object(setup))
170}
171
172/// Turn tracking of the stateful event mapper.
173#[derive(Debug, Default)]
174struct MapperState {
175    turn_counter: u64,
176    has_audio: bool,
177    has_text: bool,
178    has_transcript: bool,
179    turn_closed: bool,
180    input_audio_rate: Option<u32>,
181}
182
183impl MapperState {
184    fn response_id(&self) -> String {
185        format!("google-resp-{}", self.turn_counter)
186    }
187
188    fn item_id(&self) -> String {
189        format!("google-item-{}", self.turn_counter)
190    }
191
192    fn input_id(&self) -> String {
193        format!("google-input-{}", self.turn_counter)
194    }
195
196    fn begin_turn_if_closed(&mut self) {
197        if self.turn_closed {
198            self.turn_counter += 1;
199            self.has_audio = false;
200            self.has_text = false;
201            self.has_transcript = false;
202            self.turn_closed = false;
203        }
204    }
205}
206
207fn custom(raw_type: &str, raw: &JsonValue) -> RealtimeServerEvent {
208    RealtimeServerEvent::Custom {
209        raw_type: raw_type.to_owned(),
210        raw: raw.clone(),
211    }
212}
213
214fn decode_audio(data: &str) -> Bytes {
215    base64::engine::general_purpose::STANDARD
216        .decode(data)
217        .map(Bytes::from)
218        .unwrap_or_default()
219}
220
221fn parse_server_content(
222    state: &mut MapperState,
223    content: &JsonValue,
224    raw: &JsonValue,
225) -> Vec<RealtimeServerEvent> {
226    let mut events = Vec::new();
227    if content.get("interrupted").and_then(JsonValue::as_bool) == Some(true) {
228        events.push(RealtimeServerEvent::SpeechStarted {
229            item_id: None,
230            raw: raw.clone(),
231        });
232    }
233    if let Some(parts) = content
234        .get("modelTurn")
235        .and_then(|turn| turn.get("parts"))
236        .and_then(JsonValue::as_array)
237    {
238        state.begin_turn_if_closed();
239        for part in parts {
240            if let Some(data) = part
241                .get("inlineData")
242                .and_then(|inline| inline.get("data"))
243                .and_then(JsonValue::as_str)
244                .filter(|data| !data.is_empty())
245            {
246                state.has_audio = true;
247                events.push(RealtimeServerEvent::AudioDelta {
248                    response_id: state.response_id(),
249                    item_id: state.item_id(),
250                    delta: decode_audio(data),
251                    raw: raw.clone(),
252                });
253            }
254            if let Some(text) = part
255                .get("text")
256                .and_then(JsonValue::as_str)
257                .filter(|text| !text.is_empty())
258            {
259                state.has_text = true;
260                events.push(RealtimeServerEvent::TextDelta {
261                    response_id: state.response_id(),
262                    item_id: state.item_id(),
263                    delta: text.to_owned(),
264                    raw: raw.clone(),
265                });
266            }
267        }
268    }
269    if let Some(text) = content
270        .get("outputTranscription")
271        .and_then(|transcription| transcription.get("text"))
272        .and_then(JsonValue::as_str)
273        .filter(|text| !text.is_empty())
274    {
275        state.has_transcript = true;
276        events.push(RealtimeServerEvent::AudioTranscriptDelta {
277            response_id: state.response_id(),
278            item_id: state.item_id(),
279            delta: text.to_owned(),
280            raw: raw.clone(),
281        });
282    }
283    if let Some(text) = content
284        .get("inputTranscription")
285        .and_then(|transcription| transcription.get("text"))
286        .and_then(JsonValue::as_str)
287        .filter(|text| !text.is_empty())
288    {
289        events.push(RealtimeServerEvent::InputTranscriptionCompleted {
290            item_id: state.input_id(),
291            transcript: text.to_owned(),
292            raw: raw.clone(),
293        });
294    }
295    if content
296        .get("generationComplete")
297        .and_then(JsonValue::as_bool)
298        == Some(true)
299    {
300        events.push(custom("generationComplete", raw));
301    }
302    if content.get("turnComplete").and_then(JsonValue::as_bool) == Some(true) {
303        if state.has_audio {
304            events.push(RealtimeServerEvent::AudioDone {
305                response_id: state.response_id(),
306                item_id: state.item_id(),
307                raw: raw.clone(),
308            });
309        }
310        if state.has_text {
311            events.push(RealtimeServerEvent::TextDone {
312                response_id: state.response_id(),
313                item_id: state.item_id(),
314                text: None,
315                raw: raw.clone(),
316            });
317        }
318        if state.has_transcript {
319            events.push(RealtimeServerEvent::AudioTranscriptDone {
320                response_id: state.response_id(),
321                item_id: state.item_id(),
322                transcript: None,
323                raw: raw.clone(),
324            });
325        }
326        events.push(RealtimeServerEvent::ResponseDone {
327            response_id: state.response_id(),
328            status: "completed".to_owned(),
329            raw: raw.clone(),
330        });
331        state.turn_closed = true;
332    }
333    if events.is_empty() {
334        events.push(custom("serverContent", raw));
335    }
336    events
337}
338
339fn parse_event(state: &mut MapperState, raw: &JsonValue) -> Vec<RealtimeServerEvent> {
340    let Some(object) = raw.as_object() else {
341        return vec![custom("unknown", raw)];
342    };
343    if object.contains_key("setupComplete") {
344        return vec![RealtimeServerEvent::SessionCreated {
345            session_id: None,
346            raw: raw.clone(),
347        }];
348    }
349    if let Some(tool_call) = object.get("toolCall") {
350        state.begin_turn_if_closed();
351        let mut events = Vec::new();
352        for call in tool_call
353            .get("functionCalls")
354            .and_then(JsonValue::as_array)
355            .into_iter()
356            .flatten()
357        {
358            let args = call
359                .get("args")
360                .cloned()
361                .unwrap_or_else(|| JsonValue::Object(JsonObject::new()))
362                .to_string();
363            let call_id = call
364                .get("id")
365                .and_then(JsonValue::as_str)
366                .unwrap_or_default()
367                .to_owned();
368            let name = call
369                .get("name")
370                .and_then(JsonValue::as_str)
371                .unwrap_or_default()
372                .to_owned();
373            events.push(RealtimeServerEvent::FunctionCallArgumentsDelta {
374                response_id: state.response_id(),
375                item_id: state.item_id(),
376                call_id: call_id.clone(),
377                delta: args.clone(),
378                raw: raw.clone(),
379            });
380            events.push(RealtimeServerEvent::FunctionCallArgumentsDone {
381                response_id: state.response_id(),
382                item_id: state.item_id(),
383                call_id,
384                name,
385                arguments: args,
386                raw: raw.clone(),
387            });
388        }
389        return events;
390    }
391    for key in ["toolCallCancellation", "goAway", "sessionResumptionUpdate"] {
392        if object.contains_key(key) {
393            return vec![custom(key, raw)];
394        }
395    }
396    if let Some(content) = object.get("serverContent") {
397        return parse_server_content(state, content, raw);
398    }
399    if let Some(text) = object
400        .get("inputTranscription")
401        .and_then(|transcription| transcription.get("text"))
402        .and_then(JsonValue::as_str)
403    {
404        return vec![RealtimeServerEvent::InputTranscriptionCompleted {
405            item_id: state.input_id(),
406            transcript: text.to_owned(),
407            raw: raw.clone(),
408        }];
409    }
410    let raw_type = object.keys().next().map_or("unknown", String::as_str);
411    vec![custom(raw_type, raw)]
412}
413
414fn serialize_event(
415    state: &mut MapperState,
416    event: RealtimeClientEvent,
417    model_id: &str,
418) -> Result<JsonValue, ProviderError> {
419    Ok(match event {
420        RealtimeClientEvent::SessionUpdate { config } => {
421            if let Some(rate) = config
422                .input_audio_format
423                .as_ref()
424                .and_then(|format| format.rate)
425            {
426                state.input_audio_rate = Some(rate);
427            }
428            json!({"setup": build_session_config(Some(&config), model_id)?})
429        }
430        RealtimeClientEvent::InputAudioAppend { audio } => {
431            let rate = state.input_audio_rate.unwrap_or(DEFAULT_INPUT_AUDIO_RATE);
432            json!({"realtimeInput": {"audio": {
433                "data": base64::engine::general_purpose::STANDARD.encode(&audio),
434                "mimeType": format!("audio/pcm;rate={rate}"),
435            }}})
436        }
437        RealtimeClientEvent::InputAudioCommit => {
438            json!({"realtimeInput": {"audioStreamEnd": true}})
439        }
440        RealtimeClientEvent::ConversationItemCreate { item } => match item {
441            ConversationItem::TextMessage { text, .. } => {
442                json!({"realtimeInput": {"text": text}})
443            }
444            ConversationItem::FunctionCallOutput {
445                call_id,
446                name,
447                output,
448            } => {
449                let value = serde_json::from_str::<JsonValue>(&output)
450                    .unwrap_or_else(|_| JsonValue::from(output));
451                let response = if value.is_object() {
452                    value
453                } else {
454                    json!({"result": value})
455                };
456                let mut function_response = JsonObject::new();
457                function_response.insert("id".to_owned(), JsonValue::from(call_id));
458                if let Some(name) = name {
459                    function_response.insert("name".to_owned(), JsonValue::from(name));
460                }
461                function_response.insert("response".to_owned(), response);
462                json!({"toolResponse": {"functionResponses": [function_response]}})
463            }
464            ConversationItem::AudioMessage { .. } => {
465                return Err(ProviderError::unsupported(
466                    "realtime conversation item: audio message",
467                ));
468            }
469            #[allow(unreachable_patterns, reason = "ConversationItem is non-exhaustive")]
470            _ => return Err(ProviderError::unsupported("realtime conversation item")),
471        },
472        RealtimeClientEvent::InputAudioClear => {
473            return Err(ProviderError::unsupported(
474                "realtime client event: input audio clear",
475            ));
476        }
477        RealtimeClientEvent::ResponseCreate { .. } => {
478            return Err(ProviderError::unsupported(
479                "realtime client event: response create",
480            ));
481        }
482        RealtimeClientEvent::ResponseCancel => {
483            return Err(ProviderError::unsupported(
484                "realtime client event: response cancel",
485            ));
486        }
487        RealtimeClientEvent::ConversationItemTruncate { .. } => {
488            return Err(ProviderError::unsupported(
489                "realtime client event: conversation item truncate",
490            ));
491        }
492        #[allow(unreachable_patterns, reason = "RealtimeClientEvent is non-exhaustive")]
493        _ => return Err(ProviderError::unsupported("realtime client event")),
494    })
495}
496
497/// Live API realtime model.
498#[derive(Debug)]
499pub struct GoogleRealtimeModel {
500    config: SharedConfig,
501    provider: ProviderId,
502    model_id: ModelId,
503    state: Mutex<MapperState>,
504}
505
506impl GoogleRealtimeModel {
507    /// Creates the model.
508    #[must_use]
509    pub fn new(config: SharedConfig, model_id: impl Into<ModelId>) -> Self {
510        Self {
511            provider: config.provider_id(FAMILY),
512            config,
513            model_id: model_id.into(),
514            state: Mutex::new(MapperState::default()),
515        }
516    }
517
518    /// WebSocket URL of the constrained bidi endpoint (without the token).
519    #[must_use]
520    pub fn session_url(&self) -> Url {
521        self.config.websocket_url(WEBSOCKET_SERVICE_PATH)
522    }
523
524    fn lock(&self) -> std::sync::MutexGuard<'_, MapperState> {
525        self.state
526            .lock()
527            .unwrap_or_else(std::sync::PoisonError::into_inner)
528    }
529}
530
531impl RealtimeModel for GoogleRealtimeModel {
532    fn provider(&self) -> &ProviderId {
533        &self.provider
534    }
535
536    fn model_id(&self) -> &ModelId {
537        &self.model_id
538    }
539
540    #[tracing::instrument(skip_all, fields(model = %self.model_id))]
541    async fn do_create_client_secret(
542        &self,
543        options: ClientSecretOptions,
544    ) -> Result<ClientSecret, ProviderError> {
545        let api_key = self.config.api_key()?;
546        let now = Utc::now();
547        let window = i64::try_from(
548            options
549                .expires_after_seconds
550                .unwrap_or(DEFAULT_EXPIRES_AFTER_SECONDS),
551        )
552        .unwrap_or(i64::MAX / 4);
553        let new_session_expire_time = now + Duration::seconds(window);
554        let expire_time = new_session_expire_time + Duration::minutes(30);
555        let body = json!({
556            "uses": 0,
557            "expireTime": expire_time.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
558            "newSessionExpireTime": new_session_expire_time.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
559            "bidiGenerateContentSetup": build_session_config(
560                options.session_config.as_ref(),
561                self.model_id.as_str(),
562            )?,
563        });
564        let mut url = self.config.origin_url(AUTH_TOKENS_PATH);
565        url.query_pairs_mut()
566            .append_pair("key", api_key.expose_secret());
567        let mut headers = self.config.headers.clone();
568        headers = headers.with_user_agent_suffix([crate::config::USER_AGENT]);
569        let handlers = ResponseHandlers::new(
570            json_response_handler::<AuthTokenResponse>(),
571            failed_response_handler(),
572        );
573        let response = post_json(
574            self.config.transport.as_ref(),
575            url,
576            headers,
577            &body,
578            &handlers,
579            CancellationToken::new(),
580        )
581        .await?;
582        let expires_at = response
583            .value
584            .expire_time
585            .as_deref()
586            .and_then(|time| chrono::DateTime::parse_from_rfc3339(time).ok())
587            .and_then(|time| u64::try_from(time.timestamp()).ok());
588        Ok(ClientSecret {
589            token: response.value.name,
590            url: self.session_url(),
591            expires_at,
592        })
593    }
594
595    fn websocket_config(&self, token: &str, url: &Url) -> WebSocketConfig {
596        let mut url = url.clone();
597        url.query_pairs_mut().append_pair("access_token", token);
598        WebSocketConfig {
599            url,
600            protocols: Vec::new(),
601        }
602    }
603
604    fn parse_server_event(
605        &self,
606        raw: JsonValue,
607    ) -> Result<Vec<RealtimeServerEvent>, ProviderError> {
608        let mut state = self.lock();
609        Ok(parse_event(&mut state, &raw))
610    }
611
612    async fn serialize_client_event(
613        &self,
614        event: RealtimeClientEvent,
615    ) -> Result<JsonValue, ProviderError> {
616        let mut state = self.lock();
617        serialize_event(&mut state, event, self.model_id.as_str())
618    }
619
620    fn build_session_config(
621        &self,
622        config: &RealtimeSessionConfig,
623    ) -> Result<JsonValue, ProviderError> {
624        build_session_config(Some(config), self.model_id.as_str())
625    }
626}
627
628/// Factory for Live API models and session tokens.
629#[derive(Debug, Clone)]
630pub struct GoogleRealtimeFactory {
631    config: SharedConfig,
632    provider: ProviderId,
633}
634
635impl GoogleRealtimeFactory {
636    /// Creates the factory.
637    #[must_use]
638    pub fn new(config: SharedConfig) -> Self {
639        Self {
640            provider: config.provider_id(FAMILY),
641            config,
642        }
643    }
644
645    /// Creates a realtime model.
646    #[must_use]
647    pub fn realtime_model(&self, model_id: &str) -> GoogleRealtimeModel {
648        GoogleRealtimeModel::new(self.config.clone(), model_id)
649    }
650}
651
652impl RealtimeFactory for GoogleRealtimeFactory {
653    fn provider(&self) -> &ProviderId {
654        &self.provider
655    }
656
657    fn model(&self, model_id: &str) -> Result<RealtimeModelRef, NoSuchModelError> {
658        Ok(self.realtime_model(model_id).into())
659    }
660
661    async fn get_token(&self, options: GetTokenOptions) -> Result<ClientSecret, ProviderError> {
662        self.realtime_model(options.model.as_str())
663            .do_create_client_secret(ClientSecretOptions {
664                expires_after_seconds: options.expires_after_seconds,
665                session_config: options.session_config,
666            })
667            .await
668    }
669}
670
671/// Headers of the auth token request: configuration headers only (the key
672/// travels in the query string).
673#[must_use]
674pub fn token_request_headers(config: &GoogleConfig) -> Headers {
675    config
676        .headers
677        .clone()
678        .with_user_agent_suffix([crate::config::USER_AGENT])
679}