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