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/docs/api-surface.md b/docs/api-surface.md index de590cb..ce344eb 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -68,6 +68,51 @@ 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. + +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. + ## OpenRouter media generation `agent_integrations::openrouter` exposes the direct OpenRouter proxy under 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/gemini.rs b/src/api/agent_integrations/gemini.rs new file mode 100644 index 0000000..5ff4c18 --- /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(Box), + 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 + } +} 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/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('/') 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..b19db89 100644 --- a/tests/agent_integrations.rs +++ b/tests/agent_integrations.rs @@ -1198,3 +1198,156 @@ 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(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")); + 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); +} 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()