Skip to main content

mermaid_model/models/adapters/
gemini.rs

1//! Google Gemini adapter — bespoke handling for the `generateContent` API.
2//!
3//! Gemini's wire format is structurally different from both OpenAI Chat
4//! Completions and Anthropic Messages. Key divergences:
5//!
6//! - Endpoints are per-method: `POST /models/{model}:generateContent`
7//!   (sync) and `POST /models/{model}:streamGenerateContent?alt=sse`
8//!   (streaming). The model name lives in the URL, not the body.
9//! - Auth is `x-goog-api-key: $KEY` (header) or `?key=` (query). We use
10//!   the header — cleaner, doesn't leak via logs.
11//! - Roles are `user` and `model` (NOT `assistant`). System is a
12//!   top-level `systemInstruction` field wrapped in a `Content` object.
13//! - Content is `parts[]`, each part one of `{text}`, `{inlineData}`,
14//!   `{functionCall}`, `{functionResponse}`, or `{text, thought: true}`
15//!   (reasoning).
16//! - Tool definitions are nested: `tools: [{ functionDeclarations: [...] }]`.
17//! - Tool results are user-role messages with `functionResponse` parts.
18//!   No separate `tool` role; consecutive Tool messages collapse into
19//!   one user-role message with multiple parts (same idea as Anthropic
20//!   but a different part type).
21//! - Streaming uses untyped chunks: each SSE event is a complete partial
22//!   `GenerateContentResponse` snapshot. We accumulate `parts[]` across
23//!   chunks; no per-block-index typed-event state machine needed.
24//! - Reasoning is gated by `generationConfig.thinkingConfig.thinkingBudget`
25//!   (int) + `includeThoughts: bool`. Thoughts come back as parts with
26//!   `thought: true`.
27//! - **No signature round-trip.** Gemini has no encrypted server state
28//!   for thinking blocks — multi-turn reasoning is stateless on the
29//!   client side. This is significantly simpler than Anthropic.
30//! - Tool call IDs are synthesized (`call_<n>`) since Gemini doesn't
31//!   supply them; tool results match by **name** on the wire. The protocol has
32//!   no call-id field on `functionCall`/`functionResponse`, so two parallel
33//!   calls to the *same* tool in one turn are associated only by ORDER — we
34//!   keep calls and results in arrival order so positional matching holds. This
35//!   is an inherent Gemini limitation, not fixable in the wire format.
36//!
37//! # Caching note (Step 5b)
38//!
39//! Gemini 2.5+ enables **implicit caching** by default — repeated
40//! content prefixes get cost discounts automatically with no client
41//! code. The minimums (verified at ai.google.dev/gemini-api/docs/caching
42//! as of 2026-04) are 1,024 tokens for Flash variants and 4,096 tokens
43//! for Pro variants. Mermaid's static system prompt + tool definitions
44//! (~5-7k tokens) clears both, so users get savings for free.
45//!
46//! **Explicit** caching via the `cachedContents` API is intentionally
47//! NOT implemented here. The per-request lifecycle (create cache →
48//! reuse `cachedContent` ID → invalidate on prompt change) adds
49//! complexity for marginal gain over what implicit caching already
50//! delivers. Revisit if Mermaid's prompt grows past ~32k tokens (where
51//! implicit hit rates drop) or if users hit measurable cost issues.
52//! When that day comes, the entry point is a `cachedContents` POST in
53//! `send_chat`, returning a cache name to slot into `cachedContent`
54//! field of subsequent `generateContent` requests.
55
56use std::time::Duration;
57
58use async_trait::async_trait;
59use futures::StreamExt;
60use reqwest::Client;
61use serde::Deserialize;
62use serde_json::{Value, json};
63
64use crate::constants::MAX_RESPONSE_CHARS;
65use crate::models::ModelCapabilities;
66use crate::models::config::ModelConfig;
67use crate::models::error::{BackendError, ModelError, Result};
68use crate::models::reasoning::{
69    ReasoningCapability, ReasoningChunk, ReasoningLevel, nearest_effort,
70};
71use crate::models::stream::{StreamCallback, StreamEvent};
72use crate::models::tool_call::{FunctionCall, ToolCall};
73use crate::models::traits::Model;
74use crate::models::types::{
75    ChatMessage, FinishReason, MessageAudience, MessageRole, ModelResponse, TokenUsage,
76};
77use crate::utils::drain_sse_events;
78
79use super::ModelLimits;
80
81const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: response exceeded size limit]";
82
83/// Append `chunk` to `buf`, char-boundary-safe truncation at `cap` bytes.
84/// Sets `*truncated` once tripped; subsequent calls become no-ops. Same
85/// shape as the helpers in the other adapters.
86fn push_capped(buf: &mut String, chunk: &str, truncated: &mut bool, cap: usize) {
87    if *truncated {
88        return;
89    }
90    buf.push_str(chunk);
91    if buf.len() > cap {
92        let end = buf.floor_char_boundary(cap);
93        buf.truncate(end);
94        buf.push_str(TRUNCATION_MARKER);
95        *truncated = true;
96    }
97}
98
99/// Map Gemini's `finishReason` onto the normalized [`FinishReason`]. Gemini
100/// reports tool calls with `STOP` (the call rides in `parts`), so there is no
101/// `ToolUse` mapping; safety/recitation/blocklist reasons are content blocks.
102/// Whether an empty (no-content) Gemini candidate/chunk is a benign normal
103/// completion rather than a block to surface as an error. `FINISH_REASON_
104/// UNSPECIFIED` is included — it's not a safety block, and intermediate stream
105/// chunks legitimately carry it — so the streaming and non-streaming paths treat
106/// an empty response identically (#51).
107fn gemini_empty_is_benign(reason: &str) -> bool {
108    matches!(reason, "STOP" | "MAX_TOKENS" | "FINISH_REASON_UNSPECIFIED")
109}
110
111fn map_gemini_finish_reason(s: &str) -> FinishReason {
112    match s {
113        "STOP" => FinishReason::Stop,
114        "MAX_TOKENS" => FinishReason::Length,
115        "SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" => {
116            FinishReason::ContentFilter
117        },
118        other => FinishReason::Other(other.to_string()),
119    }
120}
121
122/// F56: whether a Gemini stream ended abnormally — it closed before the
123/// terminal `finishReason` was ever observed on a chunk. Gemini ends every
124/// normal stream (including tool-call turns, which report `STOP`) with a
125/// candidate `finishReason`; there is no separate `[DONE]`-style frame, so the
126/// `finishReason` IS the terminal marker. Its absence means the connection
127/// dropped mid-response — surfacing a clean `Ok` (with `stop_reason: None`)
128/// would be indistinguishable from a real completion, so the caller returns a
129/// stream error. A `MAX_TOKENS` truncation sets a real `finishReason`
130/// (`Length`), so it is NOT abnormal and is preserved.
131fn stream_closed_abnormally(finish_reason: Option<&FinishReason>) -> bool {
132    finish_reason.is_none()
133}
134
135/// Translate `ReasoningLevel` to Gemini 2.5's `thinkingBudget` (int
136/// tokens). `-1` means adaptive (Gemini decides up to the model's
137/// ceiling); `0` means disabled. Per-model floors come from the catalog's
138/// `GeminiBudget` row — this function returns the raw mapping which gets
139/// clamped before going on the wire.
140fn thinking_budget_for(level: ReasoningLevel) -> i32 {
141    match level {
142        ReasoningLevel::None => 0,
143        ReasoningLevel::Minimal => 512,
144        ReasoningLevel::Low => 2048,
145        ReasoningLevel::Medium => 8192,
146        ReasoningLevel::High => 24576,
147        // Max and XHigh both map to the adaptive sentinel on Gemini 2.5.
148        // Gemini has no xhigh tier so the two collapse to the same shape.
149        ReasoningLevel::Max | ReasoningLevel::XHigh => -1,
150    }
151}
152
153/// Translate `ReasoningLevel` to Gemini 3's `thinkingLevel` enum
154/// string. Gemini 3 cannot truly disable thinking — `None` maps to
155/// `"minimal"` (the documented closest-to-off; per Google's docs,
156/// "the model likely will not think though it still potentially can").
157/// Gemini 3 also has no `max` or `xhigh` tier; both collapse to `high`.
158fn thinking_level_for(level: ReasoningLevel) -> &'static str {
159    match level {
160        ReasoningLevel::None | ReasoningLevel::Minimal => "minimal",
161        ReasoningLevel::Low => "low",
162        ReasoningLevel::Medium => "medium",
163        ReasoningLevel::High | ReasoningLevel::Max | ReasoningLevel::XHigh => "high",
164    }
165}
166
167// Per-model thinking dispatch lives in the capability catalog
168// (`crate::models::catalog`): Gemini 3.x rows carry
169// `ThinkingShape::GeminiLevel` (the `thinkingLevel` enum — cannot truly
170// disable), Gemini 2.5 rows carry `ThinkingShape::GeminiBudget {min,
171// can_disable}` (integer `thinkingBudget` with the per-model floor/disable
172// rules from `ai.google.dev/gemini-api/docs/thinking`), and everything else
173// — including 2.0 and earlier, which 400 on any `thinkingConfig` — falls to
174// `ProviderDefault`, meaning the field is omitted entirely.
175
176/// Convert Mermaid's OpenAI-shaped tool definitions to Gemini's nested
177/// `[{functionDeclarations: [{name, description, parameters}]}]` shape.
178/// All declarations go into a single tool group.
179fn to_gemini_tools(openai_tools: &[&Value]) -> Vec<Value> {
180    let declarations: Vec<Value> = openai_tools
181        .iter()
182        .filter_map(|tool| {
183            let function = tool.get("function")?;
184            let name = function.get("name")?.as_str()?;
185            let description = function
186                .get("description")
187                .and_then(|d| d.as_str())
188                .unwrap_or("");
189            let parameters = function.get("parameters").cloned().unwrap_or(json!({
190                "type": "object",
191                "properties": {}
192            }));
193            Some(json!({
194                "name": name,
195                "description": description,
196                "parameters": parameters,
197            }))
198        })
199        .collect();
200
201    if declarations.is_empty() {
202        Vec::new()
203    } else {
204        vec![json!({"functionDeclarations": declarations})]
205    }
206}
207
208/// Gemini's spelling of the `<system-reminder>` contract — see the Anthropic
209/// adapter's `push_system_reminder` for why the text rides a user turn.
210/// `coalesce_consecutive_roles` folds it into a neighbouring user turn.
211fn push_system_reminder(out: &mut Vec<Value>, text: &str) {
212    out.push(json!({
213        "role": "user",
214        "parts": [{"text": format!("<system-reminder>\n{text}\n</system-reminder>")}],
215    }));
216}
217
218/// Collapse same-role neighbours so `contents` always alternates — Gemini's
219/// spelling of the Anthropic adapter's `coalesce_consecutive_roles`, and see
220/// that one for which ordinary histories produce same-role neighbours in the
221/// first place. Gemini has no must-lead part kind, so merging is a plain
222/// concatenation of `parts`.
223fn coalesce_consecutive_roles(msgs: Vec<Value>) -> Vec<Value> {
224    let mut out: Vec<Value> = Vec::with_capacity(msgs.len());
225    for msg in msgs {
226        let same_role = out.last().is_some_and(|prev| prev["role"] == msg["role"]);
227        if same_role
228            && let Some(incoming) = msg["parts"].as_array()
229            && let Some(prev) = out.last_mut()
230            && let Some(parts) = prev["parts"].as_array_mut()
231        {
232            parts.extend(incoming.iter().cloned());
233            continue;
234        }
235        out.push(msg);
236    }
237    out
238}
239
240/// Translate Mermaid's `ChatMessage` history into Gemini's
241/// `(systemInstruction, contents)` shape.
242///
243/// - `MessageRole::System` → top-level `systemInstruction` (first wins), or,
244///   when it is model-directed harness steering
245///   (`MessageAudience::ModelDirected`), a tagged part on the adjacent user
246///   turn — it must reach the model, not be dropped.
247/// - `MessageRole::User` → `{role: "user", parts: [text + inlineData]}`.
248/// - `MessageRole::Assistant` → `{role: "model", parts: [text + functionCall]}`.
249///   Note the role rename: Mermaid's `Assistant` serializes as Gemini's
250///   `model`. Thinking content (`msg.thinking`) re-emits as a `thought:
251///   true` text part — stateless, no signature.
252/// - `MessageRole::Tool` → user-role message with one `functionResponse`
253///   part per tool result. Consecutive Tool messages merge into one
254///   user-role message (same idea as Anthropic).
255fn convert_messages(messages: &[ChatMessage]) -> (Option<Value>, Vec<Value>) {
256    let mut system: Option<Value> = None;
257    let mut out: Vec<Value> = Vec::new();
258
259    let mut i = 0;
260    while i < messages.len() {
261        let msg = &messages[i];
262        match msg.role {
263            MessageRole::System
264                if msg.kind.audience() == MessageAudience::ModelDirected
265                    && !msg.content.is_empty() =>
266            {
267                // Gemini has only a top-level `systemInstruction`, so harness
268                // steering used to be dropped here. Deliver it as a tagged
269                // part on the adjacent user turn — same contract as the
270                // Anthropic adapter, same reasons (tail position, cached
271                // prefix untouched, provenance explicit).
272                push_system_reminder(&mut out, &msg.content);
273                i += 1;
274            },
275            MessageRole::System => {
276                if system.is_none() && !msg.content.is_empty() {
277                    system = Some(json!({
278                        "parts": [{"text": msg.content}],
279                    }));
280                }
281                i += 1;
282            },
283            MessageRole::User => {
284                let mut parts: Vec<Value> = Vec::new();
285                if !msg.content.is_empty() {
286                    parts.push(json!({"text": msg.content}));
287                }
288                if let Some(ref images) = msg.images {
289                    for data in images {
290                        parts.push(json!({
291                            "inlineData": {
292                                "mimeType": "image/png",
293                                "data": data,
294                            }
295                        }));
296                    }
297                }
298                if parts.is_empty() {
299                    parts.push(json!({"text": ""}));
300                }
301                out.push(json!({"role": "user", "parts": parts}));
302                i += 1;
303            },
304            MessageRole::Assistant => {
305                let mut parts: Vec<Value> = Vec::new();
306                if let Some(ref thinking) = msg.thinking
307                    && !thinking.is_empty()
308                {
309                    // Re-emit prior reasoning as a thought part so
310                    // the model sees the full chain on follow-up turns.
311                    parts.push(json!({
312                        "text": thinking,
313                        "thought": true,
314                    }));
315                }
316                if !msg.content.is_empty() {
317                    parts.push(json!({"text": msg.content}));
318                }
319                if let Some(ref tool_calls) = msg.tool_calls {
320                    for tc in tool_calls {
321                        parts.push(json!({
322                            "functionCall": {
323                                "name": tc.function.name,
324                                "args": tc.function.arguments,
325                            }
326                        }));
327                    }
328                }
329                if parts.is_empty() {
330                    // Skip empty assistant turns (shouldn't happen, but
331                    // Gemini rejects role/parts with empty parts array).
332                    i += 1;
333                    continue;
334                }
335                out.push(json!({"role": "model", "parts": parts}));
336                i += 1;
337            },
338            MessageRole::Tool => {
339                // Merge consecutive Tool messages into one user-role
340                // message containing multiple functionResponse parts.
341                let mut parts: Vec<Value> = Vec::new();
342                while i < messages.len() && messages[i].role == MessageRole::Tool {
343                    let t = &messages[i];
344                    let name = t
345                        .tool_name
346                        .clone()
347                        .unwrap_or_else(|| "unknown_tool".to_string());
348                    // Gemini expects `response` to be an object/value. We
349                    // wrap the textual tool result in `{result: <text>}`
350                    // so the model sees structured-but-typed content.
351                    parts.push(json!({
352                        "functionResponse": {
353                            "name": name,
354                            "response": {"result": t.content},
355                        }
356                    }));
357                    i += 1;
358                }
359                out.push(json!({"role": "user", "parts": parts}));
360            },
361        }
362    }
363
364    (system, coalesce_consecutive_roles(out))
365}
366
367/// Google Gemini adapter.
368pub struct GeminiAdapter {
369    client: Client,
370    api_key: String,
371    base_url: String,
372    model_name: String,
373    capabilities: ModelCapabilities,
374}
375
376impl GeminiAdapter {
377    /// Create a new adapter. `api_key` is already resolved (caller uses
378    /// `crate::utils::resolve_api_key`).
379    pub fn new(api_key: String, model_name: String, base_url: String) -> Result<Self> {
380        let client = Client::builder()
381            .pool_max_idle_per_host(10)
382            .pool_idle_timeout(Duration::from_secs(90))
383            .tcp_keepalive(Duration::from_secs(60))
384            .connect_timeout(Duration::from_secs(10))
385            .build()
386            .map_err(|e| {
387                ModelError::Backend(BackendError::ConnectionFailed {
388                    backend: "gemini".to_string(),
389                    url: base_url.clone(),
390                    reason: e.to_string(),
391                })
392            })?;
393
394        // Gemini 2.5+ and Gemini 3.x all accept `thinkingBudget` in
395        // generationConfig. Models that don't actually do extended
396        // thinking silently ignore it, so advertising the full enum
397        // is forward-compatible. Gemini has no xhigh tier — `XHigh`
398        // collapses to the model's top (Gemini 3: "high"; Gemini 2.5:
399        // adaptive sentinel -1).
400        let capabilities = ModelCapabilities {
401            supports_tools: true,
402            supports_vision: true,
403            supports_reasoning: ReasoningCapability::Levels(vec![
404                ReasoningLevel::None,
405                ReasoningLevel::Minimal,
406                ReasoningLevel::Low,
407                ReasoningLevel::Medium,
408                ReasoningLevel::High,
409                ReasoningLevel::Max,
410                ReasoningLevel::XHigh,
411            ]),
412            max_context_tokens: None,
413            max_output_tokens: None,
414        };
415
416        Ok(Self {
417            client,
418            api_key,
419            base_url,
420            model_name,
421            capabilities,
422        })
423    }
424
425    /// Build the JSON request body for `:generateContent` /
426    /// `:streamGenerateContent`. The model name lives in the URL, not
427    /// the body, so it doesn't appear here.
428    fn build_request_body(&self, messages: &[ChatMessage], config: &ModelConfig) -> Value {
429        let (system_from_msgs, gemini_contents) = convert_messages(messages);
430        // ModelConfig.system_prompt (+ optional MERMAID.md suffix) overrides
431        // any system message in the history (matches Anthropic / OpenAI-compat
432        // behavior). Gemini doesn't expose per-block cache markers in this
433        // path, so the static base + dynamic suffix are concatenated with a
434        // `---` separator via combined_system_prompt().
435        let system = match (config.combined_system_prompt(), system_from_msgs) {
436            (Some(s), _) if !s.is_empty() => Some(json!({
437                "parts": [{"text": s}],
438            })),
439            (_, Some(v)) => Some(v),
440            _ => None,
441        };
442
443        let mut body = json!({
444            "contents": gemini_contents,
445        });
446        if let Some(s) = system {
447            body["systemInstruction"] = s;
448        }
449
450        // generationConfig: temperature, max_tokens, thinkingConfig.
451        let mut gen_config = json!({});
452        // Gemini accepts 0.0..=2.0 — same as OpenAI; no clamping needed
453        // beyond what the user already validated, but be defensive.
454        gen_config["temperature"] = json!(config.temperature.clamp(0.0, 2.0));
455        if config.max_tokens > 0 {
456            gen_config["maxOutputTokens"] = json!(config.max_tokens);
457        }
458
459        // Reasoning: snap onto supported levels first (defensive — the
460        // adapter advertises the full enum, but a future per-model
461        // capability shrink lands cleanly through this path).
462        let effective_reasoning = match &self.capabilities.supports_reasoning {
463            ReasoningCapability::Levels(supported) => {
464                nearest_effort(config.reasoning, supported).unwrap_or(ReasoningLevel::None)
465            },
466            _ => config.reasoning,
467        };
468
469        // Per-model thinking dispatch from the capability catalog. Gemini 3
470        // uses the `thinkingLevel` enum; 2.5 uses `thinkingBudget` int with
471        // per-model floors + can-disable rules; older models don't support
472        // thinkingConfig at all and would 400 if we sent one.
473        match crate::models::catalog::lookup(&self.model_name).thinking {
474            crate::models::catalog::ThinkingShape::GeminiLevel => {
475                let level_str = thinking_level_for(effective_reasoning);
476                gen_config["thinkingConfig"] = json!({
477                    "thinkingLevel": level_str,
478                    "includeThoughts": effective_reasoning != ReasoningLevel::None,
479                });
480            },
481            crate::models::catalog::ThinkingShape::GeminiBudget { min, can_disable } => {
482                let raw = thinking_budget_for(effective_reasoning);
483                let budget = if effective_reasoning == ReasoningLevel::None {
484                    // None: disable entirely if the model allows;
485                    // otherwise force the minimum (e.g. 2.5 Pro can't
486                    // disable, must send at least 128).
487                    if can_disable { 0 } else { min }
488                } else if raw < 0 {
489                    // -1 (adaptive sentinel for Max) — pass through.
490                    -1
491                } else {
492                    // Clamp UP to the model's minimum if we're below it.
493                    raw.max(min)
494                };
495                gen_config["thinkingConfig"] = json!({
496                    "thinkingBudget": budget,
497                    "includeThoughts": budget != 0,
498                });
499            },
500            // No gemini thinking shape for this model (2.0 and earlier, or a
501            // non-gemini id) — omit thinkingConfig entirely; sending it 400s.
502            _ => {},
503        }
504        // `--output-schema` formatting turn: native constrained output.
505        // The reducer guarantees no tools ride this request (Gemini errors
506        // when function calling is combined with a response schema).
507        if let Some(schema) = &config.output_schema {
508            gen_config["responseMimeType"] = json!("application/json");
509            gen_config["responseJsonSchema"] = schema.clone();
510        }
511        body["generationConfig"] = gen_config;
512
513        // Tool registration is the single capability boundary. Preserve every
514        // registry-selected tool; native fetch and SearXNG are keyless.
515        let registered: Vec<&Value> = config.tools.iter().collect();
516        let gemini_tools = to_gemini_tools(&registered);
517        if !gemini_tools.is_empty() {
518            body["tools"] = json!(gemini_tools);
519        }
520
521        body
522    }
523
524    /// POST to `:generateContent` (sync) or `:streamGenerateContent`
525    /// (streaming) and return the raw response.
526    /// Transparently retries on 5xx, 429, or reqwest connect failures
527    /// via `crate::models::retry::retry_transient_http`.
528    async fn send_chat(&self, body: &Value, stream: bool) -> Result<reqwest::Response> {
529        let method = if stream {
530            "streamGenerateContent?alt=sse"
531        } else {
532            "generateContent"
533        };
534        let url = format!(
535            "{}/models/{}:{}",
536            self.base_url.trim_end_matches('/'),
537            self.model_name,
538            method
539        );
540        crate::models::retry::retry_transient_http(|| async {
541            self.client
542                .post(&url)
543                .header("x-goog-api-key", &self.api_key)
544                .header("content-type", "application/json")
545                .json(body)
546                .send()
547                .await
548                .map_err(|e| {
549                    ModelError::Backend(BackendError::ConnectionFailed {
550                        backend: "gemini".to_string(),
551                        url: url.clone(),
552                        reason: e.to_string(),
553                    })
554                })
555        })
556        .await
557    }
558
559    /// GET `{base_url}/models/{model}` — Gemini's models endpoint reports
560    /// each model's real limits (`inputTokenLimit` = context window,
561    /// `outputTokenLimit` = per-response output ceiling). A 404 is a
562    /// definitive "id not in the catalog" → `Ok` all-`None` so callers can
563    /// cache the absence; transport/auth/5xx failures are `Err` (never
564    /// cached).
565    pub async fn fetch_model_limits(&self) -> Result<ModelLimits> {
566        let url = format!(
567            "{}/models/{}",
568            self.base_url.trim_end_matches('/'),
569            self.model_name
570        );
571        let response = self
572            .client
573            .get(&url)
574            .header("x-goog-api-key", &self.api_key)
575            .send()
576            .await
577            .map_err(|e| {
578                ModelError::Backend(BackendError::ConnectionFailed {
579                    backend: "gemini".to_string(),
580                    url: url.clone(),
581                    reason: e.to_string(),
582                })
583            })?;
584        if response.status() == reqwest::StatusCode::NOT_FOUND {
585            return Ok(ModelLimits::default());
586        }
587        if !response.status().is_success() {
588            return Err(http_error_from_response(response).await);
589        }
590        let info: GeminiModelInfo = response.json().await.map_err(|e| ModelError::ParseError {
591            message: format!("Failed to parse Gemini model info: {}", e),
592            raw: None,
593        })?;
594        Ok(info.into())
595    }
596
597    /// Decode a non-streaming response into `ModelResponse`.
598    async fn decode_non_streaming(&self, response: reqwest::Response) -> Result<ModelResponse> {
599        if !response.status().is_success() {
600            return Err(http_error_from_response(response).await);
601        }
602
603        let json: GeminiResponse = response.json().await.map_err(|e| ModelError::ParseError {
604            message: format!("Failed to parse Gemini response: {}", e),
605            raw: None,
606        })?;
607
608        // F52: a prompt-level safety block returns `promptFeedback.blockReason`
609        // with NO candidates. Without this guard the `candidates.into_iter()
610        // .next()` below is `None`, the block is skipped, and we return an empty
611        // `Ok` with `stop_reason: None`. Surface a typed refusal instead — the
612        // candidate-level block (no `content` on a present candidate) is handled
613        // separately further down.
614        if json.candidates.is_empty()
615            && let Some(reason) = json
616                .prompt_feedback
617                .as_ref()
618                .and_then(|pf| pf.block_reason.as_deref())
619        {
620            return Err(ModelError::Backend(BackendError::ProviderError {
621                provider: "gemini".to_string(),
622                code: Some(reason.to_string()),
623                message: format!("Gemini blocked the prompt (blockReason={reason})"),
624                debug: crate::models::error::ResponseDebugContext::default(),
625            }));
626        }
627
628        let mut text_acc = String::new();
629        let mut thinking_acc = String::new();
630        let mut tool_calls: Vec<ToolCall> = Vec::new();
631        let mut stop_reason: Option<FinishReason> = None;
632
633        if let Some(candidate) = json.candidates.into_iter().next() {
634            stop_reason = candidate
635                .finish_reason
636                .as_deref()
637                .map(map_gemini_finish_reason);
638            match candidate.content {
639                Some(content) => {
640                    for part in content.parts {
641                        if let Some(text) = part.text {
642                            if part.thought.unwrap_or(false) {
643                                thinking_acc.push_str(&text);
644                            } else {
645                                text_acc.push_str(&text);
646                            }
647                        } else if let Some(fc) = part.function_call {
648                            let id = format!("call_{}", tool_calls.len());
649                            tool_calls.push(ToolCall {
650                                id: Some(id),
651                                function: FunctionCall {
652                                    name: fc.name,
653                                    arguments: fc.args,
654                                },
655                            });
656                        }
657                    }
658                },
659                None => {
660                    // No content: this is a block (safety/recitation/etc.), not
661                    // a parse failure. Surface a clear error keyed off the
662                    // finishReason rather than returning an empty success.
663                    let reason = candidate.finish_reason.as_deref().unwrap_or("unknown");
664                    if !gemini_empty_is_benign(reason) {
665                        return Err(ModelError::Backend(BackendError::ProviderError {
666                            provider: "gemini".to_string(),
667                            code: Some(reason.to_string()),
668                            message: format!("Gemini returned no content (finishReason={reason})"),
669                            debug: crate::models::error::ResponseDebugContext::default(),
670                        }));
671                    }
672                },
673            }
674        }
675
676        let raw_prompt_tokens = json.usage_metadata.prompt_token_count.unwrap_or(0);
677        let cached_tokens = json.usage_metadata.cached_content_token_count.unwrap_or(0);
678        // Gemini's promptTokenCount INCLUDES cachedContentTokenCount; subtract it
679        // so the input breakdown (fresh prompt + cached) isn't double-counted
680        // (#137), matching openai_compat's token_usage_from_wire.
681        let prompt_tokens = raw_prompt_tokens.saturating_sub(cached_tokens);
682        let completion_tokens = json.usage_metadata.candidates_token_count.unwrap_or(0);
683        let reasoning_tokens = json.usage_metadata.thoughts_token_count.unwrap_or(0);
684        let usage = TokenUsage::provider(prompt_tokens, completion_tokens)
685            .with_cached_input(cached_tokens)
686            .with_reasoning_output(reasoning_tokens);
687
688        Ok(ModelResponse {
689            content: text_acc,
690            usage: Some(usage),
691            model_name: self.model_name.clone(),
692            stop_reason,
693            thinking: if thinking_acc.is_empty() {
694                None
695            } else {
696                Some(thinking_acc)
697            },
698            tool_calls: if tool_calls.is_empty() {
699                None
700            } else {
701                Some(tool_calls)
702            },
703            // Gemini has no signature round-trip — leave None.
704            provider_continuation: None,
705        })
706    }
707
708    /// Stream the response, emit typed events, return the final
709    /// `ModelResponse`.
710    ///
711    /// Gemini's streaming model is much simpler than Anthropic's: each
712    /// SSE event is a complete partial `GenerateContentResponse` snapshot
713    /// (one candidate's parts so far, plus optional usageMetadata). We
714    /// walk `candidates[0].content.parts[]` and dispatch per-part —
715    /// no per-block-index state machine needed.
716    async fn handle_stream(
717        &self,
718        response: reqwest::Response,
719        callback: StreamCallback,
720        hide_reasoning_trace: bool,
721    ) -> Result<ModelResponse> {
722        if !response.status().is_success() {
723            return Err(http_error_from_response(response).await);
724        }
725
726        let mut stream = response.bytes_stream();
727        let mut buf: Vec<u8> = Vec::new();
728        let mut state = StreamState::default();
729
730        while let Some(chunk_result) = stream.next().await {
731            let chunk = chunk_result.map_err(|e| ModelError::StreamError(e.to_string()))?;
732            // Bound SSE reassembly: a server that streams bytes but never emits
733            // the `\n\n` event separator would otherwise grow `buf` without
734            // bound. At this point `buf` holds only the un-terminated residue
735            // from the previous drain, so this never trips on legitimately
736            // buffered complete events (#50).
737            if buf.len() > crate::constants::MAX_SSE_BUFFER_BYTES {
738                return Err(ModelError::StreamError(format!(
739                    "SSE stream exceeded {} byte reassembly cap without a complete event",
740                    crate::constants::MAX_SSE_BUFFER_BYTES
741                )));
742            }
743            buf.extend_from_slice(&chunk);
744
745            for payload in drain_sse_events(&mut buf) {
746                process_chunk_payload(&payload, &mut state, &callback, hide_reasoning_trace)?;
747            }
748        }
749
750        // F56: a stream that ended before any candidate `finishReason` was
751        // dropped mid-response. Surface a stream error instead of a clean `Ok`
752        // (with `stop_reason: None`) that would be indistinguishable from a real
753        // completion. A `MAX_TOKENS` truncation set a real `finishReason`, so it
754        // does NOT trip this and is preserved.
755        if stream_closed_abnormally(state.finish_reason.as_ref()) {
756            return Err(ModelError::StreamError(
757                "Gemini stream closed before a terminal finishReason; the \
758                 connection was likely dropped mid-response"
759                    .to_string(),
760            ));
761        }
762
763        // F3: wrapper emits the authoritative `Done`. See
764        // adapters/anthropic.rs for rationale.
765
766        let usage = state.usage();
767
768        Ok(ModelResponse {
769            content: state.text_acc,
770            usage,
771            model_name: self.model_name.clone(),
772            stop_reason: state.finish_reason,
773            thinking: if state.thinking_acc.is_empty() {
774                None
775            } else {
776                Some(state.thinking_acc)
777            },
778            tool_calls: if state.tool_calls_done.is_empty() {
779                None
780            } else {
781                Some(state.tool_calls_done)
782            },
783            provider_continuation: None,
784        })
785    }
786}
787
788/// Mutable accumulator state threaded through `process_chunk_payload`.
789/// Extracted from `handle_stream` so the per-payload dispatch can be
790/// tested directly with synthetic SSE event sequences.
791#[derive(Debug, Default)]
792struct StreamState {
793    text_acc: String,
794    thinking_acc: String,
795    tool_calls_done: Vec<ToolCall>,
796    truncated: bool,
797    prompt_tokens: usize,
798    completion_tokens: usize,
799    cached_input_tokens: usize,
800    reasoning_output_tokens: usize,
801    /// Set once a `usageMetadata` block is seen, so a stream that never reports
802    /// usage returns `None` instead of a misleading zero (#125).
803    saw_usage: bool,
804    finish_reason: Option<FinishReason>,
805}
806
807impl StreamState {
808    /// Build the response usage. `None` when no `usageMetadata` arrived (#125),
809    /// so the reducer keeps its estimate rather than resetting to zero. The
810    /// fresh-prompt component subtracts cached, which Gemini folds into
811    /// `promptTokenCount`, so the input breakdown isn't double-counted (#137).
812    fn usage(&self) -> Option<TokenUsage> {
813        self.saw_usage.then(|| {
814            let fresh_prompt = self.prompt_tokens.saturating_sub(self.cached_input_tokens);
815            TokenUsage::provider(fresh_prompt, self.completion_tokens)
816                .with_cached_input(self.cached_input_tokens)
817                .with_reasoning_output(self.reasoning_output_tokens)
818        })
819    }
820}
821
822/// Process one SSE event payload (already JSON-decoded). Mutates `state`
823/// and emits StreamEvents through `callback`. Returns Err on mid-stream
824/// error payloads or JSON parse failure.
825fn process_chunk_payload(
826    payload: &str,
827    state: &mut StreamState,
828    callback: &StreamCallback,
829    hide_reasoning_trace: bool,
830) -> Result<()> {
831    let parsed: Value = serde_json::from_str(payload).map_err(|e| ModelError::ParseError {
832        message: format!("Failed to parse Gemini stream chunk: {}", e),
833        raw: Some(payload.to_string()),
834    })?;
835
836    // Mid-stream error payload (rate limit, quota, etc.).
837    if let Some(err) = parsed.get("error") {
838        let code = err
839            .get("status")
840            .and_then(|v| v.as_str())
841            .unwrap_or("UNKNOWN");
842        let msg = err
843            .get("message")
844            .and_then(|v| v.as_str())
845            .unwrap_or("Gemini stream error");
846        return Err(ModelError::Backend(BackendError::ProviderError {
847            provider: "gemini".to_string(),
848            code: Some(code.to_string()),
849            message: msg.to_string(),
850            debug: crate::models::error::ResponseDebugContext::default(),
851        }));
852    }
853
854    // F52: a prompt-level safety block streams as a chunk carrying
855    // `promptFeedback.blockReason` and NO candidates. The no-parts branch below
856    // would otherwise swallow it as a benign empty success — there's no
857    // candidate `finishReason` to trip its block check. Surface a typed refusal,
858    // matching `decode_non_streaming`.
859    if let Some(reason) = parsed
860        .pointer("/promptFeedback/blockReason")
861        .and_then(|v| v.as_str())
862    {
863        return Err(ModelError::Backend(BackendError::ProviderError {
864            provider: "gemini".to_string(),
865            code: Some(reason.to_string()),
866            message: format!("Gemini blocked the prompt (blockReason={reason})"),
867            debug: crate::models::error::ResponseDebugContext::default(),
868        }));
869    }
870
871    // Usage: any chunk may carry it; the last chunk is final.
872    if let Some(usage) = parsed.get("usageMetadata") {
873        state.saw_usage = true;
874        if let Some(p) = usage.get("promptTokenCount").and_then(|v| v.as_u64()) {
875            state.prompt_tokens = p as usize;
876        }
877        if let Some(c) = usage.get("candidatesTokenCount").and_then(|v| v.as_u64()) {
878            state.completion_tokens = c as usize;
879        }
880        if let Some(cached) = usage
881            .get("cachedContentTokenCount")
882            .and_then(|v| v.as_u64())
883        {
884            state.cached_input_tokens = cached as usize;
885        }
886        if let Some(thoughts) = usage.get("thoughtsTokenCount").and_then(|v| v.as_u64()) {
887            state.reasoning_output_tokens = thoughts as usize;
888        }
889    }
890
891    // Terminal finishReason rides on the candidate (may co-arrive with the
892    // final parts, or alone on a content-free block). Record it either way.
893    let finish_reason = parsed
894        .pointer("/candidates/0/finishReason")
895        .and_then(|v| v.as_str());
896    if let Some(fr) = finish_reason {
897        state.finish_reason = Some(map_gemini_finish_reason(fr));
898    }
899
900    // Walk parts. Each chunk's parts are NEW content (concatenated
901    // client-side) — Gemini does not echo prior parts in subsequent
902    // chunks.
903    let Some(parts_arr) = parsed
904        .pointer("/candidates/0/content/parts")
905        .and_then(|v| v.as_array())
906    else {
907        // No parts. A terminal block (safety/recitation/etc.) that produced no
908        // content must surface as an error — matching the non-streaming path —
909        // rather than a silent empty success. A normal STOP/MAX_TOKENS chunk
910        // with no parts (final usage-only chunk) is fine.
911        if let Some(fr) = finish_reason
912            && !gemini_empty_is_benign(fr)
913            && state.text_acc.is_empty()
914            && state.tool_calls_done.is_empty()
915        {
916            return Err(ModelError::Backend(BackendError::ProviderError {
917                provider: "gemini".to_string(),
918                code: Some(fr.to_string()),
919                message: format!("Gemini returned no content (finishReason={fr})"),
920                debug: crate::models::error::ResponseDebugContext::default(),
921            }));
922        }
923        return Ok(());
924    };
925
926    for part in parts_arr {
927        // Function call part — emit immediately. Args arrive as a full
928        // Value object, not fragmented JSON strings (unlike OpenAI).
929        if let Some(fc) = part.get("functionCall") {
930            let name = fc
931                .get("name")
932                .and_then(|v| v.as_str())
933                .unwrap_or("")
934                .to_string();
935            let args = fc.get("args").cloned().unwrap_or_else(|| json!({}));
936            if name.is_empty() {
937                continue;
938            }
939            let id = format!("call_{}", state.tool_calls_done.len());
940            let tc = ToolCall {
941                id: Some(id),
942                function: FunctionCall {
943                    name,
944                    arguments: args,
945                },
946            };
947            callback(StreamEvent::ToolCall(tc.clone()));
948            state.tool_calls_done.push(tc);
949            continue;
950        }
951
952        // Text part — possibly with thought: true flag.
953        let Some(text) = part.get("text").and_then(|v| v.as_str()) else {
954            continue;
955        };
956        if text.is_empty() || state.truncated {
957            continue;
958        }
959        let is_thought = part
960            .get("thought")
961            .and_then(|v| v.as_bool())
962            .unwrap_or(false);
963        if is_thought {
964            if !hide_reasoning_trace {
965                callback(StreamEvent::Reasoning(ReasoningChunk {
966                    text: text.to_string(),
967                    signature: None,
968                }));
969            }
970            push_capped(
971                &mut state.thinking_acc,
972                text,
973                &mut state.truncated,
974                MAX_RESPONSE_CHARS,
975            );
976        } else {
977            callback(StreamEvent::Text(text.to_string()));
978            push_capped(
979                &mut state.text_acc,
980                text,
981                &mut state.truncated,
982                MAX_RESPONSE_CHARS,
983            );
984        }
985    }
986    Ok(())
987}
988
989#[async_trait]
990impl Model for GeminiAdapter {
991    fn name(&self) -> &str {
992        &self.model_name
993    }
994
995    fn capabilities(&self) -> &ModelCapabilities {
996        &self.capabilities
997    }
998
999    /// Gemini does expose `/v1beta/models` for discovery, but Mermaid's
1000    /// per-provider model list is curated in ``providers::factory::ProviderFactory``
1001    /// to keep `mermaid list` snappy and predictable. Surface the
1002    /// adapter-level fact rather than drift from that.
1003    async fn list_models(&self) -> Result<Vec<String>> {
1004        Err(ModelError::Unsupported {
1005            feature: "list_models (gemini)".to_string(),
1006        })
1007    }
1008
1009    async fn chat(
1010        &self,
1011        messages: &[ChatMessage],
1012        config: &ModelConfig,
1013        callback: Option<StreamCallback>,
1014    ) -> Result<ModelResponse> {
1015        let body = self.build_request_body(messages, config);
1016        let stream = callback.is_some();
1017        let response = self.send_chat(&body, stream).await?;
1018        if let Some(cb) = callback {
1019            self.handle_stream(response, cb, config.hide_reasoning_trace)
1020                .await
1021        } else {
1022            self.decode_non_streaming(response).await
1023        }
1024    }
1025}
1026
1027// ===== Wire types =====
1028
1029/// `GET {base}/models/{id}` response — only the limit fields matter here.
1030/// Both `#[serde(default)]` so an API that stops reporting one degrades to
1031/// `None` (unknown) instead of a parse error.
1032#[derive(Debug, Default, Deserialize)]
1033struct GeminiModelInfo {
1034    #[serde(default, rename = "inputTokenLimit")]
1035    input_token_limit: Option<usize>,
1036    #[serde(default, rename = "outputTokenLimit")]
1037    output_token_limit: Option<usize>,
1038}
1039
1040impl From<GeminiModelInfo> for ModelLimits {
1041    fn from(info: GeminiModelInfo) -> Self {
1042        ModelLimits {
1043            max_context_tokens: info.input_token_limit,
1044            max_output_tokens: info.output_token_limit,
1045        }
1046    }
1047}
1048
1049#[derive(Debug, Deserialize)]
1050struct GeminiResponse {
1051    #[serde(default)]
1052    candidates: Vec<Candidate>,
1053    #[serde(default, rename = "usageMetadata")]
1054    usage_metadata: UsageMetadata,
1055    // A prompt-level safety block returns `promptFeedback.blockReason` and NO
1056    // candidates (F52). Captured so `decode_non_streaming` can surface a typed
1057    // refusal instead of an empty `Ok`.
1058    #[serde(default, rename = "promptFeedback")]
1059    prompt_feedback: Option<PromptFeedback>,
1060}
1061
1062/// Prompt-level safety feedback. When Gemini blocks the *prompt* itself (rather
1063/// than filtering a candidate's output), the response carries this with a
1064/// `blockReason` and an empty `candidates` array.
1065#[derive(Debug, Deserialize)]
1066struct PromptFeedback {
1067    #[serde(default, rename = "blockReason")]
1068    block_reason: Option<String>,
1069}
1070
1071#[derive(Debug, Deserialize)]
1072struct Candidate {
1073    // Optional: a safety/recitation/other block returns a candidate with a
1074    // `finishReason` and NO `content`. Making this required turned such a
1075    // well-formed (blocked) response into a misleading whole-response parse
1076    // failure.
1077    #[serde(default)]
1078    content: Option<CandidateContent>,
1079    #[serde(default, rename = "finishReason")]
1080    finish_reason: Option<String>,
1081}
1082
1083#[derive(Debug, Deserialize)]
1084struct CandidateContent {
1085    #[serde(default)]
1086    parts: Vec<ResponsePart>,
1087}
1088
1089/// Output part. Gemini parts have one of: `text` (with optional
1090/// `thought: true`), `functionCall`, `inlineData`, `executableCode`,
1091/// `codeExecutionResult`, etc. We model the two we consume; everything
1092/// else is silently ignored via serde defaults.
1093#[derive(Debug, Deserialize)]
1094struct ResponsePart {
1095    #[serde(default)]
1096    text: Option<String>,
1097    #[serde(default)]
1098    thought: Option<bool>,
1099    #[serde(default, rename = "functionCall")]
1100    function_call: Option<FunctionCallOut>,
1101}
1102
1103#[derive(Debug, Deserialize)]
1104struct FunctionCallOut {
1105    name: String,
1106    #[serde(default)]
1107    args: Value,
1108}
1109
1110#[derive(Debug, Default, Deserialize)]
1111struct UsageMetadata {
1112    #[serde(default, rename = "promptTokenCount")]
1113    prompt_token_count: Option<usize>,
1114    #[serde(default, rename = "candidatesTokenCount")]
1115    candidates_token_count: Option<usize>,
1116    #[serde(default, rename = "cachedContentTokenCount")]
1117    cached_content_token_count: Option<usize>,
1118    #[serde(default, rename = "thoughtsTokenCount")]
1119    thoughts_token_count: Option<usize>,
1120}
1121
1122/// Translate a non-success HTTP response into a structured `ModelError`.
1123async fn http_error_from_response(response: reqwest::Response) -> ModelError {
1124    let status = response.status().as_u16();
1125    let debug = crate::models::error::ResponseDebugContext::from_headers(response.headers());
1126    let body = response
1127        .text()
1128        .await
1129        .unwrap_or_else(|_| "Unknown error".to_string());
1130    if let Ok(parsed) = serde_json::from_str::<Value>(&body)
1131        && let Some(err) = parsed.get("error")
1132    {
1133        let code = err.get("status").and_then(|v| v.as_str()).map(String::from);
1134        let msg = err
1135            .get("message")
1136            .and_then(|v| v.as_str())
1137            .unwrap_or(&body)
1138            .to_string();
1139        // PERMISSION_DENIED on Gemini almost always means the API key
1140        // is invalid or the project doesn't have the API enabled.
1141        let suffix = if code.as_deref() == Some("PERMISSION_DENIED") {
1142            " (check that GOOGLE_API_KEY is valid and the Generative Language API is enabled)"
1143        } else if code.as_deref() == Some("INVALID_ARGUMENT")
1144            && msg.to_lowercase().contains("thinkingbudget")
1145        {
1146            " (thinkingBudget out of range for this model — file an issue at github.com/noahsabaj/mermaid)"
1147        } else {
1148            ""
1149        };
1150        return ModelError::Backend(BackendError::ProviderError {
1151            provider: "gemini".to_string(),
1152            code,
1153            message: format!("{}{}", msg, suffix),
1154            debug: debug.clone(),
1155        });
1156    }
1157    ModelError::Backend(BackendError::HttpError {
1158        status,
1159        message: body,
1160        debug,
1161    })
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::*;
1167    use crate::models::tool_call::{FunctionCall, ToolCall};
1168
1169    #[test]
1170    fn model_info_parses_documented_limit_fields() {
1171        // Documented `GET {base}/models/{id}` shape: the token-limit fields
1172        // ride alongside identity fields we ignore.
1173        let body = r#"{
1174            "name": "models/gemini-2.5-pro",
1175            "displayName": "Gemini 2.5 Pro",
1176            "inputTokenLimit": 1048576,
1177            "outputTokenLimit": 65536,
1178            "supportedGenerationMethods": ["generateContent"]
1179        }"#;
1180        let info: GeminiModelInfo = serde_json::from_str(body).expect("parse");
1181        let limits: ModelLimits = info.into();
1182        assert_eq!(limits.max_context_tokens, Some(1_048_576));
1183        assert_eq!(limits.max_output_tokens, Some(65_536));
1184    }
1185
1186    #[test]
1187    fn model_info_missing_limit_fields_degrade_to_none() {
1188        let body = r#"{"name": "models/gemini-2.5-pro"}"#;
1189        let info: GeminiModelInfo = serde_json::from_str(body).expect("parse");
1190        let limits: ModelLimits = info.into();
1191        assert_eq!(limits.max_context_tokens, None);
1192        assert_eq!(limits.max_output_tokens, None);
1193    }
1194
1195    #[test]
1196    fn candidate_without_content_deserializes() {
1197        // H23: a safety/recitation block returns a candidate with a
1198        // finishReason and NO content — it must parse, not fail.
1199        let json = serde_json::json!({
1200            "candidates": [{ "finishReason": "SAFETY" }],
1201            "usageMetadata": {}
1202        });
1203        let resp: GeminiResponse = serde_json::from_value(json).expect("blocked response parses");
1204        assert_eq!(resp.candidates.len(), 1);
1205        assert!(resp.candidates[0].content.is_none());
1206        assert_eq!(resp.candidates[0].finish_reason.as_deref(), Some("SAFETY"));
1207    }
1208
1209    #[test]
1210    fn empty_response_benign_only_for_non_blocks() {
1211        // #51: both the streaming and non-streaming paths now treat an empty
1212        // response as benign for these reasons (UNSPECIFIED included — not a
1213        // block); a real block like SAFETY/RECITATION still surfaces an error.
1214        assert!(gemini_empty_is_benign("STOP"));
1215        assert!(gemini_empty_is_benign("MAX_TOKENS"));
1216        assert!(gemini_empty_is_benign("FINISH_REASON_UNSPECIFIED"));
1217        assert!(!gemini_empty_is_benign("SAFETY"));
1218        assert!(!gemini_empty_is_benign("RECITATION"));
1219    }
1220
1221    #[test]
1222    fn prompt_block_response_parses_with_no_candidates() {
1223        // F52: a prompt-level block returns `promptFeedback.blockReason` and NO
1224        // candidates. The wire type must capture it so `decode_non_streaming`
1225        // can surface a refusal instead of an empty Ok.
1226        let json = serde_json::json!({
1227            "promptFeedback": { "blockReason": "SAFETY" }
1228        });
1229        let resp: GeminiResponse = serde_json::from_value(json).expect("prompt block parses");
1230        assert!(resp.candidates.is_empty());
1231        assert_eq!(
1232            resp.prompt_feedback
1233                .as_ref()
1234                .and_then(|pf| pf.block_reason.as_deref()),
1235            Some("SAFETY")
1236        );
1237    }
1238
1239    #[test]
1240    fn stream_prompt_block_surfaces_provider_error() {
1241        // F52 (streaming): a chunk carrying `promptFeedback.blockReason` with no
1242        // candidates must surface a typed ProviderError, not be swallowed by the
1243        // no-parts branch as a silent empty success.
1244        let mut state = StreamState::default();
1245        let cb: StreamCallback = std::sync::Arc::new(|_ev| {});
1246        let payload = r#"{"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"}}"#;
1247        let err = process_chunk_payload(payload, &mut state, &cb, false)
1248            .expect_err("prompt block must error");
1249        match err {
1250            ModelError::Backend(BackendError::ProviderError {
1251                provider,
1252                code,
1253                message,
1254                ..
1255            }) => {
1256                assert_eq!(provider, "gemini");
1257                assert_eq!(code.as_deref(), Some("PROHIBITED_CONTENT"));
1258                assert!(message.contains("PROHIBITED_CONTENT"), "{message}");
1259            },
1260            other => panic!("expected ProviderError, got {other:?}"),
1261        }
1262    }
1263
1264    #[test]
1265    fn stream_prompt_feedback_without_block_reason_is_not_an_error() {
1266        // A `promptFeedback` block that only echoes safety ratings (no
1267        // `blockReason`) must NOT be treated as a block — it co-occurs with real
1268        // content on normal responses.
1269        let mut state = StreamState::default();
1270        let cb: StreamCallback = std::sync::Arc::new(|_ev| {});
1271        let payload = r#"{"promptFeedback":{"safetyRatings":[]},"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}"#;
1272        process_chunk_payload(payload, &mut state, &cb, false).expect("benign feedback is ok");
1273        assert_eq!(state.text_acc, "hello");
1274    }
1275
1276    fn test_adapter() -> GeminiAdapter {
1277        GeminiAdapter::new(
1278            "test-key".to_string(),
1279            "gemini-3-pro".to_string(),
1280            "https://generativelanguage.googleapis.com/v1beta".to_string(),
1281        )
1282        .expect("adapter constructs")
1283    }
1284
1285    // --- thinking_budget_for ---
1286
1287    #[test]
1288    fn thinking_budget_per_level() {
1289        assert_eq!(thinking_budget_for(ReasoningLevel::None), 0);
1290        assert_eq!(thinking_budget_for(ReasoningLevel::Minimal), 512);
1291        assert_eq!(thinking_budget_for(ReasoningLevel::Low), 2048);
1292        assert_eq!(thinking_budget_for(ReasoningLevel::Medium), 8192);
1293        assert_eq!(thinking_budget_for(ReasoningLevel::High), 24576);
1294        assert_eq!(thinking_budget_for(ReasoningLevel::Max), -1);
1295    }
1296
1297    // --- to_gemini_tools ---
1298
1299    #[test]
1300    fn tool_translation_groups_into_function_declarations() {
1301        let openai = [
1302            json!({
1303                "type": "function",
1304                "function": {
1305                    "name": "read_file",
1306                    "description": "Read a file",
1307                    "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}
1308                }
1309            }),
1310            json!({
1311                "type": "function",
1312                "function": {
1313                    "name": "write_file",
1314                    "description": "Write a file",
1315                    "parameters": {"type": "object", "properties": {}}
1316                }
1317            }),
1318        ];
1319        let refs: Vec<&Value> = openai.iter().collect();
1320        let gemini = to_gemini_tools(&refs);
1321        assert_eq!(gemini.len(), 1);
1322        let decls = gemini[0]["functionDeclarations"].as_array().unwrap();
1323        assert_eq!(decls.len(), 2);
1324        assert_eq!(decls[0]["name"], "read_file");
1325        assert_eq!(decls[0]["description"], "Read a file");
1326        assert!(decls[0].get("parameters").is_some());
1327        // No OpenAI wrapper should leak through.
1328        assert!(decls[0].get("function").is_none());
1329        assert!(decls[0].get("type").is_none());
1330    }
1331
1332    #[test]
1333    fn tool_translation_handles_missing_description() {
1334        let openai = [json!({
1335            "type": "function",
1336            "function": {
1337                "name": "no_desc",
1338                "parameters": {"type": "object", "properties": {}}
1339            }
1340        })];
1341        let refs: Vec<&Value> = openai.iter().collect();
1342        let gemini = to_gemini_tools(&refs);
1343        let decls = gemini[0]["functionDeclarations"].as_array().unwrap();
1344        assert_eq!(decls[0]["description"], "");
1345    }
1346
1347    #[test]
1348    fn tool_translation_empty_returns_empty() {
1349        let gemini = to_gemini_tools(&[]);
1350        assert!(gemini.is_empty());
1351    }
1352
1353    // --- convert_messages ---
1354
1355    #[test]
1356    fn convert_messages_extracts_system_first() {
1357        let messages = vec![
1358            ChatMessage::system("You are helpful."),
1359            ChatMessage::user("hi"),
1360            ChatMessage::system("ignored second system"),
1361        ];
1362        let (system, contents) = convert_messages(&messages);
1363        let sys = system.expect("system extracted");
1364        assert_eq!(sys["parts"][0]["text"], "You are helpful.");
1365        // Conversation-audience system messages are NOT included in contents.
1366        assert_eq!(contents.len(), 1);
1367        assert_eq!(contents[0]["role"], "user");
1368    }
1369
1370    /// Model-directed system messages are steering, not TUI affordances: they
1371    /// must reach the model even though Gemini has only a top-level
1372    /// `systemInstruction`. They used to be dropped on every `gemini/*` model.
1373    #[test]
1374    fn model_directed_system_messages_reach_the_wire_as_tagged_user_parts() {
1375        use crate::models::ChatMessageKind;
1376        let mut nudge = ChatMessage::system("Reminder: plan mode is active.");
1377        nudge.kind = ChatMessageKind::RecoveryNudge;
1378        let messages = vec![ChatMessage::user("ok"), nudge];
1379
1380        let (_system, contents) = convert_messages(&messages);
1381        assert_eq!(contents.len(), 1, "merged into the adjacent user turn");
1382        let parts = contents[0]["parts"].as_array().expect("parts array");
1383        assert_eq!(parts.len(), 2);
1384        let tagged = parts[1]["text"].as_str().unwrap();
1385        assert!(
1386            tagged.contains("<system-reminder>") && tagged.contains("plan mode is active"),
1387            "steering must be delivered and tagged: {tagged}",
1388        );
1389    }
1390
1391    // ── Role alternation ────────────────────────────────────────────────
1392
1393    /// Gemini's `contents` must alternate user/model. Same property, same
1394    /// shapes and same reasoning as the Anthropic adapter's version of this
1395    /// test — see there for why each shape is reachable.
1396    #[test]
1397    fn convert_messages_never_emits_consecutive_same_role_turns() {
1398        use crate::models::ChatMessageKind;
1399        let steering = || {
1400            let mut m = ChatMessage::system("Reminder: plan mode is active.");
1401            m.kind = ChatMessageKind::ContextMarker;
1402            m
1403        };
1404        let tool_call = || {
1405            let mut m = ChatMessage::assistant("");
1406            m.tool_calls = Some(vec![ToolCall {
1407                id: Some("c1".to_string()),
1408                function: FunctionCall {
1409                    name: "read_file".into(),
1410                    arguments: json!({"path": "a.txt"}),
1411                },
1412            }]);
1413            m
1414        };
1415
1416        let shapes: Vec<(&str, Vec<ChatMessage>)> = vec![
1417            (
1418                "steering between two user turns",
1419                vec![
1420                    ChatMessage::user("first"),
1421                    steering(),
1422                    ChatMessage::user("second"),
1423                ],
1424            ),
1425            (
1426                "two user turns in a row",
1427                vec![ChatMessage::user("first"), ChatMessage::user("second")],
1428            ),
1429            (
1430                "user types while tool results are pending",
1431                vec![
1432                    ChatMessage::user("read it"),
1433                    tool_call(),
1434                    ChatMessage::tool("c1", "read_file", "contents"),
1435                    ChatMessage::user("actually, stop"),
1436                ],
1437            ),
1438            (
1439                "two assistant turns from an interrupted continuation",
1440                vec![
1441                    ChatMessage::user("go"),
1442                    ChatMessage::assistant("part one"),
1443                    ChatMessage::assistant("part two"),
1444                ],
1445            ),
1446            (
1447                "back-to-back steering",
1448                vec![ChatMessage::user("go"), steering(), steering()],
1449            ),
1450            (
1451                "steering with no user turn to attach to",
1452                vec![ChatMessage::assistant("partial"), steering()],
1453            ),
1454        ];
1455
1456        for (name, messages) in shapes {
1457            let (_system, contents) = convert_messages(&messages);
1458            assert!(!contents.is_empty(), "{name}: the history must not vanish");
1459            for pair in contents.windows(2) {
1460                assert_ne!(
1461                    pair[0]["role"], pair[1]["role"],
1462                    "{name}: emitted consecutive {} turns, which Gemini rejects: {contents:#?}",
1463                    pair[0]["role"],
1464                );
1465            }
1466        }
1467    }
1468
1469    /// Coalescing must not lose parts — dropping the second turn outright
1470    /// would also satisfy the alternation property above.
1471    #[test]
1472    fn coalescing_two_user_turns_keeps_both_texts() {
1473        let messages = vec![ChatMessage::user("first"), ChatMessage::user("second")];
1474        let (_system, contents) = convert_messages(&messages);
1475        assert_eq!(contents.len(), 1);
1476        let parts = contents[0]["parts"].as_array().expect("parts array");
1477        assert_eq!(parts.len(), 2, "both texts survive: {parts:#?}");
1478        assert_eq!(parts[0]["text"], "first");
1479        assert_eq!(parts[1]["text"], "second");
1480    }
1481
1482    #[test]
1483    fn convert_messages_renames_assistant_to_model() {
1484        let messages = vec![ChatMessage::user("hi"), ChatMessage::assistant("hello")];
1485        let (_system, contents) = convert_messages(&messages);
1486        assert_eq!(contents[0]["role"], "user");
1487        assert_eq!(contents[1]["role"], "model");
1488        assert_eq!(contents[1]["parts"][0]["text"], "hello");
1489    }
1490
1491    #[test]
1492    fn convert_messages_merges_consecutive_tool_messages() {
1493        // The real agent-loop shape: the tool results answer an assistant
1494        // turn that requested them. (A `functionResponse` with no preceding
1495        // `functionCall` is not a history Gemini would accept, so the test
1496        // has to spell the call out to be exercising anything real.)
1497        let mut call = ChatMessage::assistant("");
1498        call.tool_calls = Some(vec![
1499            ToolCall {
1500                id: Some("call_0".to_string()),
1501                function: FunctionCall {
1502                    name: "read_file".into(),
1503                    arguments: json!({"path": "a.txt"}),
1504                },
1505            },
1506            ToolCall {
1507                id: Some("call_1".to_string()),
1508                function: FunctionCall {
1509                    name: "read_file".into(),
1510                    arguments: json!({"path": "b.txt"}),
1511                },
1512            },
1513        ]);
1514        let messages = vec![
1515            ChatMessage::user("read two files"),
1516            call,
1517            ChatMessage::tool("call_0", "read_file", "contents of A"),
1518            ChatMessage::tool("call_1", "read_file", "contents of B"),
1519        ];
1520        let (_, contents) = convert_messages(&messages);
1521        // user → model(functionCall) → user(both functionResponses)
1522        assert_eq!(contents.len(), 3);
1523        assert_eq!(contents[1]["role"], "model");
1524        assert_eq!(contents[2]["role"], "user");
1525        let parts = contents[2]["parts"].as_array().unwrap();
1526        assert_eq!(parts.len(), 2, "two functionResponse parts");
1527        assert_eq!(parts[0]["functionResponse"]["name"], "read_file");
1528        assert_eq!(
1529            parts[0]["functionResponse"]["response"]["result"],
1530            "contents of A"
1531        );
1532        assert_eq!(
1533            parts[1]["functionResponse"]["response"]["result"],
1534            "contents of B"
1535        );
1536    }
1537
1538    #[test]
1539    fn convert_messages_emits_function_call_part_for_assistant_tool_call() {
1540        let mut msg = ChatMessage::assistant("");
1541        msg.tool_calls = Some(vec![ToolCall {
1542            id: Some("call_0".to_string()),
1543            function: FunctionCall {
1544                name: "read_file".to_string(),
1545                arguments: json!({"path": "Cargo.toml"}),
1546            },
1547        }]);
1548        let messages = vec![ChatMessage::user("read it"), msg];
1549        let (_, contents) = convert_messages(&messages);
1550        assert_eq!(contents[1]["role"], "model");
1551        let parts = contents[1]["parts"].as_array().unwrap();
1552        assert_eq!(parts.len(), 1);
1553        let fc = &parts[0]["functionCall"];
1554        assert_eq!(fc["name"], "read_file");
1555        assert_eq!(fc["args"]["path"], "Cargo.toml");
1556    }
1557
1558    #[test]
1559    fn convert_messages_emits_inline_data_for_user_images() {
1560        let msg = ChatMessage::user("look at this").with_images(vec!["base64data".to_string()]);
1561        let (_, contents) = convert_messages(&[msg]);
1562        let parts = contents[0]["parts"].as_array().unwrap();
1563        assert_eq!(parts.len(), 2);
1564        assert_eq!(parts[0]["text"], "look at this");
1565        assert_eq!(parts[1]["inlineData"]["mimeType"], "image/png");
1566        assert_eq!(parts[1]["inlineData"]["data"], "base64data");
1567    }
1568
1569    #[test]
1570    fn convert_messages_emits_thought_part_for_assistant_thinking() {
1571        let mut msg = ChatMessage::assistant("the answer is 42");
1572        msg.thinking = Some("step 1: think hard".to_string());
1573        let messages = vec![ChatMessage::user("compute"), msg];
1574        let (_, contents) = convert_messages(&messages);
1575        let parts = contents[1]["parts"].as_array().unwrap();
1576        assert_eq!(parts.len(), 2);
1577        assert_eq!(parts[0]["text"], "step 1: think hard");
1578        assert_eq!(parts[0]["thought"], true);
1579        assert_eq!(parts[1]["text"], "the answer is 42");
1580        // No `thought` flag on the answer part.
1581        assert!(parts[1].get("thought").is_none());
1582    }
1583
1584    // --- capabilities & name ---
1585
1586    #[test]
1587    fn capabilities_advertise_full_reasoning_levels_and_vision() {
1588        let adapter = test_adapter();
1589        let caps = adapter.capabilities();
1590        assert!(caps.supports_tools);
1591        assert!(caps.supports_vision);
1592        match &caps.supports_reasoning {
1593            ReasoningCapability::Levels(levels) => {
1594                assert!(levels.contains(&ReasoningLevel::None));
1595                assert!(levels.contains(&ReasoningLevel::Minimal));
1596                assert!(levels.contains(&ReasoningLevel::Max));
1597            },
1598            other => panic!("expected Levels, got {:?}", other),
1599        }
1600    }
1601
1602    #[test]
1603    fn name_returns_model_id() {
1604        let adapter = test_adapter();
1605        assert_eq!(adapter.name(), "gemini-3-pro");
1606    }
1607
1608    // --- build_request_body ---
1609
1610    #[test]
1611    fn build_request_body_includes_required_fields() {
1612        let adapter = test_adapter();
1613        let messages = vec![ChatMessage::user("hi")];
1614        let config = ModelConfig::default();
1615        let body = adapter.build_request_body(&messages, &config);
1616        // Model name lives in the URL, not the body — verify it's not here.
1617        assert!(body.get("model").is_none());
1618        assert!(body["contents"].is_array());
1619        let contents = body["contents"].as_array().unwrap();
1620        assert_eq!(contents[0]["role"], "user");
1621        assert_eq!(contents[0]["parts"][0]["text"], "hi");
1622        assert!(body["generationConfig"].is_object());
1623    }
1624
1625    #[test]
1626    fn build_request_body_maps_output_schema_to_response_json_schema() {
1627        let adapter = test_adapter();
1628        let messages = vec![ChatMessage::user("format it")];
1629        let config = ModelConfig {
1630            output_schema: Some(serde_json::json!({"type": "object"})),
1631            ..Default::default()
1632        };
1633        let body = adapter.build_request_body(&messages, &config);
1634        assert_eq!(
1635            body["generationConfig"]["responseMimeType"],
1636            "application/json"
1637        );
1638        assert_eq!(
1639            body["generationConfig"]["responseJsonSchema"]["type"],
1640            "object"
1641        );
1642        // Absent -> neither field.
1643        let body = adapter.build_request_body(&messages, &ModelConfig::default());
1644        assert!(body["generationConfig"].get("responseMimeType").is_none());
1645        assert!(body["generationConfig"].get("responseJsonSchema").is_none());
1646    }
1647
1648    #[test]
1649    fn auto_max_tokens_omits_max_output_tokens() {
1650        // `max_tokens == 0` is AUTO: omit `maxOutputTokens` so Gemini applies
1651        // the model's own per-response maximum.
1652        let adapter = test_adapter();
1653        let config = ModelConfig {
1654            max_tokens: 0,
1655            ..Default::default()
1656        };
1657        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config);
1658        assert!(body["generationConfig"].get("maxOutputTokens").is_none());
1659    }
1660
1661    #[test]
1662    fn build_request_body_wraps_system_in_content_object() {
1663        let adapter = test_adapter();
1664        let messages = vec![ChatMessage::user("hi")];
1665        let config = ModelConfig {
1666            system_prompt: Some("You are helpful.".to_string()),
1667            ..Default::default()
1668        };
1669        let body = adapter.build_request_body(&messages, &config);
1670        let sys = &body["systemInstruction"];
1671        assert!(sys.is_object());
1672        assert_eq!(sys["parts"][0]["text"], "You are helpful.");
1673    }
1674
1675    /// Step 5h: Gemini doesn't expose per-block cache markers in this path.
1676    /// The dynamic MERMAID.md suffix is concatenated onto the static system
1677    /// instruction with a `---` separator. Both halves reach the model in
1678    /// one systemInstruction payload.
1679    #[test]
1680    fn build_request_body_concats_dynamic_suffix_to_system_instruction() {
1681        let adapter = test_adapter();
1682        let messages = vec![ChatMessage::user("hi")];
1683        let config = ModelConfig {
1684            system_prompt: Some("You are Mermaid.".to_string()),
1685            dynamic_system_suffix: Some("Project rule: always snake_case.".to_string()),
1686            ..Default::default()
1687        };
1688        let body = adapter.build_request_body(&messages, &config);
1689        let text = body["systemInstruction"]["parts"][0]["text"]
1690            .as_str()
1691            .expect("systemInstruction text");
1692        assert!(text.contains("You are Mermaid."));
1693        assert!(text.contains("Project rule: always snake_case."));
1694        assert!(text.contains("---"));
1695    }
1696
1697    /// Step 5c: gemini-3-pro (the test adapter's model) now uses
1698    /// `thinkingLevel` enum, not `thinkingBudget` int. Same Medium
1699    /// reasoning request maps to `thinkingLevel: "medium"`.
1700    #[test]
1701    fn build_request_body_thinking_level_for_medium_on_gemini_3() {
1702        let adapter = test_adapter(); // gemini-3-pro
1703        let messages = vec![ChatMessage::user("hi")];
1704        let config = ModelConfig {
1705            reasoning: ReasoningLevel::Medium,
1706            ..Default::default()
1707        };
1708        let body = adapter.build_request_body(&messages, &config);
1709        let tc = &body["generationConfig"]["thinkingConfig"];
1710        assert_eq!(tc["thinkingLevel"], "medium");
1711        assert_eq!(tc["includeThoughts"], true);
1712        // No thinkingBudget on Gemini 3 — it's the wrong field.
1713        assert!(tc.get("thinkingBudget").is_none());
1714    }
1715
1716    /// Step 5c: Gemini 3 has no `max` tier — Max collapses to `high`.
1717    #[test]
1718    fn build_request_body_thinking_level_for_max_collapses_to_high_on_gemini_3() {
1719        let adapter = test_adapter(); // gemini-3-pro
1720        let messages = vec![ChatMessage::user("hi")];
1721        let config = ModelConfig {
1722            reasoning: ReasoningLevel::Max,
1723            ..Default::default()
1724        };
1725        let body = adapter.build_request_body(&messages, &config);
1726        assert_eq!(
1727            body["generationConfig"]["thinkingConfig"]["thinkingLevel"],
1728            "high"
1729        );
1730    }
1731
1732    /// Step 5c: Gemini 3 cannot truly disable thinking — `None` maps to
1733    /// `thinkingLevel: "minimal"` (closest-to-off per Google's docs).
1734    #[test]
1735    fn build_request_body_thinking_level_minimal_for_none_on_gemini_3() {
1736        let adapter = test_adapter(); // gemini-3-pro
1737        let messages = vec![ChatMessage::user("hi")];
1738        let config = ModelConfig {
1739            reasoning: ReasoningLevel::None,
1740            ..Default::default()
1741        };
1742        let body = adapter.build_request_body(&messages, &config);
1743        let tc = &body["generationConfig"]["thinkingConfig"];
1744        assert_eq!(tc["thinkingLevel"], "minimal");
1745        // includeThoughts is false when level == None.
1746        assert_eq!(tc["includeThoughts"], false);
1747    }
1748
1749    /// Step 5c: gemini-2.5-pro uses thinkingBudget int with floor 128.
1750    /// `--reasoning none` clamps UP to 128 (can't actually disable).
1751    #[test]
1752    fn build_request_body_thinking_budget_clamps_to_min_128_on_gemini_2_5_pro_for_none() {
1753        let adapter = GeminiAdapter::new(
1754            "test-key".to_string(),
1755            "gemini-2.5-pro".to_string(),
1756            "https://generativelanguage.googleapis.com/v1beta".to_string(),
1757        )
1758        .expect("adapter constructs");
1759        let messages = vec![ChatMessage::user("hi")];
1760        let config = ModelConfig {
1761            reasoning: ReasoningLevel::None,
1762            ..Default::default()
1763        };
1764        let body = adapter.build_request_body(&messages, &config);
1765        let tc = &body["generationConfig"]["thinkingConfig"];
1766        // Pro can't disable — None clamps to the minimum (128).
1767        assert_eq!(tc["thinkingBudget"], 128);
1768        // includeThoughts true because budget != 0.
1769        assert_eq!(tc["includeThoughts"], true);
1770    }
1771
1772    /// Step 5c: gemini-2.5-flash CAN disable. `--reasoning none` → 0.
1773    #[test]
1774    fn build_request_body_thinking_budget_zero_for_none_on_gemini_2_5_flash() {
1775        let adapter = GeminiAdapter::new(
1776            "test-key".to_string(),
1777            "gemini-2.5-flash".to_string(),
1778            "https://generativelanguage.googleapis.com/v1beta".to_string(),
1779        )
1780        .expect("adapter constructs");
1781        let messages = vec![ChatMessage::user("hi")];
1782        let config = ModelConfig {
1783            reasoning: ReasoningLevel::None,
1784            ..Default::default()
1785        };
1786        let body = adapter.build_request_body(&messages, &config);
1787        let tc = &body["generationConfig"]["thinkingConfig"];
1788        assert_eq!(tc["thinkingBudget"], 0);
1789        assert_eq!(tc["includeThoughts"], false);
1790    }
1791
1792    /// Step 5c: gemini-2.5-flash with Max → -1 (adaptive sentinel).
1793    #[test]
1794    fn build_request_body_thinking_budget_adaptive_for_max_on_gemini_2_5_flash() {
1795        let adapter = GeminiAdapter::new(
1796            "test-key".to_string(),
1797            "gemini-2.5-flash".to_string(),
1798            "https://generativelanguage.googleapis.com/v1beta".to_string(),
1799        )
1800        .expect("adapter constructs");
1801        let messages = vec![ChatMessage::user("hi")];
1802        let config = ModelConfig {
1803            reasoning: ReasoningLevel::Max,
1804            ..Default::default()
1805        };
1806        let body = adapter.build_request_body(&messages, &config);
1807        assert_eq!(
1808            body["generationConfig"]["thinkingConfig"]["thinkingBudget"],
1809            -1
1810        );
1811    }
1812
1813    /// Step 5c: legacy Gemini models (2.0, 1.5) don't support
1814    /// thinkingConfig — sending one would 400 with a syntax error per
1815    /// the official docs. Adapter must omit the field entirely.
1816    #[test]
1817    fn build_request_body_omits_thinking_config_on_gemini_2_0() {
1818        let adapter = GeminiAdapter::new(
1819            "test-key".to_string(),
1820            "gemini-2.0-flash".to_string(),
1821            "https://generativelanguage.googleapis.com/v1beta".to_string(),
1822        )
1823        .expect("adapter constructs");
1824        let messages = vec![ChatMessage::user("hi")];
1825        let config = ModelConfig {
1826            reasoning: ReasoningLevel::Medium,
1827            ..Default::default()
1828        };
1829        let body = adapter.build_request_body(&messages, &config);
1830        // No thinkingConfig field on legacy models — would 400 if present.
1831        assert!(
1832            body["generationConfig"].get("thinkingConfig").is_none(),
1833            "legacy Gemini models must NOT receive thinkingConfig"
1834        );
1835    }
1836
1837    // --- catalog thinking dispatch (the old gemini_thinking_dispatch pins) ---
1838
1839    #[test]
1840    fn dispatch_is_level_for_gemini_3_models() {
1841        use crate::models::catalog::{ThinkingShape, lookup};
1842        for m in ["gemini-3-pro", "gemini-3-flash", "gemini-3-flash-lite"] {
1843            assert_eq!(lookup(m).thinking, ThinkingShape::GeminiLevel, "{m}");
1844        }
1845    }
1846
1847    #[test]
1848    fn dispatch_budgets_pin_per_model_floors_and_disable_rules() {
1849        use crate::models::catalog::{ThinkingShape, lookup};
1850        assert_eq!(
1851            lookup("gemini-2.5-pro").thinking,
1852            ThinkingShape::GeminiBudget {
1853                min: 128,
1854                can_disable: false
1855            }
1856        );
1857        assert_eq!(
1858            lookup("gemini-2.5-flash-lite").thinking,
1859            ThinkingShape::GeminiBudget {
1860                min: 512,
1861                can_disable: true
1862            }
1863        );
1864        assert_eq!(
1865            lookup("gemini-2.5-flash").thinking,
1866            ThinkingShape::GeminiBudget {
1867                min: 0,
1868                can_disable: true
1869            }
1870        );
1871    }
1872
1873    #[test]
1874    fn dispatch_is_provider_default_for_legacy_gemini_models() {
1875        use crate::models::catalog::{ThinkingShape, lookup};
1876        // 2.0 and earlier get no thinkingConfig (the build path omits the
1877        // field for ProviderDefault — pinned end-to-end by the request test).
1878        assert_eq!(
1879            lookup("gemini-2.0-flash").thinking,
1880            ThinkingShape::ProviderDefault
1881        );
1882        assert_eq!(
1883            lookup("gemini-1.5-pro").thinking,
1884            ThinkingShape::ProviderDefault
1885        );
1886    }
1887
1888    // --- thinking_level_for ---
1889
1890    #[test]
1891    fn thinking_level_per_reasoning_level() {
1892        assert_eq!(thinking_level_for(ReasoningLevel::None), "minimal");
1893        assert_eq!(thinking_level_for(ReasoningLevel::Minimal), "minimal");
1894        assert_eq!(thinking_level_for(ReasoningLevel::Low), "low");
1895        assert_eq!(thinking_level_for(ReasoningLevel::Medium), "medium");
1896        assert_eq!(thinking_level_for(ReasoningLevel::High), "high");
1897        // No `max` or `xhigh` tier on Gemini 3 — both collapse to high.
1898        assert_eq!(thinking_level_for(ReasoningLevel::Max), "high");
1899        assert_eq!(thinking_level_for(ReasoningLevel::XHigh), "high");
1900    }
1901
1902    #[test]
1903    fn thinking_budget_for_xhigh_matches_max_adaptive_sentinel() {
1904        // Gemini 2.5 has no xhigh tier — both Max and XHigh map to the
1905        // adaptive-thinking sentinel value `-1`.
1906        assert_eq!(thinking_budget_for(ReasoningLevel::Max), -1);
1907        assert_eq!(thinking_budget_for(ReasoningLevel::XHigh), -1);
1908    }
1909
1910    #[test]
1911    fn build_request_body_includes_tools_in_function_declarations_shape() {
1912        let adapter = test_adapter();
1913        let messages = vec![ChatMessage::user("hi")];
1914        // v7: config carries OpenAI-shape tools populated by the
1915        // provider wrapper; adapter translates to Gemini's
1916        // functionDeclarations shape.
1917        let config = ModelConfig {
1918            tools: (0..5)
1919                .map(|i| {
1920                    serde_json::json!({
1921                        "type": "function",
1922                        "function": {
1923                            "name": format!("tool_{}", i),
1924                            "description": "a test tool",
1925                            "parameters": {"type": "object"}
1926                        }
1927                    })
1928                })
1929                .collect(),
1930            ..Default::default()
1931        };
1932        let body = adapter.build_request_body(&messages, &config);
1933        let tools = body["tools"].as_array().expect("tools array");
1934        assert!(!tools.is_empty());
1935        assert!(tools[0]["functionDeclarations"].is_array());
1936        let decls = tools[0]["functionDeclarations"].as_array().unwrap();
1937        assert_eq!(decls.len(), 5);
1938    }
1939
1940    #[test]
1941    fn build_request_body_preserves_registry_selected_web_tools() {
1942        let adapter = test_adapter();
1943        let config = ModelConfig {
1944            tools: ["web_fetch", "web_search"]
1945                .into_iter()
1946                .map(|name| {
1947                    serde_json::json!({
1948                        "type": "function",
1949                        "function": {
1950                            "name": name,
1951                            "description": "registered web tool",
1952                            "parameters": {"type": "object"}
1953                        }
1954                    })
1955                })
1956                .collect(),
1957            ..Default::default()
1958        };
1959
1960        let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config);
1961        let names: Vec<&str> = body["tools"][0]["functionDeclarations"]
1962            .as_array()
1963            .expect("function declarations")
1964            .iter()
1965            .filter_map(|tool| tool.get("name").and_then(Value::as_str))
1966            .collect();
1967        assert_eq!(names, ["web_fetch", "web_search"]);
1968    }
1969
1970    #[test]
1971    fn build_request_body_clamps_temperature() {
1972        let adapter = test_adapter();
1973        let messages = vec![ChatMessage::user("hi")];
1974        let config = ModelConfig {
1975            temperature: 5.0, // Out-of-range
1976            ..Default::default()
1977        };
1978        let body = adapter.build_request_body(&messages, &config);
1979        let temp = body["generationConfig"]["temperature"].as_f64().unwrap();
1980        assert!(temp <= 2.0);
1981    }
1982
1983    // --- streaming state machine ---
1984
1985    use std::sync::Arc;
1986    use std::sync::Mutex;
1987
1988    /// Build a callback that records every emitted StreamEvent into a
1989    /// shared Vec for test assertions.
1990    fn record_callback() -> (StreamCallback, Arc<Mutex<Vec<StreamEvent>>>) {
1991        let events: Arc<Mutex<Vec<StreamEvent>>> = Arc::new(Mutex::new(Vec::new()));
1992        let clone = Arc::clone(&events);
1993        let cb: StreamCallback = Arc::new(move |evt| {
1994            clone.lock().unwrap().push(evt);
1995        });
1996        (cb, events)
1997    }
1998
1999    fn count_text(events: &[StreamEvent]) -> usize {
2000        events
2001            .iter()
2002            .filter(|e| matches!(e, StreamEvent::Text(_)))
2003            .count()
2004    }
2005
2006    fn count_reasoning(events: &[StreamEvent]) -> usize {
2007        events
2008            .iter()
2009            .filter(|e| matches!(e, StreamEvent::Reasoning(_)))
2010            .count()
2011    }
2012
2013    fn count_tool_calls(events: &[StreamEvent]) -> usize {
2014        events
2015            .iter()
2016            .filter(|e| matches!(e, StreamEvent::ToolCall(_)))
2017            .count()
2018    }
2019
2020    #[test]
2021    fn stream_text_only_multi_chunk() {
2022        let (cb, events) = record_callback();
2023        let mut state = StreamState::default();
2024
2025        // Chunk 1: "Hello, "
2026        let chunk1 = json!({
2027            "candidates": [{
2028                "content": {"parts": [{"text": "Hello, "}]}
2029            }]
2030        })
2031        .to_string();
2032        process_chunk_payload(&chunk1, &mut state, &cb, false).unwrap();
2033
2034        // Chunk 2: "world!" + usage.
2035        let chunk2 = json!({
2036            "candidates": [{
2037                "content": {"parts": [{"text": "world!"}]}
2038            }],
2039            "usageMetadata": {
2040                "promptTokenCount": 5,
2041                "candidatesTokenCount": 3,
2042                "totalTokenCount": 8
2043            }
2044        })
2045        .to_string();
2046        process_chunk_payload(&chunk2, &mut state, &cb, false).unwrap();
2047
2048        assert_eq!(state.text_acc, "Hello, world!");
2049        assert_eq!(state.prompt_tokens, 5);
2050        assert_eq!(state.completion_tokens, 3);
2051        assert_eq!(state.usage().expect("usage present").total_tokens(), 8);
2052
2053        let evts = events.lock().unwrap();
2054        assert_eq!(count_text(&evts), 2);
2055        assert_eq!(count_reasoning(&evts), 0);
2056        assert_eq!(count_tool_calls(&evts), 0);
2057    }
2058
2059    #[test]
2060    fn stream_usage_is_none_without_usage_metadata() {
2061        // #125: a stream that never carried a `usageMetadata` block yields None,
2062        // so the reducer keeps its char/4 estimate instead of resetting to zero.
2063        let (cb, _events) = record_callback();
2064        let mut state = StreamState::default();
2065        let chunk =
2066            json!({ "candidates": [{ "content": {"parts": [{"text": "hi"}]} }] }).to_string();
2067        process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2068        assert!(!state.saw_usage);
2069        assert!(state.usage().is_none());
2070    }
2071
2072    #[test]
2073    fn stream_usage_does_not_double_count_cached_input() {
2074        // #137: Gemini folds cached tokens into promptTokenCount; the input
2075        // breakdown must not add them a second time.
2076        let (cb, _events) = record_callback();
2077        let mut state = StreamState::default();
2078        let chunk = json!({
2079            "candidates": [{ "content": {"parts": [{"text": "hi"}]} }],
2080            "usageMetadata": {
2081                "promptTokenCount": 1000,
2082                "cachedContentTokenCount": 300,
2083                "candidatesTokenCount": 50,
2084                "totalTokenCount": 1050
2085            }
2086        })
2087        .to_string();
2088        process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2089        assert!(state.saw_usage);
2090        let usage = state.usage().expect("usage present");
2091        assert_eq!(
2092            usage.input_total_tokens(),
2093            1000,
2094            "cached input must not be double-counted"
2095        );
2096    }
2097
2098    #[test]
2099    fn stream_thought_then_text() {
2100        let (cb, events) = record_callback();
2101        let mut state = StreamState::default();
2102
2103        let chunk1 = json!({
2104            "candidates": [{
2105                "content": {"parts": [{"text": "let me think...", "thought": true}]}
2106            }]
2107        })
2108        .to_string();
2109        process_chunk_payload(&chunk1, &mut state, &cb, false).unwrap();
2110
2111        let chunk2 = json!({
2112            "candidates": [{
2113                "content": {"parts": [{"text": "the answer is 42"}]}
2114            }]
2115        })
2116        .to_string();
2117        process_chunk_payload(&chunk2, &mut state, &cb, false).unwrap();
2118
2119        assert_eq!(state.thinking_acc, "let me think...");
2120        assert_eq!(state.text_acc, "the answer is 42");
2121
2122        let evts = events.lock().unwrap();
2123        assert_eq!(count_reasoning(&evts), 1);
2124        assert_eq!(count_text(&evts), 1);
2125    }
2126
2127    #[test]
2128    fn stream_function_call_emits_tool_call_event() {
2129        let (cb, events) = record_callback();
2130        let mut state = StreamState::default();
2131
2132        let chunk = json!({
2133            "candidates": [{
2134                "content": {
2135                    "parts": [
2136                        {"functionCall": {"name": "read_file", "args": {"path": "Cargo.toml"}}}
2137                    ]
2138                }
2139            }]
2140        })
2141        .to_string();
2142        process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2143
2144        assert_eq!(state.tool_calls_done.len(), 1);
2145        let tc = &state.tool_calls_done[0];
2146        assert_eq!(tc.function.name, "read_file");
2147        assert_eq!(tc.function.arguments["path"], "Cargo.toml");
2148        assert_eq!(tc.id.as_deref(), Some("call_0"));
2149
2150        let evts = events.lock().unwrap();
2151        assert_eq!(count_tool_calls(&evts), 1);
2152    }
2153
2154    #[test]
2155    fn stream_thought_text_and_tool_call_in_one_chunk() {
2156        let (cb, events) = record_callback();
2157        let mut state = StreamState::default();
2158
2159        let chunk = json!({
2160            "candidates": [{
2161                "content": {
2162                    "parts": [
2163                        {"text": "thinking...", "thought": true},
2164                        {"text": "calling tool now"},
2165                        {"functionCall": {"name": "list_dir", "args": {"path": "."}}}
2166                    ]
2167                }
2168            }]
2169        })
2170        .to_string();
2171        process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2172
2173        assert_eq!(state.thinking_acc, "thinking...");
2174        assert_eq!(state.text_acc, "calling tool now");
2175        assert_eq!(state.tool_calls_done.len(), 1);
2176
2177        let evts = events.lock().unwrap();
2178        assert_eq!(count_reasoning(&evts), 1);
2179        assert_eq!(count_text(&evts), 1);
2180        assert_eq!(count_tool_calls(&evts), 1);
2181    }
2182
2183    #[test]
2184    fn stream_hide_reasoning_trace_suppresses_event_but_accumulates() {
2185        let (cb, events) = record_callback();
2186        let mut state = StreamState::default();
2187
2188        let chunk = json!({
2189            "candidates": [{
2190                "content": {"parts": [{"text": "hidden thoughts", "thought": true}]}
2191            }]
2192        })
2193        .to_string();
2194        // hide_reasoning_trace = true.
2195        process_chunk_payload(&chunk, &mut state, &cb, true).unwrap();
2196
2197        // Accumulator gets the text (so the final ModelResponse.thinking
2198        // is populated), but no Reasoning event is emitted.
2199        assert_eq!(state.thinking_acc, "hidden thoughts");
2200        let evts = events.lock().unwrap();
2201        assert_eq!(count_reasoning(&evts), 0);
2202    }
2203
2204    #[test]
2205    fn stream_mid_stream_error_returns_error() {
2206        let (cb, _events) = record_callback();
2207        let mut state = StreamState::default();
2208
2209        let chunk = json!({
2210            "error": {
2211                "code": 429,
2212                "message": "Resource exhausted",
2213                "status": "RESOURCE_EXHAUSTED"
2214            }
2215        })
2216        .to_string();
2217        let result = process_chunk_payload(&chunk, &mut state, &cb, false);
2218        assert!(result.is_err());
2219        match result {
2220            Err(ModelError::Backend(BackendError::ProviderError { code, message, .. })) => {
2221                assert_eq!(code.as_deref(), Some("RESOURCE_EXHAUSTED"));
2222                assert!(message.contains("Resource exhausted"));
2223            },
2224            other => panic!("expected ProviderError, got {:?}", other),
2225        }
2226    }
2227
2228    #[test]
2229    fn stream_tool_call_ids_are_synthesized_in_sequence() {
2230        let (cb, _events) = record_callback();
2231        let mut state = StreamState::default();
2232
2233        let chunk = json!({
2234            "candidates": [{
2235                "content": {
2236                    "parts": [
2237                        {"functionCall": {"name": "tool_a", "args": {}}},
2238                        {"functionCall": {"name": "tool_b", "args": {}}}
2239                    ]
2240                }
2241            }]
2242        })
2243        .to_string();
2244        process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2245
2246        assert_eq!(state.tool_calls_done.len(), 2);
2247        assert_eq!(state.tool_calls_done[0].id.as_deref(), Some("call_0"));
2248        assert_eq!(state.tool_calls_done[1].id.as_deref(), Some("call_1"));
2249    }
2250
2251    #[test]
2252    fn stream_safety_block_with_no_parts_errors() {
2253        let (cb, _events) = record_callback();
2254        let mut state = StreamState::default();
2255        // A content-free SAFETY block must error, not silently succeed (#1).
2256        let chunk = json!({ "candidates": [{ "finishReason": "SAFETY" }] }).to_string();
2257        assert!(process_chunk_payload(&chunk, &mut state, &cb, false).is_err());
2258    }
2259
2260    #[test]
2261    fn stream_max_tokens_keeps_partial_and_sets_length() {
2262        let (cb, _events) = record_callback();
2263        let mut state = StreamState::default();
2264        let chunk = json!({
2265            "candidates": [{
2266                "content": {"parts": [{"text": "partial"}]},
2267                "finishReason": "MAX_TOKENS"
2268            }]
2269        })
2270        .to_string();
2271        process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2272        assert_eq!(state.finish_reason, Some(FinishReason::Length));
2273        assert_eq!(state.text_acc, "partial");
2274    }
2275
2276    #[test]
2277    fn stream_closed_abnormally_when_no_finish_reason_observed() {
2278        // F56: content chunks with no finishReason leave the stream incomplete
2279        // until a terminal finishReason lands. A drop here must surface as an
2280        // error, not a clean Ok indistinguishable from a real completion.
2281        let (cb, _events) = record_callback();
2282        let mut state = StreamState::default();
2283        let chunk =
2284            json!({ "candidates": [{ "content": {"parts": [{"text": "partial answer"}]} }] })
2285                .to_string();
2286        process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2287        assert!(
2288            stream_closed_abnormally(state.finish_reason.as_ref()),
2289            "no finishReason observed yet → abnormal if the stream ends here"
2290        );
2291
2292        // The terminal chunk carrying STOP completes it.
2293        let final_chunk = json!({
2294            "candidates": [{ "content": {"parts": [{"text": "."}]}, "finishReason": "STOP" }]
2295        })
2296        .to_string();
2297        process_chunk_payload(&final_chunk, &mut state, &cb, false).unwrap();
2298        assert!(!stream_closed_abnormally(state.finish_reason.as_ref()));
2299    }
2300
2301    #[test]
2302    fn stream_closed_abnormally_preserves_max_tokens_truncation() {
2303        // CRUCIAL: a MAX_TOKENS truncation is a real terminal finishReason
2304        // (Length) — it must NOT be misclassified as an abnormal close.
2305        assert!(stream_closed_abnormally(None));
2306        assert!(!stream_closed_abnormally(Some(&FinishReason::Length)));
2307        assert!(!stream_closed_abnormally(Some(&FinishReason::Stop)));
2308    }
2309
2310    #[test]
2311    fn parallel_same_tool_calls_keep_call_order() {
2312        // #8: Gemini's wire protocol has no call id, so two calls to the SAME
2313        // tool in one turn are associated by POSITION. We synthesize ids in
2314        // arrival order and must emit results in the same order — that ordering
2315        // is the only correctness lever, so pin it against accidental reordering.
2316        let (cb, _events) = record_callback();
2317        let mut state = StreamState::default();
2318        let chunk = json!({
2319            "candidates": [{
2320                "content": {"parts": [
2321                    {"functionCall": {"name": "read_file", "args": {"path": "a"}}},
2322                    {"functionCall": {"name": "read_file", "args": {"path": "b"}}}
2323                ]}
2324            }]
2325        })
2326        .to_string();
2327        process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2328        assert_eq!(state.tool_calls_done.len(), 2);
2329        assert_eq!(state.tool_calls_done[0].id.as_deref(), Some("call_0"));
2330        assert_eq!(state.tool_calls_done[1].id.as_deref(), Some("call_1"));
2331        assert_eq!(state.tool_calls_done[0].function.arguments["path"], "a");
2332        assert_eq!(state.tool_calls_done[1].function.arguments["path"], "b");
2333    }
2334}