1use crate::attachment;
2use crate::retry::{RetryConfig, execute_with_retry, is_retryable_model_error};
3use adk_core::{
4 CacheCapable, CitationMetadata, CitationSource, Content, ErrorCategory, ErrorComponent,
5 FinishReason, Llm, LlmRequest, LlmResponse, LlmResponseStream, Part, Result, SchemaAdapter,
6 SchemaCache, UsageMetadata,
7};
8use adk_gemini::Gemini;
9use adk_gemini::schema_adapter::GeminiSchemaAdapter;
10use async_trait::async_trait;
11use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
12use futures::TryStreamExt;
13
14#[cfg(feature = "gemini-interactions")]
15use super::interactions_target::InteractionTarget;
16
17#[cfg(feature = "gemini-interactions")]
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum GeminiTransport {
29 #[default]
31 GenerateContent,
32 Interactions,
34}
35
36#[cfg(feature = "gemini-interactions")]
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum BackgroundMode {
47 #[default]
51 AgentTargetsOnly,
52 Always,
54 Never,
56}
57
58#[cfg(feature = "gemini-interactions")]
69#[derive(Debug, Clone)]
70pub struct InteractionOptions {
71 pub store: bool,
73 pub stateful: bool,
76 pub background: BackgroundMode,
78 pub poll_interval: std::time::Duration,
80}
81
82#[cfg(feature = "gemini-interactions")]
83impl Default for InteractionOptions {
84 fn default() -> Self {
85 Self {
86 store: true,
87 stateful: true,
88 background: BackgroundMode::AgentTargetsOnly,
89 poll_interval: std::time::Duration::from_secs(1),
90 }
91 }
92}
93
94pub struct GeminiModel {
96 client: Gemini,
97 model_name: String,
98 retry_config: RetryConfig,
99 thinking_config: Option<adk_gemini::ThinkingConfig>,
105 #[cfg(feature = "gemini-interactions")]
109 transport: GeminiTransport,
110 #[cfg(feature = "gemini-interactions")]
113 interaction_target: Option<InteractionTarget>,
114 #[cfg(feature = "gemini-interactions")]
116 interaction_options: InteractionOptions,
117 #[cfg(feature = "gemini-vertex")]
120 vertex_rag_store: Option<adk_gemini::VertexRagStore>,
121 #[cfg(feature = "gemini-vertex")]
124 vertex_backend: bool,
125}
126
127fn gemini_error_to_adk(e: &adk_gemini::ClientError) -> adk_core::AdkError {
129 fn format_error_chain(e: &dyn std::error::Error) -> String {
130 let mut msg = e.to_string();
131 let mut source = e.source();
132 while let Some(s) = source {
133 msg.push_str(": ");
134 msg.push_str(&s.to_string());
135 source = s.source();
136 }
137 msg
138 }
139
140 let message = format_error_chain(e);
141
142 let (category, code, status_code) = if message.contains("code 429")
145 || message.contains("RESOURCE_EXHAUSTED")
146 || message.contains("rate limit")
147 {
148 (ErrorCategory::RateLimited, "model.gemini.rate_limited", Some(429u16))
149 } else if message.contains("code 503") || message.contains("UNAVAILABLE") {
150 (ErrorCategory::Unavailable, "model.gemini.unavailable", Some(503))
151 } else if message.contains("code 529") || message.contains("OVERLOADED") {
152 (ErrorCategory::Unavailable, "model.gemini.overloaded", Some(529))
153 } else if message.contains("code 408")
154 || message.contains("DEADLINE_EXCEEDED")
155 || message.contains("TIMEOUT")
156 {
157 (ErrorCategory::Timeout, "model.gemini.timeout", Some(408))
158 } else if message.contains("code 401") || message.contains("Invalid API key") {
159 (ErrorCategory::Unauthorized, "model.gemini.unauthorized", Some(401))
160 } else if message.contains("code 400") {
161 (ErrorCategory::InvalidInput, "model.gemini.bad_request", Some(400))
162 } else if message.contains("code 404") {
163 (ErrorCategory::NotFound, "model.gemini.not_found", Some(404))
164 } else if message.contains("invalid generation config") {
165 (ErrorCategory::InvalidInput, "model.gemini.invalid_config", None)
166 } else {
167 (ErrorCategory::Internal, "model.gemini.internal", None)
168 };
169
170 let mut err = adk_core::AdkError::new(ErrorComponent::Model, category, code, message)
171 .with_provider("gemini");
172 if let Some(sc) = status_code {
173 err = err.with_upstream_status(sc);
174 }
175 err
176}
177
178#[cfg(feature = "gemini-interactions")]
189fn interaction_status_to_result(status: adk_gemini::interactions::InteractionStatus) -> Result<()> {
190 use adk_gemini::interactions::InteractionStatus;
191 match status {
192 InteractionStatus::Failed => Err(interaction_terminal_error(
193 "model.gemini.interactions.failed",
194 "the Gemini interaction terminated with status `failed`",
195 )),
196 InteractionStatus::BudgetExceeded => Err(interaction_terminal_error(
197 "model.gemini.interactions.budget_exceeded",
198 "the Gemini interaction terminated with status `budget_exceeded`",
199 )),
200 InteractionStatus::InProgress
201 | InteractionStatus::RequiresAction
202 | InteractionStatus::Completed
203 | InteractionStatus::Cancelled
204 | InteractionStatus::Incomplete => Ok(()),
205 }
206}
207
208#[cfg(feature = "gemini-interactions")]
210fn interaction_terminal_error(code: &'static str, message: &str) -> adk_core::AdkError {
211 adk_core::AdkError::new(
212 ErrorComponent::Model,
213 ErrorCategory::Internal,
214 code,
215 message.to_string(),
216 )
217 .with_provider("gemini")
218}
219
220fn env_non_empty(name: &str) -> Option<String> {
223 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
224}
225
226fn env_flag_truthy(value: &str) -> bool {
229 let v = value.trim();
230 v == "1" || v.eq_ignore_ascii_case("true")
231}
232
233pub fn vertex_env_requested() -> bool {
248 if let Some(v) = env_non_empty("GOOGLE_GENAI_USE_ENTERPRISE") {
252 return env_flag_truthy(&v);
253 }
254 env_non_empty("GOOGLE_GENAI_USE_VERTEXAI").is_some_and(|v| env_flag_truthy(&v))
255}
256
257#[cfg(feature = "gemini-vertex")]
260fn vertex_target_from_env() -> Result<(String, String)> {
261 let project = env_non_empty("GOOGLE_CLOUD_PROJECT");
262 let location = env_non_empty("GOOGLE_CLOUD_LOCATION");
263 if let (Some(project), Some(location)) = (&project, &location) {
264 return Ok((project.clone(), location.clone()));
265 }
266 let mut missing = Vec::new();
267 if project.is_none() {
268 missing.push("GOOGLE_CLOUD_PROJECT");
269 }
270 if location.is_none() {
271 missing.push("GOOGLE_CLOUD_LOCATION");
272 }
273 Err(adk_core::AdkError::new(
274 ErrorComponent::Model,
275 ErrorCategory::InvalidInput,
276 "model.gemini.vertex_env_incomplete",
277 format!(
278 "Vertex AI backend selected via GOOGLE_GENAI_USE_ENTERPRISE/GOOGLE_GENAI_USE_VERTEXAI, but {} not set. Set the missing variable(s) to your Google Cloud project ID and region (e.g. us-central1).",
279 missing.join(" and ")
280 ),
281 )
282 .with_provider("gemini"))
283}
284
285impl GeminiModel {
286 fn gemini_part_thought_signature(value: &serde_json::Value) -> Option<String> {
287 value.get("thoughtSignature").and_then(serde_json::Value::as_str).map(str::to_string)
288 }
289
290 fn validate_request_contract(&self, req: &LlmRequest) -> Result<()> {
291 #[cfg(feature = "gemini-vertex")]
295 if self.vertex_rag_store.is_some() && !self.vertex_backend {
296 return Err(adk_core::AdkError::new(
297 ErrorComponent::Model,
298 ErrorCategory::Unsupported,
299 "model.gemini.rag_store_requires_vertex",
300 "Vertex RAG store grounding requires the Vertex AI backend. Construct the model with new_google_cloud/new_google_cloud_adc (or set GOOGLE_GENAI_USE_VERTEXAI with GOOGLE_CLOUD_PROJECT/GOOGLE_CLOUD_LOCATION) instead of an AI Studio API key.",
301 )
302 .with_provider("gemini"));
303 }
304
305 let model = self.model_name.strip_prefix("models/").unwrap_or(&self.model_name);
306 if !matches!(model, "gemini-3.6-flash" | "gemini-3.7-flash") {
307 return Ok(());
308 }
309
310 if let Some(config) = &req.config
311 && (config.temperature.is_some() || config.top_p.is_some() || config.top_k.is_some())
312 {
313 return Err(adk_core::AdkError::new(
314 ErrorComponent::Model,
315 ErrorCategory::InvalidInput,
316 "model.gemini.sampling_unsupported",
317 format!(
318 "{model} does not accept temperature, top_p, or top_k; remove explicit sampling parameters"
319 ),
320 )
321 .with_provider("gemini"));
322 }
323 if self.thinking_config.as_ref().is_some_and(|config| config.thinking_budget.is_some()) {
324 return Err(adk_core::AdkError::new(
325 ErrorComponent::Model,
326 ErrorCategory::InvalidInput,
327 "model.gemini.thinking_budget_unsupported",
328 format!(
329 "{model} uses thinking levels instead of token budgets; set thinking_level and clear thinking_budget"
330 ),
331 )
332 .with_provider("gemini"));
333 }
334 Ok(())
335 }
336
337 fn from_client(client: Gemini, model_name: String) -> Self {
343 Self {
344 client,
345 model_name,
346 retry_config: RetryConfig::default(),
347 thinking_config: None,
348 #[cfg(feature = "gemini-interactions")]
349 transport: GeminiTransport::GenerateContent,
350 #[cfg(feature = "gemini-interactions")]
351 interaction_target: None,
352 #[cfg(feature = "gemini-interactions")]
353 interaction_options: InteractionOptions::default(),
354 #[cfg(feature = "gemini-vertex")]
355 vertex_rag_store: None,
356 #[cfg(feature = "gemini-vertex")]
357 vertex_backend: false,
358 }
359 }
360
361 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Result<Self> {
363 let model_name = model.into();
364 crate::catalog::warn_if_obsolete("gemini", &model_name);
365 let client = Gemini::with_model(api_key.into(), model_name.clone())
366 .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
367
368 Ok(Self::from_client(client, model_name))
369 }
370
371 #[cfg(feature = "gemini-vertex")]
375 pub fn new_google_cloud(
376 api_key: impl Into<String>,
377 project_id: impl AsRef<str>,
378 location: impl AsRef<str>,
379 model: impl Into<String>,
380 ) -> Result<Self> {
381 let model_name = model.into();
382 let client = Gemini::with_google_cloud_model(
383 api_key.into(),
384 project_id,
385 location,
386 model_name.clone(),
387 )
388 .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
389
390 let mut model = Self::from_client(client, model_name);
391 model.vertex_backend = true;
392 Ok(model)
393 }
394
395 #[cfg(feature = "gemini-vertex")]
399 pub fn new_google_cloud_service_account(
400 service_account_json: &str,
401 project_id: impl AsRef<str>,
402 location: impl AsRef<str>,
403 model: impl Into<String>,
404 ) -> Result<Self> {
405 let model_name = model.into();
406 let client = Gemini::with_google_cloud_service_account_json(
407 service_account_json,
408 project_id.as_ref(),
409 location.as_ref(),
410 model_name.clone(),
411 )
412 .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
413
414 let mut model = Self::from_client(client, model_name);
415 model.vertex_backend = true;
416 Ok(model)
417 }
418
419 #[cfg(feature = "gemini-vertex")]
423 pub fn new_google_cloud_adc(
424 project_id: impl AsRef<str>,
425 location: impl AsRef<str>,
426 model: impl Into<String>,
427 ) -> Result<Self> {
428 let model_name = model.into();
429 let client = Gemini::with_google_cloud_adc_model(
430 project_id.as_ref(),
431 location.as_ref(),
432 model_name.clone(),
433 )
434 .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
435
436 let mut model = Self::from_client(client, model_name);
437 model.vertex_backend = true;
438 Ok(model)
439 }
440
441 #[cfg(feature = "gemini-vertex")]
445 pub fn new_google_cloud_wif(
446 wif_json: &str,
447 project_id: impl AsRef<str>,
448 location: impl AsRef<str>,
449 model: impl Into<String>,
450 ) -> Result<Self> {
451 let model_name = model.into();
452 let client = Gemini::with_google_cloud_wif_json(
453 wif_json,
454 project_id.as_ref(),
455 location.as_ref(),
456 model_name.clone(),
457 )
458 .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
459
460 let mut model = Self::from_client(client, model_name);
461 model.vertex_backend = true;
462 Ok(model)
463 }
464
465 pub fn from_env(model: &str) -> Result<Self> {
501 if vertex_env_requested() {
502 #[cfg(feature = "gemini-vertex")]
503 {
504 let (project, location) = vertex_target_from_env()?;
505 return Self::new_google_cloud_adc(project, location, model);
506 }
507 #[cfg(not(feature = "gemini-vertex"))]
508 {
509 return Err(adk_core::AdkError::new(
510 ErrorComponent::Model,
511 ErrorCategory::Unsupported,
512 "model.gemini.vertex_feature_missing",
513 "Vertex AI backend selected via GOOGLE_GENAI_USE_ENTERPRISE/GOOGLE_GENAI_USE_VERTEXAI, but the `gemini-vertex` feature is not compiled. Enable the `gemini-vertex` feature (or `gemini-agent-platform` on the adk-rust umbrella crate) to route Gemini traffic through Vertex AI.",
514 )
515 .with_provider("gemini"));
516 }
517 }
518
519 let Some(api_key) =
520 env_non_empty("GOOGLE_API_KEY").or_else(|| env_non_empty("GEMINI_API_KEY"))
521 else {
522 return Err(adk_core::AdkError::new(
523 ErrorComponent::Model,
524 ErrorCategory::InvalidInput,
525 "model.gemini.api_key_missing",
526 "No Gemini API key found. Set GOOGLE_API_KEY or GEMINI_API_KEY, or set GOOGLE_GENAI_USE_ENTERPRISE=true with GOOGLE_CLOUD_PROJECT/GOOGLE_CLOUD_LOCATION for Vertex AI.",
527 )
528 .with_provider("gemini"));
529 };
530 Self::new(api_key, model)
531 }
532
533 #[must_use]
535 pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
536 self.retry_config = retry_config;
537 self
538 }
539
540 pub fn set_retry_config(&mut self, retry_config: RetryConfig) {
542 self.retry_config = retry_config;
543 }
544
545 pub fn retry_config(&self) -> &RetryConfig {
547 &self.retry_config
548 }
549
550 #[must_use]
574 pub fn with_thinking_config(mut self, thinking_config: adk_gemini::ThinkingConfig) -> Self {
575 self.thinking_config = Some(thinking_config);
576 self
577 }
578
579 pub fn set_thinking_config(&mut self, thinking_config: adk_gemini::ThinkingConfig) {
581 self.thinking_config = Some(thinking_config);
582 }
583
584 pub fn thinking_config(&self) -> Option<&adk_gemini::ThinkingConfig> {
586 self.thinking_config.as_ref()
587 }
588
589 #[cfg(feature = "gemini-vertex")]
613 #[must_use]
614 pub fn with_vertex_rag_store(mut self, store: adk_gemini::VertexRagStore) -> Self {
615 self.vertex_rag_store = Some(store);
616 self
617 }
618
619 #[cfg(feature = "gemini-vertex")]
621 pub fn vertex_rag_store(&self) -> Option<&adk_gemini::VertexRagStore> {
622 self.vertex_rag_store.as_ref()
623 }
624
625 #[cfg(feature = "gemini-interactions")]
649 pub fn use_interactions_api(mut self, enabled: bool) -> Result<Self> {
650 if enabled {
651 let target = InteractionTarget::parse(&self.model_name)?;
652 self.transport = GeminiTransport::Interactions;
653 self.interaction_target = Some(target);
654 } else {
655 self.transport = GeminiTransport::GenerateContent;
656 self.interaction_target = None;
657 }
658 Ok(self)
659 }
660
661 #[cfg(feature = "gemini-interactions")]
668 #[must_use]
669 pub fn interaction_options(mut self, opts: InteractionOptions) -> Self {
670 self.interaction_options = opts;
671 self
672 }
673
674 #[cfg(feature = "gemini-interactions")]
678 pub fn transport(&self) -> GeminiTransport {
679 self.transport
680 }
681
682 #[cfg(feature = "gemini-interactions")]
686 pub fn interaction_options_ref(&self) -> &InteractionOptions {
687 &self.interaction_options
688 }
689
690 #[cfg(feature = "gemini-interactions")]
702 fn resolve_background(&self) -> bool {
703 match self.interaction_options.background {
704 BackgroundMode::Always => true,
705 BackgroundMode::Never => false,
706 BackgroundMode::AgentTargetsOnly => {
707 self.interaction_target.as_ref().is_some_and(InteractionTarget::is_agent)
708 }
709 }
710 }
711
712 fn convert_response(resp: &adk_gemini::GenerationResponse) -> Result<LlmResponse> {
713 let mut converted_parts: Vec<Part> = Vec::new();
714
715 if let Some(parts) = resp.candidates.first().and_then(|c| c.content.parts.as_ref()) {
717 for p in parts {
718 match p {
719 adk_gemini::Part::Text { text, thought, thought_signature } => {
720 if thought == &Some(true) {
721 converted_parts.push(Part::Thinking {
722 thinking: text.clone(),
723 signature: thought_signature.clone(),
724 });
725 } else {
726 converted_parts.push(Part::Text { text: text.clone() });
727 }
728 }
729 adk_gemini::Part::InlineData { inline_data } => {
730 let decoded =
731 BASE64_STANDARD.decode(&inline_data.data).map_err(|error| {
732 adk_core::AdkError::model(format!(
733 "failed to decode inline data from gemini response: {error}"
734 ))
735 })?;
736 converted_parts.push(Part::InlineData {
737 mime_type: inline_data.mime_type.clone(),
738 data: decoded,
739 uri: None,
740 annotations: None,
741 });
742 }
743 adk_gemini::Part::FunctionCall { function_call, thought_signature } => {
744 converted_parts.push(Part::FunctionCall {
745 name: function_call.name.clone(),
746 args: function_call.args.clone(),
747 id: function_call.id.clone(),
748 thought_signature: thought_signature.clone(),
749 });
750 }
751 adk_gemini::Part::FunctionResponse { function_response, .. } => {
752 converted_parts.push(Part::FunctionResponse {
753 function_response: adk_core::FunctionResponseData::new(
754 function_response.name.clone(),
755 function_response
756 .response
757 .clone()
758 .unwrap_or(serde_json::Value::Null),
759 ),
760 id: None,
761 annotations: None,
762 });
763 }
764 adk_gemini::Part::ToolCall { .. } | adk_gemini::Part::ExecutableCode { .. } => {
765 if let Ok(value) = serde_json::to_value(p) {
766 converted_parts.push(Part::ServerToolCall { server_tool_call: value });
767 }
768 }
769 adk_gemini::Part::ToolResponse { .. }
770 | adk_gemini::Part::CodeExecutionResult { .. } => {
771 let value = serde_json::to_value(p).unwrap_or(serde_json::Value::Null);
772 converted_parts
773 .push(Part::ServerToolResponse { server_tool_response: value });
774 }
775 adk_gemini::Part::FileData { file_data } => {
776 converted_parts.push(Part::FileData {
777 mime_type: file_data.mime_type.clone(),
778 file_uri: file_data.file_uri.clone(),
779 annotations: None,
780 });
781 }
782 }
783 }
784 }
785
786 if let Some(grounding) = resp.candidates.first().and_then(|c| c.grounding_metadata.as_ref())
788 {
789 if let Some(queries) = &grounding.web_search_queries
790 && !queries.is_empty()
791 {
792 let search_info = format!("\n\n🔍 **Searched:** {}", queries.join(", "));
793 converted_parts.push(Part::Text { text: search_info });
794 }
795 if let Some(chunks) = &grounding.grounding_chunks {
796 let sources: Vec<String> = chunks
797 .iter()
798 .filter_map(|c| {
799 c.web.as_ref().and_then(|w| match (&w.title, &w.uri) {
800 (Some(title), Some(uri)) => Some(format!("[{}]({})", title, uri)),
801 (Some(title), None) => Some(title.clone()),
802 (None, Some(uri)) => Some(uri.to_string()),
803 (None, None) => None,
804 })
805 })
806 .collect();
807 if !sources.is_empty() {
808 let sources_info = format!("\n📚 **Sources:** {}", sources.join(" | "));
809 converted_parts.push(Part::Text { text: sources_info });
810 }
811 }
812 }
813
814 let content = if converted_parts.is_empty() {
815 None
816 } else {
817 Some(Content { role: "model".to_string(), parts: converted_parts })
818 };
819 let tool_call_turn = content.as_ref().is_some_and(Content::has_function_calls);
820
821 let usage_metadata = resp.usage_metadata.as_ref().map(|u| UsageMetadata {
822 prompt_token_count: u.prompt_token_count.unwrap_or(0),
823 candidates_token_count: u.candidates_token_count.unwrap_or(0),
824 total_token_count: u.total_token_count.unwrap_or(0),
825 thinking_token_count: u.thoughts_token_count,
826 cache_read_input_token_count: u.cached_content_token_count,
827 ..Default::default()
828 });
829
830 let finish_reason =
831 resp.candidates.first().and_then(|c| c.finish_reason.as_ref()).map(|fr| match fr {
832 adk_gemini::FinishReason::Stop => FinishReason::Stop,
833 adk_gemini::FinishReason::MaxTokens => FinishReason::MaxTokens,
834 adk_gemini::FinishReason::Safety => FinishReason::Safety,
835 adk_gemini::FinishReason::Recitation => FinishReason::Recitation,
836 _ => FinishReason::Other,
837 });
838
839 let citation_metadata =
840 resp.candidates.first().and_then(|c| c.citation_metadata.as_ref()).map(|meta| {
841 CitationMetadata {
842 citation_sources: meta
843 .citation_sources
844 .iter()
845 .map(|source| CitationSource {
846 uri: source.uri.clone(),
847 title: source.title.clone(),
848 start_index: source.start_index,
849 end_index: source.end_index,
850 license: source.license.clone(),
851 publication_date: source.publication_date.map(|d| d.to_string()),
852 })
853 .collect(),
854 }
855 });
856
857 let provider_metadata = resp
860 .candidates
861 .first()
862 .and_then(|c| c.grounding_metadata.as_ref())
863 .and_then(|g| serde_json::to_value(g).ok());
864
865 Ok(LlmResponse {
866 content,
867 usage_metadata,
868 finish_reason,
869 citation_metadata,
870 partial: false,
871 turn_complete: !tool_call_turn,
872 interrupted: false,
873 error_code: None,
874 error_message: None,
875 provider_metadata,
876 interaction_id: None,
877 })
878 }
879
880 fn gemini_function_response_payload(response: serde_json::Value) -> serde_json::Value {
881 match response {
882 serde_json::Value::Object(_) => response,
884 other => serde_json::json!({ "result": other }),
885 }
886 }
887
888 fn merge_object_value(
889 target: &mut serde_json::Map<String, serde_json::Value>,
890 value: serde_json::Value,
891 ) {
892 if let serde_json::Value::Object(object) = value {
893 for (key, value) in object {
894 target.insert(key, value);
895 }
896 }
897 }
898
899 fn build_gemini_tools(
900 tools: &std::collections::HashMap<String, serde_json::Value>,
901 adapter: &dyn SchemaAdapter,
902 cache: &SchemaCache,
903 ) -> Result<(Vec<adk_gemini::Tool>, adk_gemini::ToolConfig)> {
904 let mut gemini_tools = Vec::new();
905 let mut function_declarations = Vec::new();
906 let mut has_provider_native_tools = false;
907 let mut tool_config_json = serde_json::Map::new();
908
909 for (name, tool_decl) in tools {
910 if let Some(provider_tool) = tool_decl.get("x-adk-gemini-tool") {
911 let tool = serde_json::from_value::<adk_gemini::Tool>(provider_tool.clone())
912 .map_err(|error| {
913 adk_core::AdkError::model(format!(
914 "failed to deserialize Gemini native tool '{name}': {error}"
915 ))
916 })?;
917 has_provider_native_tools = true;
918 gemini_tools.push(tool);
919 } else {
920 let normalized_name = adapter.normalize_tool_name(name);
922
923 let schema =
926 tool_decl.get("parameters").cloned().unwrap_or_else(|| adapter.empty_schema());
927 let normalized_schema = cache.normalize(&schema);
928
929 let description =
931 tool_decl.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
932
933 let mut func_decl_json = serde_json::json!({
934 "name": normalized_name.as_ref(),
935 "description": description,
936 "parameters": normalized_schema,
937 });
938
939 if let Some(response) = tool_decl.get("response") {
941 func_decl_json["response"] = cache.normalize(response);
942 }
943
944 if let Some(behavior) = tool_decl.get("behavior") {
946 func_decl_json["behavior"] = behavior.clone();
947 }
948
949 let func_decl =
950 serde_json::from_value::<adk_gemini::FunctionDeclaration>(func_decl_json)
951 .map_err(|error| {
952 adk_core::AdkError::model(format!(
953 "failed to build Gemini function declaration for '{name}': {error}"
954 ))
955 })?;
956 function_declarations.push(func_decl);
957 }
958
959 if let Some(tool_config) = tool_decl.get("x-adk-gemini-tool-config") {
960 Self::merge_object_value(&mut tool_config_json, tool_config.clone());
961 }
962 }
963
964 let has_function_declarations = !function_declarations.is_empty();
965 if has_function_declarations {
966 gemini_tools.push(adk_gemini::Tool::with_functions(function_declarations));
967 }
968
969 if has_provider_native_tools {
970 tool_config_json.insert(
971 "includeServerSideToolInvocations".to_string(),
972 serde_json::Value::Bool(true),
973 );
974 }
975
976 let tool_config = if tool_config_json.is_empty() {
977 adk_gemini::ToolConfig::default()
978 } else {
979 serde_json::from_value::<adk_gemini::ToolConfig>(serde_json::Value::Object(
980 tool_config_json,
981 ))
982 .map_err(|error| {
983 adk_core::AdkError::model(format!(
984 "failed to deserialize Gemini tool configuration: {error}"
985 ))
986 })?
987 };
988
989 Ok((gemini_tools, tool_config))
990 }
991
992 fn stream_chunks_from_response(
993 mut response: LlmResponse,
994 saw_partial_chunk: bool,
995 ) -> (Vec<LlmResponse>, bool) {
996 let is_final = response.finish_reason.is_some();
997
998 if !is_final {
999 response.partial = true;
1000 response.turn_complete = false;
1001 return (vec![response], true);
1002 }
1003
1004 response.partial = false;
1005 response.turn_complete =
1006 response.content.as_ref().is_none_or(|content| !content.has_function_calls());
1007
1008 if saw_partial_chunk {
1009 return (vec![response], true);
1010 }
1011
1012 let synthetic_partial = LlmResponse {
1013 content: None,
1014 usage_metadata: None,
1015 finish_reason: None,
1016 citation_metadata: None,
1017 partial: true,
1018 turn_complete: false,
1019 interrupted: false,
1020 error_code: None,
1021 error_message: None,
1022 provider_metadata: None,
1023 interaction_id: None,
1024 };
1025
1026 (vec![synthetic_partial, response], true)
1027 }
1028
1029 async fn generate_content_internal(
1030 &self,
1031 req: LlmRequest,
1032 stream: bool,
1033 ) -> Result<LlmResponseStream> {
1034 let mut builder = self.client.generate_content();
1035
1036 let mut fn_call_signatures: std::collections::HashMap<String, String> =
1041 std::collections::HashMap::new();
1042 for content in &req.contents {
1043 if content.role == "model" {
1044 for part in &content.parts {
1045 if let Part::FunctionCall { name, thought_signature: Some(sig), .. } = part {
1046 fn_call_signatures.insert(name.clone(), sig.clone());
1047 }
1048 }
1049 }
1050 }
1051
1052 for content in &req.contents {
1054 match content.role.as_str() {
1055 "user" => {
1056 let mut gemini_parts = Vec::new();
1058 for part in &content.parts {
1059 match part {
1060 Part::Text { text } => {
1061 gemini_parts.push(adk_gemini::Part::Text {
1062 text: text.clone(),
1063 thought: None,
1064 thought_signature: None,
1065 });
1066 }
1067 Part::Thinking { thinking, signature } => {
1068 gemini_parts.push(adk_gemini::Part::Text {
1069 text: thinking.clone(),
1070 thought: Some(true),
1071 thought_signature: signature.clone(),
1072 });
1073 }
1074 Part::InlineData { data, mime_type, .. } => {
1075 let encoded = attachment::encode_base64(data);
1076 gemini_parts.push(adk_gemini::Part::InlineData {
1077 inline_data: adk_gemini::Blob {
1078 mime_type: mime_type.clone(),
1079 data: encoded,
1080 },
1081 });
1082 }
1083 Part::FileData { mime_type, file_uri, .. } => {
1084 gemini_parts.push(adk_gemini::Part::Text {
1085 text: attachment::file_attachment_to_text(mime_type, file_uri),
1086 thought: None,
1087 thought_signature: None,
1088 });
1089 }
1090 _ => {}
1091 }
1092 }
1093 if !gemini_parts.is_empty() {
1094 let user_content = adk_gemini::Content {
1095 role: Some(adk_gemini::Role::User),
1096 parts: Some(gemini_parts),
1097 };
1098 builder = builder.with_message(adk_gemini::Message {
1099 content: user_content,
1100 role: adk_gemini::Role::User,
1101 });
1102 }
1103 }
1104 "model" => {
1105 let mut gemini_parts = Vec::new();
1107 for part in &content.parts {
1108 match part {
1109 Part::Text { text } => {
1110 gemini_parts.push(adk_gemini::Part::Text {
1111 text: text.clone(),
1112 thought: None,
1113 thought_signature: None,
1114 });
1115 }
1116 Part::Thinking { thinking, signature } => {
1117 gemini_parts.push(adk_gemini::Part::Text {
1118 text: thinking.clone(),
1119 thought: Some(true),
1120 thought_signature: signature.clone(),
1121 });
1122 }
1123 Part::FunctionCall { name, args, thought_signature, id } => {
1124 gemini_parts.push(adk_gemini::Part::FunctionCall {
1125 function_call: adk_gemini::FunctionCall {
1126 name: name.clone(),
1127 args: args.clone(),
1128 id: id.clone(),
1129 thought_signature: None,
1130 },
1131 thought_signature: thought_signature.clone(),
1132 });
1133 }
1134 Part::ServerToolCall { server_tool_call } => {
1135 if let Ok(native_part) = serde_json::from_value::<adk_gemini::Part>(
1136 server_tool_call.clone(),
1137 ) {
1138 match native_part {
1139 adk_gemini::Part::ToolCall { .. }
1140 | adk_gemini::Part::ExecutableCode { .. } => {
1141 gemini_parts.push(native_part);
1142 continue;
1143 }
1144 _ => {}
1145 }
1146 }
1147
1148 gemini_parts.push(adk_gemini::Part::ToolCall {
1149 tool_call: server_tool_call.clone(),
1150 thought_signature: Self::gemini_part_thought_signature(
1151 server_tool_call,
1152 ),
1153 });
1154 }
1155 Part::ServerToolResponse { server_tool_response } => {
1156 if let Ok(native_part) = serde_json::from_value::<adk_gemini::Part>(
1157 server_tool_response.clone(),
1158 ) {
1159 match native_part {
1160 adk_gemini::Part::ToolResponse { .. }
1161 | adk_gemini::Part::CodeExecutionResult { .. } => {
1162 gemini_parts.push(native_part);
1163 continue;
1164 }
1165 _ => {}
1166 }
1167 }
1168
1169 gemini_parts.push(adk_gemini::Part::ToolResponse {
1170 tool_response: server_tool_response.clone(),
1171 thought_signature: Self::gemini_part_thought_signature(
1172 server_tool_response,
1173 ),
1174 });
1175 }
1176 _ => {}
1177 }
1178 }
1179 if !gemini_parts.is_empty() {
1180 let model_content = adk_gemini::Content {
1181 role: Some(adk_gemini::Role::Model),
1182 parts: Some(gemini_parts),
1183 };
1184 builder = builder.with_message(adk_gemini::Message {
1185 content: model_content,
1186 role: adk_gemini::Role::Model,
1187 });
1188 }
1189 }
1190 "function" => {
1191 let mut gemini_parts = Vec::new();
1194 for part in &content.parts {
1195 if let Part::FunctionResponse { function_response, id, .. } = part {
1196 let sig = fn_call_signatures.get(&function_response.name).cloned();
1197
1198 let mut fr_parts = Vec::new();
1200 for inline in &function_response.inline_data {
1201 let encoded = attachment::encode_base64(&inline.data);
1202 fr_parts.push(adk_gemini::FunctionResponsePart::InlineData {
1203 inline_data: adk_gemini::Blob {
1204 mime_type: inline.mime_type.clone(),
1205 data: encoded,
1206 },
1207 });
1208 }
1209 for file in &function_response.file_data {
1210 fr_parts.push(adk_gemini::FunctionResponsePart::FileData {
1211 file_data: adk_gemini::FileDataRef {
1212 mime_type: file.mime_type.clone(),
1213 file_uri: file.file_uri.clone(),
1214 },
1215 });
1216 }
1217
1218 let mut gemini_fr = adk_gemini::tools::FunctionResponse::new(
1219 &function_response.name,
1220 Self::gemini_function_response_payload(
1221 function_response.response.clone(),
1222 ),
1223 );
1224 gemini_fr.parts = fr_parts;
1225 gemini_fr.id = id.clone();
1228
1229 gemini_parts.push(adk_gemini::Part::FunctionResponse {
1230 function_response: gemini_fr,
1231 thought_signature: sig,
1232 });
1233 }
1234 }
1235 if !gemini_parts.is_empty() {
1236 let fn_content = adk_gemini::Content {
1237 role: Some(adk_gemini::Role::User),
1238 parts: Some(gemini_parts),
1239 };
1240 builder = builder.with_message(adk_gemini::Message {
1241 content: fn_content,
1242 role: adk_gemini::Role::User,
1243 });
1244 }
1245 }
1246 _ => {}
1247 }
1248 }
1249
1250 if let Some(config) = req.config {
1252 let has_schema = config.response_schema.is_some();
1253 let gen_config = adk_gemini::GenerationConfig {
1254 temperature: config.temperature,
1255 top_p: config.top_p,
1256 top_k: config.top_k,
1257 max_output_tokens: config.max_output_tokens,
1258 response_schema: config.response_schema,
1259 response_mime_type: if has_schema {
1260 Some("application/json".to_string())
1261 } else {
1262 None
1263 },
1264 thinking_config: self.thinking_config.clone(),
1265 ..Default::default()
1266 };
1267 builder = builder.with_generation_config(gen_config);
1268
1269 if let Some(ref name) = config.cached_content {
1271 let handle = self.client.get_cached_content(name);
1272 builder = builder.with_cached_content(&handle);
1273 }
1274 } else if self.thinking_config.is_some() {
1275 let gen_config = adk_gemini::GenerationConfig {
1278 thinking_config: self.thinking_config.clone(),
1279 ..Default::default()
1280 };
1281 builder = builder.with_generation_config(gen_config);
1282 }
1283
1284 if !req.tools.is_empty() {
1286 let adapter = self.schema_adapter();
1287 use std::sync::LazyLock;
1288 static SCHEMA_CACHE: LazyLock<SchemaCache> = LazyLock::new(|| {
1289 SchemaCache::for_adapter(std::sync::Arc::new(GeminiSchemaAdapter::new()))
1290 });
1291 let (gemini_tools, tool_config) =
1292 Self::build_gemini_tools(&req.tools, adapter, &SCHEMA_CACHE)?;
1293 for tool in gemini_tools {
1294 builder = builder.with_tool(tool);
1295 }
1296 if tool_config != adk_gemini::ToolConfig::default() {
1297 builder = builder.with_tool_config(tool_config);
1298 }
1299 }
1300
1301 #[cfg(feature = "gemini-vertex")]
1305 if let Some(store) = &self.vertex_rag_store {
1306 builder = builder.with_vertex_rag_store(store.clone());
1307 }
1308
1309 if stream {
1310 adk_telemetry::debug!("Executing streaming request");
1311 let response_stream = builder.execute_stream().await.map_err(|e| {
1312 adk_telemetry::error!(error = %e, "Model request failed");
1313 gemini_error_to_adk(&e)
1314 })?;
1315
1316 let mapped_stream = async_stream::stream! {
1317 let mut stream = response_stream;
1318 let mut saw_partial_chunk = false;
1319 while let Some(result) = stream.try_next().await.transpose() {
1320 match result {
1321 Ok(resp) => {
1322 match Self::convert_response(&resp) {
1323 Ok(llm_resp) => {
1324 let (chunks, next_saw_partial) =
1325 Self::stream_chunks_from_response(llm_resp, saw_partial_chunk);
1326 saw_partial_chunk = next_saw_partial;
1327 for chunk in chunks {
1328 yield Ok(chunk);
1329 }
1330 }
1331 Err(e) => {
1332 adk_telemetry::error!(error = %e, "Failed to convert response");
1333 yield Err(e);
1334 }
1335 }
1336 }
1337 Err(e) => {
1338 adk_telemetry::error!(error = %e, "Stream error");
1339 yield Err(gemini_error_to_adk(&e));
1340 }
1341 }
1342 }
1343 };
1344
1345 Ok(Box::pin(mapped_stream))
1346 } else {
1347 adk_telemetry::debug!("Executing blocking request");
1348 let response = builder.execute().await.map_err(|e| {
1349 adk_telemetry::error!(error = %e, "Model request failed");
1350 gemini_error_to_adk(&e)
1351 })?;
1352
1353 let llm_response = Self::convert_response(&response)?;
1354
1355 let stream = async_stream::stream! {
1356 yield Ok(llm_response);
1357 };
1358
1359 Ok(Box::pin(stream))
1360 }
1361 }
1362
1363 pub async fn create_cached_content(
1368 &self,
1369 system_instruction: &str,
1370 tools: &std::collections::HashMap<String, serde_json::Value>,
1371 ttl_seconds: u32,
1372 ) -> Result<String> {
1373 let mut cache_builder = self
1374 .client
1375 .create_cache()
1376 .with_system_instruction(system_instruction)
1377 .with_ttl(std::time::Duration::from_secs(u64::from(ttl_seconds)));
1378
1379 let adapter = self.schema_adapter();
1380 use std::sync::LazyLock;
1381 static SCHEMA_CACHE: LazyLock<SchemaCache> = LazyLock::new(|| {
1382 SchemaCache::for_adapter(std::sync::Arc::new(GeminiSchemaAdapter::new()))
1383 });
1384 let (gemini_tools, tool_config) = Self::build_gemini_tools(tools, adapter, &SCHEMA_CACHE)?;
1385 if !gemini_tools.is_empty() {
1386 cache_builder = cache_builder.with_tools(gemini_tools);
1387 }
1388 if tool_config != adk_gemini::ToolConfig::default() {
1389 cache_builder = cache_builder.with_tool_config(tool_config);
1390 }
1391
1392 let handle = cache_builder
1393 .execute()
1394 .await
1395 .map_err(|e| adk_core::AdkError::model(format!("cache creation failed: {e}")))?;
1396
1397 Ok(handle.name().to_string())
1398 }
1399
1400 pub async fn delete_cached_content(&self, name: &str) -> Result<()> {
1402 let handle = self.client.get_cached_content(name);
1403 handle
1404 .delete()
1405 .await
1406 .map_err(|(_, e)| adk_core::AdkError::model(format!("cache deletion failed: {e}")))?;
1407 Ok(())
1408 }
1409
1410 #[cfg(feature = "gemini-interactions")]
1438 async fn generate_interactions_once(&self, req: LlmRequest) -> Result<LlmResponse> {
1439 use super::interactions_convert;
1440
1441 let target = self.interaction_target.as_ref().ok_or_else(|| {
1444 adk_core::AdkError::new(
1445 ErrorComponent::Model,
1446 ErrorCategory::InvalidInput,
1447 "model.gemini.interactions.missing_target",
1448 "the Interactions transport is active but no validated target is configured",
1449 )
1450 .with_provider("gemini")
1451 })?;
1452
1453 let thinking_level = self.thinking_config.as_ref().and_then(|c| c.thinking_level);
1456
1457 let stateful = self.interaction_options.stateful;
1458 let store = self.interaction_options.store;
1459 let background = self.resolve_background();
1460
1461 let mut request =
1463 interactions_convert::build_request(&req, target, thinking_level, stateful, store)?;
1464 request.background = Some(background);
1465
1466 let interaction = match self.client.send_interaction(request.clone()).await {
1468 Ok(interaction) => interaction,
1469 Err(error) => {
1470 let mapped = gemini_error_to_adk(&error);
1471 if mapped.category == ErrorCategory::NotFound && req.previous_response_id.is_some()
1475 {
1476 let mut fallback = interactions_convert::build_request(
1477 &req,
1478 target,
1479 thinking_level,
1480 false,
1481 store,
1482 )?;
1483 fallback.background = Some(background);
1484 self.client
1485 .send_interaction(fallback)
1486 .await
1487 .map_err(|e| gemini_error_to_adk(&e))?
1488 } else {
1489 return Err(mapped);
1490 }
1491 }
1492 };
1493
1494 let final_interaction = if background {
1496 self.poll_interaction_to_completion(interaction).await?
1497 } else {
1498 interaction
1499 };
1500
1501 interaction_status_to_result(final_interaction.status)?;
1503
1504 Ok(interactions_convert::to_llm_response(&final_interaction))
1505 }
1506
1507 #[cfg(feature = "gemini-interactions")]
1538 async fn poll_interaction_to_completion(
1539 &self,
1540 interaction: adk_gemini::interactions::Interaction,
1541 ) -> Result<adk_gemini::interactions::Interaction> {
1542 const MAX_POLL_ATTEMPTS: u32 = 600;
1547
1548 let mut current = interaction;
1549 let mut attempts: u32 = 0;
1550 while !current.status.is_terminal() && !current.status.requires_action() {
1551 if attempts >= MAX_POLL_ATTEMPTS {
1552 return Err(adk_core::AdkError::new(
1553 ErrorComponent::Model,
1554 ErrorCategory::Timeout,
1555 "model.gemini.interactions.poll_timeout",
1556 format!(
1557 "the Gemini interaction did not reach a terminal or requires_action \
1558 state after {MAX_POLL_ATTEMPTS} poll attempts"
1559 ),
1560 )
1561 .with_provider("gemini"));
1562 }
1563 attempts += 1;
1564 tokio::time::sleep(self.interaction_options.poll_interval).await;
1565 current = self
1566 .client
1567 .get_interaction(¤t.id, false)
1568 .await
1569 .map_err(|e| gemini_error_to_adk(&e))?;
1570 }
1571 Ok(current)
1572 }
1573
1574 #[cfg(feature = "gemini-interactions")]
1599 async fn generate_interactions_stream(&self, req: LlmRequest) -> Result<LlmResponseStream> {
1600 use super::interactions_convert::{self, SseAccumulator, sse_event_to_chunk};
1601
1602 let target = self.interaction_target.as_ref().ok_or_else(|| {
1603 adk_core::AdkError::new(
1604 ErrorComponent::Model,
1605 ErrorCategory::InvalidInput,
1606 "model.gemini.interactions.missing_target",
1607 "the Interactions transport is active but no validated target is configured",
1608 )
1609 .with_provider("gemini")
1610 })?;
1611
1612 let thinking_level = self.thinking_config.as_ref().and_then(|c| c.thinking_level);
1613 let stateful = self.interaction_options.stateful;
1614 let store = self.interaction_options.store;
1615
1616 let mut request =
1617 interactions_convert::build_request(&req, target, thinking_level, stateful, store)?;
1618 request.background = Some(false);
1623
1624 let sse_stream = self
1625 .client
1626 .send_interaction_stream(request)
1627 .await
1628 .map_err(|e| gemini_error_to_adk(&e))?;
1629
1630 let mapped = async_stream::stream! {
1631 let mut sse_stream = sse_stream;
1632 let mut acc = SseAccumulator::new();
1633 while let Some(result) = sse_stream.try_next().await.transpose() {
1634 match result {
1635 Ok(event) => {
1636 if let Some(chunk) = sse_event_to_chunk(event, &mut acc) {
1637 yield chunk;
1638 }
1639 }
1640 Err(e) => {
1641 adk_telemetry::error!(error = %e, "Interaction stream error");
1642 yield Err(gemini_error_to_adk(&e));
1643 }
1644 }
1645 }
1646 };
1647
1648 Ok(Box::pin(mapped))
1649 }
1650}
1651
1652#[async_trait]
1653impl Llm for GeminiModel {
1654 fn name(&self) -> &str {
1655 &self.model_name
1656 }
1657
1658 fn schema_adapter(&self) -> &dyn SchemaAdapter {
1659 use std::sync::LazyLock;
1660 static ADAPTER: LazyLock<GeminiSchemaAdapter> = LazyLock::new(GeminiSchemaAdapter::new);
1661 &*ADAPTER
1662 }
1663
1664 #[cfg(feature = "gemini-interactions")]
1665 fn uses_interactions_api(&self) -> bool {
1666 self.transport == GeminiTransport::Interactions
1667 }
1668
1669 #[adk_telemetry::instrument(
1674 name = "model.generate_content",
1675 skip(self, req),
1676 fields(
1677 model.name = %self.model_name,
1678 stream = %stream,
1679 request.contents_count = %req.contents.len(),
1680 request.tools_count = %req.tools.len()
1681 )
1682 )]
1683 async fn generate_content(&self, req: LlmRequest, stream: bool) -> Result<LlmResponseStream> {
1684 adk_telemetry::info!("Generating content");
1685 self.validate_request_contract(&req)?;
1686 let usage_span = adk_telemetry::llm_generate_span("gemini", &self.model_name, stream);
1687
1688 #[cfg(feature = "gemini-interactions")]
1696 if self.transport == GeminiTransport::Interactions {
1697 if stream {
1706 let mapped =
1707 execute_with_retry(&self.retry_config, is_retryable_model_error, || {
1708 self.generate_interactions_stream(req.clone())
1709 })
1710 .await?;
1711 return Ok(crate::usage_tracking::with_usage_tracking(mapped, usage_span));
1712 }
1713 let response = execute_with_retry(&self.retry_config, is_retryable_model_error, || {
1714 self.generate_interactions_once(req.clone())
1715 })
1716 .await?;
1717 let single = async_stream::stream! {
1718 yield Ok(response);
1719 };
1720 return Ok(crate::usage_tracking::with_usage_tracking(Box::pin(single), usage_span));
1721 }
1722
1723 let result = execute_with_retry(&self.retry_config, is_retryable_model_error, || {
1724 self.generate_content_internal(req.clone(), stream)
1725 })
1726 .await?;
1727 Ok(crate::usage_tracking::with_usage_tracking(result, usage_span))
1728 }
1729}
1730
1731#[cfg(test)]
1732mod native_tool_tests {
1733 use super::*;
1734
1735 fn test_adapter() -> GeminiSchemaAdapter {
1736 GeminiSchemaAdapter::new()
1737 }
1738
1739 fn test_cache() -> SchemaCache {
1740 SchemaCache::for_adapter(std::sync::Arc::new(GeminiSchemaAdapter::new()))
1741 }
1742
1743 #[test]
1744 fn test_build_gemini_tools_supports_native_tool_metadata() {
1745 let mut tools = std::collections::HashMap::new();
1746 tools.insert(
1747 "google_search".to_string(),
1748 serde_json::json!({
1749 "x-adk-gemini-tool": {
1750 "google_search": {}
1751 }
1752 }),
1753 );
1754 tools.insert(
1755 "lookup_weather".to_string(),
1756 serde_json::json!({
1757 "name": "lookup_weather",
1758 "description": "lookup weather",
1759 "parameters": {
1760 "type": "object",
1761 "properties": {
1762 "city": { "type": "string" }
1763 }
1764 }
1765 }),
1766 );
1767
1768 let adapter = test_adapter();
1769 let cache = test_cache();
1770 let (gemini_tools, tool_config) = GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1771 .expect("tool conversion should succeed");
1772
1773 assert_eq!(gemini_tools.len(), 2);
1774 assert_eq!(tool_config.include_server_side_tool_invocations, Some(true));
1775 }
1776
1777 #[test]
1778 fn test_build_gemini_tools_sets_flag_for_builtin_only() {
1779 let mut tools = std::collections::HashMap::new();
1780 tools.insert(
1781 "google_search".to_string(),
1782 serde_json::json!({
1783 "x-adk-gemini-tool": {
1784 "google_search": {}
1785 }
1786 }),
1787 );
1788
1789 let adapter = test_adapter();
1790 let cache = test_cache();
1791 let (_gemini_tools, tool_config) =
1792 GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1793 .expect("tool conversion should succeed");
1794
1795 assert_eq!(
1796 tool_config.include_server_side_tool_invocations,
1797 Some(true),
1798 "includeServerSideToolInvocations should be set even with only built-in tools"
1799 );
1800 }
1801
1802 #[test]
1803 fn test_build_gemini_tools_no_flag_for_function_only() {
1804 let mut tools = std::collections::HashMap::new();
1805 tools.insert(
1806 "lookup_weather".to_string(),
1807 serde_json::json!({
1808 "name": "lookup_weather",
1809 "description": "lookup weather",
1810 "parameters": {
1811 "type": "object",
1812 "properties": {
1813 "city": { "type": "string" }
1814 }
1815 }
1816 }),
1817 );
1818
1819 let adapter = test_adapter();
1820 let cache = test_cache();
1821 let (_gemini_tools, tool_config) =
1822 GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1823 .expect("tool conversion should succeed");
1824
1825 assert_eq!(
1826 tool_config.include_server_side_tool_invocations, None,
1827 "includeServerSideToolInvocations should NOT be set for function-only tools"
1828 );
1829 }
1830
1831 #[test]
1832 fn test_build_gemini_tools_merges_native_tool_config() {
1833 let mut tools = std::collections::HashMap::new();
1834 tools.insert(
1835 "google_maps".to_string(),
1836 serde_json::json!({
1837 "x-adk-gemini-tool": {
1838 "google_maps": {
1839 "enable_widget": true
1840 }
1841 },
1842 "x-adk-gemini-tool-config": {
1843 "retrievalConfig": {
1844 "latLng": {
1845 "latitude": 1.23,
1846 "longitude": 4.56
1847 }
1848 }
1849 }
1850 }),
1851 );
1852
1853 let adapter = test_adapter();
1854 let cache = test_cache();
1855 let (_gemini_tools, tool_config) =
1856 GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1857 .expect("tool conversion should succeed");
1858
1859 assert_eq!(
1860 tool_config.retrieval_config,
1861 Some(serde_json::json!({
1862 "latLng": {
1863 "latitude": 1.23,
1864 "longitude": 4.56
1865 }
1866 }))
1867 );
1868 }
1869
1870 #[test]
1871 fn test_response_schema_is_normalized_like_parameters() {
1872 let mut tools = std::collections::HashMap::new();
1878 tools.insert(
1879 "read_file".to_string(),
1880 serde_json::json!({
1881 "name": "read_file",
1882 "description": "Read a file from the filesystem",
1883 "parameters": {
1884 "$schema": "http://json-schema.org/draft-07/schema#",
1885 "type": "object",
1886 "properties": {
1887 "path": { "type": "string", "description": "File path" }
1888 },
1889 "required": ["path"],
1890 "additionalProperties": false
1891 },
1892 "response": {
1893 "$schema": "http://json-schema.org/draft-07/schema#",
1894 "type": "object",
1895 "properties": {
1896 "content": { "type": "string", "description": "File contents" }
1897 },
1898 "required": ["content"],
1899 "additionalProperties": false
1900 }
1901 }),
1902 );
1903
1904 let adapter = test_adapter();
1905 let cache = test_cache();
1906 let (gemini_tools, _) = GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1907 .expect("tool conversion should succeed");
1908
1909 let func_tool = gemini_tools
1911 .iter()
1912 .find(|t| matches!(t, adk_gemini::Tool::Function { .. }))
1913 .expect("should have function declarations");
1914
1915 let decls = match func_tool {
1916 adk_gemini::Tool::Function { function_declarations } => function_declarations,
1917 _ => panic!("expected Function tool"),
1918 };
1919
1920 let decl = &decls[0];
1921 let decl_json = serde_json::to_value(decl).unwrap();
1922
1923 let params = &decl_json["parameters"];
1925 assert!(params.get("$schema").is_none(), "parameters.$schema should be stripped");
1926 assert!(
1927 params.get("additionalProperties").is_none(),
1928 "parameters.additionalProperties should be stripped"
1929 );
1930
1931 let response = &decl_json["response"];
1933 assert!(
1934 response.get("$schema").is_none(),
1935 "response.$schema should be stripped (was the bug: copied raw)"
1936 );
1937 assert!(
1938 response.get("additionalProperties").is_none(),
1939 "response.additionalProperties should be stripped (was the bug: copied raw)"
1940 );
1941 }
1942}
1943
1944#[async_trait]
1945impl CacheCapable for GeminiModel {
1946 async fn create_cache(
1947 &self,
1948 system_instruction: &str,
1949 tools: &std::collections::HashMap<String, serde_json::Value>,
1950 ttl_seconds: u32,
1951 ) -> Result<String> {
1952 self.create_cached_content(system_instruction, tools, ttl_seconds).await
1953 }
1954
1955 async fn delete_cache(&self, name: &str) -> Result<()> {
1956 self.delete_cached_content(name).await
1957 }
1958}
1959
1960#[cfg(test)]
1961mod tests {
1962 use super::*;
1963 use adk_core::AdkError;
1964 use std::{
1965 sync::{
1966 Arc,
1967 atomic::{AtomicU32, Ordering},
1968 },
1969 time::Duration,
1970 };
1971
1972 #[test]
1973 fn env_flag_truthy_accepts_one_and_case_insensitive_true() {
1974 for truthy in ["1", "true", "TRUE", "True", " true ", "tRuE"] {
1975 assert!(env_flag_truthy(truthy), "expected {truthy:?} to be truthy");
1976 }
1977 for falsy in ["0", "false", "FALSE", "yes", "on", "", "2", "vertex"] {
1978 assert!(!env_flag_truthy(falsy), "expected {falsy:?} to be falsy");
1979 }
1980 }
1981
1982 #[test]
1983 fn constructor_is_backward_compatible_and_sync() {
1984 fn accepts_sync_constructor<F>(_f: F)
1985 where
1986 F: Fn(&str, &str) -> Result<GeminiModel>,
1987 {
1988 }
1989
1990 accepts_sync_constructor(|api_key, model| GeminiModel::new(api_key, model));
1991 }
1992
1993 #[test]
1994 fn stream_chunks_from_response_injects_partial_before_lone_final_chunk() {
1995 let response = LlmResponse {
1996 content: Some(Content::new("model").with_text("hello")),
1997 usage_metadata: None,
1998 finish_reason: Some(FinishReason::Stop),
1999 citation_metadata: None,
2000 partial: false,
2001 turn_complete: true,
2002 interrupted: false,
2003 error_code: None,
2004 error_message: None,
2005 provider_metadata: None,
2006 interaction_id: None,
2007 };
2008
2009 let (chunks, saw_partial) = GeminiModel::stream_chunks_from_response(response, false);
2010 assert!(saw_partial);
2011 assert_eq!(chunks.len(), 2);
2012 assert!(chunks[0].partial);
2013 assert!(!chunks[0].turn_complete);
2014 assert!(chunks[0].content.is_none());
2015 assert!(!chunks[1].partial);
2016 assert!(chunks[1].turn_complete);
2017 }
2018
2019 #[test]
2020 fn stream_chunks_from_response_keeps_final_only_when_partial_already_seen() {
2021 let response = LlmResponse {
2022 content: Some(Content::new("model").with_text("done")),
2023 usage_metadata: None,
2024 finish_reason: Some(FinishReason::Stop),
2025 citation_metadata: None,
2026 partial: false,
2027 turn_complete: true,
2028 interrupted: false,
2029 error_code: None,
2030 error_message: None,
2031 provider_metadata: None,
2032 interaction_id: None,
2033 };
2034
2035 let (chunks, saw_partial) = GeminiModel::stream_chunks_from_response(response, true);
2036 assert!(saw_partial);
2037 assert_eq!(chunks.len(), 1);
2038 assert!(!chunks[0].partial);
2039 assert!(chunks[0].turn_complete);
2040 }
2041
2042 #[test]
2043 fn stream_chunks_from_response_keeps_tool_call_turn_open() {
2044 let response = LlmResponse {
2045 content: Some(Content {
2046 role: "model".to_string(),
2047 parts: vec![Part::FunctionCall {
2048 name: "get_weather".to_string(),
2049 args: serde_json::json!({"city": "Boston"}),
2050 id: Some("call-1".to_string()),
2051 thought_signature: None,
2052 }],
2053 }),
2054 finish_reason: Some(FinishReason::Stop),
2055 turn_complete: true,
2056 ..Default::default()
2057 };
2058
2059 let (chunks, _) = GeminiModel::stream_chunks_from_response(response, true);
2060
2061 assert_eq!(chunks.len(), 1);
2062 assert!(!chunks[0].partial);
2063 assert!(!chunks[0].turn_complete);
2064 }
2065
2066 #[tokio::test]
2067 async fn execute_with_retry_retries_retryable_errors() {
2068 let retry_config = RetryConfig::default()
2069 .with_max_retries(2)
2070 .with_initial_delay(Duration::from_millis(0))
2071 .with_max_delay(Duration::from_millis(0));
2072 let attempts = Arc::new(AtomicU32::new(0));
2073
2074 let result = execute_with_retry(&retry_config, is_retryable_model_error, || {
2075 let attempts = Arc::clone(&attempts);
2076 async move {
2077 let attempt = attempts.fetch_add(1, Ordering::SeqCst);
2078 if attempt < 2 {
2079 return Err(AdkError::model("code 429 RESOURCE_EXHAUSTED"));
2080 }
2081 Ok("ok")
2082 }
2083 })
2084 .await
2085 .expect("retry should eventually succeed");
2086
2087 assert_eq!(result, "ok");
2088 assert_eq!(attempts.load(Ordering::SeqCst), 3);
2089 }
2090
2091 #[tokio::test]
2092 async fn execute_with_retry_does_not_retry_non_retryable_errors() {
2093 let retry_config = RetryConfig::default()
2094 .with_max_retries(3)
2095 .with_initial_delay(Duration::from_millis(0))
2096 .with_max_delay(Duration::from_millis(0));
2097 let attempts = Arc::new(AtomicU32::new(0));
2098
2099 let error = execute_with_retry(&retry_config, is_retryable_model_error, || {
2100 let attempts = Arc::clone(&attempts);
2101 async move {
2102 attempts.fetch_add(1, Ordering::SeqCst);
2103 Err::<(), _>(AdkError::model("code 400 invalid request"))
2104 }
2105 })
2106 .await
2107 .expect_err("non-retryable error should return immediately");
2108
2109 assert!(error.is_model());
2110 assert_eq!(attempts.load(Ordering::SeqCst), 1);
2111 }
2112
2113 #[tokio::test]
2114 async fn execute_with_retry_respects_disabled_config() {
2115 let retry_config = RetryConfig::disabled().with_max_retries(10);
2116 let attempts = Arc::new(AtomicU32::new(0));
2117
2118 let error = execute_with_retry(&retry_config, is_retryable_model_error, || {
2119 let attempts = Arc::clone(&attempts);
2120 async move {
2121 attempts.fetch_add(1, Ordering::SeqCst);
2122 Err::<(), _>(AdkError::model("code 429 RESOURCE_EXHAUSTED"))
2123 }
2124 })
2125 .await
2126 .expect_err("disabled retries should return first error");
2127
2128 assert!(error.is_model());
2129 assert_eq!(attempts.load(Ordering::SeqCst), 1);
2130 }
2131
2132 #[test]
2133 fn convert_response_preserves_citation_metadata() {
2134 let response = adk_gemini::GenerationResponse {
2135 candidates: vec![adk_gemini::Candidate {
2136 content: adk_gemini::Content {
2137 role: Some(adk_gemini::Role::Model),
2138 parts: Some(vec![adk_gemini::Part::Text {
2139 text: "hello world".to_string(),
2140 thought: None,
2141 thought_signature: None,
2142 }]),
2143 },
2144 safety_ratings: None,
2145 citation_metadata: Some(adk_gemini::CitationMetadata {
2146 citation_sources: vec![adk_gemini::CitationSource {
2147 uri: Some("https://example.com".to_string()),
2148 title: Some("Example".to_string()),
2149 start_index: Some(0),
2150 end_index: Some(5),
2151 license: Some("CC-BY".to_string()),
2152 publication_date: None,
2153 }],
2154 }),
2155 grounding_metadata: None,
2156 finish_reason: Some(adk_gemini::FinishReason::Stop),
2157 index: Some(0),
2158 }],
2159 prompt_feedback: None,
2160 usage_metadata: None,
2161 model_version: None,
2162 response_id: None,
2163 };
2164
2165 let converted =
2166 GeminiModel::convert_response(&response).expect("conversion should succeed");
2167 let metadata = converted.citation_metadata.expect("citation metadata should be mapped");
2168 assert_eq!(metadata.citation_sources.len(), 1);
2169 assert_eq!(metadata.citation_sources[0].uri.as_deref(), Some("https://example.com"));
2170 assert_eq!(metadata.citation_sources[0].start_index, Some(0));
2171 assert_eq!(metadata.citation_sources[0].end_index, Some(5));
2172 }
2173
2174 #[test]
2175 fn convert_response_keeps_function_call_turn_open() {
2176 let response = adk_gemini::GenerationResponse {
2177 candidates: vec![adk_gemini::Candidate {
2178 content: adk_gemini::Content {
2179 role: Some(adk_gemini::Role::Model),
2180 parts: Some(vec![adk_gemini::Part::FunctionCall {
2181 function_call: adk_gemini::FunctionCall {
2182 name: "get_weather".to_string(),
2183 args: serde_json::json!({"city": "Boston"}),
2184 id: Some("call-1".to_string()),
2185 thought_signature: None,
2186 },
2187 thought_signature: None,
2188 }]),
2189 },
2190 safety_ratings: None,
2191 citation_metadata: None,
2192 grounding_metadata: None,
2193 finish_reason: Some(adk_gemini::FinishReason::Stop),
2194 index: Some(0),
2195 }],
2196 prompt_feedback: None,
2197 usage_metadata: None,
2198 model_version: None,
2199 response_id: None,
2200 };
2201
2202 let converted =
2203 GeminiModel::convert_response(&response).expect("conversion should succeed");
2204
2205 assert!(!converted.turn_complete);
2206 assert!(converted.content.is_some_and(|content| content.has_function_calls()));
2207 }
2208
2209 #[test]
2210 fn convert_response_handles_inline_data_from_model() {
2211 let image_bytes = vec![0x89, 0x50, 0x4E, 0x47];
2212 let encoded = crate::attachment::encode_base64(&image_bytes);
2213
2214 let response = adk_gemini::GenerationResponse {
2215 candidates: vec![adk_gemini::Candidate {
2216 content: adk_gemini::Content {
2217 role: Some(adk_gemini::Role::Model),
2218 parts: Some(vec![
2219 adk_gemini::Part::Text {
2220 text: "Here is the image".to_string(),
2221 thought: None,
2222 thought_signature: None,
2223 },
2224 adk_gemini::Part::InlineData {
2225 inline_data: adk_gemini::Blob {
2226 mime_type: "image/png".to_string(),
2227 data: encoded,
2228 },
2229 },
2230 ]),
2231 },
2232 safety_ratings: None,
2233 citation_metadata: None,
2234 grounding_metadata: None,
2235 finish_reason: Some(adk_gemini::FinishReason::Stop),
2236 index: Some(0),
2237 }],
2238 prompt_feedback: None,
2239 usage_metadata: None,
2240 model_version: None,
2241 response_id: None,
2242 };
2243
2244 let converted =
2245 GeminiModel::convert_response(&response).expect("conversion should succeed");
2246 let content = converted.content.expect("should have content");
2247 assert!(
2248 content
2249 .parts
2250 .iter()
2251 .any(|part| matches!(part, Part::Text { text } if text == "Here is the image"))
2252 );
2253 assert!(content.parts.iter().any(|part| {
2254 matches!(
2255 part,
2256 Part::InlineData { mime_type, data, .. }
2257 if mime_type == "image/png" && data.as_slice() == image_bytes.as_slice()
2258 )
2259 }));
2260 }
2261
2262 #[test]
2263 fn gemini_function_response_payload_preserves_objects() {
2264 let value = serde_json::json!({
2265 "documents": [
2266 { "id": "pricing", "score": 0.91 }
2267 ]
2268 });
2269
2270 let payload = GeminiModel::gemini_function_response_payload(value.clone());
2271
2272 assert_eq!(payload, value);
2273 }
2274
2275 #[test]
2276 fn gemini_function_response_payload_wraps_arrays() {
2277 let payload =
2278 GeminiModel::gemini_function_response_payload(serde_json::json!([{ "id": "pricing" }]));
2279
2280 assert_eq!(payload, serde_json::json!({ "result": [{ "id": "pricing" }] }));
2281 }
2282
2283 fn convert_function_response_to_gemini_fr(
2288 frd: &adk_core::FunctionResponseData,
2289 ) -> adk_gemini::tools::FunctionResponse {
2290 let mut fr_parts = Vec::new();
2291
2292 for inline in &frd.inline_data {
2293 let encoded = crate::attachment::encode_base64(&inline.data);
2294 fr_parts.push(adk_gemini::FunctionResponsePart::InlineData {
2295 inline_data: adk_gemini::Blob {
2296 mime_type: inline.mime_type.clone(),
2297 data: encoded,
2298 },
2299 });
2300 }
2301
2302 for file in &frd.file_data {
2303 fr_parts.push(adk_gemini::FunctionResponsePart::FileData {
2304 file_data: adk_gemini::FileDataRef {
2305 mime_type: file.mime_type.clone(),
2306 file_uri: file.file_uri.clone(),
2307 },
2308 });
2309 }
2310
2311 let mut gemini_fr = adk_gemini::tools::FunctionResponse::new(
2312 &frd.name,
2313 GeminiModel::gemini_function_response_payload(frd.response.clone()),
2314 );
2315 gemini_fr.parts = fr_parts;
2316 gemini_fr
2317 }
2318
2319 #[test]
2320 fn json_only_function_response_has_no_nested_parts() {
2321 let frd = adk_core::FunctionResponseData::new("tool", serde_json::json!({"ok": true}));
2322 let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2323 assert!(gemini_fr.parts.is_empty());
2324 let json = serde_json::to_string(&gemini_fr).unwrap();
2326 assert!(!json.contains("\"parts\""));
2327 }
2328
2329 #[test]
2330 fn function_response_with_inline_data_has_nested_parts() {
2331 let frd = adk_core::FunctionResponseData::with_inline_data(
2332 "chart",
2333 serde_json::json!({"status": "ok"}),
2334 vec![adk_core::InlineDataPart {
2335 mime_type: "image/png".to_string(),
2336 data: vec![0x89, 0x50, 0x4E, 0x47],
2337 uri: None,
2338 annotations: None,
2339 }],
2340 );
2341 let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2342 assert_eq!(gemini_fr.parts.len(), 1);
2343 match &gemini_fr.parts[0] {
2344 adk_gemini::FunctionResponsePart::InlineData { inline_data } => {
2345 assert_eq!(inline_data.mime_type, "image/png");
2346 let decoded = BASE64_STANDARD.decode(&inline_data.data).unwrap();
2347 assert_eq!(decoded, vec![0x89, 0x50, 0x4E, 0x47]);
2348 }
2349 other => panic!("expected InlineData, got {other:?}"),
2350 }
2351 }
2352
2353 #[test]
2354 fn function_response_with_file_data_has_nested_parts() {
2355 let frd = adk_core::FunctionResponseData::with_file_data(
2356 "doc",
2357 serde_json::json!({"ok": true}),
2358 vec![adk_core::FileDataPart {
2359 mime_type: "application/pdf".to_string(),
2360 file_uri: "gs://bucket/report.pdf".to_string(),
2361 annotations: None,
2362 }],
2363 );
2364 let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2365 assert_eq!(gemini_fr.parts.len(), 1);
2366 match &gemini_fr.parts[0] {
2367 adk_gemini::FunctionResponsePart::FileData { file_data } => {
2368 assert_eq!(file_data.mime_type, "application/pdf");
2369 assert_eq!(file_data.file_uri, "gs://bucket/report.pdf");
2370 }
2371 other => panic!("expected FileData, got {other:?}"),
2372 }
2373 }
2374
2375 #[test]
2376 fn function_response_with_both_inline_and_file_data_ordering() {
2377 let frd = adk_core::FunctionResponseData::with_multimodal(
2378 "multi",
2379 serde_json::json!({}),
2380 vec![
2381 adk_core::InlineDataPart {
2382 mime_type: "image/png".to_string(),
2383 data: vec![1, 2],
2384 uri: None,
2385 annotations: None,
2386 },
2387 adk_core::InlineDataPart {
2388 mime_type: "image/jpeg".to_string(),
2389 data: vec![3, 4],
2390 uri: None,
2391 annotations: None,
2392 },
2393 ],
2394 vec![adk_core::FileDataPart {
2395 mime_type: "application/pdf".to_string(),
2396 file_uri: "gs://b/f.pdf".to_string(),
2397 annotations: None,
2398 }],
2399 );
2400 let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2401 assert_eq!(gemini_fr.parts.len(), 3);
2403 assert!(matches!(&gemini_fr.parts[0], adk_gemini::FunctionResponsePart::InlineData { .. }));
2404 assert!(matches!(&gemini_fr.parts[1], adk_gemini::FunctionResponsePart::InlineData { .. }));
2405 assert!(matches!(&gemini_fr.parts[2], adk_gemini::FunctionResponsePart::FileData { .. }));
2406 }
2407}
2408
2409#[cfg(all(test, feature = "gemini-interactions"))]
2410mod interactions_transport_tests {
2411 use super::*;
2412 use adk_core::GenerateContentConfig;
2413
2414 #[test]
2419 fn default_interaction_options_match_api_posture() {
2420 let opts = InteractionOptions::default();
2421 assert!(opts.store, "store should default to true");
2422 assert!(opts.stateful, "stateful should default to true");
2423 assert_eq!(opts.background, BackgroundMode::AgentTargetsOnly);
2424 assert_eq!(opts.poll_interval, std::time::Duration::from_secs(1));
2425 }
2426
2427 #[test]
2428 fn new_model_defaults_to_generate_content_transport() {
2429 let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2430 .expect("constructing a Gemini model should not require network");
2431 assert_eq!(model.transport(), GeminiTransport::GenerateContent);
2432 }
2433
2434 #[test]
2435 fn use_interactions_api_enables_transport_for_allowlisted_model() {
2436 let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2437 .expect("construct model")
2438 .use_interactions_api(true)
2439 .expect("allowlisted model should enable the Interactions transport");
2440
2441 assert_eq!(model.transport(), GeminiTransport::Interactions);
2442 assert_eq!(
2443 model.interaction_target,
2444 Some(InteractionTarget::Model("gemini-2.5-flash".to_string()))
2445 );
2446 }
2447
2448 #[test]
2449 fn use_interactions_api_rejects_unsupported_model_with_invalid_input() {
2450 let result = GeminiModel::new("test-key", "gemini-2.0-flash")
2451 .expect("construct model")
2452 .use_interactions_api(true);
2453
2454 let err = match result {
2455 Ok(_) => panic!("unsupported model should be rejected"),
2456 Err(err) => err,
2457 };
2458 assert_eq!(err.category, adk_core::ErrorCategory::InvalidInput);
2459 assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2460 }
2461
2462 #[test]
2463 fn use_interactions_api_false_reverts_to_generate_content() {
2464 let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2465 .expect("construct model")
2466 .use_interactions_api(true)
2467 .expect("enable interactions")
2468 .use_interactions_api(false)
2469 .expect("disabling should always succeed");
2470
2471 assert_eq!(model.transport(), GeminiTransport::GenerateContent);
2472 assert_eq!(model.interaction_target, None);
2473 }
2474
2475 #[test]
2476 fn interaction_options_override_is_stored() {
2477 let opts = InteractionOptions {
2478 store: false,
2479 stateful: false,
2480 background: BackgroundMode::Always,
2481 poll_interval: std::time::Duration::from_millis(250),
2482 };
2483 let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2484 .expect("construct model")
2485 .interaction_options(opts.clone());
2486
2487 let stored = model.interaction_options_ref();
2488 assert_eq!(stored.store, opts.store);
2489 assert_eq!(stored.stateful, opts.stateful);
2490 assert_eq!(stored.background, opts.background);
2491 assert_eq!(stored.poll_interval, opts.poll_interval);
2492 }
2493
2494 #[test]
2495 fn resolve_background_honors_background_mode() {
2496 let always = GeminiModel::new("test-key", "gemini-2.5-flash")
2498 .expect("construct model")
2499 .use_interactions_api(true)
2500 .expect("enable")
2501 .interaction_options(InteractionOptions {
2502 background: BackgroundMode::Always,
2503 ..InteractionOptions::default()
2504 });
2505 assert!(always.resolve_background());
2506
2507 let never = GeminiModel::new("test-key", "gemini-2.5-flash")
2509 .expect("construct model")
2510 .use_interactions_api(true)
2511 .expect("enable")
2512 .interaction_options(InteractionOptions {
2513 background: BackgroundMode::Never,
2514 ..InteractionOptions::default()
2515 });
2516 assert!(!never.resolve_background());
2517
2518 let model_target = GeminiModel::new("test-key", "gemini-2.5-flash")
2520 .expect("construct model")
2521 .use_interactions_api(true)
2522 .expect("enable");
2523 assert!(!model_target.resolve_background());
2524
2525 let agent_target = GeminiModel::new("test-key", "deep-research-preview-04-2026")
2527 .expect("construct model")
2528 .use_interactions_api(true)
2529 .expect("enable");
2530 assert!(agent_target.resolve_background());
2531 }
2532
2533 #[test]
2536 fn terminal_failed_status_maps_to_error() {
2537 use adk_gemini::interactions::InteractionStatus;
2538
2539 let err = interaction_status_to_result(InteractionStatus::Failed)
2540 .expect_err("a failed interaction must surface an error");
2541 assert_eq!(err.category, adk_core::ErrorCategory::Internal);
2542 assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2543 assert_eq!(err.code, "model.gemini.interactions.failed");
2544 }
2545
2546 #[test]
2549 fn terminal_budget_exceeded_status_maps_to_error() {
2550 use adk_gemini::interactions::InteractionStatus;
2551
2552 let err = interaction_status_to_result(InteractionStatus::BudgetExceeded)
2553 .expect_err("a budget_exceeded interaction must surface an error");
2554 assert_eq!(err.category, adk_core::ErrorCategory::Internal);
2555 assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2556 assert_eq!(err.code, "model.gemini.interactions.budget_exceeded");
2557 }
2558
2559 #[test]
2562 fn non_failure_statuses_are_ok() {
2563 use adk_gemini::interactions::InteractionStatus;
2564
2565 for status in [
2566 InteractionStatus::InProgress,
2567 InteractionStatus::RequiresAction,
2568 InteractionStatus::Completed,
2569 InteractionStatus::Cancelled,
2570 InteractionStatus::Incomplete,
2571 ] {
2572 assert!(
2573 interaction_status_to_result(status).is_ok(),
2574 "status {status:?} should not map to an error"
2575 );
2576 }
2577 }
2578
2579 #[test]
2582 fn failed_interaction_resource_surfaces_error() {
2583 use adk_gemini::interactions::{Interaction, InteractionStatus};
2584
2585 let interaction = Interaction {
2586 id: "v1_failed".to_string(),
2587 model: Some("gemini-2.5-flash".to_string()),
2588 agent: None,
2589 status: InteractionStatus::Failed,
2590 steps: Vec::new(),
2591 usage: None,
2592 created: None,
2593 updated: None,
2594 environment_id: None,
2595 };
2596
2597 let err = interaction_status_to_result(interaction.status)
2598 .expect_err("failed interaction must surface an error");
2599 assert_eq!(err.category, adk_core::ErrorCategory::Internal);
2600 assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2601 }
2602 #[test]
2603 fn gemini_37_rejects_sampling_before_network_io() {
2604 let model = GeminiModel::new("test-key", "gemini-3.7-flash").expect("construct model");
2605 let mut request = LlmRequest::new("gemini-3.7-flash", Vec::new());
2606 request.config =
2607 Some(GenerateContentConfig { temperature: Some(0.2), ..Default::default() });
2608
2609 let error = model
2610 .validate_request_contract(&request)
2611 .expect_err("sampling must be rejected locally");
2612 assert_eq!(error.code, "model.gemini.sampling_unsupported");
2613 }
2614
2615 #[test]
2616 fn gemini_37_rejects_token_budget_thinking() {
2617 let model = GeminiModel::new("test-key", "gemini-3.7-flash")
2618 .expect("construct model")
2619 .with_thinking_config(adk_gemini::ThinkingConfig {
2620 thinking_budget: Some(1024),
2621 include_thoughts: None,
2622 thinking_level: None,
2623 });
2624
2625 let error = model
2626 .validate_request_contract(&LlmRequest::new("gemini-3.7-flash", Vec::new()))
2627 .expect_err("token budgets must be rejected locally");
2628 assert_eq!(error.code, "model.gemini.thinking_budget_unsupported");
2629 }
2630}
2631
2632#[cfg(all(test, feature = "gemini-vertex"))]
2633mod vertex_rag_tests {
2634 use super::*;
2635 use adk_gemini::VertexRagStore;
2636
2637 fn test_store() -> VertexRagStore {
2638 VertexRagStore::corpus("projects/p/locations/us-central1/ragCorpora/123").with_top_k(5)
2639 }
2640
2641 fn vertex_model() -> GeminiModel {
2642 GeminiModel::new_google_cloud("test-key", "p", "us-central1", "gemini-3.7-flash")
2643 .expect("construct vertex model")
2644 }
2645
2646 #[tokio::test]
2649 async fn vertex_backend_accepts_and_stores_the_rag_store() {
2650 let model = vertex_model().with_vertex_rag_store(test_store());
2651
2652 model
2653 .validate_request_contract(&LlmRequest::new("gemini-3.7-flash", Vec::new()))
2654 .expect("vertex backend must accept the rag store");
2655
2656 assert_eq!(model.vertex_rag_store(), Some(&test_store()));
2660 }
2661
2662 #[test]
2663 fn studio_backend_rejects_rag_store_before_network_io() {
2664 let model = GeminiModel::new("test-key", "gemini-3.7-flash")
2665 .expect("construct studio model")
2666 .with_vertex_rag_store(test_store());
2667
2668 let error = model
2669 .validate_request_contract(&LlmRequest::new("gemini-3.7-flash", Vec::new()))
2670 .expect_err("studio backend must reject the rag store locally");
2671 assert_eq!(error.code, "model.gemini.rag_store_requires_vertex");
2672 assert_eq!(error.category, adk_core::ErrorCategory::Unsupported);
2673 assert_eq!(error.details.provider.as_deref(), Some("gemini"));
2674 }
2675
2676 #[test]
2677 fn no_rag_store_means_no_gate() {
2678 let model = GeminiModel::new("test-key", "gemini-3.7-flash").expect("construct model");
2679 assert_eq!(model.vertex_rag_store(), None);
2680 model
2681 .validate_request_contract(&LlmRequest::new("gemini-3.7-flash", Vec::new()))
2682 .expect("no store configured, nothing to reject");
2683 }
2684}