1use 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
44pub const FAMILY: &str = "realtime";
46
47pub const WEBSOCKET_SERVICE_PATH: &str =
49 "google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained";
50
51pub const DEFAULT_EXPIRES_AFTER_SECONDS: u64 = 60;
53
54pub 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
64pub 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#[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 response = ferrin_schema::json::parse(&output).unwrap_or_else(|_| json!({}));
450 let mut function_response = JsonObject::new();
451 function_response.insert("id".to_owned(), JsonValue::from(call_id));
452 if let Some(name) = name {
453 function_response.insert("name".to_owned(), JsonValue::from(name));
454 }
455 function_response.insert("response".to_owned(), response);
456 json!({"toolResponse": {"functionResponses": [function_response]}})
457 }
458 ConversationItem::AudioMessage { .. } => JsonValue::Null,
459 #[allow(unreachable_patterns, reason = "ConversationItem is non-exhaustive")]
460 _ => return Err(ProviderError::unsupported("realtime conversation item")),
461 },
462 RealtimeClientEvent::InputAudioClear
463 | RealtimeClientEvent::ResponseCreate { .. }
464 | RealtimeClientEvent::ResponseCancel
465 | RealtimeClientEvent::ConversationItemTruncate { .. } => JsonValue::Null,
466 #[allow(unreachable_patterns, reason = "RealtimeClientEvent is non-exhaustive")]
467 _ => return Err(ProviderError::unsupported("realtime client event")),
468 })
469}
470
471#[derive(Debug)]
473pub struct GoogleRealtimeModel {
474 config: SharedConfig,
475 provider: ProviderId,
476 model_id: ModelId,
477 state: Mutex<MapperState>,
478}
479
480impl GoogleRealtimeModel {
481 #[must_use]
483 pub fn new(config: SharedConfig, model_id: impl Into<ModelId>) -> Self {
484 Self {
485 provider: config.provider_id(FAMILY),
486 config,
487 model_id: model_id.into(),
488 state: Mutex::new(MapperState::default()),
489 }
490 }
491
492 #[must_use]
494 pub fn session_url(&self) -> Url {
495 self.config.websocket_url(WEBSOCKET_SERVICE_PATH)
496 }
497
498 fn lock(&self) -> std::sync::MutexGuard<'_, MapperState> {
499 self.state
500 .lock()
501 .unwrap_or_else(std::sync::PoisonError::into_inner)
502 }
503}
504
505impl RealtimeModel for GoogleRealtimeModel {
506 fn provider(&self) -> &ProviderId {
507 &self.provider
508 }
509
510 fn model_id(&self) -> &ModelId {
511 &self.model_id
512 }
513
514 #[tracing::instrument(skip_all, fields(model = %self.model_id))]
515 async fn do_create_client_secret(
516 &self,
517 options: ClientSecretOptions,
518 ) -> Result<ClientSecret, ProviderError> {
519 let api_key = self.config.api_key()?;
520 let now = Utc::now();
521 let window = i64::try_from(
522 options
523 .expires_after_seconds
524 .unwrap_or(DEFAULT_EXPIRES_AFTER_SECONDS),
525 )
526 .unwrap_or(i64::MAX / 4);
527 let new_session_expire_time = now + Duration::seconds(window);
528 let expire_time = new_session_expire_time + Duration::minutes(30);
529 let body = json!({
530 "uses": 0,
531 "expireTime": expire_time.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
532 "newSessionExpireTime": new_session_expire_time.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
533 "bidiGenerateContentSetup": build_session_config(
534 options.session_config.as_ref(),
535 self.model_id.as_str(),
536 )?,
537 });
538 let mut url = self.config.origin_url(AUTH_TOKENS_PATH);
539 url.query_pairs_mut()
540 .append_pair("key", api_key.expose_secret());
541 let mut headers = self.config.headers.clone();
542 headers = headers.with_user_agent_suffix([crate::config::USER_AGENT]);
543 let handlers = ResponseHandlers::new(
544 json_response_handler::<AuthTokenResponse>(),
545 failed_response_handler(),
546 );
547 let response = post_json(
548 self.config.transport.as_ref(),
549 url,
550 headers,
551 &body,
552 &handlers,
553 CancellationToken::new(),
554 )
555 .await?;
556 let expires_at = response
557 .value
558 .expire_time
559 .as_deref()
560 .and_then(|time| chrono::DateTime::parse_from_rfc3339(time).ok())
561 .and_then(|time| u64::try_from(time.timestamp()).ok());
562 Ok(ClientSecret {
563 token: response.value.name,
564 url: self.session_url(),
565 expires_at,
566 })
567 }
568
569 fn websocket_config(&self, token: &str, url: &Url) -> WebSocketConfig {
570 let mut url = url.clone();
571 url.query_pairs_mut().append_pair("access_token", token);
572 WebSocketConfig {
573 url,
574 protocols: Vec::new(),
575 }
576 }
577
578 fn parse_server_event(
579 &self,
580 raw: JsonValue,
581 ) -> Result<Vec<RealtimeServerEvent>, ProviderError> {
582 let mut state = self.lock();
583 Ok(parse_event(&mut state, &raw))
584 }
585
586 async fn serialize_client_event(
587 &self,
588 event: RealtimeClientEvent,
589 ) -> Result<JsonValue, ProviderError> {
590 let mut state = self.lock();
591 serialize_event(&mut state, event, self.model_id.as_str())
592 }
593
594 fn build_session_config(
595 &self,
596 config: &RealtimeSessionConfig,
597 ) -> Result<JsonValue, ProviderError> {
598 build_session_config(Some(config), self.model_id.as_str())
599 }
600}
601
602#[derive(Debug, Clone)]
604pub struct GoogleRealtimeFactory {
605 config: SharedConfig,
606 provider: ProviderId,
607}
608
609impl GoogleRealtimeFactory {
610 #[must_use]
612 pub fn new(config: SharedConfig) -> Self {
613 Self {
614 provider: config.provider_id(FAMILY),
615 config,
616 }
617 }
618
619 #[must_use]
621 pub fn realtime_model(&self, model_id: &str) -> GoogleRealtimeModel {
622 GoogleRealtimeModel::new(self.config.clone(), model_id)
623 }
624}
625
626impl RealtimeFactory for GoogleRealtimeFactory {
627 fn provider(&self) -> &ProviderId {
628 &self.provider
629 }
630
631 fn model(&self, model_id: &str) -> Result<RealtimeModelRef, NoSuchModelError> {
632 Ok(self.realtime_model(model_id).into())
633 }
634
635 async fn get_token(&self, options: GetTokenOptions) -> Result<ClientSecret, ProviderError> {
636 self.realtime_model(options.model.as_str())
637 .do_create_client_secret(ClientSecretOptions {
638 expires_after_seconds: options.expires_after_seconds,
639 session_config: options.session_config,
640 })
641 .await
642 }
643}
644
645#[must_use]
648pub fn token_request_headers(config: &GoogleConfig) -> Headers {
649 config
650 .headers
651 .clone()
652 .with_user_agent_suffix([crate::config::USER_AGENT])
653}