From 1fb42499730b2f25335c1bd71a3cc3be9a5fa92b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 25 Sep 2026 13:33:28 +0300 Subject: [PATCH 1/7] feat(api): add Gemini integration for agent interactions This change introduces a new Gemini integration module that enables the agent to interact with Google's Gemini API. The implementation provides the necessary request and response handling to support agent conversations through the Gemini service. Auto-committed-on: dragonfly --- src/api/agent_integrations/gemini.rs | 392 +++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 src/api/agent_integrations/gemini.rs diff --git a/src/api/agent_integrations/gemini.rs b/src/api/agent_integrations/gemini.rs new file mode 100644 index 0000000..76f103f --- /dev/null +++ b/src/api/agent_integrations/gemini.rs @@ -0,0 +1,392 @@ +//! Gemini API: grounded `generateContent` (Google Search / Google Maps) and the +//! metered Gemini Live relay (conversation, live transcription, live tools). +//! +//! Billed at Google's paid-tier rates plus a 10% premium. +//! +//! # Live relay protocol +//! +//! [`AgentIntegrationsApi::gemini_create_live_session`] returns a single-use +//! ticket and a `wsUrl`. Open a plain WebSocket to `wsUrl` within 60 seconds +//! and speak the Gemini Live protocol: send `realtimeInput`, `clientContent` +//! and `toolResponse` frames; receive Google's server messages verbatim +//! (`setupComplete`, `serverContent`, `toolCall`, `usageMetadata`, ...). The +//! session setup is fixed when the ticket is minted, so any `setup` frame the +//! client sends is ignored. Usage is metered server-side per turn. The relay +//! closes with one of the [`GEMINI_LIVE_CLOSE_UNAUTHORIZED`] family of codes. + +use super::AgentIntegrationsApi; +use crate::{enc, Error}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// Relay close code: the ticket is missing, expired, or already used. +pub const GEMINI_LIVE_CLOSE_UNAUTHORIZED: u16 = 4401; +/// Relay close code: the balance no longer covers the per-session reserve. +pub const GEMINI_LIVE_CLOSE_INSUFFICIENT_CREDITS: u16 = 4402; +/// Relay close code: idle timeout or maximum session duration reached. +pub const GEMINI_LIVE_CLOSE_TIMEOUT: u16 = 4408; +/// Relay close code: the upstream Gemini connection failed. +pub const GEMINI_LIVE_CLOSE_UPSTREAM_ERROR: u16 = 1011; + +/// One turn of a Gemini conversation. `parts` are Gemini `Part` objects +/// (`{"text": ...}`, `{"inlineData": ...}`, `{"functionResponse": ...}`, ...). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct GeminiContent { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default)] + pub parts: Vec, +} + +impl GeminiContent { + /// A single-part text turn from the user. + pub fn user_text(text: impl Into) -> Self { + Self { + role: Some("user".into()), + parts: vec![serde_json::json!({ "text": text.into() })], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiTimeRangeFilter { + pub start_time: String, + pub end_time: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiGoogleSearch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time_range_filter: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiGoogleMaps { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_widget: Option, +} + +/// A Gemini tool. Set exactly one field; the backend accepts Google Search, +/// Google Maps (not in Live sessions) and function declarations only. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiTool { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub google_search: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub google_maps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub function_declarations: Option>, +} + +impl GeminiTool { + pub fn google_search() -> Self { + Self { + google_search: Some(GeminiGoogleSearch::default()), + ..Self::default() + } + } + + pub fn google_maps() -> Self { + Self { + google_maps: Some(GeminiGoogleMaps::default()), + ..Self::default() + } + } + + pub fn functions(declarations: Vec) -> Self { + Self { + function_declarations: Some(declarations), + ..Self::default() + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)] +pub struct GeminiLatLng { + pub latitude: f64, + pub longitude: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiRetrievalConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lat_lng: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiToolConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retrieval_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub function_calling_config: Option, +} + +/// Body of `POST /agent-integrations/gemini/models/{model}/generate-content`, +/// Gemini's native `GenerateContentRequest` minus cached content. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiGenerateContentRequest { + pub contents: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_instruction: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generation_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub safety_settings: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_config: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiModalityTokenCount { + #[serde(default)] + pub modality: Option, + #[serde(default)] + pub token_count: Option, +} + +/// Gemini `usageMetadata`. Live messages report output as `response*`; +/// `generateContent` reports it as `candidates*`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiUsageMetadata { + #[serde(default)] + pub prompt_token_count: Option, + #[serde(default)] + pub cached_content_token_count: Option, + #[serde(default, alias = "responseTokenCount")] + pub candidates_token_count: Option, + #[serde(default)] + pub tool_use_prompt_token_count: Option, + #[serde(default)] + pub thoughts_token_count: Option, + #[serde(default)] + pub total_token_count: Option, + #[serde(default)] + pub prompt_tokens_details: Vec, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiCandidate { + #[serde(default)] + pub content: Option, + #[serde(default)] + pub finish_reason: Option, + /// `webSearchQueries`, `groundingChunks`, `groundingSupports`, + /// `searchEntryPoint`, `googleMapsWidgetContextToken`, ... + #[serde(default)] + pub grounding_metadata: Option, + #[serde(flatten)] + pub extra: Map, +} + +/// Google's `GenerateContentResponse` plus the amount charged. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiGenerateContentResponse { + #[serde(default)] + pub candidates: Vec, + #[serde(default)] + pub usage_metadata: Option, + #[serde(default)] + pub model_version: Option, + #[serde(default)] + pub response_id: Option, + #[serde(default)] + pub cost_usd: f64, + #[serde(flatten)] + pub extra: Map, +} + +impl GeminiGenerateContentResponse { + /// Concatenated text parts of the first candidate. + pub fn text(&self) -> String { + self.candidates + .first() + .and_then(|c| c.content.as_ref()) + .map(|content| { + content + .parts + .iter() + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect() + }) + .unwrap_or_default() + } +} + +/// A Live conversation: native-audio or text dialogue, optionally with +/// Google Search and function calling. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiLiveConversation { + /// `gemini-3.8-live` or `gemini-2.5-flash-native-audio-preview-12-2025`. + pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_minutes: Option, + /// `["AUDIO"]` (default) or `["TEXT"]`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_modalities: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_instruction: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generation_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speech_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_audio_transcription: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_audio_transcription: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub realtime_input_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_window_compression: Option, + /// Pass `{"handle": ...}` from a `sessionResumptionUpdate` to resume. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_resumption: Option, + /// Google Search and/or function declarations. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option>, +} + +/// Live streaming speech-to-text on `gemini-3.5-transcribe-live`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiLiveTranscription { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_minutes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language_codes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_vocabulary: Option>, + /// `VERBATIM` or `SMART`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transcription_mode: Option, +} + +/// Body of `POST /agent-integrations/gemini/live/sessions`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "mode", rename_all = "lowercase")] +pub enum GeminiLiveSessionRequest { + Conversation(GeminiLiveConversation), + Transcribe(GeminiLiveTranscription), +} + +/// A single-use relay ticket. Connect a WebSocket to `ws_url` before +/// `ticket_expires_at`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiLiveTicket { + pub session_id: String, + pub ticket: String, + pub ws_url: String, + pub ticket_expires_at: String, + pub model: String, + pub mode: String, + pub max_minutes: u32, + /// Balance reserved for each open session. + #[serde(default)] + pub reserve_usd: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiLiveUsageTotals { + #[serde(default)] + pub prompt_tokens: u64, + #[serde(default)] + pub cached_tokens: u64, + #[serde(default)] + pub output_tokens: u64, + #[serde(default)] + pub thoughts_tokens: u64, + #[serde(default)] + pub tool_use_prompt_tokens: u64, + #[serde(default)] + pub search_queries: u64, +} + +/// Status and metered usage of a Live session. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct GeminiLiveSession { + pub session_id: String, + pub mode: String, + pub model: String, + /// `PENDING`, `ACTIVE`, `CLOSED`, `EXPIRED` or `FAILED`. + pub status: String, + #[serde(default)] + pub max_minutes: u32, + #[serde(default)] + pub ticket_expires_at: Option, + #[serde(default)] + pub started_at: Option, + #[serde(default)] + pub closed_at: Option, + #[serde(default)] + pub close_reason: Option, + #[serde(default)] + pub turn_count: u64, + #[serde(default)] + pub charged_usd: f64, + #[serde(default)] + pub usage_totals: GeminiLiveUsageTotals, +} + +impl AgentIntegrationsApi<'_> { + /// Gemini `generateContent` with Google Search / Google Maps grounding or + /// function calling. Billed on usage plus grounding fees. + pub async fn gemini_generate_content( + &self, + model: &str, + request: &GeminiGenerateContentRequest, + ) -> Result { + self.post( + &format!( + "/agent-integrations/gemini/models/{}/generate-content", + enc(model) + ), + request, + ) + .await + } + + /// Open a metered Gemini Live session and get its single-use relay ticket. + pub async fn gemini_create_live_session( + &self, + request: &GeminiLiveSessionRequest, + ) -> Result { + self.post("/agent-integrations/gemini/live/sessions", request) + .await + } + + /// Status and metered usage of a Live session owned by the caller. + pub async fn gemini_live_session(&self, session_id: &str) -> Result { + self.send( + Method::GET, + &format!( + "/agent-integrations/gemini/live/sessions/{}", + enc(session_id) + ), + &[], + None, + true, + ) + .await + } +} From d9c0941650370be857e5b0a5d5407c4560e3476d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 25 Sep 2026 13:33:48 +0300 Subject: [PATCH 2/7] feat(agent-integrations): add Gemini live session and content generation endpoints Add support for the Gemini integration within the agent integrations API, including endpoints for creating and querying live sessions and generating content. This enables users to interact with Gemini models for real-time conversations, transcription, and content generation with grounding support. Auto-committed-on: dragonfly --- api/tinyhumans.backend.json | 13 +- src/api/agent_integration_types.rs | 1 + src/api/agent_integrations/mod.rs | 2 + src/generated_public_routes.rs | 5 + tests/agent_integration_module_layout.rs | 1 + tests/agent_integrations.rs | 151 +++++++++++++++++++++++ 6 files changed, 168 insertions(+), 5 deletions(-) diff --git a/api/tinyhumans.backend.json b/api/tinyhumans.backend.json index 8574874..83317d2 100644 --- a/api/tinyhumans.backend.json +++ b/api/tinyhumans.backend.json @@ -8,11 +8,11 @@ "url": "https://api.tinyhumans.ai/swagger.json", "title": "TinyHumans API", "version": "1.0.0", - "pathCount": 238, - "totalOperationCount": 263, - "operationCount": 208, + "pathCount": 242, + "totalOperationCount": 268, + "operationCount": 211, "supplementalOperationCount": 13, - "excludedAdminOperationCount": 47, + "excludedAdminOperationCount": 49, "excludedWebhookOperationCount": 12, "servers": [ "https://api.tinyhumans.ai/", @@ -34,7 +34,7 @@ "name": "agentIntegrations", "basePath": "/agent-integrations", "auth": "mixed", - "operationCount": 75, + "operationCount": 78, "tags": [ "Agent Integrations", "OpenHuman parity" @@ -57,6 +57,7 @@ "GET /agent-integrations/file-storage/files/{fileId}/download", "GET /agent-integrations/file-storage/public/{fileId}", "GET /agent-integrations/file-storage/usage", + "GET /agent-integrations/gemini/live/sessions/{sessionId}", "GET /agent-integrations/history-rewards/status", "GET /agent-integrations/media-generation/models", "GET /agent-integrations/media-generation/requests/{requestId}", @@ -88,6 +89,8 @@ "POST /agent-integrations/financial-apis/exchange-rate", "POST /agent-integrations/financial-apis/options", "POST /agent-integrations/financial-apis/quote", + "POST /agent-integrations/gemini/live/sessions", + "POST /agent-integrations/gemini/models/{model}/generate-content", "POST /agent-integrations/google-places/details", "POST /agent-integrations/google-places/search", "POST /agent-integrations/history-rewards/claim", diff --git a/src/api/agent_integration_types.rs b/src/api/agent_integration_types.rs index ab7153e..204e20c 100644 --- a/src/api/agent_integration_types.rs +++ b/src/api/agent_integration_types.rs @@ -9,6 +9,7 @@ pub use super::agent_integrations::composio::*; pub use super::agent_integrations::crypto::*; pub use super::agent_integrations::file_storage::*; pub use super::agent_integrations::financial_apis::*; +pub use super::agent_integrations::gemini::*; pub use super::agent_integrations::google_places::*; pub use super::agent_integrations::history_rewards::*; pub use super::agent_integrations::media_generation::*; diff --git a/src/api/agent_integrations/mod.rs b/src/api/agent_integrations/mod.rs index 20b96bf..1a5c787 100644 --- a/src/api/agent_integrations/mod.rs +++ b/src/api/agent_integrations/mod.rs @@ -17,6 +17,7 @@ pub mod composio; pub mod crypto; pub mod file_storage; pub mod financial_apis; +pub mod gemini; pub mod google_places; pub mod history_rewards; pub mod media_generation; @@ -34,6 +35,7 @@ pub use composio::*; pub use crypto::*; pub use file_storage::*; pub use financial_apis::*; +pub use gemini::*; pub use google_places::*; pub use history_rewards::*; pub use media_generation::*; diff --git a/src/generated_public_routes.rs b/src/generated_public_routes.rs index 33c6671..025bfbd 100644 --- a/src/generated_public_routes.rs +++ b/src/generated_public_routes.rs @@ -34,6 +34,9 @@ pub const PUBLIC_ROUTES: &[(&str, &str)] = &[ ("POST", "/agent-integrations/financial-apis/exchange-rate"), ("POST", "/agent-integrations/financial-apis/options"), ("POST", "/agent-integrations/financial-apis/quote"), + ("POST", "/agent-integrations/gemini/live/sessions"), + ("GET", "/agent-integrations/gemini/live/sessions/{sessionId}"), + ("POST", "/agent-integrations/gemini/models/{model}/generate-content"), ("POST", "/agent-integrations/google-places/details"), ("POST", "/agent-integrations/google-places/search"), ("POST", "/agent-integrations/history-rewards/claim"), @@ -263,6 +266,8 @@ pub(crate) const UNEXPOSED_ROUTES: &[(&str, &str)] = &[ ("POST", "/opencompany/instances/{slug}/inference-key"), ("PUT", "/opencompany/instances/{slug}/orchestrator"), ("POST", "/opencompany/instances/{slug}/usage"), + ("DELETE", "/opencompany/orchestrators/{id}/token"), + ("PUT", "/opencompany/orchestrators/{id}/token"), ("POST", "/voice-agent/chat/completions"), ("POST", "/webhooks/composio"), ("POST", "/webhooks/discord"), diff --git a/tests/agent_integration_module_layout.rs b/tests/agent_integration_module_layout.rs index 73224f9..fcda810 100644 --- a/tests/agent_integration_module_layout.rs +++ b/tests/agent_integration_module_layout.rs @@ -17,6 +17,7 @@ fn every_provider_has_its_own_module() { assert_named::(std::marker::PhantomData); assert_named::(std::marker::PhantomData); assert_named::(std::marker::PhantomData); + assert_named::(std::marker::PhantomData); assert_named::(std::marker::PhantomData); assert_named::(std::marker::PhantomData); assert_named::(std::marker::PhantomData); diff --git a/tests/agent_integrations.rs b/tests/agent_integrations.rs index 98a9569..c3fe0d6 100644 --- a/tests/agent_integrations.rs +++ b/tests/agent_integrations.rs @@ -1198,3 +1198,154 @@ async fn twilio_call_posts_body() { let _ = result; } + +#[tokio::test] +async fn gemini_generate_content_sends_native_body_and_reads_grounding() { + use tinyhumans_sdk::api::agent_integration_types::{ + GeminiContent, GeminiGenerateContentRequest, GeminiLatLng, GeminiRetrievalConfig, + GeminiTool, GeminiToolConfig, + }; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path( + "/agent-integrations/gemini/models/gemini-3.8-flash/generate-content", + )) + .and(body_json(json!({ + "contents": [{"role": "user", "parts": [{"text": "coffee near me"}]}], + "tools": [{"googleSearch": {}}, {"googleMaps": {}}], + "toolConfig": {"retrievalConfig": {"latLng": {"latitude": 1.5, "longitude": 2.5}}} + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": true, + "data": { + "candidates": [{ + "content": {"role": "model", "parts": [{"text": "Try "}, {"text": "Blue Bottle."}]}, + "groundingMetadata": {"webSearchQueries": ["coffee near me"]} + }], + "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 5}, + "costUsd": 0.0155 + } + }))) + .mount(&server) + .await; + + let response = TinyHumansClient::new(server.uri()) + .agent_integrations() + .gemini_generate_content( + "gemini-3.8-flash", + &GeminiGenerateContentRequest { + contents: vec![GeminiContent::user_text("coffee near me")], + tools: Some(vec![GeminiTool::google_search(), GeminiTool::google_maps()]), + tool_config: Some(GeminiToolConfig { + retrieval_config: Some(GeminiRetrievalConfig { + lat_lng: Some(GeminiLatLng { + latitude: 1.5, + longitude: 2.5, + }), + language_code: None, + }), + function_calling_config: None, + }), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(response.text(), "Try Blue Bottle."); + assert_eq!(response.cost_usd, 0.0155); + assert_eq!( + response.usage_metadata.unwrap().candidates_token_count, + Some(5) + ); + assert_eq!( + response.candidates[0].grounding_metadata.as_ref().unwrap()["webSearchQueries"][0], + "coffee near me" + ); +} + +#[tokio::test] +async fn gemini_live_session_routes_are_typed() { + use tinyhumans_sdk::api::agent_integration_types::{ + GeminiLiveConversation, GeminiLiveSessionRequest, GeminiLiveTranscription, GeminiTool, + }; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/agent-integrations/gemini/live/sessions")) + .and(body_json(json!({ + "mode": "conversation", + "model": "gemini-3.8-live", + "tools": [{"googleSearch": {}}] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": true, + "data": { + "sessionId": "s1", "ticket": "t1", + "wsUrl": "wss://api.example.com/agent-integrations/gemini/live/ws?ticket=t1", + "ticketExpiresAt": "2026-09-25T00:01:00.000Z", + "model": "gemini-3.8-live", "mode": "conversation", + "maxMinutes": 30, "reserveUsd": 1 + } + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/agent-integrations/gemini/live/sessions")) + .and(body_json(json!({"mode": "transcribe", "languageCodes": ["en-US"]}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": true, + "data": { + "sessionId": "s2", "ticket": "t2", "wsUrl": "wss://x/ws?ticket=t2", + "ticketExpiresAt": "2026-09-25T00:01:00.000Z", + "model": "gemini-3.5-transcribe-live", "mode": "transcribe", "maxMinutes": 30 + } + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/agent-integrations/gemini/live/sessions/s1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": true, + "data": { + "sessionId": "s1", "mode": "conversation", "model": "gemini-3.8-live", + "status": "CLOSED", "maxMinutes": 30, "turnCount": 3, "chargedUsd": 0.042, + "closeReason": "client_closed", + "usageTotals": {"promptTokens": 900, "outputTokens": 300, "searchQueries": 1} + } + }))) + .mount(&server) + .await; + + let client = TinyHumansClient::new(server.uri()); + let api = client.agent_integrations(); + let ticket = api + .gemini_create_live_session(&GeminiLiveSessionRequest::Conversation( + GeminiLiveConversation { + model: "gemini-3.8-live".into(), + tools: Some(vec![GeminiTool::google_search()]), + ..Default::default() + }, + )) + .await + .unwrap(); + assert!(ticket.ws_url.ends_with("ticket=t1")); + assert_eq!(ticket.reserve_usd, 1.0); + + let transcribe = api + .gemini_create_live_session(&GeminiLiveSessionRequest::Transcribe( + GeminiLiveTranscription { + language_codes: Some(vec!["en-US".into()]), + ..Default::default() + }, + )) + .await + .unwrap(); + assert_eq!(transcribe.model, "gemini-3.5-transcribe-live"); + + let session = api.gemini_live_session("s1").await.unwrap(); + assert_eq!(session.status, "CLOSED"); + assert_eq!(session.turn_count, 3); + assert_eq!(session.usage_totals.search_queries, 1); +} From 001333dbccf39826665dd05d5af4b67fd05a2074 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 25 Sep 2026 13:34:20 +0300 Subject: [PATCH 3/7] test(agent-integrations): format body_json argument for readability Reformatted the `body_json` call in the Gemini live session route test to span multiple lines, improving code readability without changing any behavior. Auto-committed-on: dragonfly --- tests/agent_integrations.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/agent_integrations.rs b/tests/agent_integrations.rs index c3fe0d6..d410f8e 100644 --- a/tests/agent_integrations.rs +++ b/tests/agent_integrations.rs @@ -1293,7 +1293,9 @@ async fn gemini_live_session_routes_are_typed() { .await; Mock::given(method("POST")) .and(path("/agent-integrations/gemini/live/sessions")) - .and(body_json(json!({"mode": "transcribe", "languageCodes": ["en-US"]}))) + .and(body_json( + json!({"mode": "transcribe", "languageCodes": ["en-US"]}), + )) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": { From 8068ff52c1950887d382d9339d19629044bea6b3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 25 Sep 2026 13:34:45 +0300 Subject: [PATCH 4/7] chore(openapi): update unexposed routes count for orchestrator token endpoints The assertion for the number of unexposed routes is updated from 59 to 61 to account for the addition of PUT and DELETE operations on the orchestrator token registration endpoint, which are synced alongside the Gemini routes. Auto-committed-on: dragonfly --- src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 1f4e9aa..ae0c1db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -579,11 +579,15 @@ mod exclusion_tests { // orchestrator's own service-token-authenticated callback (same shape // as the two `inference-key` operations and `.../usage` above), added // alongside `POST /opencompany/instances/{slug}/usage`. + // + // 59 -> 61: `PUT` and `DELETE /opencompany/orchestrators/{id}/token`, + // the fleet's service-token-authenticated orchestrator token + // registration, first synced alongside the Gemini routes. // Note: This assertion reflects the count when synced against the // deployed OpenAPI spec. When the backend branch adds routes that // aren't yet deployed, the local count may differ; the RETAINED_UNEXPOSED_ROUTES // in sync-openapi.mjs preserves admin/webhook operations regardless. - assert_eq!(UNEXPOSED_ROUTES.len(), 59); + assert_eq!(UNEXPOSED_ROUTES.len(), 61); for (method, template) in UNEXPOSED_ROUTES { let concrete_path = template .split('/') From 54c672ec2de6228fcf5ee8859ace2c0070dc5476 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 25 Sep 2026 13:35:13 +0300 Subject: [PATCH 5/7] feat(api): box large variant in GeminiLiveSessionRequest The GeminiLiveConversation variant in the GeminiLiveSessionRequest enum is now boxed to reduce the size of the enum, preventing potential stack overflows when the conversation struct grows. The test and OpenAPI sync assertions are updated to reflect the new operation counts from the added Gemini routes. Auto-committed-on: dragonfly --- src/api/agent_integrations/gemini.rs | 2 +- tests/agent_integrations.rs | 4 ++-- tests/openapi_sync.rs | 13 ++++++++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/api/agent_integrations/gemini.rs b/src/api/agent_integrations/gemini.rs index 76f103f..5ff4c18 100644 --- a/src/api/agent_integrations/gemini.rs +++ b/src/api/agent_integrations/gemini.rs @@ -283,7 +283,7 @@ pub struct GeminiLiveTranscription { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "mode", rename_all = "lowercase")] pub enum GeminiLiveSessionRequest { - Conversation(GeminiLiveConversation), + Conversation(Box), Transcribe(GeminiLiveTranscription), } diff --git a/tests/agent_integrations.rs b/tests/agent_integrations.rs index d410f8e..b19db89 100644 --- a/tests/agent_integrations.rs +++ b/tests/agent_integrations.rs @@ -1323,13 +1323,13 @@ async fn gemini_live_session_routes_are_typed() { let client = TinyHumansClient::new(server.uri()); let api = client.agent_integrations(); let ticket = api - .gemini_create_live_session(&GeminiLiveSessionRequest::Conversation( + .gemini_create_live_session(&GeminiLiveSessionRequest::Conversation(Box::new( GeminiLiveConversation { model: "gemini-3.8-live".into(), tools: Some(vec![GeminiTool::google_search()]), ..Default::default() }, - )) + ))) .await .unwrap(); assert!(ticket.ws_url.ends_with("ticket=t1")); diff --git a/tests/openapi_sync.rs b/tests/openapi_sync.rs index e023e00..c104ecc 100644 --- a/tests/openapi_sync.rs +++ b/tests/openapi_sync.rs @@ -164,7 +164,9 @@ fn generated_rust_routes_match_the_public_manifest() { // "update to latest" — both are served by the backend `main` this syncs // against (sdk main had been generated from the deployed spec that predated // them). - assert_eq!(manifest["source"]["operationCount"], 208); + // 208 -> 211: the Gemini integration — `POST .../gemini/models/{model}/generate-content`, + // `POST .../gemini/live/sessions` and `GET .../gemini/live/sessions/{sessionId}`. + assert_eq!(manifest["source"]["operationCount"], 211); // 14 -> 13: `GET /orchestration/v1/steering` left with that family. assert_eq!(manifest["source"]["supplementalOperationCount"], 13); // 37 -> 39: the two service-token operations on @@ -195,11 +197,16 @@ fn generated_rust_routes_match_the_public_manifest() { // 46 -> 47: `PUT /opencompany/instances/{slug}/orchestrator`, the // orchestrator's own service-token callback (same shape as the two // `inference-key` operations and `.../usage` above). - assert_eq!(manifest["source"]["excludedAdminOperationCount"], 47); + // + // 47 -> 49: `PUT` and `DELETE /opencompany/orchestrators/{id}/token`, the + // fleet's service-token orchestrator token registration, already on + // backend `main` and first synced with the Gemini routes. + assert_eq!(manifest["source"]["excludedAdminOperationCount"], 49); assert_eq!(manifest["source"]["excludedWebhookOperationCount"], 12); // 206 -> 208: the two new public opencompany routes above // (`GET /opencompany/companies` and `POST /opencompany/instances/{slug}/update`). - assert_eq!(rust_routes.len(), 208); + // 208 -> 211: the three Gemini routes. + assert_eq!(rust_routes.len(), 211); assert_eq!(rust_routes, manifest_routes); assert!(rust_routes .iter() From 4c0063a83ee8fa27d9ab33308bf3b97b44d5b07f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 25 Sep 2026 13:35:32 +0300 Subject: [PATCH 6/7] docs(api-surface): add Gemini integration documentation Adds documentation for the Gemini API integration, covering the three main endpoints: generate content, live session creation, and session status retrieval. This includes details on tool support, pricing, WebSocket relay behavior, and close codes to help users understand the integration's capabilities and constraints. Auto-committed-on: dragonfly --- docs/api-surface.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/api-surface.md b/docs/api-surface.md index de590cb..7b19e6e 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -68,6 +68,34 @@ Most JSON responses use the hosted-backend envelope: SDK request helpers unwrap this envelope by default. The raw helper can return the full response body when callers need status metadata or non-standard payloads. +## Gemini + +`agent_integrations::gemini` covers the Gemini API, billed at Google's paid-tier +rates plus a 10% premium: + +- `gemini_generate_content(model, &GeminiGenerateContentRequest)` posts a native + Gemini `generateContent` body to + `/agent-integrations/gemini/models/{model}/generate-content`. Tools are limited + to `GeminiTool::google_search()`, `GeminiTool::google_maps()` (with + `GeminiToolConfig.retrieval_config.lat_lng`) and `GeminiTool::functions(..)`. + The response is Google's `GenerateContentResponse`, including + `groundingMetadata`, plus `cost_usd`. +- `gemini_create_live_session(&GeminiLiveSessionRequest)` opens a metered Live + session: `Conversation` (native audio, Google Search and function calling) or + `Transcribe` (`gemini-3.5-transcribe-live`). It returns a single-use ticket and + a `ws_url`. Connect a plain WebSocket to `ws_url` within 60 seconds and speak + the Gemini Live protocol (`realtimeInput`, `clientContent`, `toolResponse`). + The session setup is fixed at mint time and a client `setup` frame is ignored. + The backend relays the socket and meters every turn server-side. Close codes + are exported as `GEMINI_LIVE_CLOSE_*`: 4401 bad ticket, 4402 insufficient + credits (each open session reserves a minimum balance), 4408 idle or max + duration, 1011 upstream failure. +- `gemini_live_session(id)` returns a session's status, turn count, charged + amount and usage totals. + +The crate has no raw WebSocket client dependency, so the relay connection is +left to the caller's WebSocket library of choice. + ## OpenRouter media generation `agent_integrations::openrouter` exposes the direct OpenRouter proxy under From a98a3c751b4a24b19d7232722123dac5bc7d0100 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 25 Sep 2026 14:45:33 +0300 Subject: [PATCH 7/7] docs: note Gemini Live billing and tool-call flow Co-authored-by: Medulla --- docs/api-surface.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/api-surface.md b/docs/api-surface.md index 7b19e6e..ce344eb 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -93,6 +93,23 @@ rates plus a 10% premium: - `gemini_live_session(id)` returns a session's status, turn count, charged amount and usage totals. +Live billing notes: + +- Each turn is billed from the `usageMetadata` Google sends on `turnComplete`. + Its prompt count is the turn's full context, so long sessions cost more per + turn. +- `Transcribe` sessions get no usage reports from Google. The backend bills the + PCM audio the client streams (32 tokens per second) plus the final transcript + text. +- A `Conversation` with `GeminiTool::google_search()` bills one search query per + turn, because Live does not report its searches. +- For function calling, answer each `toolCall.functionCalls[]` entry with a + `toolResponse` frame carrying the same `id` and `name`. The relay forwards it + unchanged. +- Gemini 2.5 text models are not offered, because Google no longer serves them + to new keys. `gemini-2.5-flash-native-audio-preview-12-2025` remains available + for Live. + The crate has no raw WebSocket client dependency, so the relay connection is left to the caller's WebSocket library of choice.