Skip to main content

adk_model/gemini/
client.rs

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/// Which Gemini wire API a [`GeminiModel`] uses.
18///
19/// Defaults to [`GeminiTransport::GenerateContent`], the classic
20/// `models/{model}:generateContent` endpoint. Selecting
21/// [`GeminiTransport::Interactions`] (via [`GeminiModel::use_interactions_api`])
22/// routes requests through the Interactions API (Beta): a stateful, step-based
23/// transport that drives the same [`adk_core::Llm`] contract.
24///
25/// Only compiled when the `gemini-interactions` feature is enabled.
26#[cfg(feature = "gemini-interactions")]
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum GeminiTransport {
29    /// The classic `models/{model}:generateContent` API (default).
30    #[default]
31    GenerateContent,
32    /// The Interactions API (Beta): stateful, step-based.
33    Interactions,
34}
35
36/// Background-execution policy for the Interactions transport.
37///
38/// Controls whether interactions run with `background=true`. The default,
39/// [`BackgroundMode::AgentTargetsOnly`], keeps low-latency chat turns
40/// foreground while letting long-running agent targets (e.g. Deep Research)
41/// run in the background.
42///
43/// Only compiled when the `gemini-interactions` feature is enabled.
44#[cfg(feature = "gemini-interactions")]
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum BackgroundMode {
47    /// `background=true` for agent targets, `false` for model targets
48    /// (default). Keeps chat turns low-latency while letting Deep Research and
49    /// other long-running agents run in the background.
50    #[default]
51    AgentTargetsOnly,
52    /// Always run interactions with `background=true`.
53    Always,
54    /// Never run interactions in the background.
55    Never,
56}
57
58/// Faithful-to-API options for the Interactions transport.
59///
60/// The defaults mirror the Interactions API's intended posture: interactions
61/// are stored (`store = true`), stateful continuation via
62/// `previous_interaction_id` is enabled (`stateful = true`), background
63/// execution applies to agent targets only
64/// ([`BackgroundMode::AgentTargetsOnly`]), and background interactions are
65/// polled once per second.
66///
67/// Only compiled when the `gemini-interactions` feature is enabled.
68#[cfg(feature = "gemini-interactions")]
69#[derive(Debug, Clone)]
70pub struct InteractionOptions {
71    /// Whether interactions are stored server-side. Default: `true`.
72    pub store: bool,
73    /// Whether to continue conversations statefully via
74    /// `previous_interaction_id`. Default: `true`.
75    pub stateful: bool,
76    /// Background-execution policy. Default: [`BackgroundMode::AgentTargetsOnly`].
77    pub background: BackgroundMode,
78    /// Poll interval for background interactions. Default: 1 second.
79    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
94/// Gemini model client wrapping the `adk-gemini` crate for the `Llm` trait.
95pub struct GeminiModel {
96    client: Gemini,
97    model_name: String,
98    retry_config: RetryConfig,
99    /// Default thinking configuration applied to every request.
100    ///
101    /// Controls the model's reasoning effort. For Gemini 3 series, use
102    /// `ThinkingLevel` (Low/Medium/High). For Gemini 2.5 series, use
103    /// `thinking_budget` (token count).
104    thinking_config: Option<adk_gemini::ThinkingConfig>,
105    /// Selected wire transport. Defaults to
106    /// [`GeminiTransport::GenerateContent`]; set to
107    /// [`GeminiTransport::Interactions`] via [`GeminiModel::use_interactions_api`].
108    #[cfg(feature = "gemini-interactions")]
109    transport: GeminiTransport,
110    /// The validated Interactions destination, populated when the Interactions
111    /// transport is enabled. `None` for the generateContent transport.
112    #[cfg(feature = "gemini-interactions")]
113    interaction_target: Option<InteractionTarget>,
114    /// Faithful-to-API options for the Interactions transport.
115    #[cfg(feature = "gemini-interactions")]
116    interaction_options: InteractionOptions,
117    /// Server-side RAG grounding declaration attached to every request.
118    /// Vertex AI backend only; see [`GeminiModel::with_vertex_rag_store`].
119    #[cfg(feature = "gemini-vertex")]
120    vertex_rag_store: Option<adk_gemini::VertexRagStore>,
121    /// Whether the wrapped client targets the Vertex AI backend (as opposed
122    /// to AI Studio). Set by the `new_google_cloud*` constructors.
123    #[cfg(feature = "gemini-vertex")]
124    vertex_backend: bool,
125}
126
127/// Convert a Gemini client error to a structured `AdkError` with proper category and retry hints.
128pub(crate) fn 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    // Vertex no longer replays a failed request over REST, so a transport failure
142    // is left to the configured retry policy instead.
143    #[cfg(feature = "gemini-vertex")]
144    let transport_failure =
145        adk_gemini::backend::vertex::VertexBackend::is_transport_error(&message);
146    #[cfg(not(feature = "gemini-vertex"))]
147    let transport_failure = false;
148
149    // Extract status code from BadResponse variant via Display output
150    // BadResponse format: "bad response from server; code {code}; description: ..."
151    let (category, code, status_code) = if message.contains("code 429")
152        || message.contains("RESOURCE_EXHAUSTED")
153        || message.contains("rate limit")
154    {
155        (ErrorCategory::RateLimited, "model.gemini.rate_limited", Some(429u16))
156    } else if message.contains("code 503") || message.contains("UNAVAILABLE") {
157        (ErrorCategory::Unavailable, "model.gemini.unavailable", Some(503))
158    } else if message.contains("code 529") || message.contains("OVERLOADED") {
159        (ErrorCategory::Unavailable, "model.gemini.overloaded", Some(529))
160    } else if message.contains("code 408")
161        || message.contains("DEADLINE_EXCEEDED")
162        || message.contains("TIMEOUT")
163    {
164        (ErrorCategory::Timeout, "model.gemini.timeout", Some(408))
165    } else if message.contains("code 401") || message.contains("Invalid API key") {
166        (ErrorCategory::Unauthorized, "model.gemini.unauthorized", Some(401))
167    } else if message.contains("code 400") {
168        (ErrorCategory::InvalidInput, "model.gemini.bad_request", Some(400))
169    } else if message.contains("code 404") {
170        (ErrorCategory::NotFound, "model.gemini.not_found", Some(404))
171    } else if message.contains("invalid generation config") {
172        (ErrorCategory::InvalidInput, "model.gemini.invalid_config", None)
173    } else if transport_failure {
174        (ErrorCategory::Unavailable, "model.gemini.unavailable", None)
175    } else {
176        (ErrorCategory::Internal, "model.gemini.internal", None)
177    };
178
179    let mut err = adk_core::AdkError::new(ErrorComponent::Model, category, code, message)
180        .with_provider("gemini");
181    if let Some(sc) = status_code {
182        err = err.with_upstream_status(sc);
183    }
184    err
185}
186
187/// Maps a terminal [`InteractionStatus`](adk_gemini::interactions::InteractionStatus)
188/// to a `Result`, surfacing the API's failure states as errors.
189///
190/// The Interactions API can terminate an interaction in `failed` or
191/// `budget_exceeded` (Requirements 7.7 / 9.2). This helper converts those two
192/// states into an [`adk_core::AdkError`] (provider `"gemini"`, category
193/// [`ErrorCategory::Internal`]) and treats every other status as success — the
194/// caller has already decided to read the interaction's content for non-failure
195/// states. Factored out as a free function so the terminal-status mapping is
196/// unit-testable without a network round-trip.
197#[cfg(feature = "gemini-interactions")]
198fn interaction_status_to_result(status: adk_gemini::interactions::InteractionStatus) -> Result<()> {
199    use adk_gemini::interactions::InteractionStatus;
200    match status {
201        InteractionStatus::Failed => Err(interaction_terminal_error(
202            "model.gemini.interactions.failed",
203            "the Gemini interaction terminated with status `failed`",
204        )),
205        InteractionStatus::BudgetExceeded => Err(interaction_terminal_error(
206            "model.gemini.interactions.budget_exceeded",
207            "the Gemini interaction terminated with status `budget_exceeded`",
208        )),
209        InteractionStatus::InProgress
210        | InteractionStatus::RequiresAction
211        | InteractionStatus::Completed
212        | InteractionStatus::Cancelled
213        | InteractionStatus::Incomplete => Ok(()),
214    }
215}
216
217/// Builds the [`adk_core::AdkError`] for a terminal Interactions failure status.
218#[cfg(feature = "gemini-interactions")]
219fn interaction_terminal_error(code: &'static str, message: &str) -> adk_core::AdkError {
220    adk_core::AdkError::new(
221        ErrorComponent::Model,
222        ErrorCategory::Internal,
223        code,
224        message.to_string(),
225    )
226    .with_provider("gemini")
227}
228
229/// Returns the value of the environment variable `name` when it is set and
230/// non-empty (after trimming whitespace).
231fn env_non_empty(name: &str) -> Option<String> {
232    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
233}
234
235/// Returns `true` when an opt-in flag value selects the Vertex AI backend:
236/// `1` or a case-insensitive `true`.
237fn env_flag_truthy(value: &str) -> bool {
238    let v = value.trim();
239    v == "1" || v.eq_ignore_ascii_case("true")
240}
241
242/// Reports whether the environment opts in to the Vertex AI backend.
243///
244/// Consults `GOOGLE_GENAI_USE_ENTERPRISE` first and, when that variable is
245/// unset or empty, the deprecated `GOOGLE_GENAI_USE_VERTEXAI`. A flag is
246/// truthy when its value is `1` or a case-insensitive `true`.
247/// `GOOGLE_GENAI_USE_ENTERPRISE` takes precedence when both are set.
248///
249/// # Example
250///
251/// ```rust,no_run
252/// if adk_model::gemini::vertex_env_requested() {
253///     // route Gemini traffic through Vertex AI
254/// }
255/// ```
256pub fn vertex_env_requested() -> bool {
257    // `GOOGLE_GENAI_USE_VERTEXAI` is the deprecated name adk-python still
258    // honors. Deliberately no deprecation warning for it —
259    // google/adk-python#6168 documents the churn such a warning caused.
260    if let Some(v) = env_non_empty("GOOGLE_GENAI_USE_ENTERPRISE") {
261        return env_flag_truthy(&v);
262    }
263    env_non_empty("GOOGLE_GENAI_USE_VERTEXAI").is_some_and(|v| env_flag_truthy(&v))
264}
265
266/// Reads `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION`, erroring with a
267/// message that names whichever variable is missing.
268#[cfg(feature = "gemini-vertex")]
269fn vertex_target_from_env() -> Result<(String, String)> {
270    let project = env_non_empty("GOOGLE_CLOUD_PROJECT");
271    let location = env_non_empty("GOOGLE_CLOUD_LOCATION");
272    if let (Some(project), Some(location)) = (&project, &location) {
273        return Ok((project.clone(), location.clone()));
274    }
275    let mut missing = Vec::new();
276    if project.is_none() {
277        missing.push("GOOGLE_CLOUD_PROJECT");
278    }
279    if location.is_none() {
280        missing.push("GOOGLE_CLOUD_LOCATION");
281    }
282    Err(adk_core::AdkError::new(
283        ErrorComponent::Model,
284        ErrorCategory::InvalidInput,
285        "model.gemini.vertex_env_incomplete",
286        format!(
287            "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).",
288            missing.join(" and ")
289        ),
290    )
291    .with_provider("gemini"))
292}
293
294impl GeminiModel {
295    fn gemini_part_thought_signature(value: &serde_json::Value) -> Option<String> {
296        value.get("thoughtSignature").and_then(serde_json::Value::as_str).map(str::to_string)
297    }
298
299    fn validate_request_contract(&self, req: &LlmRequest) -> Result<()> {
300        // Server-side RAG grounding is a Vertex AI capability; reject it on
301        // the AI Studio backend before any network IO (mirrors the request-
302        // time gating the Vertex backend applies to Studio-only fields).
303        #[cfg(feature = "gemini-vertex")]
304        if self.vertex_rag_store.is_some() && !self.vertex_backend {
305            return Err(adk_core::AdkError::new(
306                ErrorComponent::Model,
307                ErrorCategory::Unsupported,
308                "model.gemini.rag_store_requires_vertex",
309                "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.",
310            )
311            .with_provider("gemini"));
312        }
313
314        let model = self.model_name.strip_prefix("models/").unwrap_or(&self.model_name);
315        if !matches!(model, "gemini-3.6-flash" | "gemini-3.7-flash") {
316            return Ok(());
317        }
318
319        if let Some(config) = &req.config
320            && (config.temperature.is_some() || config.top_p.is_some() || config.top_k.is_some())
321        {
322            return Err(adk_core::AdkError::new(
323                ErrorComponent::Model,
324                ErrorCategory::InvalidInput,
325                "model.gemini.sampling_unsupported",
326                format!(
327                    "{model} does not accept temperature, top_p, or top_k; remove explicit sampling parameters"
328                ),
329            )
330            .with_provider("gemini"));
331        }
332        if self.thinking_config.as_ref().is_some_and(|config| config.thinking_budget.is_some()) {
333            return Err(adk_core::AdkError::new(
334                ErrorComponent::Model,
335                ErrorCategory::InvalidInput,
336                "model.gemini.thinking_budget_unsupported",
337                format!(
338                    "{model} uses thinking levels instead of token budgets; set thinking_level and clear thinking_budget"
339                ),
340            )
341            .with_provider("gemini"));
342        }
343        Ok(())
344    }
345
346    /// Builds a `GeminiModel` from a constructed client and model name with all
347    /// configurable fields defaulted.
348    ///
349    /// Centralizing struct construction here keeps the cfg-gated Interactions
350    /// fields out of every public constructor's `Self { .. }` literal.
351    pub(crate) fn from_client(client: Gemini, model_name: String) -> Self {
352        Self {
353            client,
354            model_name,
355            retry_config: RetryConfig::default(),
356            thinking_config: None,
357            #[cfg(feature = "gemini-interactions")]
358            transport: GeminiTransport::GenerateContent,
359            #[cfg(feature = "gemini-interactions")]
360            interaction_target: None,
361            #[cfg(feature = "gemini-interactions")]
362            interaction_options: InteractionOptions::default(),
363            #[cfg(feature = "gemini-vertex")]
364            vertex_rag_store: None,
365            #[cfg(feature = "gemini-vertex")]
366            vertex_backend: false,
367        }
368    }
369
370    /// Create a new Gemini model client with an API key and model name.
371    pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Result<Self> {
372        let model_name = model.into();
373        crate::catalog::warn_if_obsolete("gemini", &model_name);
374        let client = Gemini::with_model(api_key.into(), model_name.clone())
375            .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
376
377        Ok(Self::from_client(client, model_name))
378    }
379
380    /// Create an API-key model using an explicit REST base URL.
381    ///
382    /// The base URL includes the API version and ends with a slash. No environment
383    /// variable is read.
384    ///
385    /// # Errors
386    ///
387    /// Returns an error when `base_url` is not a valid URL or the client cannot be built.
388    ///
389    /// # Example
390    ///
391    /// ```
392    /// use adk_model::GeminiModel;
393    ///
394    /// let model = GeminiModel::new_with_base_url(
395    ///     "api-key",
396    ///     "gemini-3.7-flash",
397    ///     "https://gateway.example.com/v1beta/",
398    /// )?;
399    /// # Ok::<(), adk_core::AdkError>(())
400    /// ```
401    pub fn new_with_base_url(
402        api_key: impl Into<String>,
403        model: impl Into<String>,
404        base_url: impl AsRef<str>,
405    ) -> Result<Self> {
406        let model_name = model.into();
407        let base_url = base_url
408            .as_ref()
409            .parse()
410            .map_err(|_| adk_core::AdkError::model("Invalid Gemini base URL"))?;
411        let client = Gemini::with_model_and_base_url(api_key.into(), model_name.clone(), base_url)
412            .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
413        Ok(Self::from_client(client, model_name))
414    }
415
416    /// Create a Gemini model via Vertex AI with API key auth.
417    ///
418    /// Requires `gemini-vertex` feature.
419    #[cfg(feature = "gemini-vertex")]
420    pub fn new_google_cloud(
421        api_key: impl Into<String>,
422        project_id: impl AsRef<str>,
423        location: impl AsRef<str>,
424        model: impl Into<String>,
425    ) -> Result<Self> {
426        let model_name = model.into();
427        let client = Gemini::with_google_cloud_model(
428            api_key.into(),
429            project_id,
430            location,
431            model_name.clone(),
432        )
433        .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
434
435        let mut model = Self::from_client(client, model_name);
436        model.vertex_backend = true;
437        Ok(model)
438    }
439
440    /// Use an explicit Vertex endpoint and either a supplied key or execution-host ADC.
441    ///
442    /// Requires `gemini-vertex` feature.
443    ///
444    /// # Errors
445    ///
446    /// Returns an error when Application Default Credentials are requested
447    /// (`api_key` is `None`) but unavailable, or when the client cannot be built.
448    ///
449    /// # Example
450    ///
451    /// ```no_run
452    /// use adk_model::GeminiModel;
453    ///
454    /// let model = GeminiModel::new_google_cloud_endpoint(
455    ///     None,
456    ///     "my-project",
457    ///     "us-central1",
458    ///     "gemini-3.7-flash",
459    ///     "https://us-central1-aiplatform.googleapis.com",
460    /// )?;
461    /// # Ok::<(), adk_core::AdkError>(())
462    /// ```
463    #[cfg(feature = "gemini-vertex")]
464    pub fn new_google_cloud_endpoint(
465        api_key: Option<&str>,
466        project: &str,
467        location: &str,
468        model_name: &str,
469        endpoint: &str,
470    ) -> Result<Self> {
471        let mut builder = adk_gemini::GeminiBuilder::new(api_key.unwrap_or_default())
472            .with_model(model_name.to_owned())
473            .with_google_cloud(project, location)
474            .with_google_cloud_endpoint(endpoint);
475        if api_key.is_none() {
476            builder = builder.with_google_cloud_adc().map_err(|e| gemini_error_to_adk(&e))?;
477        }
478        let client = builder.build().map_err(|e| gemini_error_to_adk(&e))?;
479        let mut model = Self::from_client(client, model_name.to_owned());
480        model.vertex_backend = true;
481        Ok(model)
482    }
483
484    /// Create a Gemini model via Vertex AI with service account JSON.
485    ///
486    /// Requires `gemini-vertex` feature.
487    #[cfg(feature = "gemini-vertex")]
488    pub fn new_google_cloud_service_account(
489        service_account_json: &str,
490        project_id: impl AsRef<str>,
491        location: impl AsRef<str>,
492        model: impl Into<String>,
493    ) -> Result<Self> {
494        let model_name = model.into();
495        let client = Gemini::with_google_cloud_service_account_json(
496            service_account_json,
497            project_id.as_ref(),
498            location.as_ref(),
499            model_name.clone(),
500        )
501        .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
502
503        let mut model = Self::from_client(client, model_name);
504        model.vertex_backend = true;
505        Ok(model)
506    }
507
508    /// Create a Gemini model via Vertex AI with Application Default Credentials.
509    ///
510    /// Requires `gemini-vertex` feature.
511    #[cfg(feature = "gemini-vertex")]
512    pub fn new_google_cloud_adc(
513        project_id: impl AsRef<str>,
514        location: impl AsRef<str>,
515        model: impl Into<String>,
516    ) -> Result<Self> {
517        let model_name = model.into();
518        let client = Gemini::with_google_cloud_adc_model(
519            project_id.as_ref(),
520            location.as_ref(),
521            model_name.clone(),
522        )
523        .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
524
525        let mut model = Self::from_client(client, model_name);
526        model.vertex_backend = true;
527        Ok(model)
528    }
529
530    /// Create a Gemini model via Vertex AI with Workload Identity Federation.
531    ///
532    /// Requires `gemini-vertex` feature.
533    #[cfg(feature = "gemini-vertex")]
534    pub fn new_google_cloud_wif(
535        wif_json: &str,
536        project_id: impl AsRef<str>,
537        location: impl AsRef<str>,
538        model: impl Into<String>,
539    ) -> Result<Self> {
540        let model_name = model.into();
541        let client = Gemini::with_google_cloud_wif_json(
542            wif_json,
543            project_id.as_ref(),
544            location.as_ref(),
545            model_name.clone(),
546        )
547        .map_err(|e| adk_core::AdkError::model(e.to_string()))?;
548
549        let mut model = Self::from_client(client, model_name);
550        model.vertex_backend = true;
551        Ok(model)
552    }
553
554    /// Create a Gemini model from environment variables.
555    ///
556    /// When `GOOGLE_GENAI_USE_ENTERPRISE` or `GOOGLE_GENAI_USE_VERTEXAI` is
557    /// truthy (`1` or a case-insensitive `true`), builds a Vertex AI client
558    /// with Application Default Credentials from `GOOGLE_CLOUD_PROJECT` and
559    /// `GOOGLE_CLOUD_LOCATION` — this path requires the `gemini-vertex`
560    /// feature. Otherwise builds a Gemini API (AI Studio) client from
561    /// `GOOGLE_API_KEY` or `GEMINI_API_KEY`.
562    ///
563    /// `GOOGLE_GENAI_USE_ENTERPRISE` takes precedence when both flags are set.
564    ///
565    /// The Vertex path builds Application Default Credentials, which requires
566    /// a Tokio runtime to be current — call from async code or under
567    /// `#[tokio::main]`.
568    ///
569    /// # Errors
570    ///
571    /// - A Vertex flag is truthy but `GOOGLE_CLOUD_PROJECT` or
572    ///   `GOOGLE_CLOUD_LOCATION` is not set.
573    /// - A Vertex flag is truthy but the `gemini-vertex` feature is not
574    ///   compiled.
575    /// - No Vertex flag is truthy and neither `GOOGLE_API_KEY` nor
576    ///   `GEMINI_API_KEY` is set.
577    ///
578    /// # Example
579    ///
580    /// ```rust,no_run
581    /// use adk_model::gemini::GeminiModel;
582    ///
583    /// # #[tokio::main]
584    /// # async fn main() -> adk_core::Result<()> {
585    /// let model = GeminiModel::from_env("gemini-2.5-flash")?;
586    /// # Ok(())
587    /// # }
588    /// ```
589    pub fn from_env(model: &str) -> Result<Self> {
590        if vertex_env_requested() {
591            #[cfg(feature = "gemini-vertex")]
592            {
593                let (project, location) = vertex_target_from_env()?;
594                return Self::new_google_cloud_adc(project, location, model);
595            }
596            #[cfg(not(feature = "gemini-vertex"))]
597            {
598                return Err(adk_core::AdkError::new(
599                    ErrorComponent::Model,
600                    ErrorCategory::Unsupported,
601                    "model.gemini.vertex_feature_missing",
602                    "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.",
603                )
604                .with_provider("gemini"));
605            }
606        }
607
608        let Some(api_key) =
609            env_non_empty("GOOGLE_API_KEY").or_else(|| env_non_empty("GEMINI_API_KEY"))
610        else {
611            return Err(adk_core::AdkError::new(
612                ErrorComponent::Model,
613                ErrorCategory::InvalidInput,
614                "model.gemini.api_key_missing",
615                "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.",
616            )
617            .with_provider("gemini"));
618        };
619        Self::new(api_key, model)
620    }
621
622    /// Set the retry configuration (builder pattern).
623    #[must_use]
624    pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
625        self.retry_config = retry_config;
626        self
627    }
628
629    /// Set the retry configuration (mutable reference).
630    pub fn set_retry_config(&mut self, retry_config: RetryConfig) {
631        self.retry_config = retry_config;
632    }
633
634    /// Returns the current retry configuration.
635    pub fn retry_config(&self) -> &RetryConfig {
636        &self.retry_config
637    }
638
639    /// Set the default thinking configuration applied to every request.
640    ///
641    /// Controls the model's reasoning effort. For Gemini 3 series models,
642    /// use `ThinkingLevel` (Low/Medium/High). For Gemini 2.5 series, use
643    /// `thinking_budget` (token count).
644    ///
645    /// # Example
646    ///
647    /// ```rust,ignore
648    /// use adk_gemini::{ThinkingConfig, ThinkingLevel};
649    ///
650    /// // Gemini 3 — level-based thinking
651    /// let model = GeminiModel::new(api_key, "gemini-3.1-pro-preview")?
652    ///     .with_thinking_config(
653    ///         ThinkingConfig::new().with_thinking_level(ThinkingLevel::Low)
654    ///     );
655    ///
656    /// // Gemini 2.5 — budget-based thinking
657    /// let model = GeminiModel::new(api_key, "gemini-2.5-flash")?
658    ///     .with_thinking_config(
659    ///         ThinkingConfig::new().with_thinking_budget(2048)
660    ///     );
661    /// ```
662    #[must_use]
663    pub fn with_thinking_config(mut self, thinking_config: adk_gemini::ThinkingConfig) -> Self {
664        self.thinking_config = Some(thinking_config);
665        self
666    }
667
668    /// Set the thinking configuration (mutable reference variant).
669    pub fn set_thinking_config(&mut self, thinking_config: adk_gemini::ThinkingConfig) {
670        self.thinking_config = Some(thinking_config);
671    }
672
673    /// Returns the current thinking configuration, if set.
674    pub fn thinking_config(&self) -> Option<&adk_gemini::ThinkingConfig> {
675        self.thinking_config.as_ref()
676    }
677
678    /// Ground every request in a Vertex AI RAG store (server-side retrieval).
679    ///
680    /// Declares a `retrieval` tool on each outgoing `generateContent` request
681    /// so Vertex AI retrieves grounding context from the referenced RAG
682    /// corpora before generating. Requires a Vertex AI backend — requests on
683    /// an AI Studio model fail with the structured error
684    /// `model.gemini.rag_store_requires_vertex` before any network IO.
685    ///
686    /// # Example
687    ///
688    /// ```rust,no_run
689    /// use adk_gemini::VertexRagStore;
690    /// use adk_model::gemini::GeminiModel;
691    ///
692    /// # fn main() -> adk_core::Result<()> {
693    /// let store = VertexRagStore::corpus("projects/p/locations/us-central1/ragCorpora/123")
694    ///     .with_top_k(5);
695    /// let model =
696    ///     GeminiModel::new_google_cloud("api-key", "p", "us-central1", "gemini-3.7-flash")?
697    ///         .with_vertex_rag_store(store);
698    /// # Ok(())
699    /// # }
700    /// ```
701    #[cfg(feature = "gemini-vertex")]
702    #[must_use]
703    pub fn with_vertex_rag_store(mut self, store: adk_gemini::VertexRagStore) -> Self {
704        self.vertex_rag_store = Some(store);
705        self
706    }
707
708    /// Returns the configured Vertex RAG store, if set.
709    #[cfg(feature = "gemini-vertex")]
710    pub fn vertex_rag_store(&self) -> Option<&adk_gemini::VertexRagStore> {
711        self.vertex_rag_store.as_ref()
712    }
713
714    /// Enable (or disable) the Interactions API transport.
715    ///
716    /// When enabling, the model's configured model id is validated against the
717    /// Interactions allowlist via [`InteractionTarget::parse`]. On success the
718    /// transport switches to [`GeminiTransport::Interactions`] and the
719    /// validated target is stored. Disabling reverts to
720    /// [`GeminiTransport::GenerateContent`] and clears the stored target.
721    ///
722    /// Requires the `gemini-interactions` feature.
723    ///
724    /// # Errors
725    ///
726    /// Returns an [`adk_core::AdkError`] with category `InvalidInput` when
727    /// enabling the transport for a model id outside the Interactions
728    /// allowlist (Requirement 2.4). The error message names the supported
729    /// model and agent targets.
730    ///
731    /// # Example
732    ///
733    /// ```rust,ignore
734    /// let model = GeminiModel::new(api_key, "gemini-2.5-flash")?
735    ///     .use_interactions_api(true)?;
736    /// ```
737    #[cfg(feature = "gemini-interactions")]
738    pub fn use_interactions_api(mut self, enabled: bool) -> Result<Self> {
739        if enabled {
740            let target = InteractionTarget::parse(&self.model_name)?;
741            self.transport = GeminiTransport::Interactions;
742            self.interaction_target = Some(target);
743        } else {
744            self.transport = GeminiTransport::GenerateContent;
745            self.interaction_target = None;
746        }
747        Ok(self)
748    }
749
750    /// Override the Interactions transport options (store, stateful, background
751    /// mode, poll interval).
752    ///
753    /// Requires the `gemini-interactions` feature. The defaults (see
754    /// [`InteractionOptions::default`]) mirror the Interactions API's intended
755    /// posture; override only when a different behavior is required.
756    #[cfg(feature = "gemini-interactions")]
757    #[must_use]
758    pub fn interaction_options(mut self, opts: InteractionOptions) -> Self {
759        self.interaction_options = opts;
760        self
761    }
762
763    /// Returns the currently selected wire transport.
764    ///
765    /// Requires the `gemini-interactions` feature.
766    #[cfg(feature = "gemini-interactions")]
767    pub fn transport(&self) -> GeminiTransport {
768        self.transport
769    }
770
771    /// Returns the configured Interactions options.
772    ///
773    /// Requires the `gemini-interactions` feature.
774    #[cfg(feature = "gemini-interactions")]
775    pub fn interaction_options_ref(&self) -> &InteractionOptions {
776        &self.interaction_options
777    }
778
779    /// Resolves whether background execution should be used for the configured
780    /// Interactions target, honoring [`BackgroundMode`].
781    ///
782    /// Returns `true` when the mode is [`BackgroundMode::Always`], `false` when
783    /// [`BackgroundMode::Never`], and (for [`BackgroundMode::AgentTargetsOnly`])
784    /// `true` only when the configured target is an agent target. When no
785    /// Interactions target is configured, agent-targets-only resolves to
786    /// `false`.
787    ///
788    /// Used by the non-streaming/background Interactions path (task 7.3); kept
789    /// here so the transport state and its policy resolution live together.
790    #[cfg(feature = "gemini-interactions")]
791    fn resolve_background(&self) -> bool {
792        match self.interaction_options.background {
793            BackgroundMode::Always => true,
794            BackgroundMode::Never => false,
795            BackgroundMode::AgentTargetsOnly => {
796                self.interaction_target.as_ref().is_some_and(InteractionTarget::is_agent)
797            }
798        }
799    }
800
801    fn convert_response(resp: &adk_gemini::GenerationResponse) -> Result<LlmResponse> {
802        let mut converted_parts: Vec<Part> = Vec::new();
803
804        // Convert content parts
805        if let Some(parts) = resp.candidates.first().and_then(|c| c.content.parts.as_ref()) {
806            for p in parts {
807                match p {
808                    adk_gemini::Part::Text { text, thought, thought_signature } => {
809                        if thought == &Some(true) {
810                            converted_parts.push(Part::Thinking {
811                                thinking: text.clone(),
812                                signature: thought_signature.clone(),
813                            });
814                        } else {
815                            converted_parts.push(Part::Text { text: text.clone() });
816                        }
817                    }
818                    adk_gemini::Part::InlineData { inline_data } => {
819                        let decoded =
820                            BASE64_STANDARD.decode(&inline_data.data).map_err(|error| {
821                                adk_core::AdkError::model(format!(
822                                    "failed to decode inline data from gemini response: {error}"
823                                ))
824                            })?;
825                        converted_parts.push(Part::InlineData {
826                            mime_type: inline_data.mime_type.clone(),
827                            data: decoded,
828                            uri: None,
829                            annotations: None,
830                        });
831                    }
832                    adk_gemini::Part::FunctionCall { function_call, thought_signature } => {
833                        converted_parts.push(Part::FunctionCall {
834                            name: function_call.name.clone(),
835                            args: function_call.args.clone(),
836                            id: function_call.id.clone(),
837                            thought_signature: thought_signature.clone(),
838                        });
839                    }
840                    adk_gemini::Part::FunctionResponse { function_response, .. } => {
841                        converted_parts.push(Part::FunctionResponse {
842                            function_response: adk_core::FunctionResponseData::new(
843                                function_response.name.clone(),
844                                function_response
845                                    .response
846                                    .clone()
847                                    .unwrap_or(serde_json::Value::Null),
848                            ),
849                            id: None,
850                            annotations: None,
851                        });
852                    }
853                    adk_gemini::Part::ToolCall { .. } | adk_gemini::Part::ExecutableCode { .. } => {
854                        if let Ok(value) = serde_json::to_value(p) {
855                            converted_parts.push(Part::ServerToolCall { server_tool_call: value });
856                        }
857                    }
858                    adk_gemini::Part::ToolResponse { .. }
859                    | adk_gemini::Part::CodeExecutionResult { .. } => {
860                        let value = serde_json::to_value(p).unwrap_or(serde_json::Value::Null);
861                        converted_parts
862                            .push(Part::ServerToolResponse { server_tool_response: value });
863                    }
864                    adk_gemini::Part::FileData { file_data } => {
865                        converted_parts.push(Part::FileData {
866                            mime_type: file_data.mime_type.clone(),
867                            file_uri: file_data.file_uri.clone(),
868                            annotations: None,
869                        });
870                    }
871                }
872            }
873        }
874
875        let content = if converted_parts.is_empty() {
876            None
877        } else {
878            Some(Content { role: "model".to_string(), parts: converted_parts })
879        };
880        let tool_call_turn = content.as_ref().is_some_and(Content::has_function_calls);
881
882        let usage_metadata = resp.usage_metadata.as_ref().map(|u| UsageMetadata {
883            prompt_token_count: u.prompt_token_count.unwrap_or(0),
884            // Gemini's candidate count excludes the separately reported thoughts.
885            candidates_token_count: u
886                .candidates_token_count
887                .unwrap_or(0)
888                .saturating_add(u.thoughts_token_count.unwrap_or(0)),
889            total_token_count: u.total_token_count.unwrap_or(0),
890            thinking_token_count: u.thoughts_token_count,
891            cache_read_input_token_count: u.cached_content_token_count,
892            provider_usage: serde_json::to_value(u).ok(),
893            ..Default::default()
894        });
895
896        let finish_reason =
897            resp.candidates.first().and_then(|c| c.finish_reason.as_ref()).map(|fr| match fr {
898                adk_gemini::FinishReason::Stop => FinishReason::Stop,
899                adk_gemini::FinishReason::MaxTokens => FinishReason::MaxTokens,
900                adk_gemini::FinishReason::Safety => FinishReason::Safety,
901                adk_gemini::FinishReason::Recitation => FinishReason::Recitation,
902                _ => FinishReason::Other,
903            });
904
905        let citation_metadata =
906            resp.candidates.first().and_then(|c| c.citation_metadata.as_ref()).map(|meta| {
907                CitationMetadata {
908                    citation_sources: meta
909                        .citation_sources
910                        .iter()
911                        .map(|source| CitationSource {
912                            uri: source.uri.clone(),
913                            title: source.title.clone(),
914                            start_index: source.start_index,
915                            end_index: source.end_index,
916                            license: source.license.clone(),
917                            publication_date: source.publication_date.map(|d| d.to_string()),
918                        })
919                        .collect(),
920                }
921            });
922
923        // Serialize grounding metadata into provider_metadata so consumers
924        // can display search queries and sources without changing the answer.
925        let provider_metadata = resp
926            .candidates
927            .first()
928            .and_then(|c| c.grounding_metadata.as_ref())
929            .and_then(|g| serde_json::to_value(g).ok());
930
931        Ok(LlmResponse {
932            content,
933            usage_metadata,
934            finish_reason,
935            citation_metadata,
936            partial: false,
937            turn_complete: !tool_call_turn,
938            interrupted: false,
939            error_code: None,
940            error_message: None,
941            provider_metadata,
942            interaction_id: None,
943        })
944    }
945
946    fn gemini_function_response_payload(response: serde_json::Value) -> serde_json::Value {
947        match response {
948            // Gemini functionResponse.response must be a JSON object.
949            serde_json::Value::Object(_) => response,
950            other => serde_json::json!({ "result": other }),
951        }
952    }
953
954    fn merge_object_value(
955        target: &mut serde_json::Map<String, serde_json::Value>,
956        value: serde_json::Value,
957    ) {
958        if let serde_json::Value::Object(object) = value {
959            for (key, value) in object {
960                target.insert(key, value);
961            }
962        }
963    }
964
965    fn build_gemini_tools(
966        tools: &std::collections::HashMap<String, serde_json::Value>,
967        adapter: &dyn SchemaAdapter,
968        cache: &SchemaCache,
969    ) -> Result<(Vec<adk_gemini::Tool>, adk_gemini::ToolConfig)> {
970        let mut gemini_tools = Vec::new();
971        let mut function_declarations = Vec::new();
972        let mut has_provider_native_tools = false;
973        let mut tool_config_json = serde_json::Map::new();
974
975        for (name, tool_decl) in tools {
976            if let Some(provider_tool) = tool_decl.get("x-adk-gemini-tool") {
977                let tool = serde_json::from_value::<adk_gemini::Tool>(provider_tool.clone())
978                    .map_err(|error| {
979                        adk_core::AdkError::model(format!(
980                            "failed to deserialize Gemini native tool '{name}': {error}"
981                        ))
982                    })?;
983                has_provider_native_tools = true;
984                gemini_tools.push(tool);
985            } else {
986                // Normalize tool name via the schema adapter
987                let normalized_name = adapter.normalize_tool_name(name);
988
989                // Get the parameters schema from the declaration, or use the
990                // adapter's empty_schema fallback when none is provided.
991                let schema =
992                    tool_decl.get("parameters").cloned().unwrap_or_else(|| adapter.empty_schema());
993                let normalized_schema = cache.normalize(&schema);
994
995                // Build the FunctionDeclaration with normalized values
996                let description =
997                    tool_decl.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
998
999                let mut func_decl_json = serde_json::json!({
1000                    "name": normalized_name.as_ref(),
1001                    "description": description,
1002                    "parameters": normalized_schema,
1003                });
1004
1005                // Preserve response schema if present (normalized like parameters)
1006                if let Some(response) = tool_decl.get("response") {
1007                    func_decl_json["response"] = cache.normalize(response);
1008                }
1009
1010                // Preserve behavior if present
1011                if let Some(behavior) = tool_decl.get("behavior") {
1012                    func_decl_json["behavior"] = behavior.clone();
1013                }
1014
1015                let func_decl =
1016                    serde_json::from_value::<adk_gemini::FunctionDeclaration>(func_decl_json)
1017                        .map_err(|error| {
1018                            adk_core::AdkError::model(format!(
1019                                "failed to build Gemini function declaration for '{name}': {error}"
1020                            ))
1021                        })?;
1022                function_declarations.push(func_decl);
1023            }
1024
1025            if let Some(tool_config) = tool_decl.get("x-adk-gemini-tool-config") {
1026                Self::merge_object_value(&mut tool_config_json, tool_config.clone());
1027            }
1028        }
1029
1030        let has_function_declarations = !function_declarations.is_empty();
1031        if has_function_declarations {
1032            gemini_tools.push(adk_gemini::Tool::with_functions(function_declarations));
1033        }
1034
1035        if has_provider_native_tools {
1036            tool_config_json.insert(
1037                "includeServerSideToolInvocations".to_string(),
1038                serde_json::Value::Bool(true),
1039            );
1040        }
1041
1042        let tool_config = if tool_config_json.is_empty() {
1043            adk_gemini::ToolConfig::default()
1044        } else {
1045            serde_json::from_value::<adk_gemini::ToolConfig>(serde_json::Value::Object(
1046                tool_config_json,
1047            ))
1048            .map_err(|error| {
1049                adk_core::AdkError::model(format!(
1050                    "failed to deserialize Gemini tool configuration: {error}"
1051                ))
1052            })?
1053        };
1054
1055        Ok((gemini_tools, tool_config))
1056    }
1057
1058    fn stream_chunks_from_response(
1059        mut response: LlmResponse,
1060        saw_partial_chunk: bool,
1061    ) -> (Vec<LlmResponse>, bool) {
1062        let is_final = response.finish_reason.is_some();
1063
1064        if !is_final {
1065            response.partial = true;
1066            response.turn_complete = false;
1067            return (vec![response], true);
1068        }
1069
1070        response.partial = false;
1071        response.turn_complete =
1072            response.content.as_ref().is_none_or(|content| !content.has_function_calls());
1073
1074        if saw_partial_chunk {
1075            return (vec![response], true);
1076        }
1077
1078        let synthetic_partial = LlmResponse {
1079            content: None,
1080            usage_metadata: None,
1081            finish_reason: None,
1082            citation_metadata: None,
1083            partial: true,
1084            turn_complete: false,
1085            interrupted: false,
1086            error_code: None,
1087            error_message: None,
1088            provider_metadata: None,
1089            interaction_id: None,
1090        };
1091
1092        (vec![synthetic_partial, response], true)
1093    }
1094
1095    async fn generate_content_internal(
1096        &self,
1097        req: LlmRequest,
1098        stream: bool,
1099    ) -> Result<LlmResponseStream> {
1100        let mut builder = self.client.generate_content();
1101
1102        // Build a map of function_name → thought_signature from FunctionCall parts
1103        // in model content. Gemini 3.x requires thought_signature on FunctionResponse
1104        // parts when thinking is active, but adk_core::Part::FunctionResponse doesn't
1105        // carry it (it's Gemini-specific). We recover it here at the provider boundary.
1106        let mut fn_call_signatures: std::collections::HashMap<String, String> =
1107            std::collections::HashMap::new();
1108        for content in &req.contents {
1109            if content.role == "model" {
1110                for part in &content.parts {
1111                    if let Part::FunctionCall { name, thought_signature: Some(sig), .. } = part {
1112                        fn_call_signatures.insert(name.clone(), sig.clone());
1113                    }
1114                }
1115            }
1116        }
1117
1118        // Add contents using proper builder methods
1119        for content in &req.contents {
1120            match content.role.as_str() {
1121                "user" => {
1122                    // For user messages, build gemini Content with potentially multiple parts
1123                    let mut gemini_parts = Vec::new();
1124                    for part in &content.parts {
1125                        match part {
1126                            Part::Text { text } => {
1127                                gemini_parts.push(adk_gemini::Part::Text {
1128                                    text: text.clone(),
1129                                    thought: None,
1130                                    thought_signature: None,
1131                                });
1132                            }
1133                            Part::Thinking { thinking, signature } => {
1134                                gemini_parts.push(adk_gemini::Part::Text {
1135                                    text: thinking.clone(),
1136                                    thought: Some(true),
1137                                    thought_signature: signature.clone(),
1138                                });
1139                            }
1140                            Part::InlineData { data, mime_type, .. } => {
1141                                let encoded = attachment::encode_base64(data);
1142                                gemini_parts.push(adk_gemini::Part::InlineData {
1143                                    inline_data: adk_gemini::Blob {
1144                                        mime_type: mime_type.clone(),
1145                                        data: encoded,
1146                                    },
1147                                });
1148                            }
1149                            Part::FileData { mime_type, file_uri, .. } => {
1150                                gemini_parts.push(adk_gemini::Part::Text {
1151                                    text: attachment::file_attachment_to_text(mime_type, file_uri),
1152                                    thought: None,
1153                                    thought_signature: None,
1154                                });
1155                            }
1156                            _ => {}
1157                        }
1158                    }
1159                    if !gemini_parts.is_empty() {
1160                        let user_content = adk_gemini::Content {
1161                            role: Some(adk_gemini::Role::User),
1162                            parts: Some(gemini_parts),
1163                        };
1164                        builder = builder.with_message(adk_gemini::Message {
1165                            content: user_content,
1166                            role: adk_gemini::Role::User,
1167                        });
1168                    }
1169                }
1170                "model" => {
1171                    // For model messages, build gemini Content
1172                    let mut gemini_parts = Vec::new();
1173                    for part in &content.parts {
1174                        match part {
1175                            Part::Text { text } => {
1176                                gemini_parts.push(adk_gemini::Part::Text {
1177                                    text: text.clone(),
1178                                    thought: None,
1179                                    thought_signature: None,
1180                                });
1181                            }
1182                            Part::Thinking { thinking, signature } => {
1183                                gemini_parts.push(adk_gemini::Part::Text {
1184                                    text: thinking.clone(),
1185                                    thought: Some(true),
1186                                    thought_signature: signature.clone(),
1187                                });
1188                            }
1189                            Part::FunctionCall { name, args, thought_signature, id } => {
1190                                gemini_parts.push(adk_gemini::Part::FunctionCall {
1191                                    function_call: adk_gemini::FunctionCall {
1192                                        name: name.clone(),
1193                                        args: args.clone(),
1194                                        id: id.clone(),
1195                                        thought_signature: None,
1196                                    },
1197                                    thought_signature: thought_signature.clone(),
1198                                });
1199                            }
1200                            Part::ServerToolCall { server_tool_call } => {
1201                                if let Ok(native_part) = serde_json::from_value::<adk_gemini::Part>(
1202                                    server_tool_call.clone(),
1203                                ) {
1204                                    match native_part {
1205                                        adk_gemini::Part::ToolCall { .. }
1206                                        | adk_gemini::Part::ExecutableCode { .. } => {
1207                                            gemini_parts.push(native_part);
1208                                            continue;
1209                                        }
1210                                        _ => {}
1211                                    }
1212                                }
1213
1214                                gemini_parts.push(adk_gemini::Part::ToolCall {
1215                                    tool_call: server_tool_call.clone(),
1216                                    thought_signature: Self::gemini_part_thought_signature(
1217                                        server_tool_call,
1218                                    ),
1219                                });
1220                            }
1221                            Part::ServerToolResponse { server_tool_response } => {
1222                                if let Ok(native_part) = serde_json::from_value::<adk_gemini::Part>(
1223                                    server_tool_response.clone(),
1224                                ) {
1225                                    match native_part {
1226                                        adk_gemini::Part::ToolResponse { .. }
1227                                        | adk_gemini::Part::CodeExecutionResult { .. } => {
1228                                            gemini_parts.push(native_part);
1229                                            continue;
1230                                        }
1231                                        _ => {}
1232                                    }
1233                                }
1234
1235                                gemini_parts.push(adk_gemini::Part::ToolResponse {
1236                                    tool_response: server_tool_response.clone(),
1237                                    thought_signature: Self::gemini_part_thought_signature(
1238                                        server_tool_response,
1239                                    ),
1240                                });
1241                            }
1242                            _ => {}
1243                        }
1244                    }
1245                    if !gemini_parts.is_empty() {
1246                        let model_content = adk_gemini::Content {
1247                            role: Some(adk_gemini::Role::Model),
1248                            parts: Some(gemini_parts),
1249                        };
1250                        builder = builder.with_message(adk_gemini::Message {
1251                            content: model_content,
1252                            role: adk_gemini::Role::Model,
1253                        });
1254                    }
1255                }
1256                "function" => {
1257                    // For function responses, build content directly to attach thought_signature
1258                    // recovered from the preceding FunctionCall (Gemini 3.x requirement)
1259                    let mut gemini_parts = Vec::new();
1260                    for part in &content.parts {
1261                        if let Part::FunctionResponse { function_response, id, .. } = part {
1262                            let sig = fn_call_signatures.get(&function_response.name).cloned();
1263
1264                            // Build nested FunctionResponsePart entries for multimodal data
1265                            let mut fr_parts = Vec::new();
1266                            for inline in &function_response.inline_data {
1267                                let encoded = attachment::encode_base64(&inline.data);
1268                                fr_parts.push(adk_gemini::FunctionResponsePart::InlineData {
1269                                    inline_data: adk_gemini::Blob {
1270                                        mime_type: inline.mime_type.clone(),
1271                                        data: encoded,
1272                                    },
1273                                });
1274                            }
1275                            for file in &function_response.file_data {
1276                                fr_parts.push(adk_gemini::FunctionResponsePart::FileData {
1277                                    file_data: adk_gemini::FileDataRef {
1278                                        mime_type: file.mime_type.clone(),
1279                                        file_uri: file.file_uri.clone(),
1280                                    },
1281                                });
1282                            }
1283
1284                            let mut gemini_fr = adk_gemini::tools::FunctionResponse::new(
1285                                &function_response.name,
1286                                Self::gemini_function_response_payload(
1287                                    function_response.response.clone(),
1288                                ),
1289                            );
1290                            gemini_fr.parts = fr_parts;
1291                            // Echo the call id so Gemini 3.x strict response matching
1292                            // (id + name + count) can correlate this response.
1293                            gemini_fr.id = id.clone();
1294
1295                            gemini_parts.push(adk_gemini::Part::FunctionResponse {
1296                                function_response: gemini_fr,
1297                                thought_signature: sig,
1298                            });
1299                        }
1300                    }
1301                    if !gemini_parts.is_empty() {
1302                        let fn_content = adk_gemini::Content {
1303                            role: Some(adk_gemini::Role::User),
1304                            parts: Some(gemini_parts),
1305                        };
1306                        builder = builder.with_message(adk_gemini::Message {
1307                            content: fn_content,
1308                            role: adk_gemini::Role::User,
1309                        });
1310                    }
1311                }
1312                _ => {}
1313            }
1314        }
1315
1316        // Add generation config
1317        if let Some(config) = req.config {
1318            let has_schema = config.response_schema.is_some();
1319            let gen_config = adk_gemini::GenerationConfig {
1320                temperature: config.temperature,
1321                top_p: config.top_p,
1322                top_k: config.top_k,
1323                max_output_tokens: config.max_output_tokens,
1324                response_schema: config.response_schema,
1325                response_mime_type: if has_schema {
1326                    Some("application/json".to_string())
1327                } else {
1328                    None
1329                },
1330                thinking_config: self.thinking_config.clone(),
1331                ..Default::default()
1332            };
1333            builder = builder.with_generation_config(gen_config);
1334
1335            // Attach cached content reference if provided
1336            if let Some(ref name) = config.cached_content {
1337                let handle = self.client.get_cached_content(name);
1338                builder = builder.with_cached_content(&handle);
1339            }
1340        } else if self.thinking_config.is_some() {
1341            // No generation config from the request, but we have a default
1342            // thinking config — apply it in an otherwise-default gen config.
1343            let gen_config = adk_gemini::GenerationConfig {
1344                thinking_config: self.thinking_config.clone(),
1345                ..Default::default()
1346            };
1347            builder = builder.with_generation_config(gen_config);
1348        }
1349
1350        // Add tools
1351        if !req.tools.is_empty() {
1352            let adapter = self.schema_adapter();
1353            use std::sync::LazyLock;
1354            static SCHEMA_CACHE: LazyLock<SchemaCache> = LazyLock::new(|| {
1355                SchemaCache::for_adapter(std::sync::Arc::new(GeminiSchemaAdapter::new()))
1356            });
1357            let (gemini_tools, tool_config) =
1358                Self::build_gemini_tools(&req.tools, adapter, &SCHEMA_CACHE)?;
1359            for tool in gemini_tools {
1360                builder = builder.with_tool(tool);
1361            }
1362            if tool_config != adk_gemini::ToolConfig::default() {
1363                builder = builder.with_tool_config(tool_config);
1364            }
1365        }
1366
1367        // Declare server-side RAG grounding; the Vertex backend injects it
1368        // as a `retrieval` tool at send time (validate_request_contract has
1369        // already rejected the Studio backend at this point).
1370        #[cfg(feature = "gemini-vertex")]
1371        if let Some(store) = &self.vertex_rag_store {
1372            builder = builder.with_vertex_rag_store(store.clone());
1373        }
1374
1375        if stream {
1376            adk_telemetry::debug!("Executing streaming request");
1377            let response_stream = builder.execute_stream().await.map_err(|e| {
1378                adk_telemetry::error!(error = %e, "Model request failed");
1379                gemini_error_to_adk(&e)
1380            })?;
1381
1382            let mapped_stream = async_stream::stream! {
1383                let mut stream = response_stream;
1384                let mut saw_partial_chunk = false;
1385                while let Some(result) = stream.try_next().await.transpose() {
1386                    match result {
1387                        Ok(resp) => {
1388                            match Self::convert_response(&resp) {
1389                                Ok(llm_resp) => {
1390                                    let (chunks, next_saw_partial) =
1391                                        Self::stream_chunks_from_response(llm_resp, saw_partial_chunk);
1392                                    saw_partial_chunk = next_saw_partial;
1393                                    for chunk in chunks {
1394                                        yield Ok(chunk);
1395                                    }
1396                                }
1397                                Err(e) => {
1398                                    adk_telemetry::error!(error = %e, "Failed to convert response");
1399                                    yield Err(e);
1400                                }
1401                            }
1402                        }
1403                        Err(e) => {
1404                            adk_telemetry::error!(error = %e, "Stream error");
1405                            yield Err(gemini_error_to_adk(&e));
1406                        }
1407                    }
1408                }
1409            };
1410
1411            Ok(Box::pin(mapped_stream))
1412        } else {
1413            adk_telemetry::debug!("Executing blocking request");
1414            let response = builder.execute().await.map_err(|e| {
1415                adk_telemetry::error!(error = %e, "Model request failed");
1416                gemini_error_to_adk(&e)
1417            })?;
1418
1419            let llm_response = Self::convert_response(&response)?;
1420
1421            let stream = async_stream::stream! {
1422                yield Ok(llm_response);
1423            };
1424
1425            Ok(Box::pin(stream))
1426        }
1427    }
1428
1429    /// Create a cached content resource with the given system instruction, tools, and TTL.
1430    ///
1431    /// Returns the cache name (e.g., "cachedContents/abc123") on success.
1432    /// The cache is created using the model configured on this `GeminiModel` instance.
1433    pub async fn create_cached_content(
1434        &self,
1435        system_instruction: &str,
1436        tools: &std::collections::HashMap<String, serde_json::Value>,
1437        ttl_seconds: u32,
1438    ) -> Result<String> {
1439        let mut cache_builder = self
1440            .client
1441            .create_cache()
1442            .with_system_instruction(system_instruction)
1443            .with_ttl(std::time::Duration::from_secs(u64::from(ttl_seconds)));
1444
1445        let adapter = self.schema_adapter();
1446        use std::sync::LazyLock;
1447        static SCHEMA_CACHE: LazyLock<SchemaCache> = LazyLock::new(|| {
1448            SchemaCache::for_adapter(std::sync::Arc::new(GeminiSchemaAdapter::new()))
1449        });
1450        let (gemini_tools, tool_config) = Self::build_gemini_tools(tools, adapter, &SCHEMA_CACHE)?;
1451        if !gemini_tools.is_empty() {
1452            cache_builder = cache_builder.with_tools(gemini_tools);
1453        }
1454        if tool_config != adk_gemini::ToolConfig::default() {
1455            cache_builder = cache_builder.with_tool_config(tool_config);
1456        }
1457
1458        let handle = cache_builder
1459            .execute()
1460            .await
1461            .map_err(|e| adk_core::AdkError::model(format!("cache creation failed: {e}")))?;
1462
1463        Ok(handle.name().to_string())
1464    }
1465
1466    /// Delete a cached content resource by name.
1467    pub async fn delete_cached_content(&self, name: &str) -> Result<()> {
1468        let handle = self.client.get_cached_content(name);
1469        handle
1470            .delete()
1471            .await
1472            .map_err(|(_, e)| adk_core::AdkError::model(format!("cache deletion failed: {e}")))?;
1473        Ok(())
1474    }
1475
1476    /// Drives a single turn through the Interactions API (Beta), non-streaming.
1477    ///
1478    /// This is the Interactions counterpart to
1479    /// [`generate_content_internal`](Self::generate_content_internal). It builds
1480    /// a [`CreateInteractionRequest`](adk_gemini::interactions::CreateInteractionRequest)
1481    /// from `req` via [`interactions_convert::build_request`], sends it, polls to
1482    /// completion when running in the background, maps terminal failure statuses
1483    /// to errors, and converts the final interaction into a single
1484    /// [`LlmResponse`].
1485    ///
1486    /// Behavior of note:
1487    ///
1488    /// - **Background completion (Requirement 7.5).** When `background` is set
1489    ///   and the first response is neither terminal nor awaiting a tool result,
1490    ///   the interaction is polled via
1491    ///   [`get_interaction`](adk_gemini::Gemini::get_interaction) every
1492    ///   `poll_interval` until it reaches a terminal or `requires_action` state.
1493    /// - **Stale continuation fallback (Requirement 4.4).** If the initial send
1494    ///   fails with a `NotFound` error *and* the request carried a
1495    ///   `previous_response_id`, the request is transparently rebuilt without
1496    ///   stateful continuation (full transcript, no `previous_interaction_id`)
1497    ///   and re-sent once. The original `NotFound` is not surfaced.
1498    /// - **Terminal failure (Requirements 7.7 / 9.2).** A final `failed` /
1499    ///   `budget_exceeded` status becomes an [`adk_core::AdkError`].
1500    ///
1501    /// The streaming counterpart is
1502    /// [`generate_interactions_stream`](Self::generate_interactions_stream).
1503    #[cfg(feature = "gemini-interactions")]
1504    async fn generate_interactions_once(&self, req: LlmRequest) -> Result<LlmResponse> {
1505        use super::interactions_convert;
1506
1507        // The Interactions target is always populated when the transport is
1508        // active (set by `use_interactions_api`); guard defensively.
1509        let target = self.interaction_target.as_ref().ok_or_else(|| {
1510            adk_core::AdkError::new(
1511                ErrorComponent::Model,
1512                ErrorCategory::InvalidInput,
1513                "model.gemini.interactions.missing_target",
1514                "the Interactions transport is active but no validated target is configured",
1515            )
1516            .with_provider("gemini")
1517        })?;
1518
1519        // Resolve the thinking level (Gemini 3 level-based reasoning). Budget-only
1520        // configs (Gemini 2.5) carry no level, so this stays `None` for them.
1521        let thinking_level = self.thinking_config.as_ref().and_then(|c| c.thinking_level);
1522
1523        let stateful = self.interaction_options.stateful;
1524        let store = self.interaction_options.store;
1525        let background = self.resolve_background();
1526
1527        // Build the request and stamp the background flag.
1528        let mut request =
1529            interactions_convert::build_request(&req, target, thinking_level, stateful, store)?;
1530        request.background = Some(background);
1531
1532        // Send, with a transparent transcript fallback for a stale continuation id.
1533        let interaction = match self.client.send_interaction(request.clone()).await {
1534            Ok(interaction) => interaction,
1535            Err(error) => {
1536                let mapped = gemini_error_to_adk(&error);
1537                // Requirement 4.4: a rejected `previous_interaction_id` (retention
1538                // expiry) maps to NotFound. Rebuild statelessly (full transcript,
1539                // no continuation id) and retry once, without surfacing the error.
1540                if mapped.category == ErrorCategory::NotFound && req.previous_response_id.is_some()
1541                {
1542                    let mut fallback = interactions_convert::build_request(
1543                        &req,
1544                        target,
1545                        thinking_level,
1546                        /* stateful */ false,
1547                        store,
1548                    )?;
1549                    fallback.background = Some(background);
1550                    self.client
1551                        .send_interaction(fallback)
1552                        .await
1553                        .map_err(|e| gemini_error_to_adk(&e))?
1554                } else {
1555                    return Err(mapped);
1556                }
1557            }
1558        };
1559
1560        // Background completion: poll until terminal or awaiting a tool result.
1561        let final_interaction = if background {
1562            self.poll_interaction_to_completion(interaction).await?
1563        } else {
1564            interaction
1565        };
1566
1567        // Requirements 7.7 / 9.2: surface terminal failure statuses as errors.
1568        interaction_status_to_result(final_interaction.status)?;
1569
1570        Ok(interactions_convert::to_llm_response(&final_interaction))
1571    }
1572
1573    /// Polls a background interaction until it reaches a terminal or
1574    /// `requires_action` state, honoring the configured `poll_interval`
1575    /// (Requirement 7.5).
1576    ///
1577    /// Returns immediately when the interaction is already terminal or awaiting
1578    /// a tool result. Otherwise it sleeps for `poll_interval` and re-fetches via
1579    /// [`get_interaction`](adk_gemini::Gemini::get_interaction) (with
1580    /// `include_input = false`) until one of those states is reached, or until a
1581    /// bounded safeguard (`MAX_POLL_ATTEMPTS`) trips.
1582    ///
1583    /// ## Cancellation (Requirement 7.6)
1584    ///
1585    /// True invocation-driven cancellation (calling
1586    /// [`cancel_interaction`](adk_gemini::Gemini::cancel_interaction) when the
1587    /// caller cancels) is **not reachable from this layer**: the [`Llm`] trait's
1588    /// [`generate_content`](Llm::generate_content) signature receives only an
1589    /// [`LlmRequest`] and a `stream` flag — it has no `InvocationContext` or
1590    /// cancellation token. Cancellation is handled by the runner at the
1591    /// event-stream boundary: when a run is cancelled the runner stops consuming
1592    /// the agent's event stream, which drops this future and cancels its
1593    /// `await` points (the in-flight `sleep` / `get_interaction`) cooperatively.
1594    /// The polled interaction is left running server-side; reviving it to issue
1595    /// an explicit `cancel_interaction` would require threading the cancellation
1596    /// token through the trait, which is intentionally out of scope here (the
1597    /// trait is transport-only and shared by every provider).
1598    ///
1599    /// The `MAX_POLL_ATTEMPTS` bound is a safeguard against an interaction that
1600    /// never reaches a terminal/`requires_action` state (e.g. a server-side
1601    /// stall): rather than looping forever it returns a [`ErrorCategory::Timeout`]
1602    /// error so the call fails fast instead of hanging.
1603    #[cfg(feature = "gemini-interactions")]
1604    async fn poll_interaction_to_completion(
1605        &self,
1606        interaction: adk_gemini::interactions::Interaction,
1607    ) -> Result<adk_gemini::interactions::Interaction> {
1608        /// Upper bound on poll iterations before giving up, guarding against an
1609        /// interaction that never settles. With the default 1s `poll_interval`
1610        /// this is ~10 minutes; shorter intervals trade latency for a tighter
1611        /// wall-clock cap. Deep Research agents complete well within this.
1612        const MAX_POLL_ATTEMPTS: u32 = 600;
1613
1614        let mut current = interaction;
1615        let mut attempts: u32 = 0;
1616        while !current.status.is_terminal() && !current.status.requires_action() {
1617            if attempts >= MAX_POLL_ATTEMPTS {
1618                return Err(adk_core::AdkError::new(
1619                    ErrorComponent::Model,
1620                    ErrorCategory::Timeout,
1621                    "model.gemini.interactions.poll_timeout",
1622                    format!(
1623                        "the Gemini interaction did not reach a terminal or requires_action \
1624                         state after {MAX_POLL_ATTEMPTS} poll attempts"
1625                    ),
1626                )
1627                .with_provider("gemini"));
1628            }
1629            attempts += 1;
1630            tokio::time::sleep(self.interaction_options.poll_interval).await;
1631            current = self
1632                .client
1633                .get_interaction(&current.id, false)
1634                .await
1635                .map_err(|e| gemini_error_to_adk(&e))?;
1636        }
1637        Ok(current)
1638    }
1639
1640    /// Drives a single turn through the Interactions API (Beta) as an SSE
1641    /// stream, yielding partial→final [`LlmResponse`] chunks (Requirement 7.4).
1642    ///
1643    /// This is the streaming counterpart to
1644    /// [`generate_interactions_once`](Self::generate_interactions_once). It
1645    /// builds the request the same way, forces non-background completion
1646    /// (streaming and background polling are mutually exclusive completion
1647    /// modes — SSE delivers the turn incrementally, so `background` is set to
1648    /// `false`), opens the SSE stream via
1649    /// [`send_interaction_stream`](adk_gemini::Gemini::send_interaction_stream),
1650    /// and folds each [`InteractionSseEvent`](adk_gemini::interactions::InteractionSseEvent)
1651    /// into chunks via [`interactions_convert::sse_event_to_chunk`].
1652    ///
1653    /// Stream setup (target resolution, request building, opening the SSE
1654    /// connection) is fallible and returns `Err` synchronously, so
1655    /// `execute_with_retry` (which wraps this in `generate_content`) can retry
1656    /// transient setup failures. Errors that occur *after* the stream starts
1657    /// are yielded into the stream and not retried, mirroring the
1658    /// generateContent streaming path.
1659    ///
1660    /// The stale-continuation fallback used by the non-streaming path is not
1661    /// applied here: a `NotFound` on stream setup surfaces as a normal error
1662    /// (the streaming path is opt-in and lower-level; callers that need
1663    /// transparent retention fallback use the default non-streaming path).
1664    #[cfg(feature = "gemini-interactions")]
1665    async fn generate_interactions_stream(&self, req: LlmRequest) -> Result<LlmResponseStream> {
1666        use super::interactions_convert::{self, SseAccumulator, sse_event_to_chunk};
1667
1668        let target = self.interaction_target.as_ref().ok_or_else(|| {
1669            adk_core::AdkError::new(
1670                ErrorComponent::Model,
1671                ErrorCategory::InvalidInput,
1672                "model.gemini.interactions.missing_target",
1673                "the Interactions transport is active but no validated target is configured",
1674            )
1675            .with_provider("gemini")
1676        })?;
1677
1678        let thinking_level = self.thinking_config.as_ref().and_then(|c| c.thinking_level);
1679        let stateful = self.interaction_options.stateful;
1680        let store = self.interaction_options.store;
1681
1682        let mut request =
1683            interactions_convert::build_request(&req, target, thinking_level, stateful, store)?;
1684        // Streaming uses SSE for incremental completion, not background polling;
1685        // the two are distinct completion modes (Requirement 7.4 vs 7.5). Force
1686        // foreground so the server streams the turn rather than returning a
1687        // background handle.
1688        request.background = Some(false);
1689
1690        let sse_stream = self
1691            .client
1692            .send_interaction_stream(request)
1693            .await
1694            .map_err(|e| gemini_error_to_adk(&e))?;
1695
1696        let mapped = async_stream::stream! {
1697            let mut sse_stream = sse_stream;
1698            let mut acc = SseAccumulator::new();
1699            while let Some(result) = sse_stream.try_next().await.transpose() {
1700                match result {
1701                    Ok(event) => {
1702                        if let Some(chunk) = sse_event_to_chunk(event, &mut acc) {
1703                            yield chunk;
1704                        }
1705                    }
1706                    Err(e) => {
1707                        adk_telemetry::error!(error = %e, "Interaction stream error");
1708                        yield Err(gemini_error_to_adk(&e));
1709                    }
1710                }
1711            }
1712        };
1713
1714        Ok(Box::pin(mapped))
1715    }
1716}
1717
1718#[async_trait]
1719impl Llm for GeminiModel {
1720    fn name(&self) -> &str {
1721        &self.model_name
1722    }
1723
1724    fn schema_adapter(&self) -> &dyn SchemaAdapter {
1725        use std::sync::LazyLock;
1726        static ADAPTER: LazyLock<GeminiSchemaAdapter> = LazyLock::new(GeminiSchemaAdapter::new);
1727        &*ADAPTER
1728    }
1729
1730    #[cfg(feature = "gemini-interactions")]
1731    fn uses_interactions_api(&self) -> bool {
1732        self.transport == GeminiTransport::Interactions
1733    }
1734
1735    // Named distinctly from the agent layer's `call_llm` span (which carries
1736    // the gcp.vertex.agent.* attributes and is what trace exporters capture):
1737    // this is the model-transport layer, so traces show one `call_llm` per
1738    // LLM call instead of an identical nested pair.
1739    #[adk_telemetry::instrument(
1740        name = "model.generate_content",
1741        skip(self, req),
1742        fields(
1743            model.name = %self.model_name,
1744            stream = %stream,
1745            request.contents_count = %req.contents.len(),
1746            request.tools_count = %req.tools.len()
1747        )
1748    )]
1749    async fn generate_content(&self, req: LlmRequest, stream: bool) -> Result<LlmResponseStream> {
1750        adk_telemetry::info!("Generating content");
1751        self.validate_request_contract(&req)?;
1752        let usage_span = adk_telemetry::llm_generate_span("gemini", &self.model_name, stream);
1753
1754        // Dispatch on the configured transport. The default `GenerateContent`
1755        // path is unchanged; the Interactions path (task 7.3/7.4) is only
1756        // reachable when `use_interactions_api` switched the transport.
1757        //
1758        // Retries only cover request setup/execution. Stream failures after the
1759        // stream starts are yielded to the caller and are not replayed
1760        // automatically.
1761        #[cfg(feature = "gemini-interactions")]
1762        if self.transport == GeminiTransport::Interactions {
1763            // Streaming and non-streaming are distinct completion modes. The
1764            // streaming path consumes the Interactions SSE stream and yields
1765            // partial→final chunks; the non-streaming path sends a single
1766            // request and (optionally) polls a background interaction to
1767            // completion. `execute_with_retry` wraps only request *setup* in
1768            // both cases — once a stream starts, its mid-flight errors are
1769            // surfaced to the caller rather than replayed (mirroring the
1770            // generateContent streaming path).
1771            if stream {
1772                let mapped =
1773                    execute_with_retry(&self.retry_config, is_retryable_model_error, || {
1774                        self.generate_interactions_stream(req.clone())
1775                    })
1776                    .await?;
1777                return Ok(crate::usage_tracking::with_usage_tracking(mapped, usage_span));
1778            }
1779            let response = execute_with_retry(&self.retry_config, is_retryable_model_error, || {
1780                self.generate_interactions_once(req.clone())
1781            })
1782            .await?;
1783            let single = async_stream::stream! {
1784                yield Ok(response);
1785            };
1786            return Ok(crate::usage_tracking::with_usage_tracking(Box::pin(single), usage_span));
1787        }
1788
1789        let result = execute_with_retry(&self.retry_config, is_retryable_model_error, || {
1790            self.generate_content_internal(req.clone(), stream)
1791        })
1792        .await?;
1793        Ok(crate::usage_tracking::with_usage_tracking(result, usage_span))
1794    }
1795}
1796
1797#[cfg(test)]
1798mod native_tool_tests {
1799    use super::*;
1800
1801    fn test_adapter() -> GeminiSchemaAdapter {
1802        GeminiSchemaAdapter::new()
1803    }
1804
1805    fn test_cache() -> SchemaCache {
1806        SchemaCache::for_adapter(std::sync::Arc::new(GeminiSchemaAdapter::new()))
1807    }
1808
1809    #[test]
1810    fn test_build_gemini_tools_supports_native_tool_metadata() {
1811        let mut tools = std::collections::HashMap::new();
1812        tools.insert(
1813            "google_search".to_string(),
1814            serde_json::json!({
1815                "x-adk-gemini-tool": {
1816                    "google_search": {}
1817                }
1818            }),
1819        );
1820        tools.insert(
1821            "lookup_weather".to_string(),
1822            serde_json::json!({
1823                "name": "lookup_weather",
1824                "description": "lookup weather",
1825                "parameters": {
1826                    "type": "object",
1827                    "properties": {
1828                        "city": { "type": "string" }
1829                    }
1830                }
1831            }),
1832        );
1833
1834        let adapter = test_adapter();
1835        let cache = test_cache();
1836        let (gemini_tools, tool_config) = GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1837            .expect("tool conversion should succeed");
1838
1839        assert_eq!(gemini_tools.len(), 2);
1840        assert_eq!(tool_config.include_server_side_tool_invocations, Some(true));
1841    }
1842
1843    #[test]
1844    fn test_build_gemini_tools_sets_flag_for_builtin_only() {
1845        let mut tools = std::collections::HashMap::new();
1846        tools.insert(
1847            "google_search".to_string(),
1848            serde_json::json!({
1849                "x-adk-gemini-tool": {
1850                    "google_search": {}
1851                }
1852            }),
1853        );
1854
1855        let adapter = test_adapter();
1856        let cache = test_cache();
1857        let (_gemini_tools, tool_config) =
1858            GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1859                .expect("tool conversion should succeed");
1860
1861        assert_eq!(
1862            tool_config.include_server_side_tool_invocations,
1863            Some(true),
1864            "includeServerSideToolInvocations should be set even with only built-in tools"
1865        );
1866    }
1867
1868    #[test]
1869    fn test_build_gemini_tools_no_flag_for_function_only() {
1870        let mut tools = std::collections::HashMap::new();
1871        tools.insert(
1872            "lookup_weather".to_string(),
1873            serde_json::json!({
1874                "name": "lookup_weather",
1875                "description": "lookup weather",
1876                "parameters": {
1877                    "type": "object",
1878                    "properties": {
1879                        "city": { "type": "string" }
1880                    }
1881                }
1882            }),
1883        );
1884
1885        let adapter = test_adapter();
1886        let cache = test_cache();
1887        let (_gemini_tools, tool_config) =
1888            GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1889                .expect("tool conversion should succeed");
1890
1891        assert_eq!(
1892            tool_config.include_server_side_tool_invocations, None,
1893            "includeServerSideToolInvocations should NOT be set for function-only tools"
1894        );
1895    }
1896
1897    #[test]
1898    fn test_build_gemini_tools_merges_native_tool_config() {
1899        let mut tools = std::collections::HashMap::new();
1900        tools.insert(
1901            "google_maps".to_string(),
1902            serde_json::json!({
1903                "x-adk-gemini-tool": {
1904                    "google_maps": {
1905                        "enable_widget": true
1906                    }
1907                },
1908                "x-adk-gemini-tool-config": {
1909                    "retrievalConfig": {
1910                        "latLng": {
1911                            "latitude": 1.23,
1912                            "longitude": 4.56
1913                        }
1914                    }
1915                }
1916            }),
1917        );
1918
1919        let adapter = test_adapter();
1920        let cache = test_cache();
1921        let (_gemini_tools, tool_config) =
1922            GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1923                .expect("tool conversion should succeed");
1924
1925        assert_eq!(
1926            tool_config.retrieval_config,
1927            Some(serde_json::json!({
1928                "latLng": {
1929                    "latitude": 1.23,
1930                    "longitude": 4.56
1931                }
1932            }))
1933        );
1934    }
1935
1936    #[test]
1937    fn test_response_schema_is_normalized_like_parameters() {
1938        // Regression test: MCP tools provide an output_schema that becomes the
1939        // `response` field in the tool declaration. Gemini rejects JSON-Schema
1940        // dialect fields ($schema, additionalProperties) in the response schema.
1941        // This test verifies that the response schema is normalized the same way
1942        // parameters are — stripping unsupported keywords.
1943        let mut tools = std::collections::HashMap::new();
1944        tools.insert(
1945            "read_file".to_string(),
1946            serde_json::json!({
1947                "name": "read_file",
1948                "description": "Read a file from the filesystem",
1949                "parameters": {
1950                    "$schema": "http://json-schema.org/draft-07/schema#",
1951                    "type": "object",
1952                    "properties": {
1953                        "path": { "type": "string", "description": "File path" }
1954                    },
1955                    "required": ["path"],
1956                    "additionalProperties": false
1957                },
1958                "response": {
1959                    "$schema": "http://json-schema.org/draft-07/schema#",
1960                    "type": "object",
1961                    "properties": {
1962                        "content": { "type": "string", "description": "File contents" }
1963                    },
1964                    "required": ["content"],
1965                    "additionalProperties": false
1966                }
1967            }),
1968        );
1969
1970        let adapter = test_adapter();
1971        let cache = test_cache();
1972        let (gemini_tools, _) = GeminiModel::build_gemini_tools(&tools, &adapter, &cache)
1973            .expect("tool conversion should succeed");
1974
1975        // Find the function declaration
1976        let func_tool = gemini_tools
1977            .iter()
1978            .find(|t| matches!(t, adk_gemini::Tool::Function { .. }))
1979            .expect("should have function declarations");
1980
1981        let decls = match func_tool {
1982            adk_gemini::Tool::Function { function_declarations } => function_declarations,
1983            _ => panic!("expected Function tool"),
1984        };
1985
1986        let decl = &decls[0];
1987        let decl_json = serde_json::to_value(decl).unwrap();
1988
1989        // Parameters should be normalized (no $schema, no additionalProperties)
1990        let params = &decl_json["parameters"];
1991        assert!(params.get("$schema").is_none(), "parameters.$schema should be stripped");
1992        assert!(
1993            params.get("additionalProperties").is_none(),
1994            "parameters.additionalProperties should be stripped"
1995        );
1996
1997        // Response should ALSO be normalized (this was the bug)
1998        let response = &decl_json["response"];
1999        assert!(
2000            response.get("$schema").is_none(),
2001            "response.$schema should be stripped (was the bug: copied raw)"
2002        );
2003        assert!(
2004            response.get("additionalProperties").is_none(),
2005            "response.additionalProperties should be stripped (was the bug: copied raw)"
2006        );
2007    }
2008}
2009
2010#[async_trait]
2011impl CacheCapable for GeminiModel {
2012    async fn create_cache(
2013        &self,
2014        system_instruction: &str,
2015        tools: &std::collections::HashMap<String, serde_json::Value>,
2016        ttl_seconds: u32,
2017    ) -> Result<String> {
2018        self.create_cached_content(system_instruction, tools, ttl_seconds).await
2019    }
2020
2021    async fn delete_cache(&self, name: &str) -> Result<()> {
2022        self.delete_cached_content(name).await
2023    }
2024}
2025
2026#[cfg(test)]
2027mod tests {
2028    use super::*;
2029    use adk_core::AdkError;
2030    use std::{
2031        sync::{
2032            Arc,
2033            atomic::{AtomicU32, Ordering},
2034        },
2035        time::Duration,
2036    };
2037
2038    #[test]
2039    fn env_flag_truthy_accepts_one_and_case_insensitive_true() {
2040        for truthy in ["1", "true", "TRUE", "True", " true ", "tRuE"] {
2041            assert!(env_flag_truthy(truthy), "expected {truthy:?} to be truthy");
2042        }
2043        for falsy in ["0", "false", "FALSE", "yes", "on", "", "2", "vertex"] {
2044            assert!(!env_flag_truthy(falsy), "expected {falsy:?} to be falsy");
2045        }
2046    }
2047
2048    #[test]
2049    fn constructor_is_backward_compatible_and_sync() {
2050        fn accepts_sync_constructor<F>(_f: F)
2051        where
2052            F: Fn(&str, &str) -> Result<GeminiModel>,
2053        {
2054        }
2055
2056        accepts_sync_constructor(|api_key, model| GeminiModel::new(api_key, model));
2057    }
2058
2059    #[test]
2060    fn stream_chunks_from_response_injects_partial_before_lone_final_chunk() {
2061        let response = LlmResponse {
2062            content: Some(Content::new("model").with_text("hello")),
2063            usage_metadata: None,
2064            finish_reason: Some(FinishReason::Stop),
2065            citation_metadata: None,
2066            partial: false,
2067            turn_complete: true,
2068            interrupted: false,
2069            error_code: None,
2070            error_message: None,
2071            provider_metadata: None,
2072            interaction_id: None,
2073        };
2074
2075        let (chunks, saw_partial) = GeminiModel::stream_chunks_from_response(response, false);
2076        assert!(saw_partial);
2077        assert_eq!(chunks.len(), 2);
2078        assert!(chunks[0].partial);
2079        assert!(!chunks[0].turn_complete);
2080        assert!(chunks[0].content.is_none());
2081        assert!(!chunks[1].partial);
2082        assert!(chunks[1].turn_complete);
2083    }
2084
2085    #[test]
2086    fn stream_chunks_from_response_keeps_final_only_when_partial_already_seen() {
2087        let response = LlmResponse {
2088            content: Some(Content::new("model").with_text("done")),
2089            usage_metadata: None,
2090            finish_reason: Some(FinishReason::Stop),
2091            citation_metadata: None,
2092            partial: false,
2093            turn_complete: true,
2094            interrupted: false,
2095            error_code: None,
2096            error_message: None,
2097            provider_metadata: None,
2098            interaction_id: None,
2099        };
2100
2101        let (chunks, saw_partial) = GeminiModel::stream_chunks_from_response(response, true);
2102        assert!(saw_partial);
2103        assert_eq!(chunks.len(), 1);
2104        assert!(!chunks[0].partial);
2105        assert!(chunks[0].turn_complete);
2106    }
2107
2108    #[test]
2109    fn stream_chunks_from_response_keeps_tool_call_turn_open() {
2110        let response = LlmResponse {
2111            content: Some(Content {
2112                role: "model".to_string(),
2113                parts: vec![Part::FunctionCall {
2114                    name: "get_weather".to_string(),
2115                    args: serde_json::json!({"city": "Boston"}),
2116                    id: Some("call-1".to_string()),
2117                    thought_signature: None,
2118                }],
2119            }),
2120            finish_reason: Some(FinishReason::Stop),
2121            turn_complete: true,
2122            ..Default::default()
2123        };
2124
2125        let (chunks, _) = GeminiModel::stream_chunks_from_response(response, true);
2126
2127        assert_eq!(chunks.len(), 1);
2128        assert!(!chunks[0].partial);
2129        assert!(!chunks[0].turn_complete);
2130    }
2131
2132    #[tokio::test]
2133    async fn execute_with_retry_retries_retryable_errors() {
2134        let retry_config = RetryConfig::default()
2135            .with_max_retries(2)
2136            .with_initial_delay(Duration::from_millis(0))
2137            .with_max_delay(Duration::from_millis(0));
2138        let attempts = Arc::new(AtomicU32::new(0));
2139
2140        let result = execute_with_retry(&retry_config, is_retryable_model_error, || {
2141            let attempts = Arc::clone(&attempts);
2142            async move {
2143                let attempt = attempts.fetch_add(1, Ordering::SeqCst);
2144                if attempt < 2 {
2145                    return Err(AdkError::model("code 429 RESOURCE_EXHAUSTED"));
2146                }
2147                Ok("ok")
2148            }
2149        })
2150        .await
2151        .expect("retry should eventually succeed");
2152
2153        assert_eq!(result, "ok");
2154        assert_eq!(attempts.load(Ordering::SeqCst), 3);
2155    }
2156
2157    #[tokio::test]
2158    async fn execute_with_retry_does_not_retry_non_retryable_errors() {
2159        let retry_config = RetryConfig::default()
2160            .with_max_retries(3)
2161            .with_initial_delay(Duration::from_millis(0))
2162            .with_max_delay(Duration::from_millis(0));
2163        let attempts = Arc::new(AtomicU32::new(0));
2164
2165        let error = execute_with_retry(&retry_config, is_retryable_model_error, || {
2166            let attempts = Arc::clone(&attempts);
2167            async move {
2168                attempts.fetch_add(1, Ordering::SeqCst);
2169                Err::<(), _>(AdkError::model("code 400 invalid request"))
2170            }
2171        })
2172        .await
2173        .expect_err("non-retryable error should return immediately");
2174
2175        assert!(error.is_model());
2176        assert_eq!(attempts.load(Ordering::SeqCst), 1);
2177    }
2178
2179    #[tokio::test]
2180    async fn execute_with_retry_respects_disabled_config() {
2181        let retry_config = RetryConfig::disabled().with_max_retries(10);
2182        let attempts = Arc::new(AtomicU32::new(0));
2183
2184        let error = execute_with_retry(&retry_config, is_retryable_model_error, || {
2185            let attempts = Arc::clone(&attempts);
2186            async move {
2187                attempts.fetch_add(1, Ordering::SeqCst);
2188                Err::<(), _>(AdkError::model("code 429 RESOURCE_EXHAUSTED"))
2189            }
2190        })
2191        .await
2192        .expect_err("disabled retries should return first error");
2193
2194        assert!(error.is_model());
2195        assert_eq!(attempts.load(Ordering::SeqCst), 1);
2196    }
2197
2198    #[test]
2199    fn convert_response_preserves_citation_metadata() {
2200        let response = adk_gemini::GenerationResponse {
2201            candidates: vec![adk_gemini::Candidate {
2202                content: adk_gemini::Content {
2203                    role: Some(adk_gemini::Role::Model),
2204                    parts: Some(vec![adk_gemini::Part::Text {
2205                        text: "hello world".to_string(),
2206                        thought: None,
2207                        thought_signature: None,
2208                    }]),
2209                },
2210                safety_ratings: None,
2211                citation_metadata: Some(adk_gemini::CitationMetadata {
2212                    citation_sources: vec![adk_gemini::CitationSource {
2213                        uri: Some("https://example.com".to_string()),
2214                        title: Some("Example".to_string()),
2215                        start_index: Some(0),
2216                        end_index: Some(5),
2217                        license: Some("CC-BY".to_string()),
2218                        publication_date: None,
2219                    }],
2220                }),
2221                grounding_metadata: None,
2222                finish_reason: Some(adk_gemini::FinishReason::Stop),
2223                index: Some(0),
2224            }],
2225            prompt_feedback: None,
2226            usage_metadata: None,
2227            model_version: None,
2228            response_id: None,
2229        };
2230
2231        let converted =
2232            GeminiModel::convert_response(&response).expect("conversion should succeed");
2233        let metadata = converted.citation_metadata.expect("citation metadata should be mapped");
2234        assert_eq!(metadata.citation_sources.len(), 1);
2235        assert_eq!(metadata.citation_sources[0].uri.as_deref(), Some("https://example.com"));
2236        assert_eq!(metadata.citation_sources[0].start_index, Some(0));
2237        assert_eq!(metadata.citation_sources[0].end_index, Some(5));
2238    }
2239
2240    #[test]
2241    fn convert_response_keeps_function_call_turn_open() {
2242        let response = adk_gemini::GenerationResponse {
2243            candidates: vec![adk_gemini::Candidate {
2244                content: adk_gemini::Content {
2245                    role: Some(adk_gemini::Role::Model),
2246                    parts: Some(vec![adk_gemini::Part::FunctionCall {
2247                        function_call: adk_gemini::FunctionCall {
2248                            name: "get_weather".to_string(),
2249                            args: serde_json::json!({"city": "Boston"}),
2250                            id: Some("call-1".to_string()),
2251                            thought_signature: None,
2252                        },
2253                        thought_signature: None,
2254                    }]),
2255                },
2256                safety_ratings: None,
2257                citation_metadata: None,
2258                grounding_metadata: None,
2259                finish_reason: Some(adk_gemini::FinishReason::Stop),
2260                index: Some(0),
2261            }],
2262            prompt_feedback: None,
2263            usage_metadata: None,
2264            model_version: None,
2265            response_id: None,
2266        };
2267
2268        let converted =
2269            GeminiModel::convert_response(&response).expect("conversion should succeed");
2270
2271        assert!(!converted.turn_complete);
2272        assert!(converted.content.is_some_and(|content| content.has_function_calls()));
2273    }
2274
2275    #[test]
2276    fn convert_response_handles_inline_data_from_model() {
2277        let image_bytes = vec![0x89, 0x50, 0x4E, 0x47];
2278        let encoded = crate::attachment::encode_base64(&image_bytes);
2279
2280        let response = adk_gemini::GenerationResponse {
2281            candidates: vec![adk_gemini::Candidate {
2282                content: adk_gemini::Content {
2283                    role: Some(adk_gemini::Role::Model),
2284                    parts: Some(vec![
2285                        adk_gemini::Part::Text {
2286                            text: "Here is the image".to_string(),
2287                            thought: None,
2288                            thought_signature: None,
2289                        },
2290                        adk_gemini::Part::InlineData {
2291                            inline_data: adk_gemini::Blob {
2292                                mime_type: "image/png".to_string(),
2293                                data: encoded,
2294                            },
2295                        },
2296                    ]),
2297                },
2298                safety_ratings: None,
2299                citation_metadata: None,
2300                grounding_metadata: None,
2301                finish_reason: Some(adk_gemini::FinishReason::Stop),
2302                index: Some(0),
2303            }],
2304            prompt_feedback: None,
2305            usage_metadata: None,
2306            model_version: None,
2307            response_id: None,
2308        };
2309
2310        let converted =
2311            GeminiModel::convert_response(&response).expect("conversion should succeed");
2312        let content = converted.content.expect("should have content");
2313        assert!(
2314            content
2315                .parts
2316                .iter()
2317                .any(|part| matches!(part, Part::Text { text } if text == "Here is the image"))
2318        );
2319        assert!(content.parts.iter().any(|part| {
2320            matches!(
2321                part,
2322                Part::InlineData { mime_type, data, .. }
2323                    if mime_type == "image/png" && data.as_slice() == image_bytes.as_slice()
2324            )
2325        }));
2326    }
2327
2328    #[test]
2329    fn gemini_function_response_payload_preserves_objects() {
2330        let value = serde_json::json!({
2331            "documents": [
2332                { "id": "pricing", "score": 0.91 }
2333            ]
2334        });
2335
2336        let payload = GeminiModel::gemini_function_response_payload(value.clone());
2337
2338        assert_eq!(payload, value);
2339    }
2340
2341    #[test]
2342    fn gemini_function_response_payload_wraps_arrays() {
2343        let payload =
2344            GeminiModel::gemini_function_response_payload(serde_json::json!([{ "id": "pricing" }]));
2345
2346        assert_eq!(payload, serde_json::json!({ "result": [{ "id": "pricing" }] }));
2347    }
2348
2349    // ===== Multimodal function response conversion tests =====
2350
2351    /// Helper to build a FunctionResponse with nested multimodal parts
2352    /// simulating the conversion logic from generate_content_internal.
2353    fn convert_function_response_to_gemini_fr(
2354        frd: &adk_core::FunctionResponseData,
2355    ) -> adk_gemini::tools::FunctionResponse {
2356        let mut fr_parts = Vec::new();
2357
2358        for inline in &frd.inline_data {
2359            let encoded = crate::attachment::encode_base64(&inline.data);
2360            fr_parts.push(adk_gemini::FunctionResponsePart::InlineData {
2361                inline_data: adk_gemini::Blob {
2362                    mime_type: inline.mime_type.clone(),
2363                    data: encoded,
2364                },
2365            });
2366        }
2367
2368        for file in &frd.file_data {
2369            fr_parts.push(adk_gemini::FunctionResponsePart::FileData {
2370                file_data: adk_gemini::FileDataRef {
2371                    mime_type: file.mime_type.clone(),
2372                    file_uri: file.file_uri.clone(),
2373                },
2374            });
2375        }
2376
2377        let mut gemini_fr = adk_gemini::tools::FunctionResponse::new(
2378            &frd.name,
2379            GeminiModel::gemini_function_response_payload(frd.response.clone()),
2380        );
2381        gemini_fr.parts = fr_parts;
2382        gemini_fr
2383    }
2384
2385    #[test]
2386    fn json_only_function_response_has_no_nested_parts() {
2387        let frd = adk_core::FunctionResponseData::new("tool", serde_json::json!({"ok": true}));
2388        let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2389        assert!(gemini_fr.parts.is_empty());
2390        // Serialized JSON should have name and response but no parts key
2391        let json = serde_json::to_string(&gemini_fr).unwrap();
2392        assert!(!json.contains("\"parts\""));
2393    }
2394
2395    #[test]
2396    fn function_response_with_inline_data_has_nested_parts() {
2397        let frd = adk_core::FunctionResponseData::with_inline_data(
2398            "chart",
2399            serde_json::json!({"status": "ok"}),
2400            vec![adk_core::InlineDataPart {
2401                mime_type: "image/png".to_string(),
2402                data: vec![0x89, 0x50, 0x4E, 0x47],
2403                uri: None,
2404                annotations: None,
2405            }],
2406        );
2407        let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2408        assert_eq!(gemini_fr.parts.len(), 1);
2409        match &gemini_fr.parts[0] {
2410            adk_gemini::FunctionResponsePart::InlineData { inline_data } => {
2411                assert_eq!(inline_data.mime_type, "image/png");
2412                let decoded = BASE64_STANDARD.decode(&inline_data.data).unwrap();
2413                assert_eq!(decoded, vec![0x89, 0x50, 0x4E, 0x47]);
2414            }
2415            other => panic!("expected InlineData, got {other:?}"),
2416        }
2417    }
2418
2419    #[test]
2420    fn function_response_with_file_data_has_nested_parts() {
2421        let frd = adk_core::FunctionResponseData::with_file_data(
2422            "doc",
2423            serde_json::json!({"ok": true}),
2424            vec![adk_core::FileDataPart {
2425                mime_type: "application/pdf".to_string(),
2426                file_uri: "gs://bucket/report.pdf".to_string(),
2427                annotations: None,
2428            }],
2429        );
2430        let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2431        assert_eq!(gemini_fr.parts.len(), 1);
2432        match &gemini_fr.parts[0] {
2433            adk_gemini::FunctionResponsePart::FileData { file_data } => {
2434                assert_eq!(file_data.mime_type, "application/pdf");
2435                assert_eq!(file_data.file_uri, "gs://bucket/report.pdf");
2436            }
2437            other => panic!("expected FileData, got {other:?}"),
2438        }
2439    }
2440
2441    #[test]
2442    fn function_response_with_both_inline_and_file_data_ordering() {
2443        let frd = adk_core::FunctionResponseData::with_multimodal(
2444            "multi",
2445            serde_json::json!({}),
2446            vec![
2447                adk_core::InlineDataPart {
2448                    mime_type: "image/png".to_string(),
2449                    data: vec![1, 2],
2450                    uri: None,
2451                    annotations: None,
2452                },
2453                adk_core::InlineDataPart {
2454                    mime_type: "image/jpeg".to_string(),
2455                    data: vec![3, 4],
2456                    uri: None,
2457                    annotations: None,
2458                },
2459            ],
2460            vec![adk_core::FileDataPart {
2461                mime_type: "application/pdf".to_string(),
2462                file_uri: "gs://b/f.pdf".to_string(),
2463                annotations: None,
2464            }],
2465        );
2466        let gemini_fr = convert_function_response_to_gemini_fr(&frd);
2467        // 2 inline + 1 file = 3 nested parts
2468        assert_eq!(gemini_fr.parts.len(), 3);
2469        assert!(matches!(&gemini_fr.parts[0], adk_gemini::FunctionResponsePart::InlineData { .. }));
2470        assert!(matches!(&gemini_fr.parts[1], adk_gemini::FunctionResponsePart::InlineData { .. }));
2471        assert!(matches!(&gemini_fr.parts[2], adk_gemini::FunctionResponsePart::FileData { .. }));
2472    }
2473}
2474
2475#[cfg(all(test, feature = "gemini-interactions"))]
2476mod interactions_transport_tests {
2477    use super::*;
2478    use adk_core::GenerateContentConfig;
2479
2480    /// **Feature: gemini-interactions-runtime, Property 2: Default options match the API**
2481    /// *For any* default `InteractionOptions`, `store == true`, `stateful == true`,
2482    /// `background` is `AgentTargetsOnly`, and `poll_interval` is 1 second.
2483    /// **Validates: Requirements 3.1, 3.2, 3.3**
2484    #[test]
2485    fn default_interaction_options_match_api_posture() {
2486        let opts = InteractionOptions::default();
2487        assert!(opts.store, "store should default to true");
2488        assert!(opts.stateful, "stateful should default to true");
2489        assert_eq!(opts.background, BackgroundMode::AgentTargetsOnly);
2490        assert_eq!(opts.poll_interval, std::time::Duration::from_secs(1));
2491    }
2492
2493    #[test]
2494    fn new_model_defaults_to_generate_content_transport() {
2495        let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2496            .expect("constructing a Gemini model should not require network");
2497        assert_eq!(model.transport(), GeminiTransport::GenerateContent);
2498    }
2499
2500    #[test]
2501    fn use_interactions_api_enables_transport_for_allowlisted_model() {
2502        let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2503            .expect("construct model")
2504            .use_interactions_api(true)
2505            .expect("allowlisted model should enable the Interactions transport");
2506
2507        assert_eq!(model.transport(), GeminiTransport::Interactions);
2508        assert_eq!(
2509            model.interaction_target,
2510            Some(InteractionTarget::Model("gemini-2.5-flash".to_string()))
2511        );
2512    }
2513
2514    #[test]
2515    fn use_interactions_api_rejects_unsupported_model_with_invalid_input() {
2516        let result = GeminiModel::new("test-key", "gemini-2.0-flash")
2517            .expect("construct model")
2518            .use_interactions_api(true);
2519
2520        let err = match result {
2521            Ok(_) => panic!("unsupported model should be rejected"),
2522            Err(err) => err,
2523        };
2524        assert_eq!(err.category, adk_core::ErrorCategory::InvalidInput);
2525        assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2526    }
2527
2528    #[test]
2529    fn use_interactions_api_false_reverts_to_generate_content() {
2530        let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2531            .expect("construct model")
2532            .use_interactions_api(true)
2533            .expect("enable interactions")
2534            .use_interactions_api(false)
2535            .expect("disabling should always succeed");
2536
2537        assert_eq!(model.transport(), GeminiTransport::GenerateContent);
2538        assert_eq!(model.interaction_target, None);
2539    }
2540
2541    #[test]
2542    fn interaction_options_override_is_stored() {
2543        let opts = InteractionOptions {
2544            store: false,
2545            stateful: false,
2546            background: BackgroundMode::Always,
2547            poll_interval: std::time::Duration::from_millis(250),
2548        };
2549        let model = GeminiModel::new("test-key", "gemini-2.5-flash")
2550            .expect("construct model")
2551            .interaction_options(opts.clone());
2552
2553        let stored = model.interaction_options_ref();
2554        assert_eq!(stored.store, opts.store);
2555        assert_eq!(stored.stateful, opts.stateful);
2556        assert_eq!(stored.background, opts.background);
2557        assert_eq!(stored.poll_interval, opts.poll_interval);
2558    }
2559
2560    #[test]
2561    fn resolve_background_honors_background_mode() {
2562        // Always → true regardless of target.
2563        let always = GeminiModel::new("test-key", "gemini-2.5-flash")
2564            .expect("construct model")
2565            .use_interactions_api(true)
2566            .expect("enable")
2567            .interaction_options(InteractionOptions {
2568                background: BackgroundMode::Always,
2569                ..InteractionOptions::default()
2570            });
2571        assert!(always.resolve_background());
2572
2573        // Never → false regardless of target.
2574        let never = GeminiModel::new("test-key", "gemini-2.5-flash")
2575            .expect("construct model")
2576            .use_interactions_api(true)
2577            .expect("enable")
2578            .interaction_options(InteractionOptions {
2579                background: BackgroundMode::Never,
2580                ..InteractionOptions::default()
2581            });
2582        assert!(!never.resolve_background());
2583
2584        // AgentTargetsOnly → false for a model target.
2585        let model_target = GeminiModel::new("test-key", "gemini-2.5-flash")
2586            .expect("construct model")
2587            .use_interactions_api(true)
2588            .expect("enable");
2589        assert!(!model_target.resolve_background());
2590
2591        // AgentTargetsOnly → true for an agent target.
2592        let agent_target = GeminiModel::new("test-key", "deep-research-preview-04-2026")
2593            .expect("construct model")
2594            .use_interactions_api(true)
2595            .expect("enable");
2596        assert!(agent_target.resolve_background());
2597    }
2598
2599    /// Requirements 7.7 / 9.2: a terminal `failed` status maps to an
2600    /// `Internal` `AdkError` (provider `"gemini"`).
2601    #[test]
2602    fn terminal_failed_status_maps_to_error() {
2603        use adk_gemini::interactions::InteractionStatus;
2604
2605        let err = interaction_status_to_result(InteractionStatus::Failed)
2606            .expect_err("a failed interaction must surface an error");
2607        assert_eq!(err.category, adk_core::ErrorCategory::Internal);
2608        assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2609        assert_eq!(err.code, "model.gemini.interactions.failed");
2610    }
2611
2612    /// Requirements 7.7 / 9.2: a terminal `budget_exceeded` status maps to an
2613    /// `Internal` `AdkError` (provider `"gemini"`).
2614    #[test]
2615    fn terminal_budget_exceeded_status_maps_to_error() {
2616        use adk_gemini::interactions::InteractionStatus;
2617
2618        let err = interaction_status_to_result(InteractionStatus::BudgetExceeded)
2619            .expect_err("a budget_exceeded interaction must surface an error");
2620        assert_eq!(err.category, adk_core::ErrorCategory::Internal);
2621        assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2622        assert_eq!(err.code, "model.gemini.interactions.budget_exceeded");
2623    }
2624
2625    /// Non-failure statuses (including `requires_action` and the other terminal
2626    /// states the transport reads content from) do not produce an error.
2627    #[test]
2628    fn non_failure_statuses_are_ok() {
2629        use adk_gemini::interactions::InteractionStatus;
2630
2631        for status in [
2632            InteractionStatus::InProgress,
2633            InteractionStatus::RequiresAction,
2634            InteractionStatus::Completed,
2635            InteractionStatus::Cancelled,
2636            InteractionStatus::Incomplete,
2637        ] {
2638            assert!(
2639                interaction_status_to_result(status).is_ok(),
2640                "status {status:?} should not map to an error"
2641            );
2642        }
2643    }
2644
2645    /// A constructed `failed` `Interaction` flows through the same status check
2646    /// the transport uses, surfacing an error after conversion.
2647    #[test]
2648    fn failed_interaction_resource_surfaces_error() {
2649        use adk_gemini::interactions::{Interaction, InteractionStatus};
2650
2651        let interaction = Interaction {
2652            id: "v1_failed".to_string(),
2653            model: Some("gemini-2.5-flash".to_string()),
2654            agent: None,
2655            status: InteractionStatus::Failed,
2656            steps: Vec::new(),
2657            usage: None,
2658            created: None,
2659            updated: None,
2660            environment_id: None,
2661        };
2662
2663        let err = interaction_status_to_result(interaction.status)
2664            .expect_err("failed interaction must surface an error");
2665        assert_eq!(err.category, adk_core::ErrorCategory::Internal);
2666        assert_eq!(err.details.provider.as_deref(), Some("gemini"));
2667    }
2668    #[test]
2669    fn gemini_37_rejects_sampling_before_network_io() {
2670        let model = GeminiModel::new("test-key", "gemini-3.7-flash").expect("construct model");
2671        let mut request = LlmRequest::new("gemini-3.7-flash", Vec::new());
2672        request.config =
2673            Some(GenerateContentConfig { temperature: Some(0.2), ..Default::default() });
2674
2675        let error = model
2676            .validate_request_contract(&request)
2677            .expect_err("sampling must be rejected locally");
2678        assert_eq!(error.code, "model.gemini.sampling_unsupported");
2679    }
2680
2681    #[test]
2682    fn gemini_37_rejects_token_budget_thinking() {
2683        let model = GeminiModel::new("test-key", "gemini-3.7-flash")
2684            .expect("construct model")
2685            .with_thinking_config(adk_gemini::ThinkingConfig {
2686                thinking_budget: Some(1024),
2687                include_thoughts: None,
2688                thinking_level: None,
2689            });
2690
2691        let error = model
2692            .validate_request_contract(&LlmRequest::new("gemini-3.7-flash", Vec::new()))
2693            .expect_err("token budgets must be rejected locally");
2694        assert_eq!(error.code, "model.gemini.thinking_budget_unsupported");
2695    }
2696}
2697
2698#[cfg(all(test, feature = "gemini-vertex"))]
2699mod vertex_rag_tests {
2700    use super::*;
2701    use adk_gemini::VertexRagStore;
2702
2703    fn test_store() -> VertexRagStore {
2704        VertexRagStore::corpus("projects/p/locations/us-central1/ragCorpora/123").with_top_k(5)
2705    }
2706
2707    fn vertex_model() -> GeminiModel {
2708        GeminiModel::new_google_cloud("test-key", "p", "us-central1", "gemini-3.7-flash")
2709            .expect("construct vertex model")
2710    }
2711
2712    // The Vertex constructor builds credential plumbing that requires a
2713    // current Tokio runtime, so this test is async.
2714    #[tokio::test]
2715    async fn vertex_backend_accepts_and_stores_the_rag_store() {
2716        let model = vertex_model().with_vertex_rag_store(test_store());
2717
2718        model
2719            .validate_request_contract(&LlmRequest::new("gemini-3.7-flash", Vec::new()))
2720            .expect("vertex backend must accept the rag store");
2721
2722        // The stored store is handed to adk-gemini's ContentBuilder per
2723        // request; the exact wire-shape injection is asserted in adk-gemini's
2724        // backend tests.
2725        assert_eq!(model.vertex_rag_store(), Some(&test_store()));
2726    }
2727
2728    #[test]
2729    fn studio_backend_rejects_rag_store_before_network_io() {
2730        let model = GeminiModel::new("test-key", "gemini-3.7-flash")
2731            .expect("construct studio model")
2732            .with_vertex_rag_store(test_store());
2733
2734        let error = model
2735            .validate_request_contract(&LlmRequest::new("gemini-3.7-flash", Vec::new()))
2736            .expect_err("studio backend must reject the rag store locally");
2737        assert_eq!(error.code, "model.gemini.rag_store_requires_vertex");
2738        assert_eq!(error.category, adk_core::ErrorCategory::Unsupported);
2739        assert_eq!(error.details.provider.as_deref(), Some("gemini"));
2740    }
2741
2742    #[test]
2743    fn no_rag_store_means_no_gate() {
2744        let model = GeminiModel::new("test-key", "gemini-3.7-flash").expect("construct model");
2745        assert_eq!(model.vertex_rag_store(), None);
2746        model
2747            .validate_request_contract(&LlmRequest::new("gemini-3.7-flash", Vec::new()))
2748            .expect("no store configured, nothing to reject");
2749    }
2750}
2751
2752#[cfg(all(test, feature = "gemini-vertex"))]
2753mod vertex_transport_tests {
2754    use super::*;
2755
2756    #[test]
2757    fn vertex_transport_failures_are_retryable() {
2758        let error = adk_gemini::ClientError::Io {
2759            source: std::io::Error::other(
2760                "the transport reports an error: client error (SendRequest): http2 error",
2761            ),
2762        };
2763        let mapped = gemini_error_to_adk(&error);
2764        assert_eq!(
2765            (mapped.category, mapped.code, mapped.is_retryable()),
2766            (ErrorCategory::Unavailable, "model.gemini.unavailable", true)
2767        );
2768
2769        let denied =
2770            adk_gemini::ClientError::Io { source: std::io::Error::other("permission denied") };
2771        assert_eq!(gemini_error_to_adk(&denied).category, ErrorCategory::Internal);
2772    }
2773}