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}
118
119fn gemini_error_to_adk(e: &adk_gemini::ClientError) -> adk_core::AdkError {
121 fn format_error_chain(e: &dyn std::error::Error) -> String {
122 let mut msg = e.to_string();
123 let mut source = e.source();
124 while let Some(s) = source {
125 msg.push_str(": ");
126 msg.push_str(&s.to_string());
127 source = s.source();
128 }
129 msg
130 }
131
132 let message = format_error_chain(e);
133
134 let (category, code, status_code) = if message.contains("code 429")
137 || message.contains("RESOURCE_EXHAUSTED")
138 || message.contains("rate limit")
139 {
140 (ErrorCategory::RateLimited, "model.gemini.rate_limited", Some(429u16))
141 } else if message.contains("code 503") || message.contains("UNAVAILABLE") {
142 (ErrorCategory::Unavailable, "model.gemini.unavailable", Some(503))
143 } else if message.contains("code 529") || message.contains("OVERLOADED") {
144 (ErrorCategory::Unavailable, "model.gemini.overloaded", Some(529))
145 } else if message.contains("code 408")
146 || message.contains("DEADLINE_EXCEEDED")
147 || message.contains("TIMEOUT")
148 {
149 (ErrorCategory::Timeout, "model.gemini.timeout", Some(408))
150 } else if message.contains("code 401") || message.contains("Invalid API key") {
151 (ErrorCategory::Unauthorized, "model.gemini.unauthorized", Some(401))
152 } else if message.contains("code 400") {
153 (ErrorCategory::InvalidInput, "model.gemini.bad_request", Some(400))
154 } else if message.contains("code 404") {
155 (ErrorCategory::NotFound, "model.gemini.not_found", Some(404))
156 } else if message.contains("invalid generation config") {
157 (ErrorCategory::InvalidInput, "model.gemini.invalid_config", None)
158 } else {
159 (ErrorCategory::Internal, "model.gemini.internal", None)
160 };
161
162 let mut err = adk_core::AdkError::new(ErrorComponent::Model, category, code, message)
163 .with_provider("gemini");
164 if let Some(sc) = status_code {
165 err = err.with_upstream_status(sc);
166 }
167 err
168}
169
170#[cfg(feature = "gemini-interactions")]
181fn interaction_status_to_result(status: adk_gemini::interactions::InteractionStatus) -> Result<()> {
182 use adk_gemini::interactions::InteractionStatus;
183 match status {
184 InteractionStatus::Failed => Err(interaction_terminal_error(
185 "model.gemini.interactions.failed",
186 "the Gemini interaction terminated with status `failed`",
187 )),
188 InteractionStatus::BudgetExceeded => Err(interaction_terminal_error(
189 "model.gemini.interactions.budget_exceeded",
190 "the Gemini interaction terminated with status `budget_exceeded`",
191 )),
192 InteractionStatus::InProgress
193 | InteractionStatus::RequiresAction
194 | InteractionStatus::Completed
195 | InteractionStatus::Cancelled
196 | InteractionStatus::Incomplete => Ok(()),
197 }
198}
199
200#[cfg(feature = "gemini-interactions")]
202fn interaction_terminal_error(code: &'static str, message: &str) -> adk_core::AdkError {
203 adk_core::AdkError::new(
204 ErrorComponent::Model,
205 ErrorCategory::Internal,
206 code,
207 message.to_string(),
208 )
209 .with_provider("gemini")
210}
211
212fn env_non_empty(name: &str) -> Option<String> {
215 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
216}
217
218fn env_flag_truthy(value: &str) -> bool {
221 let v = value.trim();
222 v == "1" || v.eq_ignore_ascii_case("true")
223}
224
225pub fn vertex_env_requested() -> bool {
240 if let Some(v) = env_non_empty("GOOGLE_GENAI_USE_ENTERPRISE") {
244 return env_flag_truthy(&v);
245 }
246 env_non_empty("GOOGLE_GENAI_USE_VERTEXAI").is_some_and(|v| env_flag_truthy(&v))
247}
248
249#[cfg(feature = "gemini-vertex")]
252fn vertex_target_from_env() -> Result<(String, String)> {
253 let project = env_non_empty("GOOGLE_CLOUD_PROJECT");
254 let location = env_non_empty("GOOGLE_CLOUD_LOCATION");
255 if let (Some(project), Some(location)) = (&project, &location) {
256 return Ok((project.clone(), location.clone()));
257 }
258 let mut missing = Vec::new();
259 if project.is_none() {
260 missing.push("GOOGLE_CLOUD_PROJECT");
261 }
262 if location.is_none() {
263 missing.push("GOOGLE_CLOUD_LOCATION");
264 }
265 Err(adk_core::AdkError::new(
266 ErrorComponent::Model,
267 ErrorCategory::InvalidInput,
268 "model.gemini.vertex_env_incomplete",
269 format!(
270 "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).",
271 missing.join(" and ")
272 ),
273 )
274 .with_provider("gemini"))
275}
276
277impl GeminiModel {
278 fn gemini_part_thought_signature(value: &serde_json::Value) -> Option<String> {
279 value.get("thoughtSignature").and_then(serde_json::Value::as_str).map(str::to_string)
280 }
281
282 fn validate_request_contract(&self, req: &LlmRequest) -> Result<()> {
283 let model = self.model_name.strip_prefix("models/").unwrap_or(&self.model_name);
284 if !matches!(model, "gemini-3.6-flash" | "gemini-3.7-flash") {
285 return Ok(());
286 }
287
288 if let Some(config) = &req.config
289 && (config.temperature.is_some() || config.top_p.is_some() || config.top_k.is_some())
290 {
291 return Err(adk_core::AdkError::new(
292 ErrorComponent::Model,
293 ErrorCategory::InvalidInput,
294 "model.gemini.sampling_unsupported",
295 format!(
296 "{model} does not accept temperature, top_p, or top_k; remove explicit sampling parameters"
297 ),
298 )
299 .with_provider("gemini"));
300 }
301 if self.thinking_config.as_ref().is_some_and(|config| config.thinking_budget.is_some()) {
302 return Err(adk_core::AdkError::new(
303 ErrorComponent::Model,
304 ErrorCategory::InvalidInput,
305 "model.gemini.thinking_budget_unsupported",
306 format!(
307 "{model} uses thinking levels instead of token budgets; set thinking_level and clear thinking_budget"
308 ),
309 )
310 .with_provider("gemini"));
311 }
312 Ok(())
313 }
314
315 fn from_client(client: Gemini, model_name: String) -> Self {
321 Self {
322 client,
323 model_name,
324 retry_config: RetryConfig::default(),
325 thinking_config: None,
326 #[cfg(feature = "gemini-interactions")]
327 transport: GeminiTransport::GenerateContent,
328 #[cfg(feature = "gemini-interactions")]
329 interaction_target: None,
330 #[cfg(feature = "gemini-interactions")]
331 interaction_options: InteractionOptions::default(),
332 }
333 }
334
335 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Result<Self> {
337 let model_name = model.into();
338 crate::catalog::warn_if_obsolete("gemini", &model_name);
339 let client = Gemini::with_model(api_key.into(), model_name.clone())
340 .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
341
342 Ok(Self::from_client(client, model_name))
343 }
344
345 #[cfg(feature = "gemini-vertex")]
349 pub fn new_google_cloud(
350 api_key: impl Into<String>,
351 project_id: impl AsRef<str>,
352 location: impl AsRef<str>,
353 model: impl Into<String>,
354 ) -> Result<Self> {
355 let model_name = model.into();
356 let client = Gemini::with_google_cloud_model(
357 api_key.into(),
358 project_id,
359 location,
360 model_name.clone(),
361 )
362 .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
363
364 Ok(Self::from_client(client, model_name))
365 }
366
367 #[cfg(feature = "gemini-vertex")]
371 pub fn new_google_cloud_service_account(
372 service_account_json: &str,
373 project_id: impl AsRef<str>,
374 location: impl AsRef<str>,
375 model: impl Into<String>,
376 ) -> Result<Self> {
377 let model_name = model.into();
378 let client = Gemini::with_google_cloud_service_account_json(
379 service_account_json,
380 project_id.as_ref(),
381 location.as_ref(),
382 model_name.clone(),
383 )
384 .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
385
386 Ok(Self::from_client(client, model_name))
387 }
388
389 #[cfg(feature = "gemini-vertex")]
393 pub fn new_google_cloud_adc(
394 project_id: impl AsRef<str>,
395 location: impl AsRef<str>,
396 model: impl Into<String>,
397 ) -> Result<Self> {
398 let model_name = model.into();
399 let client = Gemini::with_google_cloud_adc_model(
400 project_id.as_ref(),
401 location.as_ref(),
402 model_name.clone(),
403 )
404 .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
405
406 Ok(Self::from_client(client, model_name))
407 }
408
409 #[cfg(feature = "gemini-vertex")]
413 pub fn new_google_cloud_wif(
414 wif_json: &str,
415 project_id: impl AsRef<str>,
416 location: impl AsRef<str>,
417 model: impl Into<String>,
418 ) -> Result<Self> {
419 let model_name = model.into();
420 let client = Gemini::with_google_cloud_wif_json(
421 wif_json,
422 project_id.as_ref(),
423 location.as_ref(),
424 model_name.clone(),
425 )
426 .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
427
428 Ok(Self::from_client(client, model_name))
429 }
430
431 pub fn from_env(model: &str) -> Result<Self> {
467 if vertex_env_requested() {
468 #[cfg(feature = "gemini-vertex")]
469 {
470 let (project, location) = vertex_target_from_env()?;
471 return Self::new_google_cloud_adc(project, location, model);
472 }
473 #[cfg(not(feature = "gemini-vertex"))]
474 {
475 return Err(adk_core::AdkError::new(
476 ErrorComponent::Model,
477 ErrorCategory::Unsupported,
478 "model.gemini.vertex_feature_missing",
479 "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.",
480 )
481 .with_provider("gemini"));
482 }
483 }
484
485 let Some(api_key) =
486 env_non_empty("GOOGLE_API_KEY").or_else(|| env_non_empty("GEMINI_API_KEY"))
487 else {
488 return Err(adk_core::AdkError::new(
489 ErrorComponent::Model,
490 ErrorCategory::InvalidInput,
491 "model.gemini.api_key_missing",
492 "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.",
493 )
494 .with_provider("gemini"));
495 };
496 Self::new(api_key, model)
497 }
498
499 #[must_use]
501 pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
502 self.retry_config = retry_config;
503 self
504 }
505
506 pub fn set_retry_config(&mut self, retry_config: RetryConfig) {
508 self.retry_config = retry_config;
509 }
510
511 pub fn retry_config(&self) -> &RetryConfig {
513 &self.retry_config
514 }
515
516 #[must_use]
540 pub fn with_thinking_config(mut self, thinking_config: adk_gemini::ThinkingConfig) -> Self {
541 self.thinking_config = Some(thinking_config);
542 self
543 }
544
545 pub fn set_thinking_config(&mut self, thinking_config: adk_gemini::ThinkingConfig) {
547 self.thinking_config = Some(thinking_config);
548 }
549
550 pub fn thinking_config(&self) -> Option<&adk_gemini::ThinkingConfig> {
552 self.thinking_config.as_ref()
553 }
554
555 #[cfg(feature = "gemini-interactions")]
579 pub fn use_interactions_api(mut self, enabled: bool) -> Result<Self> {
580 if enabled {
581 let target = InteractionTarget::parse(&self.model_name)?;
582 self.transport = GeminiTransport::Interactions;
583 self.interaction_target = Some(target);
584 } else {
585 self.transport = GeminiTransport::GenerateContent;
586 self.interaction_target = None;
587 }
588 Ok(self)
589 }
590
591 #[cfg(feature = "gemini-interactions")]
598 #[must_use]
599 pub fn interaction_options(mut self, opts: InteractionOptions) -> Self {
600 self.interaction_options = opts;
601 self
602 }
603
604 #[cfg(feature = "gemini-interactions")]
608 pub fn transport(&self) -> GeminiTransport {
609 self.transport
610 }
611
612 #[cfg(feature = "gemini-interactions")]
616 pub fn interaction_options_ref(&self) -> &InteractionOptions {
617 &self.interaction_options
618 }
619
620 #[cfg(feature = "gemini-interactions")]
632 fn resolve_background(&self) -> bool {
633 match self.interaction_options.background {
634 BackgroundMode::Always => true,
635 BackgroundMode::Never => false,
636 BackgroundMode::AgentTargetsOnly => {
637 self.interaction_target.as_ref().is_some_and(InteractionTarget::is_agent)
638 }
639 }
640 }
641
642 fn convert_response(resp: &adk_gemini::GenerationResponse) -> Result<LlmResponse> {
643 let mut converted_parts: Vec<Part> = Vec::new();
644
645 if let Some(parts) = resp.candidates.first().and_then(|c| c.content.parts.as_ref()) {
647 for p in parts {
648 match p {
649 adk_gemini::Part::Text { text, thought, thought_signature } => {
650 if thought == &Some(true) {
651 converted_parts.push(Part::Thinking {
652 thinking: text.clone(),
653 signature: thought_signature.clone(),
654 });
655 } else {
656 converted_parts.push(Part::Text { text: text.clone() });
657 }
658 }
659 adk_gemini::Part::InlineData { inline_data } => {
660 let decoded =
661 BASE64_STANDARD.decode(&inline_data.data).map_err(|error| {
662 adk_core::AdkError::model(format!(
663 "failed to decode inline data from gemini response: {error}"
664 ))
665 })?;
666 converted_parts.push(Part::InlineData {
667 mime_type: inline_data.mime_type.clone(),
668 data: decoded,
669 uri: None,
670 annotations: None,
671 });
672 }
673 adk_gemini::Part::FunctionCall { function_call, thought_signature } => {
674 converted_parts.push(Part::FunctionCall {
675 name: function_call.name.clone(),
676 args: function_call.args.clone(),
677 id: function_call.id.clone(),
678 thought_signature: thought_signature.clone(),
679 });
680 }
681 adk_gemini::Part::FunctionResponse { function_response, .. } => {
682 converted_parts.push(Part::FunctionResponse {
683 function_response: adk_core::FunctionResponseData::new(
684 function_response.name.clone(),
685 function_response
686 .response
687 .clone()
688 .unwrap_or(serde_json::Value::Null),
689 ),
690 id: None,
691 annotations: None,
692 });
693 }
694 adk_gemini::Part::ToolCall { .. } | adk_gemini::Part::ExecutableCode { .. } => {
695 if let Ok(value) = serde_json::to_value(p) {
696 converted_parts.push(Part::ServerToolCall { server_tool_call: value });
697 }
698 }
699 adk_gemini::Part::ToolResponse { .. }
700 | adk_gemini::Part::CodeExecutionResult { .. } => {
701 let value = serde_json::to_value(p).unwrap_or(serde_json::Value::Null);
702 converted_parts
703 .push(Part::ServerToolResponse { server_tool_response: value });
704 }
705 adk_gemini::Part::FileData { file_data } => {
706 converted_parts.push(Part::FileData {
707 mime_type: file_data.mime_type.clone(),
708 file_uri: file_data.file_uri.clone(),
709 annotations: None,
710 });
711 }
712 }
713 }
714 }
715
716 if let Some(grounding) = resp.candidates.first().and_then(|c| c.grounding_metadata.as_ref())
718 {
719 if let Some(queries) = &grounding.web_search_queries
720 && !queries.is_empty()
721 {
722 let search_info = format!("\n\n🔍 **Searched:** {}", queries.join(", "));
723 converted_parts.push(Part::Text { text: search_info });
724 }
725 if let Some(chunks) = &grounding.grounding_chunks {
726 let sources: Vec<String> = chunks
727 .iter()
728 .filter_map(|c| {
729 c.web.as_ref().and_then(|w| match (&w.title, &w.uri) {
730 (Some(title), Some(uri)) => Some(format!("[{}]({})", title, uri)),
731 (Some(title), None) => Some(title.clone()),
732 (None, Some(uri)) => Some(uri.to_string()),
733 (None, None) => None,
734 })
735 })
736 .collect();
737 if !sources.is_empty() {
738 let sources_info = format!("\n📚 **Sources:** {}", sources.join(" | "));
739 converted_parts.push(Part::Text { text: sources_info });
740 }
741 }
742 }
743
744 let content = if converted_parts.is_empty() {
745 None
746 } else {
747 Some(Content { role: "model".to_string(), parts: converted_parts })
748 };
749 let tool_call_turn = content.as_ref().is_some_and(Content::has_function_calls);
750
751 let usage_metadata = resp.usage_metadata.as_ref().map(|u| UsageMetadata {
752 prompt_token_count: u.prompt_token_count.unwrap_or(0),
753 candidates_token_count: u.candidates_token_count.unwrap_or(0),
754 total_token_count: u.total_token_count.unwrap_or(0),
755 thinking_token_count: u.thoughts_token_count,
756 cache_read_input_token_count: u.cached_content_token_count,
757 ..Default::default()
758 });
759
760 let finish_reason =
761 resp.candidates.first().and_then(|c| c.finish_reason.as_ref()).map(|fr| match fr {
762 adk_gemini::FinishReason::Stop => FinishReason::Stop,
763 adk_gemini::FinishReason::MaxTokens => FinishReason::MaxTokens,
764 adk_gemini::FinishReason::Safety => FinishReason::Safety,
765 adk_gemini::FinishReason::Recitation => FinishReason::Recitation,
766 _ => FinishReason::Other,
767 });
768
769 let citation_metadata =
770 resp.candidates.first().and_then(|c| c.citation_metadata.as_ref()).map(|meta| {
771 CitationMetadata {
772 citation_sources: meta
773 .citation_sources
774 .iter()
775 .map(|source| CitationSource {
776 uri: source.uri.clone(),
777 title: source.title.clone(),
778 start_index: source.start_index,
779 end_index: source.end_index,
780 license: source.license.clone(),
781 publication_date: source.publication_date.map(|d| d.to_string()),
782 })
783 .collect(),
784 }
785 });
786
787 let provider_metadata = resp
790 .candidates
791 .first()
792 .and_then(|c| c.grounding_metadata.as_ref())
793 .and_then(|g| serde_json::to_value(g).ok());
794
795 Ok(LlmResponse {
796 content,
797 usage_metadata,
798 finish_reason,
799 citation_metadata,
800 partial: false,
801 turn_complete: !tool_call_turn,
802 interrupted: false,
803 error_code: None,
804 error_message: None,
805 provider_metadata,
806 interaction_id: None,
807 })
808 }
809
810 fn gemini_function_response_payload(response: serde_json::Value) -> serde_json::Value {
811 match response {
812 serde_json::Value::Object(_) => response,
814 other => serde_json::json!({ "result": other }),
815 }
816 }
817
818 fn merge_object_value(
819 target: &mut serde_json::Map<String, serde_json::Value>,
820 value: serde_json::Value,
821 ) {
822 if let serde_json::Value::Object(object) = value {
823 for (key, value) in object {
824 target.insert(key, value);
825 }
826 }
827 }
828
829 fn build_gemini_tools(
830 tools: &std::collections::HashMap<String, serde_json::Value>,
831 adapter: &dyn SchemaAdapter,
832 cache: &SchemaCache,
833 ) -> Result<(Vec<adk_gemini::Tool>, adk_gemini::ToolConfig)> {
834 let mut gemini_tools = Vec::new();
835 let mut function_declarations = Vec::new();
836 let mut has_provider_native_tools = false;
837 let mut tool_config_json = serde_json::Map::new();
838
839 for (name, tool_decl) in tools {
840 if let Some(provider_tool) = tool_decl.get("x-adk-gemini-tool") {
841 let tool = serde_json::from_value::<adk_gemini::Tool>(provider_tool.clone())
842 .map_err(|error| {
843 adk_core::AdkError::model(format!(
844 "failed to deserialize Gemini native tool '{name}': {error}"
845 ))
846 })?;
847 has_provider_native_tools = true;
848 gemini_tools.push(tool);
849 } else {
850 let normalized_name = adapter.normalize_tool_name(name);
852
853 let schema =
856 tool_decl.get("parameters").cloned().unwrap_or_else(|| adapter.empty_schema());
857 let normalized_schema = cache.normalize(&schema);
858
859 let description =
861 tool_decl.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
862
863 let mut func_decl_json = serde_json::json!({
864 "name": normalized_name.as_ref(),
865 "description": description,
866 "parameters": normalized_schema,
867 });
868
869 if let Some(response) = tool_decl.get("response") {
871 func_decl_json["response"] = cache.normalize(response);
872 }
873
874 if let Some(behavior) = tool_decl.get("behavior") {
876 func_decl_json["behavior"] = behavior.clone();
877 }
878
879 let func_decl =
880 serde_json::from_value::<adk_gemini::FunctionDeclaration>(func_decl_json)
881 .map_err(|error| {
882 adk_core::AdkError::model(format!(
883 "failed to build Gemini function declaration for '{name}': {error}"
884 ))
885 })?;
886 function_declarations.push(func_decl);
887 }
888
889 if let Some(tool_config) = tool_decl.get("x-adk-gemini-tool-config") {
890 Self::merge_object_value(&mut tool_config_json, tool_config.clone());
891 }
892 }
893
894 let has_function_declarations = !function_declarations.is_empty();
895 if has_function_declarations {
896 gemini_tools.push(adk_gemini::Tool::with_functions(function_declarations));
897 }
898
899 if has_provider_native_tools {
900 tool_config_json.insert(
901 "includeServerSideToolInvocations".to_string(),
902 serde_json::Value::Bool(true),
903 );
904 }
905
906 let tool_config = if tool_config_json.is_empty() {
907 adk_gemini::ToolConfig::default()
908 } else {
909 serde_json::from_value::<adk_gemini::ToolConfig>(serde_json::Value::Object(
910 tool_config_json,
911 ))
912 .map_err(|error| {
913 adk_core::AdkError::model(format!(
914 "failed to deserialize Gemini tool configuration: {error}"
915 ))
916 })?
917 };
918
919 Ok((gemini_tools, tool_config))
920 }
921
922 fn stream_chunks_from_response(
923 mut response: LlmResponse,
924 saw_partial_chunk: bool,
925 ) -> (Vec<LlmResponse>, bool) {
926 let is_final = response.finish_reason.is_some();
927
928 if !is_final {
929 response.partial = true;
930 response.turn_complete = false;
931 return (vec![response], true);
932 }
933
934 response.partial = false;
935 response.turn_complete =
936 response.content.as_ref().is_none_or(|content| !content.has_function_calls());
937
938 if saw_partial_chunk {
939 return (vec![response], true);
940 }
941
942 let synthetic_partial = LlmResponse {
943 content: None,
944 usage_metadata: None,
945 finish_reason: None,
946 citation_metadata: None,
947 partial: true,
948 turn_complete: false,
949 interrupted: false,
950 error_code: None,
951 error_message: None,
952 provider_metadata: None,
953 interaction_id: None,
954 };
955
956 (vec![synthetic_partial, response], true)
957 }
958
959 async fn generate_content_internal(
960 &self,
961 req: LlmRequest,
962 stream: bool,
963 ) -> Result<LlmResponseStream> {
964 let mut builder = self.client.generate_content();
965
966 let mut fn_call_signatures: std::collections::HashMap<String, String> =
971 std::collections::HashMap::new();
972 for content in &req.contents {
973 if content.role == "model" {
974 for part in &content.parts {
975 if let Part::FunctionCall { name, thought_signature: Some(sig), .. } = part {
976 fn_call_signatures.insert(name.clone(), sig.clone());
977 }
978 }
979 }
980 }
981
982 for content in &req.contents {
984 match content.role.as_str() {
985 "user" => {
986 let mut gemini_parts = Vec::new();
988 for part in &content.parts {
989 match part {
990 Part::Text { text } => {
991 gemini_parts.push(adk_gemini::Part::Text {
992 text: text.clone(),
993 thought: None,
994 thought_signature: None,
995 });
996 }
997 Part::Thinking { thinking, signature } => {
998 gemini_parts.push(adk_gemini::Part::Text {
999 text: thinking.clone(),
1000 thought: Some(true),
1001 thought_signature: signature.clone(),
1002 });
1003 }
1004 Part::InlineData { data, mime_type, .. } => {
1005 let encoded = attachment::encode_base64(data);
1006 gemini_parts.push(adk_gemini::Part::InlineData {
1007 inline_data: adk_gemini::Blob {
1008 mime_type: mime_type.clone(),
1009 data: encoded,
1010 },
1011 });
1012 }
1013 Part::FileData { mime_type, file_uri, .. } => {
1014 gemini_parts.push(adk_gemini::Part::Text {
1015 text: attachment::file_attachment_to_text(mime_type, file_uri),
1016 thought: None,
1017 thought_signature: None,
1018 });
1019 }
1020 _ => {}
1021 }
1022 }
1023 if !gemini_parts.is_empty() {
1024 let user_content = adk_gemini::Content {
1025 role: Some(adk_gemini::Role::User),
1026 parts: Some(gemini_parts),
1027 };
1028 builder = builder.with_message(adk_gemini::Message {
1029 content: user_content,
1030 role: adk_gemini::Role::User,
1031 });
1032 }
1033 }
1034 "model" => {
1035 let mut gemini_parts = Vec::new();
1037 for part in &content.parts {
1038 match part {
1039 Part::Text { text } => {
1040 gemini_parts.push(adk_gemini::Part::Text {
1041 text: text.clone(),
1042 thought: None,
1043 thought_signature: None,
1044 });
1045 }
1046 Part::Thinking { thinking, signature } => {
1047 gemini_parts.push(adk_gemini::Part::Text {
1048 text: thinking.clone(),
1049 thought: Some(true),
1050 thought_signature: signature.clone(),
1051 });
1052 }
1053 Part::FunctionCall { name, args, thought_signature, id } => {
1054 gemini_parts.push(adk_gemini::Part::FunctionCall {
1055 function_call: adk_gemini::FunctionCall {
1056 name: name.clone(),
1057 args: args.clone(),
1058 id: id.clone(),
1059 thought_signature: None,
1060 },
1061 thought_signature: thought_signature.clone(),
1062 });
1063 }
1064 Part::ServerToolCall { server_tool_call } => {
1065 if let Ok(native_part) = serde_json::from_value::<adk_gemini::Part>(
1066 server_tool_call.clone(),
1067 ) {
1068 match native_part {
1069 adk_gemini::Part::ToolCall { .. }
1070 | adk_gemini::Part::ExecutableCode { .. } => {
1071 gemini_parts.push(native_part);
1072 continue;
1073 }
1074 _ => {}
1075 }
1076 }
1077
1078 gemini_parts.push(adk_gemini::Part::ToolCall {
1079 tool_call: server_tool_call.clone(),
1080 thought_signature: Self::gemini_part_thought_signature(
1081 server_tool_call,
1082 ),
1083 });
1084 }
1085 Part::ServerToolResponse { server_tool_response } => {
1086 if let Ok(native_part) = serde_json::from_value::<adk_gemini::Part>(
1087 server_tool_response.clone(),
1088 ) {
1089 match native_part {
1090 adk_gemini::Part::ToolResponse { .. }
1091 | adk_gemini::Part::CodeExecutionResult { .. } => {
1092 gemini_parts.push(native_part);
1093 continue;
1094 }
1095 _ => {}
1096 }
1097 }
1098
1099 gemini_parts.push(adk_gemini::Part::ToolResponse {
1100 tool_response: server_tool_response.clone(),
1101 thought_signature: Self::gemini_part_thought_signature(
1102 server_tool_response,
1103 ),
1104 });
1105 }
1106 _ => {}
1107 }
1108 }
1109 if !gemini_parts.is_empty() {
1110 let model_content = adk_gemini::Content {
1111 role: Some(adk_gemini::Role::Model),
1112 parts: Some(gemini_parts),
1113 };
1114 builder = builder.with_message(adk_gemini::Message {
1115 content: model_content,
1116 role: adk_gemini::Role::Model,
1117 });
1118 }
1119 }
1120 "function" => {
1121 let mut gemini_parts = Vec::new();
1124 for part in &content.parts {
1125 if let Part::FunctionResponse { function_response, id, .. } = part {
1126 let sig = fn_call_signatures.get(&function_response.name).cloned();
1127
1128 let mut fr_parts = Vec::new();
1130 for inline in &function_response.inline_data {
1131 let encoded = attachment::encode_base64(&inline.data);
1132 fr_parts.push(adk_gemini::FunctionResponsePart::InlineData {
1133 inline_data: adk_gemini::Blob {
1134 mime_type: inline.mime_type.clone(),
1135 data: encoded,
1136 },
1137 });
1138 }
1139 for file in &function_response.file_data {
1140 fr_parts.push(adk_gemini::FunctionResponsePart::FileData {
1141 file_data: adk_gemini::FileDataRef {
1142 mime_type: file.mime_type.clone(),
1143 file_uri: file.file_uri.clone(),
1144 },
1145 });
1146 }
1147
1148 let mut gemini_fr = adk_gemini::tools::FunctionResponse::new(
1149 &function_response.name,
1150 Self::gemini_function_response_payload(
1151 function_response.response.clone(),
1152 ),
1153 );
1154 gemini_fr.parts = fr_parts;
1155 gemini_fr.id = id.clone();
1158
1159 gemini_parts.push(adk_gemini::Part::FunctionResponse {
1160 function_response: gemini_fr,
1161 thought_signature: sig,
1162 });
1163 }
1164 }
1165 if !gemini_parts.is_empty() {
1166 let fn_content = adk_gemini::Content {
1167 role: Some(adk_gemini::Role::User),
1168 parts: Some(gemini_parts),
1169 };
1170 builder = builder.with_message(adk_gemini::Message {
1171 content: fn_content,
1172 role: adk_gemini::Role::User,
1173 });
1174 }
1175 }
1176 _ => {}
1177 }
1178 }
1179
1180 if let Some(config) = req.config {
1182 let has_schema = config.response_schema.is_some();
1183 let gen_config = adk_gemini::GenerationConfig {
1184 temperature: config.temperature,
1185 top_p: config.top_p,
1186 top_k: config.top_k,
1187 max_output_tokens: config.max_output_tokens,
1188 response_schema: config.response_schema,
1189 response_mime_type: if has_schema {
1190 Some("application/json".to_string())
1191 } else {
1192 None
1193 },
1194 thinking_config: self.thinking_config.clone(),
1195 ..Default::default()
1196 };
1197 builder = builder.with_generation_config(gen_config);
1198
1199 if let Some(ref name) = config.cached_content {
1201 let handle = self.client.get_cached_content(name);
1202 builder = builder.with_cached_content(&handle);
1203 }
1204 } else if self.thinking_config.is_some() {
1205 let gen_config = adk_gemini::GenerationConfig {
1208 thinking_config: self.thinking_config.clone(),
1209 ..Default::default()
1210 };
1211 builder = builder.with_generation_config(gen_config);
1212 }
1213
1214 if !req.tools.is_empty() {
1216 let adapter = self.schema_adapter();
1217 use std::sync::LazyLock;
1218 static SCHEMA_CACHE: LazyLock<SchemaCache> = LazyLock::new(|| {
1219 SchemaCache::for_adapter(std::sync::Arc::new(GeminiSchemaAdapter::new()))
1220 });
1221 let (gemini_tools, tool_config) =
1222 Self::build_gemini_tools(&req.tools, adapter, &SCHEMA_CACHE)?;
1223 for tool in gemini_tools {
1224 builder = builder.with_tool(tool);
1225 }
1226 if tool_config != adk_gemini::ToolConfig::default() {
1227 builder = builder.with_tool_config(tool_config);
1228 }
1229 }
1230
1231 if stream {
1232 adk_telemetry::debug!("Executing streaming request");
1233 let response_stream = builder.execute_stream().await.map_err(|e| {
1234 adk_telemetry::error!(error = %e, "Model request failed");
1235 gemini_error_to_adk(&e)
1236 })?;
1237
1238 let mapped_stream = async_stream::stream! {
1239 let mut stream = response_stream;
1240 let mut saw_partial_chunk = false;
1241 while let Some(result) = stream.try_next().await.transpose() {
1242 match result {
1243 Ok(resp) => {
1244 match Self::convert_response(&resp) {
1245 Ok(llm_resp) => {
1246 let (chunks, next_saw_partial) =
1247 Self::stream_chunks_from_response(llm_resp, saw_partial_chunk);
1248 saw_partial_chunk = next_saw_partial;
1249 for chunk in chunks {
1250 yield Ok(chunk);
1251 }
1252 }
1253 Err(e) => {
1254 adk_telemetry::error!(error = %e, "Failed to convert response");
1255 yield Err(e);
1256 }
1257 }
1258 }
1259 Err(e) => {
1260 adk_telemetry::error!(error = %e, "Stream error");
1261 yield Err(gemini_error_to_adk(&e));
1262 }
1263 }
1264 }
1265 };
1266
1267 Ok(Box::pin(mapped_stream))
1268 } else {
1269 adk_telemetry::debug!("Executing blocking request");
1270 let response = builder.execute().await.map_err(|e| {
1271 adk_telemetry::error!(error = %e, "Model request failed");
1272 gemini_error_to_adk(&e)
1273 })?;
1274
1275 let llm_response = Self::convert_response(&response)?;
1276
1277 let stream = async_stream::stream! {
1278 yield Ok(llm_response);
1279 };
1280
1281 Ok(Box::pin(stream))
1282 }
1283 }
1284
1285 pub async fn create_cached_content(
1290 &self,
1291 system_instruction: &str,
1292 tools: &std::collections::HashMap<String, serde_json::Value>,
1293 ttl_seconds: u32,
1294 ) -> Result<String> {
1295 let mut cache_builder = self
1296 .client
1297 .create_cache()
1298 .with_system_instruction(system_instruction)
1299 .with_ttl(std::time::Duration::from_secs(u64::from(ttl_seconds)));
1300
1301 let adapter = self.schema_adapter();
1302 use std::sync::LazyLock;
1303 static SCHEMA_CACHE: LazyLock<SchemaCache> = LazyLock::new(|| {
1304 SchemaCache::for_adapter(std::sync::Arc::new(GeminiSchemaAdapter::new()))
1305 });
1306 let (gemini_tools, tool_config) = Self::build_gemini_tools(tools, adapter, &SCHEMA_CACHE)?;
1307 if !gemini_tools.is_empty() {
1308 cache_builder = cache_builder.with_tools(gemini_tools);
1309 }
1310 if tool_config != adk_gemini::ToolConfig::default() {
1311 cache_builder = cache_builder.with_tool_config(tool_config);
1312 }
1313
1314 let handle = cache_builder
1315 .execute()
1316 .await
1317 .map_err(|e| adk_core::AdkError::model(format!("cache creation failed: {e}")))?;
1318
1319 Ok(handle.name().to_string())
1320 }
1321
1322 pub async fn delete_cached_content(&self, name: &str) -> Result<()> {
1324 let handle = self.client.get_cached_content(name);
1325 handle
1326 .delete()
1327 .await
1328 .map_err(|(_, e)| adk_core::AdkError::model(format!("cache deletion failed: {e}")))?;
1329 Ok(())
1330 }
1331
1332 #[cfg(feature = "gemini-interactions")]
1360 async fn generate_interactions_once(&self, req: LlmRequest) -> Result<LlmResponse> {
1361 use super::interactions_convert;
1362
1363 let target = self.interaction_target.as_ref().ok_or_else(|| {
1366 adk_core::AdkError::new(
1367 ErrorComponent::Model,
1368 ErrorCategory::InvalidInput,
1369 "model.gemini.interactions.missing_target",
1370 "the Interactions transport is active but no validated target is configured",
1371 )
1372 .with_provider("gemini")
1373 })?;
1374
1375 let thinking_level = self.thinking_config.as_ref().and_then(|c| c.thinking_level);
1378
1379 let stateful = self.interaction_options.stateful;
1380 let store = self.interaction_options.store;
1381 let background = self.resolve_background();
1382
1383 let mut request =
1385 interactions_convert::build_request(&req, target, thinking_level, stateful, store)?;
1386 request.background = Some(background);
1387
1388 let interaction = match self.client.send_interaction(request.clone()).await {
1390 Ok(interaction) => interaction,
1391 Err(error) => {
1392 let mapped = gemini_error_to_adk(&error);
1393 if mapped.category == ErrorCategory::NotFound && req.previous_response_id.is_some()
1397 {
1398 let mut fallback = interactions_convert::build_request(
1399 &req,
1400 target,
1401 thinking_level,
1402 false,
1403 store,
1404 )?;
1405 fallback.background = Some(background);
1406 self.client
1407 .send_interaction(fallback)
1408 .await
1409 .map_err(|e| gemini_error_to_adk(&e))?
1410 } else {
1411 return Err(mapped);
1412 }
1413 }
1414 };
1415
1416 let final_interaction = if background {
1418 self.poll_interaction_to_completion(interaction).await?
1419 } else {
1420 interaction
1421 };
1422
1423 interaction_status_to_result(final_interaction.status)?;
1425
1426 Ok(interactions_convert::to_llm_response(&final_interaction))
1427 }
1428
1429 #[cfg(feature = "gemini-interactions")]
1460 async fn poll_interaction_to_completion(
1461 &self,
1462 interaction: adk_gemini::interactions::Interaction,
1463 ) -> Result<adk_gemini::interactions::Interaction> {
1464 const MAX_POLL_ATTEMPTS: u32 = 600;
1469
1470 let mut current = interaction;
1471 let mut attempts: u32 = 0;
1472 while !current.status.is_terminal() && !current.status.requires_action() {
1473 if attempts >= MAX_POLL_ATTEMPTS {
1474 return Err(adk_core::AdkError::new(
1475 ErrorComponent::Model,
1476 ErrorCategory::Timeout,
1477 "model.gemini.interactions.poll_timeout",
1478 format!(
1479 "the Gemini interaction did not reach a terminal or requires_action \
1480 state after {MAX_POLL_ATTEMPTS} poll attempts"
1481 ),
1482 )
1483 .with_provider("gemini"));
1484 }
1485 attempts += 1;
1486 tokio::time::sleep(self.interaction_options.poll_interval).await;
1487 current = self
1488 .client
1489 .get_interaction(¤t.id, false)
1490 .await
1491 .map_err(|e| gemini_error_to_adk(&e))?;
1492 }
1493 Ok(current)
1494 }
1495
1496 #[cfg(feature = "gemini-interactions")]
1521 async fn generate_interactions_stream(&self, req: LlmRequest) -> Result<LlmResponseStream> {
1522 use super::interactions_convert::{self, SseAccumulator, sse_event_to_chunk};
1523
1524 let target = self.interaction_target.as_ref().ok_or_else(|| {
1525 adk_core::AdkError::new(
1526 ErrorComponent::Model,
1527 ErrorCategory::InvalidInput,
1528 "model.gemini.interactions.missing_target",
1529 "the Interactions transport is active but no validated target is configured",
1530 )
1531 .with_provider("gemini")
1532 })?;
1533
1534 let thinking_level = self.thinking_config.as_ref().and_then(|c| c.thinking_level);
1535 let stateful = self.interaction_options.stateful;
1536 let store = self.interaction_options.store;
1537
1538 let mut request =
1539 interactions_convert::build_request(&req, target, thinking_level, stateful, store)?;
1540 request.background = Some(false);
1545
1546 let sse_stream = self
1547 .client
1548 .send_interaction_stream(request)
1549 .await
1550 .map_err(|e| gemini_error_to_adk(&e))?;
1551
1552 let mapped = async_stream::stream! {
1553 let mut sse_stream = sse_stream;
1554 let mut acc = SseAccumulator::new();
1555 while let Some(result) = sse_stream.try_next().await.transpose() {
1556 match result {
1557 Ok(event) => {
1558 if let Some(chunk) = sse_event_to_chunk(event, &mut acc) {
1559 yield chunk;
1560 }
1561 }
1562 Err(e) => {
1563 adk_telemetry::error!(error = %e, "Interaction stream error");
1564 yield Err(gemini_error_to_adk(&e));
1565 }
1566 }
1567 }
1568 };
1569
1570 Ok(Box::pin(mapped))
1571 }
1572}
1573
1574#[async_trait]
1575impl Llm for GeminiModel {
1576 fn name(&self) -> &str {
1577 &self.model_name
1578 }
1579
1580 fn schema_adapter(&self) -> &dyn SchemaAdapter {
1581 use std::sync::LazyLock;
1582 static ADAPTER: LazyLock<GeminiSchemaAdapter> = LazyLock::new(GeminiSchemaAdapter::new);
1583 &*ADAPTER
1584 }
1585
1586 #[cfg(feature = "gemini-interactions")]
1587 fn uses_interactions_api(&self) -> bool {
1588 self.transport == GeminiTransport::Interactions
1589 }
1590
1591 #[adk_telemetry::instrument(
1596 name = "model.generate_content",
1597 skip(self, req),
1598 fields(
1599 model.name = %self.model_name,
1600 stream = %stream,
1601 request.contents_count = %req.contents.len(),
1602 request.tools_count = %req.tools.len()
1603 )
1604 )]
1605 async fn generate_content(&self, req: LlmRequest, stream: bool) -> Result<LlmResponseStream> {
1606 adk_telemetry::info!("Generating content");
1607 self.validate_request_contract(&req)?;
1608 let usage_span = adk_telemetry::llm_generate_span("gemini", &self.model_name, stream);
1609
1610 #[cfg(feature = "gemini-interactions")]
1618 if self.transport == GeminiTransport::Interactions {
1619 if stream {
1628 let mapped =
1629 execute_with_retry(&self.retry_config, is_retryable_model_error, || {
1630 self.generate_interactions_stream(req.clone())
1631 })
1632 .await?;
1633 return Ok(crate::usage_tracking::with_usage_tracking(mapped, usage_span));
1634 }
1635 let response = execute_with_retry(&self.retry_config, is_retryable_model_error, || {
1636 self.generate_interactions_once(req.clone())
1637 })
1638 .await?;
1639 let single = async_stream::stream! {
1640 yield Ok(response);
1641 };
1642 return Ok(crate::usage_tracking::with_usage_tracking(Box::pin(single), usage_span));
1643 }
1644
1645 let result = execute_with_retry(&self.retry_config, is_retryable_model_error, || {
1646 self.generate_content_internal(req.clone(), stream)
1647 })
1648 .await?;
1649 Ok(crate::usage_tracking::with_usage_tracking(result, usage_span))
1650 }
1651}
1652
1653#[cfg(test)]
1654mod native_tool_tests {
1655 use super::*;
1656
1657 fn test_adapter() -> GeminiSchemaAdapter {
1658 GeminiSchemaAdapter::new()
1659 }
1660
1661 fn test_cache() -> SchemaCache {
1662 SchemaCache::for_adapter(std::sync::Arc::new(GeminiSchemaAdapter::new()))
1663 }
1664
1665 #[test]
1666 fn test_build_gemini_tools_supports_native_tool_metadata() {
1667 let mut tools = std::collections::HashMap::new();
1668 tools.insert(
1669 "google_search".to_string(),
1670 serde_json::json!({
1671 "x-adk-gemini-tool": {
1672 "google_search": {}
1673 }
1674 }),
1675 );
1676 tools.insert(
1677 "lookup_weather".to_string(),
1678 serde_json::json!({
1679 "name": "lookup_weather",
1680 "description": "lookup weather",
1681 "parameters": {
1682 "type": "object",
1683 "properties": {
1684 "city": { "type": "string" }
1685 }
1686 }
1687 }),
1688 );
1689
1690 let adapter = test_adapter();
1691 let cache = test_cache();
1692 let (gemini_tools, tool_config) = GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1693 .expect("tool conversion should succeed");
1694
1695 assert_eq!(gemini_tools.len(), 2);
1696 assert_eq!(tool_config.include_server_side_tool_invocations, Some(true));
1697 }
1698
1699 #[test]
1700 fn test_build_gemini_tools_sets_flag_for_builtin_only() {
1701 let mut tools = std::collections::HashMap::new();
1702 tools.insert(
1703 "google_search".to_string(),
1704 serde_json::json!({
1705 "x-adk-gemini-tool": {
1706 "google_search": {}
1707 }
1708 }),
1709 );
1710
1711 let adapter = test_adapter();
1712 let cache = test_cache();
1713 let (_gemini_tools, tool_config) =
1714 GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1715 .expect("tool conversion should succeed");
1716
1717 assert_eq!(
1718 tool_config.include_server_side_tool_invocations,
1719 Some(true),
1720 "includeServerSideToolInvocations should be set even with only built-in tools"
1721 );
1722 }
1723
1724 #[test]
1725 fn test_build_gemini_tools_no_flag_for_function_only() {
1726 let mut tools = std::collections::HashMap::new();
1727 tools.insert(
1728 "lookup_weather".to_string(),
1729 serde_json::json!({
1730 "name": "lookup_weather",
1731 "description": "lookup weather",
1732 "parameters": {
1733 "type": "object",
1734 "properties": {
1735 "city": { "type": "string" }
1736 }
1737 }
1738 }),
1739 );
1740
1741 let adapter = test_adapter();
1742 let cache = test_cache();
1743 let (_gemini_tools, tool_config) =
1744 GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1745 .expect("tool conversion should succeed");
1746
1747 assert_eq!(
1748 tool_config.include_server_side_tool_invocations, None,
1749 "includeServerSideToolInvocations should NOT be set for function-only tools"
1750 );
1751 }
1752
1753 #[test]
1754 fn test_build_gemini_tools_merges_native_tool_config() {
1755 let mut tools = std::collections::HashMap::new();
1756 tools.insert(
1757 "google_maps".to_string(),
1758 serde_json::json!({
1759 "x-adk-gemini-tool": {
1760 "google_maps": {
1761 "enable_widget": true
1762 }
1763 },
1764 "x-adk-gemini-tool-config": {
1765 "retrievalConfig": {
1766 "latLng": {
1767 "latitude": 1.23,
1768 "longitude": 4.56
1769 }
1770 }
1771 }
1772 }),
1773 );
1774
1775 let adapter = test_adapter();
1776 let cache = test_cache();
1777 let (_gemini_tools, tool_config) =
1778 GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1779 .expect("tool conversion should succeed");
1780
1781 assert_eq!(
1782 tool_config.retrieval_config,
1783 Some(serde_json::json!({
1784 "latLng": {
1785 "latitude": 1.23,
1786 "longitude": 4.56
1787 }
1788 }))
1789 );
1790 }
1791
1792 #[test]
1793 fn test_response_schema_is_normalized_like_parameters() {
1794 let mut tools = std::collections::HashMap::new();
1800 tools.insert(
1801 "read_file".to_string(),
1802 serde_json::json!({
1803 "name": "read_file",
1804 "description": "Read a file from the filesystem",
1805 "parameters": {
1806 "$schema": "http://json-schema.org/draft-07/schema#",
1807 "type": "object",
1808 "properties": {
1809 "path": { "type": "string", "description": "File path" }
1810 },
1811 "required": ["path"],
1812 "additionalProperties": false
1813 },
1814 "response": {
1815 "$schema": "http://json-schema.org/draft-07/schema#",
1816 "type": "object",
1817 "properties": {
1818 "content": { "type": "string", "description": "File contents" }
1819 },
1820 "required": ["content"],
1821 "additionalProperties": false
1822 }
1823 }),
1824 );
1825
1826 let adapter = test_adapter();
1827 let cache = test_cache();
1828 let (gemini_tools, _) = GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1829 .expect("tool conversion should succeed");
1830
1831 let func_tool = gemini_tools
1833 .iter()
1834 .find(|t| matches!(t, adk_gemini::Tool::Function { .. }))
1835 .expect("should have function declarations");
1836
1837 let decls = match func_tool {
1838 adk_gemini::Tool::Function { function_declarations } => function_declarations,
1839 _ => panic!("expected Function tool"),
1840 };
1841
1842 let decl = &decls[0];
1843 let decl_json = serde_json::to_value(decl).unwrap();
1844
1845 let params = &decl_json["parameters"];
1847 assert!(params.get("$schema").is_none(), "parameters.$schema should be stripped");
1848 assert!(
1849 params.get("additionalProperties").is_none(),
1850 "parameters.additionalProperties should be stripped"
1851 );
1852
1853 let response = &decl_json["response"];
1855 assert!(
1856 response.get("$schema").is_none(),
1857 "response.$schema should be stripped (was the bug: copied raw)"
1858 );
1859 assert!(
1860 response.get("additionalProperties").is_none(),
1861 "response.additionalProperties should be stripped (was the bug: copied raw)"
1862 );
1863 }
1864}
1865
1866#[async_trait]
1867impl CacheCapable for GeminiModel {
1868 async fn create_cache(
1869 &self,
1870 system_instruction: &str,
1871 tools: &std::collections::HashMap<String, serde_json::Value>,
1872 ttl_seconds: u32,
1873 ) -> Result<String> {
1874 self.create_cached_content(system_instruction, tools, ttl_seconds).await
1875 }
1876
1877 async fn delete_cache(&self, name: &str) -> Result<()> {
1878 self.delete_cached_content(name).await
1879 }
1880}
1881
1882#[cfg(test)]
1883mod tests {
1884 use super::*;
1885 use adk_core::AdkError;
1886 use std::{
1887 sync::{
1888 Arc,
1889 atomic::{AtomicU32, Ordering},
1890 },
1891 time::Duration,
1892 };
1893
1894 #[test]
1895 fn env_flag_truthy_accepts_one_and_case_insensitive_true() {
1896 for truthy in ["1", "true", "TRUE", "True", " true ", "tRuE"] {
1897 assert!(env_flag_truthy(truthy), "expected {truthy:?} to be truthy");
1898 }
1899 for falsy in ["0", "false", "FALSE", "yes", "on", "", "2", "vertex"] {
1900 assert!(!env_flag_truthy(falsy), "expected {falsy:?} to be falsy");
1901 }
1902 }
1903
1904 #[test]
1905 fn constructor_is_backward_compatible_and_sync() {
1906 fn accepts_sync_constructor<F>(_f: F)
1907 where
1908 F: Fn(&str, &str) -> Result<GeminiModel>,
1909 {
1910 }
1911
1912 accepts_sync_constructor(|api_key, model| GeminiModel::new(api_key, model));
1913 }
1914
1915 #[test]
1916 fn stream_chunks_from_response_injects_partial_before_lone_final_chunk() {
1917 let response = LlmResponse {
1918 content: Some(Content::new("model").with_text("hello")),
1919 usage_metadata: None,
1920 finish_reason: Some(FinishReason::Stop),
1921 citation_metadata: None,
1922 partial: false,
1923 turn_complete: true,
1924 interrupted: false,
1925 error_code: None,
1926 error_message: None,
1927 provider_metadata: None,
1928 interaction_id: None,
1929 };
1930
1931 let (chunks, saw_partial) = GeminiModel::stream_chunks_from_response(response, false);
1932 assert!(saw_partial);
1933 assert_eq!(chunks.len(), 2);
1934 assert!(chunks[0].partial);
1935 assert!(!chunks[0].turn_complete);
1936 assert!(chunks[0].content.is_none());
1937 assert!(!chunks[1].partial);
1938 assert!(chunks[1].turn_complete);
1939 }
1940
1941 #[test]
1942 fn stream_chunks_from_response_keeps_final_only_when_partial_already_seen() {
1943 let response = LlmResponse {
1944 content: Some(Content::new("model").with_text("done")),
1945 usage_metadata: None,
1946 finish_reason: Some(FinishReason::Stop),
1947 citation_metadata: None,
1948 partial: false,
1949 turn_complete: true,
1950 interrupted: false,
1951 error_code: None,
1952 error_message: None,
1953 provider_metadata: None,
1954 interaction_id: None,
1955 };
1956
1957 let (chunks, saw_partial) = GeminiModel::stream_chunks_from_response(response, true);
1958 assert!(saw_partial);
1959 assert_eq!(chunks.len(), 1);
1960 assert!(!chunks[0].partial);
1961 assert!(chunks[0].turn_complete);
1962 }
1963
1964 #[test]
1965 fn stream_chunks_from_response_keeps_tool_call_turn_open() {
1966 let response = LlmResponse {
1967 content: Some(Content {
1968 role: "model".to_string(),
1969 parts: vec![Part::FunctionCall {
1970 name: "get_weather".to_string(),
1971 args: serde_json::json!({"city": "Boston"}),
1972 id: Some("call-1".to_string()),
1973 thought_signature: None,
1974 }],
1975 }),
1976 finish_reason: Some(FinishReason::Stop),
1977 turn_complete: true,
1978 ..Default::default()
1979 };
1980
1981 let (chunks, _) = GeminiModel::stream_chunks_from_response(response, true);
1982
1983 assert_eq!(chunks.len(), 1);
1984 assert!(!chunks[0].partial);
1985 assert!(!chunks[0].turn_complete);
1986 }
1987
1988 #[tokio::test]
1989 async fn execute_with_retry_retries_retryable_errors() {
1990 let retry_config = RetryConfig::default()
1991 .with_max_retries(2)
1992 .with_initial_delay(Duration::from_millis(0))
1993 .with_max_delay(Duration::from_millis(0));
1994 let attempts = Arc::new(AtomicU32::new(0));
1995
1996 let result = execute_with_retry(&retry_config, is_retryable_model_error, || {
1997 let attempts = Arc::clone(&attempts);
1998 async move {
1999 let attempt = attempts.fetch_add(1, Ordering::SeqCst);
2000 if attempt < 2 {
2001 return Err(AdkError::model("code 429 RESOURCE_EXHAUSTED"));
2002 }
2003 Ok("ok")
2004 }
2005 })
2006 .await
2007 .expect("retry should eventually succeed");
2008
2009 assert_eq!(result, "ok");
2010 assert_eq!(attempts.load(Ordering::SeqCst), 3);
2011 }
2012
2013 #[tokio::test]
2014 async fn execute_with_retry_does_not_retry_non_retryable_errors() {
2015 let retry_config = RetryConfig::default()
2016 .with_max_retries(3)
2017 .with_initial_delay(Duration::from_millis(0))
2018 .with_max_delay(Duration::from_millis(0));
2019 let attempts = Arc::new(AtomicU32::new(0));
2020
2021 let error = execute_with_retry(&retry_config, is_retryable_model_error, || {
2022 let attempts = Arc::clone(&attempts);
2023 async move {
2024 attempts.fetch_add(1, Ordering::SeqCst);
2025 Err::<(), _>(AdkError::model("code 400 invalid request"))
2026 }
2027 })
2028 .await
2029 .expect_err("non-retryable error should return immediately");
2030
2031 assert!(error.is_model());
2032 assert_eq!(attempts.load(Ordering::SeqCst), 1);
2033 }
2034
2035 #[tokio::test]
2036 async fn execute_with_retry_respects_disabled_config() {
2037 let retry_config = RetryConfig::disabled().with_max_retries(10);
2038 let attempts = Arc::new(AtomicU32::new(0));
2039
2040 let error = execute_with_retry(&retry_config, is_retryable_model_error, || {
2041 let attempts = Arc::clone(&attempts);
2042 async move {
2043 attempts.fetch_add(1, Ordering::SeqCst);
2044 Err::<(), _>(AdkError::model("code 429 RESOURCE_EXHAUSTED"))
2045 }
2046 })
2047 .await
2048 .expect_err("disabled retries should return first error");
2049
2050 assert!(error.is_model());
2051 assert_eq!(attempts.load(Ordering::SeqCst), 1);
2052 }
2053
2054 #[test]
2055 fn convert_response_preserves_citation_metadata() {
2056 let response = adk_gemini::GenerationResponse {
2057 candidates: vec![adk_gemini::Candidate {
2058 content: adk_gemini::Content {
2059 role: Some(adk_gemini::Role::Model),
2060 parts: Some(vec![adk_gemini::Part::Text {
2061 text: "hello world".to_string(),
2062 thought: None,
2063 thought_signature: None,
2064 }]),
2065 },
2066 safety_ratings: None,
2067 citation_metadata: Some(adk_gemini::CitationMetadata {
2068 citation_sources: vec![adk_gemini::CitationSource {
2069 uri: Some("https://example.com".to_string()),
2070 title: Some("Example".to_string()),
2071 start_index: Some(0),
2072 end_index: Some(5),
2073 license: Some("CC-BY".to_string()),
2074 publication_date: None,
2075 }],
2076 }),
2077 grounding_metadata: None,
2078 finish_reason: Some(adk_gemini::FinishReason::Stop),
2079 index: Some(0),
2080 }],
2081 prompt_feedback: None,
2082 usage_metadata: None,
2083 model_version: None,
2084 response_id: None,
2085 };
2086
2087 let converted =
2088 GeminiModel::convert_response(&response).expect("conversion should succeed");
2089 let metadata = converted.citation_metadata.expect("citation metadata should be mapped");
2090 assert_eq!(metadata.citation_sources.len(), 1);
2091 assert_eq!(metadata.citation_sources[0].uri.as_deref(), Some("https://example.com"));
2092 assert_eq!(metadata.citation_sources[0].start_index, Some(0));
2093 assert_eq!(metadata.citation_sources[0].end_index, Some(5));
2094 }
2095
2096 #[test]
2097 fn convert_response_keeps_function_call_turn_open() {
2098 let response = adk_gemini::GenerationResponse {
2099 candidates: vec![adk_gemini::Candidate {
2100 content: adk_gemini::Content {
2101 role: Some(adk_gemini::Role::Model),
2102 parts: Some(vec![adk_gemini::Part::FunctionCall {
2103 function_call: adk_gemini::FunctionCall {
2104 name: "get_weather".to_string(),
2105 args: serde_json::json!({"city": "Boston"}),
2106 id: Some("call-1".to_string()),
2107 thought_signature: None,
2108 },
2109 thought_signature: None,
2110 }]),
2111 },
2112 safety_ratings: None,
2113 citation_metadata: None,
2114 grounding_metadata: None,
2115 finish_reason: Some(adk_gemini::FinishReason::Stop),
2116 index: Some(0),
2117 }],
2118 prompt_feedback: None,
2119 usage_metadata: None,
2120 model_version: None,
2121 response_id: None,
2122 };
2123
2124 let converted =
2125 GeminiModel::convert_response(&response).expect("conversion should succeed");
2126
2127 assert!(!converted.turn_complete);
2128 assert!(converted.content.is_some_and(|content| content.has_function_calls()));
2129 }
2130
2131 #[test]
2132 fn convert_response_handles_inline_data_from_model() {
2133 let image_bytes = vec![0x89, 0x50, 0x4E, 0x47];
2134 let encoded = crate::attachment::encode_base64(&image_bytes);
2135
2136 let response = adk_gemini::GenerationResponse {
2137 candidates: vec![adk_gemini::Candidate {
2138 content: adk_gemini::Content {
2139 role: Some(adk_gemini::Role::Model),
2140 parts: Some(vec![
2141 adk_gemini::Part::Text {
2142 text: "Here is the image".to_string(),
2143 thought: None,
2144 thought_signature: None,
2145 },
2146 adk_gemini::Part::InlineData {
2147 inline_data: adk_gemini::Blob {
2148 mime_type: "image/png".to_string(),
2149 data: encoded,
2150 },
2151 },
2152 ]),
2153 },
2154 safety_ratings: None,
2155 citation_metadata: None,
2156 grounding_metadata: None,
2157 finish_reason: Some(adk_gemini::FinishReason::Stop),
2158 index: Some(0),
2159 }],
2160 prompt_feedback: None,
2161 usage_metadata: None,
2162 model_version: None,
2163 response_id: None,
2164 };
2165
2166 let converted =
2167 GeminiModel::convert_response(&response).expect("conversion should succeed");
2168 let content = converted.content.expect("should have content");
2169 assert!(
2170 content
2171 .parts
2172 .iter()
2173 .any(|part| matches!(part, Part::Text { text } if text == "Here is the image"))
2174 );
2175 assert!(content.parts.iter().any(|part| {
2176 matches!(
2177 part,
2178 Part::InlineData { mime_type, data, .. }
2179 if mime_type == "image/png" && data.as_slice() == image_bytes.as_slice()
2180 )
2181 }));
2182 }
2183
2184 #[test]
2185 fn gemini_function_response_payload_preserves_objects() {
2186 let value = serde_json::json!({
2187 "documents": [
2188 { "id": "pricing", "score": 0.91 }
2189 ]
2190 });
2191
2192 let payload = GeminiModel::gemini_function_response_payload(value.clone());
2193
2194 assert_eq!(payload, value);
2195 }
2196
2197 #[test]
2198 fn gemini_function_response_payload_wraps_arrays() {
2199 let payload =
2200 GeminiModel::gemini_function_response_payload(serde_json::json!([{ "id": "pricing" }]));
2201
2202 assert_eq!(payload, serde_json::json!({ "result": [{ "id": "pricing" }] }));
2203 }
2204
2205 fn convert_function_response_to_gemini_fr(
2210 frd: &adk_core::FunctionResponseData,
2211 ) -> adk_gemini::tools::FunctionResponse {
2212 let mut fr_parts = Vec::new();
2213
2214 for inline in &frd.inline_data {
2215 let encoded = crate::attachment::encode_base64(&inline.data);
2216 fr_parts.push(adk_gemini::FunctionResponsePart::InlineData {
2217 inline_data: adk_gemini::Blob {
2218 mime_type: inline.mime_type.clone(),
2219 data: encoded,
2220 },
2221 });
2222 }
2223
2224 for file in &frd.file_data {
2225 fr_parts.push(adk_gemini::FunctionResponsePart::FileData {
2226 file_data: adk_gemini::FileDataRef {
2227 mime_type: file.mime_type.clone(),
2228 file_uri: file.file_uri.clone(),
2229 },
2230 });
2231 }
2232
2233 let mut gemini_fr = adk_gemini::tools::FunctionResponse::new(
2234 &frd.name,
2235 GeminiModel::gemini_function_response_payload(frd.response.clone()),
2236 );
2237 gemini_fr.parts = fr_parts;
2238 gemini_fr
2239 }
2240
2241 #[test]
2242 fn json_only_function_response_has_no_nested_parts() {
2243 let frd = adk_core::FunctionResponseData::new("tool", serde_json::json!({"ok": true}));
2244 let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2245 assert!(gemini_fr.parts.is_empty());
2246 let json = serde_json::to_string(&gemini_fr).unwrap();
2248 assert!(!json.contains("\"parts\""));
2249 }
2250
2251 #[test]
2252 fn function_response_with_inline_data_has_nested_parts() {
2253 let frd = adk_core::FunctionResponseData::with_inline_data(
2254 "chart",
2255 serde_json::json!({"status": "ok"}),
2256 vec![adk_core::InlineDataPart {
2257 mime_type: "image/png".to_string(),
2258 data: vec![0x89, 0x50, 0x4E, 0x47],
2259 uri: None,
2260 annotations: None,
2261 }],
2262 );
2263 let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2264 assert_eq!(gemini_fr.parts.len(), 1);
2265 match &gemini_fr.parts[0] {
2266 adk_gemini::FunctionResponsePart::InlineData { inline_data } => {
2267 assert_eq!(inline_data.mime_type, "image/png");
2268 let decoded = BASE64_STANDARD.decode(&inline_data.data).unwrap();
2269 assert_eq!(decoded, vec![0x89, 0x50, 0x4E, 0x47]);
2270 }
2271 other => panic!("expected InlineData, got {other:?}"),
2272 }
2273 }
2274
2275 #[test]
2276 fn function_response_with_file_data_has_nested_parts() {
2277 let frd = adk_core::FunctionResponseData::with_file_data(
2278 "doc",
2279 serde_json::json!({"ok": true}),
2280 vec![adk_core::FileDataPart {
2281 mime_type: "application/pdf".to_string(),
2282 file_uri: "gs://bucket/report.pdf".to_string(),
2283 annotations: None,
2284 }],
2285 );
2286 let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2287 assert_eq!(gemini_fr.parts.len(), 1);
2288 match &gemini_fr.parts[0] {
2289 adk_gemini::FunctionResponsePart::FileData { file_data } => {
2290 assert_eq!(file_data.mime_type, "application/pdf");
2291 assert_eq!(file_data.file_uri, "gs://bucket/report.pdf");
2292 }
2293 other => panic!("expected FileData, got {other:?}"),
2294 }
2295 }
2296
2297 #[test]
2298 fn function_response_with_both_inline_and_file_data_ordering() {
2299 let frd = adk_core::FunctionResponseData::with_multimodal(
2300 "multi",
2301 serde_json::json!({}),
2302 vec![
2303 adk_core::InlineDataPart {
2304 mime_type: "image/png".to_string(),
2305 data: vec![1, 2],
2306 uri: None,
2307 annotations: None,
2308 },
2309 adk_core::InlineDataPart {
2310 mime_type: "image/jpeg".to_string(),
2311 data: vec![3, 4],
2312 uri: None,
2313 annotations: None,
2314 },
2315 ],
2316 vec![adk_core::FileDataPart {
2317 mime_type: "application/pdf".to_string(),
2318 file_uri: "gs://b/f.pdf".to_string(),
2319 annotations: None,
2320 }],
2321 );
2322 let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2323 assert_eq!(gemini_fr.parts.len(), 3);
2325 assert!(matches!(&gemini_fr.parts[0], adk_gemini::FunctionResponsePart::InlineData { .. }));
2326 assert!(matches!(&gemini_fr.parts[1], adk_gemini::FunctionResponsePart::InlineData { .. }));
2327 assert!(matches!(&gemini_fr.parts[2], adk_gemini::FunctionResponsePart::FileData { .. }));
2328 }
2329}
2330
2331#[cfg(all(test, feature = "gemini-interactions"))]
2332mod interactions_transport_tests {
2333 use super::*;
2334 use adk_core::GenerateContentConfig;
2335
2336 #[test]
2341 fn default_interaction_options_match_api_posture() {
2342 let opts = InteractionOptions::default();
2343 assert!(opts.store, "store should default to true");
2344 assert!(opts.stateful, "stateful should default to true");
2345 assert_eq!(opts.background, BackgroundMode::AgentTargetsOnly);
2346 assert_eq!(opts.poll_interval, std::time::Duration::from_secs(1));
2347 }
2348
2349 #[test]
2350 fn new_model_defaults_to_generate_content_transport() {
2351 let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2352 .expect("constructing a Gemini model should not require network");
2353 assert_eq!(model.transport(), GeminiTransport::GenerateContent);
2354 }
2355
2356 #[test]
2357 fn use_interactions_api_enables_transport_for_allowlisted_model() {
2358 let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2359 .expect("construct model")
2360 .use_interactions_api(true)
2361 .expect("allowlisted model should enable the Interactions transport");
2362
2363 assert_eq!(model.transport(), GeminiTransport::Interactions);
2364 assert_eq!(
2365 model.interaction_target,
2366 Some(InteractionTarget::Model("gemini-2.5-flash".to_string()))
2367 );
2368 }
2369
2370 #[test]
2371 fn use_interactions_api_rejects_unsupported_model_with_invalid_input() {
2372 let result = GeminiModel::new("test-key", "gemini-2.0-flash")
2373 .expect("construct model")
2374 .use_interactions_api(true);
2375
2376 let err = match result {
2377 Ok(_) => panic!("unsupported model should be rejected"),
2378 Err(err) => err,
2379 };
2380 assert_eq!(err.category, adk_core::ErrorCategory::InvalidInput);
2381 assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2382 }
2383
2384 #[test]
2385 fn use_interactions_api_false_reverts_to_generate_content() {
2386 let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2387 .expect("construct model")
2388 .use_interactions_api(true)
2389 .expect("enable interactions")
2390 .use_interactions_api(false)
2391 .expect("disabling should always succeed");
2392
2393 assert_eq!(model.transport(), GeminiTransport::GenerateContent);
2394 assert_eq!(model.interaction_target, None);
2395 }
2396
2397 #[test]
2398 fn interaction_options_override_is_stored() {
2399 let opts = InteractionOptions {
2400 store: false,
2401 stateful: false,
2402 background: BackgroundMode::Always,
2403 poll_interval: std::time::Duration::from_millis(250),
2404 };
2405 let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2406 .expect("construct model")
2407 .interaction_options(opts.clone());
2408
2409 let stored = model.interaction_options_ref();
2410 assert_eq!(stored.store, opts.store);
2411 assert_eq!(stored.stateful, opts.stateful);
2412 assert_eq!(stored.background, opts.background);
2413 assert_eq!(stored.poll_interval, opts.poll_interval);
2414 }
2415
2416 #[test]
2417 fn resolve_background_honors_background_mode() {
2418 let always = GeminiModel::new("test-key", "gemini-2.5-flash")
2420 .expect("construct model")
2421 .use_interactions_api(true)
2422 .expect("enable")
2423 .interaction_options(InteractionOptions {
2424 background: BackgroundMode::Always,
2425 ..InteractionOptions::default()
2426 });
2427 assert!(always.resolve_background());
2428
2429 let never = GeminiModel::new("test-key", "gemini-2.5-flash")
2431 .expect("construct model")
2432 .use_interactions_api(true)
2433 .expect("enable")
2434 .interaction_options(InteractionOptions {
2435 background: BackgroundMode::Never,
2436 ..InteractionOptions::default()
2437 });
2438 assert!(!never.resolve_background());
2439
2440 let model_target = GeminiModel::new("test-key", "gemini-2.5-flash")
2442 .expect("construct model")
2443 .use_interactions_api(true)
2444 .expect("enable");
2445 assert!(!model_target.resolve_background());
2446
2447 let agent_target = GeminiModel::new("test-key", "deep-research-preview-04-2026")
2449 .expect("construct model")
2450 .use_interactions_api(true)
2451 .expect("enable");
2452 assert!(agent_target.resolve_background());
2453 }
2454
2455 #[test]
2458 fn terminal_failed_status_maps_to_error() {
2459 use adk_gemini::interactions::InteractionStatus;
2460
2461 let err = interaction_status_to_result(InteractionStatus::Failed)
2462 .expect_err("a failed interaction must surface an error");
2463 assert_eq!(err.category, adk_core::ErrorCategory::Internal);
2464 assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2465 assert_eq!(err.code, "model.gemini.interactions.failed");
2466 }
2467
2468 #[test]
2471 fn terminal_budget_exceeded_status_maps_to_error() {
2472 use adk_gemini::interactions::InteractionStatus;
2473
2474 let err = interaction_status_to_result(InteractionStatus::BudgetExceeded)
2475 .expect_err("a budget_exceeded interaction must surface an error");
2476 assert_eq!(err.category, adk_core::ErrorCategory::Internal);
2477 assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2478 assert_eq!(err.code, "model.gemini.interactions.budget_exceeded");
2479 }
2480
2481 #[test]
2484 fn non_failure_statuses_are_ok() {
2485 use adk_gemini::interactions::InteractionStatus;
2486
2487 for status in [
2488 InteractionStatus::InProgress,
2489 InteractionStatus::RequiresAction,
2490 InteractionStatus::Completed,
2491 InteractionStatus::Cancelled,
2492 InteractionStatus::Incomplete,
2493 ] {
2494 assert!(
2495 interaction_status_to_result(status).is_ok(),
2496 "status {status:?} should not map to an error"
2497 );
2498 }
2499 }
2500
2501 #[test]
2504 fn failed_interaction_resource_surfaces_error() {
2505 use adk_gemini::interactions::{Interaction, InteractionStatus};
2506
2507 let interaction = Interaction {
2508 id: "v1_failed".to_string(),
2509 model: Some("gemini-2.5-flash".to_string()),
2510 agent: None,
2511 status: InteractionStatus::Failed,
2512 steps: Vec::new(),
2513 usage: None,
2514 created: None,
2515 updated: None,
2516 environment_id: None,
2517 };
2518
2519 let err = interaction_status_to_result(interaction.status)
2520 .expect_err("failed interaction must surface an error");
2521 assert_eq!(err.category, adk_core::ErrorCategory::Internal);
2522 assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2523 }
2524 #[test]
2525 fn gemini_37_rejects_sampling_before_network_io() {
2526 let model = GeminiModel::new("test-key", "gemini-3.7-flash").expect("construct model");
2527 let mut request = LlmRequest::new("gemini-3.7-flash", Vec::new());
2528 request.config =
2529 Some(GenerateContentConfig { temperature: Some(0.2), ..Default::default() });
2530
2531 let error = model
2532 .validate_request_contract(&request)
2533 .expect_err("sampling must be rejected locally");
2534 assert_eq!(error.code, "model.gemini.sampling_unsupported");
2535 }
2536
2537 #[test]
2538 fn gemini_37_rejects_token_budget_thinking() {
2539 let model = GeminiModel::new("test-key", "gemini-3.7-flash")
2540 .expect("construct model")
2541 .with_thinking_config(adk_gemini::ThinkingConfig {
2542 thinking_budget: Some(1024),
2543 include_thoughts: None,
2544 thinking_level: None,
2545 });
2546
2547 let error = model
2548 .validate_request_contract(&LlmRequest::new("gemini-3.7-flash", Vec::new()))
2549 .expect_err("token budgets must be rejected locally");
2550 assert_eq!(error.code, "model.gemini.thinking_budget_unsupported");
2551 }
2552}