Skip to main content

meerkat_gemini/
client.rs

1//! Google Gemini API client
2//!
3//! Implements the LlmClient trait for Google's Gemini API.
4
5use async_trait::async_trait;
6use futures::StreamExt;
7use meerkat_core::lifecycle::run_primitive::{GeminiProviderTag, ProviderTag};
8use meerkat_core::schema::{CompiledSchema, SchemaCompat, SchemaError, SchemaWarning};
9use meerkat_core::{
10    ContentBlock, GeminiImageMetadata, ImageData, ImageGenerationIntent,
11    ImageOperationTerminalClass, Message, OutputSchema, Provider, ProviderImageMetadata,
12    ProviderTextDisposition, StopReason, Usage,
13};
14use meerkat_llm_core::LlmError;
15use meerkat_llm_core::{
16    ImageGenerationExecutor, LlmClient, LlmDoneOutcome, LlmEvent, LlmRequest, LlmStream,
17    ProviderGeneratedImage, ProviderImageGenerationOutput, ProviderImageGenerationRequest,
18    dimensions_from_size_preference, normalize_base64_image_data,
19};
20use meerkat_llm_core::{http, streaming};
21use serde::Deserialize;
22use serde_json::{Map, Value, json};
23use std::collections::HashMap;
24
25use crate::image_generation::{GeminiImageOutputOptions, GeminiImageTurnPlan};
26
27/// Extract the typed Gemini provider tag from a request.
28fn gemini_tag(request: &LlmRequest) -> Option<&GeminiProviderTag> {
29    match request.provider_params.as_ref()? {
30        ProviderTag::Gemini(t) => Some(t),
31        _ => None,
32    }
33}
34
35/// Client for Google Gemini API
36pub struct GeminiClient {
37    api_key: String,
38    base_url: String,
39    http: reqwest::Client,
40    wire_mode: GeminiWireMode,
41    code_assist_project_id: Option<String>,
42    /// Dynamic authorizer — when set, replaces the `x-goog-api-key`
43    /// header path with `HttpAuthorizer::authorize` invocation (used
44    /// for Code Assist + Vertex ADC backends where the Bearer token
45    /// is acquired at request time from Google auth / metadata server).
46    authorizer: Option<std::sync::Arc<dyn meerkat_core::HttpAuthorizer>>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum GeminiWireMode {
51    PublicGenerateContent,
52    CodeAssist,
53}
54
55fn code_assist_model(model: &str) -> &str {
56    match model {
57        "gemini-3.1-flash-lite" | "gemini-3.1-flash-lite-preview" => "gemini-2.5-flash",
58        other => other,
59    }
60}
61
62impl GeminiClient {
63    /// Create a new Gemini client with the given API key
64    pub fn new(api_key: String) -> Self {
65        Self::new_with_base_url(
66            api_key,
67            "https://generativelanguage.googleapis.com".to_string(),
68        )
69    }
70
71    /// Create a new Gemini client with an explicit base URL
72    pub fn new_with_base_url(api_key: String, base_url: String) -> Self {
73        let http = http::build_http_client_for_base_url(reqwest::Client::builder(), &base_url)
74            .unwrap_or_else(|_| reqwest::Client::new());
75        Self {
76            api_key,
77            base_url,
78            http,
79            wire_mode: GeminiWireMode::PublicGenerateContent,
80            code_assist_project_id: None,
81            authorizer: None,
82        }
83    }
84
85    /// Set custom base URL
86    pub fn with_base_url(mut self, url: String) -> Self {
87        if let Ok(http) = http::build_http_client_for_base_url(reqwest::Client::builder(), &url) {
88            self.http = http;
89        }
90        self.base_url = url;
91        self
92    }
93
94    /// Attach a dynamic authorizer (Code Assist / Vertex ADC Bearer
95    /// token path). When set, `x-goog-api-key` is NOT sent; the
96    /// authorizer injects its own headers (typically
97    /// `Authorization: Bearer <token>`).
98    pub fn with_authorizer(
99        mut self,
100        authorizer: std::sync::Arc<dyn meerkat_core::HttpAuthorizer>,
101    ) -> Self {
102        self.authorizer = Some(authorizer);
103        self
104    }
105
106    pub fn with_code_assist_wire(mut self) -> Self {
107        self.wire_mode = GeminiWireMode::CodeAssist;
108        self
109    }
110
111    pub fn with_code_assist_project_id(mut self, project_id: Option<String>) -> Self {
112        self.code_assist_project_id = project_id.filter(|project| !project.trim().is_empty());
113        self
114    }
115
116    /// Build request body for Gemini API
117    fn build_request_body(&self, request: &LlmRequest) -> Result<Value, LlmError> {
118        let mut contents = Vec::new();
119        let mut system_instruction = None;
120
121        let mut tool_name_by_id: HashMap<String, String> = HashMap::new();
122
123        for msg in &request.messages {
124            match msg {
125                Message::System(s) => {
126                    system_instruction = Some(serde_json::json!({
127                        "parts": [{"text": s.content}]
128                    }));
129                }
130                Message::SystemNotice(notice) => {
131                    contents.push(serde_json::json!({
132                        "role": "user",
133                        "parts": [{"text": notice.rendered_text()}]
134                    }));
135                }
136                Message::User(u) => {
137                    if meerkat_core::has_non_text_content(&u.content) {
138                        let parts: Vec<Value> = u
139                            .content
140                            .iter()
141                            .map(|block| match block {
142                                ContentBlock::Text { text } => serde_json::json!({
143                                    "text": text
144                                }),
145                                ContentBlock::Image {
146                                    media_type,
147                                    data: ImageData::Inline { data },
148                                } => serde_json::json!({
149                                    "inlineData": {
150                                        "mimeType": media_type,
151                                        "data": data
152                                    }
153                                }),
154                                ContentBlock::Video {
155                                    media_type,
156                                    duration_ms: _,
157                                    data: meerkat_core::VideoData::Inline { data },
158                                } => serde_json::json!({
159                                    "inlineData": {
160                                        "mimeType": media_type,
161                                        "data": data
162                                    }
163                                }),
164                                _ => serde_json::json!({
165                                    "text": block.text_projection()
166                                }),
167                            })
168                            .collect();
169                        contents.push(serde_json::json!({
170                            "role": "user",
171                            "parts": parts
172                        }));
173                    } else {
174                        contents.push(serde_json::json!({
175                            "role": "user",
176                            "parts": [{"text": u.text_content()}]
177                        }));
178                    }
179                }
180                Message::Assistant(_) => {
181                    return Err(LlmError::InvalidRequest {
182                        message: "Legacy Message::Assistant is not supported by Gemini adapter; use BlockAssistant".to_string(),
183                    });
184                }
185                Message::BlockAssistant(a) => {
186                    // New format: ordered blocks with ProviderMeta
187                    let mut parts = Vec::new();
188
189                    for block in &a.blocks {
190                        match block {
191                            meerkat_core::AssistantBlock::Text { text, meta } => {
192                                if !text.is_empty() {
193                                    let mut part = serde_json::json!({"text": text});
194                                    // Gemini may have thoughtSignature on text parts for continuity
195                                    if let Some(meerkat_core::ProviderMeta::Gemini {
196                                        thought_signature,
197                                    }) = meta.as_deref()
198                                    {
199                                        part["thoughtSignature"] =
200                                            serde_json::json!(thought_signature);
201                                    }
202                                    parts.push(part);
203                                }
204                            }
205                            meerkat_core::AssistantBlock::Reasoning { text, .. } => {
206                                // Gemini doesn't accept reasoning blocks back
207                                // Just include as text if needed for context
208                                if !text.is_empty() {
209                                    parts.push(serde_json::json!({"text": format!("[Reasoning: {}]", text)}));
210                                }
211                            }
212                            meerkat_core::AssistantBlock::ToolUse {
213                                id,
214                                name,
215                                args,
216                                meta,
217                            } => {
218                                tool_name_by_id.insert(id.clone(), name.clone());
219                                // Parse RawValue to Value
220                                let args_value: Value = serde_json::from_str(args.get())
221                                    .unwrap_or_else(|_| serde_json::json!({}));
222                                let mut part = serde_json::json!({"functionCall": {"name": name, "args": args_value}});
223                                // Only add signature if present (first parallel call has it)
224                                if let Some(meerkat_core::ProviderMeta::Gemini {
225                                    thought_signature,
226                                }) = meta.as_deref()
227                                {
228                                    part["thoughtSignature"] = serde_json::json!(thought_signature);
229                                }
230                                parts.push(part);
231                            }
232                            _ => {} // non_exhaustive: ignore unknown future variants
233                        }
234                    }
235
236                    contents.push(serde_json::json!({
237                        "role": "model",
238                        "parts": parts
239                    }));
240                }
241                Message::ToolResults { results, .. } => {
242                    // Per spec section 2.3: thoughtSignature NEVER on functionResponse
243                    // Signature belongs on functionCall, not on the response
244                    let mut parts: Vec<Value> = Vec::new();
245
246                    for r in results {
247                        if r.has_video() {
248                            return Err(LlmError::InvalidRequest {
249                                message: "video blocks are not supported in Gemini tool results"
250                                    .to_string(),
251                            });
252                        }
253                        let function_name = tool_name_by_id
254                            .get(&r.tool_use_id)
255                            .cloned()
256                            .unwrap_or_else(|| r.tool_use_id.clone());
257
258                        // functionResponse only supports text content. For tool
259                        // results with images we emit text in the functionResponse
260                        // and append the images as separate inlineData parts so
261                        // the model still sees the actual image data.
262                        parts.push(serde_json::json!({
263                            "functionResponse": {
264                                "name": function_name,
265                                "response": {
266                                    "content": r.text_content(),
267                                    "error": r.is_error
268                                }
269                            }
270                        }));
271                        // NOTE: thoughtSignature is intentionally NOT included here
272                        // Verified by scripts/test_gemini_thought_signature.py:
273                        // - sig on functionCall only: PASS
274                        // - sig on functionResponse only: FAIL (400 error)
275
276                        if r.has_images() {
277                            for block in &r.content {
278                                if let ContentBlock::Image { media_type, data } = block {
279                                    match data {
280                                        ImageData::Inline { data } => {
281                                            parts.push(serde_json::json!({
282                                                "inlineData": {
283                                                    "mimeType": media_type,
284                                                    "data": data
285                                                }
286                                            }));
287                                        }
288                                        ImageData::Blob { .. } => parts.push(serde_json::json!({
289                                            "text": block.text_projection()
290                                        })),
291                                    }
292                                }
293                            }
294                        }
295                    }
296
297                    contents.push(serde_json::json!({
298                        "role": "user",
299                        "parts": parts
300                    }));
301                }
302            }
303        }
304
305        let mut body = serde_json::json!({
306            "contents": contents,
307            "generationConfig": {
308                "maxOutputTokens": request.max_tokens,
309            }
310        });
311
312        if let Some(system) = system_instruction {
313            body["systemInstruction"] = system;
314        }
315
316        if let Some(temp) = request.temperature
317            && let Some(num) = serde_json::Number::from_f64(temp as f64)
318        {
319            body["generationConfig"]["temperature"] = Value::Number(num);
320        }
321
322        if let Some(tag) = gemini_tag(request) {
323            // Thinking: Gemini 3 uses thinking_level; legacy rows may still
324            // supply thinking_budget through the nested or flat shape.
325            let thinking_level = tag
326                .thinking
327                .as_ref()
328                .and_then(|cfg| cfg.thinking_level)
329                .or(tag.thinking_level);
330            let thinking_budget = tag
331                .thinking
332                .as_ref()
333                .and_then(|cfg| cfg.thinking_budget)
334                .or(tag.thinking_budget);
335
336            if thinking_level.is_some() || thinking_budget.is_some() {
337                let mut thinking_config = Map::new();
338                if let Some(level) = thinking_level {
339                    thinking_config
340                        .insert("thinkingLevel".into(), Value::String(level.as_str().into()));
341                } else if let Some(budget) = thinking_budget {
342                    thinking_config.insert(
343                        "thinkingBudget".into(),
344                        Value::Number(serde_json::Number::from(budget)),
345                    );
346                }
347                body["generationConfig"]["thinkingConfig"] = Value::Object(thinking_config);
348            }
349
350            if let Some(top_k) = tag.top_k {
351                body["generationConfig"]["topK"] = Value::Number(serde_json::Number::from(top_k));
352            }
353
354            if let Some(top_p) = tag.top_p
355                && let Some(n) = serde_json::Number::from_f64(top_p as f64)
356            {
357                body["generationConfig"]["topP"] = Value::Number(n);
358            }
359
360            if let Some(output_schema) = tag.structured_output.as_ref() {
361                let compiled = Self::compile_schema_for_gemini(output_schema).map_err(|e| {
362                    LlmError::InvalidRequest {
363                        message: e.to_string(),
364                    }
365                })?;
366                body["generationConfig"]["responseMimeType"] =
367                    Value::String("application/json".to_string());
368                body["generationConfig"]["responseJsonSchema"] = compiled.schema;
369            }
370        }
371
372        let has_function_declarations = !request.tools.is_empty();
373        if has_function_declarations {
374            let function_declarations: Vec<Value> = request
375                .tools
376                .iter()
377                .map(|t| {
378                    let parameters = normalize_gemini_function_parameters_schema(&t.input_schema)?;
379                    Ok(serde_json::json!({
380                        "name": t.name,
381                        "description": t.description,
382                        "parameters": parameters
383                    }))
384                })
385                .collect::<Result<Vec<_>, LlmError>>()?;
386
387            body["tools"] = serde_json::json!([{
388                "functionDeclarations": function_declarations
389            }]);
390        }
391
392        // Inject provider-native google_search tool from typed tag.
393        let mut has_server_side_tool = false;
394        if let Some(gs) = gemini_tag(request).and_then(|t| t.google_search.as_ref()) {
395            let gs_value = gs.as_value();
396            if gs_value.is_object() {
397                has_server_side_tool = true;
398                match body.get_mut("tools").and_then(|v| v.as_array_mut()) {
399                    Some(arr) => arr.push(serde_json::json!({"google_search": gs_value})),
400                    None => {
401                        body["tools"] =
402                            Value::Array(vec![serde_json::json!({"google_search": gs_value})]);
403                    }
404                }
405            }
406        }
407
408        if has_function_declarations && has_server_side_tool {
409            if !body["toolConfig"].is_object() {
410                body["toolConfig"] = serde_json::json!({});
411            }
412            body["toolConfig"]["includeServerSideToolInvocations"] = Value::Bool(true);
413        }
414
415        Ok(body)
416    }
417
418    fn build_stream_request_body(&self, request: &LlmRequest) -> Result<Value, LlmError> {
419        let body = self.build_request_body(request)?;
420        match self.wire_mode {
421            GeminiWireMode::PublicGenerateContent => Ok(body),
422            GeminiWireMode::CodeAssist => {
423                let mut outer = Map::new();
424                outer.insert(
425                    "model".to_string(),
426                    Value::String(code_assist_model(&request.model).to_string()),
427                );
428                outer.insert(
429                    "user_prompt_id".to_string(),
430                    Value::String(format!(
431                        "meerkat-{}",
432                        meerkat_core::time_compat::new_uuid_v7()
433                    )),
434                );
435                if let Some(project_id) = &self.code_assist_project_id {
436                    outer.insert("project".to_string(), Value::String(project_id.clone()));
437                }
438                outer.insert("request".to_string(), body);
439                Ok(Value::Object(outer))
440            }
441        }
442    }
443
444    fn stream_generate_content_url(&self, model: &str) -> String {
445        match self.wire_mode {
446            GeminiWireMode::PublicGenerateContent => format!(
447                "{}/v1beta/models/{}:streamGenerateContent?alt=sse",
448                self.base_url.trim_end_matches('/'),
449                model,
450            ),
451            GeminiWireMode::CodeAssist => {
452                let base_url = self.base_url.trim_end_matches('/');
453                let versioned = if base_url.ends_with("/v1internal") {
454                    base_url.to_string()
455                } else {
456                    format!("{base_url}/v1internal")
457                };
458                format!("{versioned}:streamGenerateContent?alt=sse")
459            }
460        }
461    }
462
463    /// Parse streaming response line
464    fn parse_stream_line(line: &str) -> Option<GenerateContentResponse> {
465        serde_json::from_str(line).ok()
466    }
467
468    fn parse_stream_line_for_wire(&self, line: &str) -> Option<GenerateContentResponse> {
469        match self.wire_mode {
470            GeminiWireMode::PublicGenerateContent => Self::parse_stream_line(line),
471            GeminiWireMode::CodeAssist => {
472                let wrapper: Option<CodeAssistGenerateContentResponse> =
473                    serde_json::from_str(line).ok();
474                wrapper
475                    .map(|wrapper| {
476                        if let Some(mut response) = wrapper.response {
477                            if response.response_id.is_none() {
478                                response.response_id = wrapper.trace_id;
479                            }
480                            response
481                        } else {
482                            GenerateContentResponse {
483                                candidates: Some(Vec::new()),
484                                usage_metadata: None,
485                                response_id: wrapper.trace_id,
486                                prompt_feedback: None,
487                            }
488                        }
489                    })
490                    .or_else(|| Self::parse_stream_line(line))
491            }
492        }
493    }
494
495    fn image_prompt(request: &ProviderImageGenerationRequest) -> String {
496        match &request.generate_request.intent {
497            ImageGenerationIntent::Generate { prompt, .. } => prompt.content.clone(),
498            ImageGenerationIntent::Edit { instruction, .. } => instruction.content.clone(),
499        }
500    }
501
502    async fn post_gemini_json(
503        &self,
504        endpoint: &str,
505        body: &Value,
506    ) -> Result<reqwest::Response, LlmError> {
507        let mut req = self
508            .http
509            .post(endpoint)
510            .header("Content-Type", "application/json");
511        if let Some(authorizer) = &self.authorizer {
512            let mut extra: Vec<(String, String)> = Vec::new();
513            let mut auth_req = meerkat_core::HttpAuthorizationRequest {
514                method: "POST",
515                url: endpoint,
516                headers: &mut extra,
517            };
518            authorizer.authorize(&mut auth_req).await.map_err(|e| {
519                LlmError::AuthenticationFailed {
520                    message: format!("gemini authorizer failed: {e}"),
521                }
522            })?;
523            for (name, value) in extra {
524                req = req.header(name, value);
525            }
526        } else {
527            req = req.header("x-goog-api-key", &self.api_key);
528        }
529        req.json(body)
530            .send()
531            .await
532            .map_err(|_| LlmError::NetworkTimeout { duration_ms: 30000 })
533    }
534
535    fn build_image_request_body(
536        &self,
537        request: &ProviderImageGenerationRequest,
538        output: &GeminiImageOutputOptions,
539    ) -> Result<Value, LlmError> {
540        let messages = if request.projected_messages.is_empty() {
541            vec![Message::User(meerkat_core::UserMessage::text(
542                Self::image_prompt(request),
543            ))]
544        } else {
545            request.projected_messages.clone()
546        };
547        let llm_request = LlmRequest::new(&request.model, messages);
548        let mut body = self.build_request_body(&llm_request)?;
549        body["generationConfig"]["responseModalities"] =
550            Value::Array(vec![Value::String("IMAGE".into())]);
551        body["generationConfig"]["imageConfig"] = gemini_image_config(output);
552        Ok(body)
553    }
554
555    fn gemini_error_terminal(status_code: u16, text: &str) -> ImageOperationTerminalClass {
556        if status_code == 408 || status_code == 504 {
557            ImageOperationTerminalClass::Timeout
558        } else if let Ok(value) = serde_json::from_str::<Value>(text) {
559            Self::gemini_structured_error_terminal(&value)
560                .unwrap_or(ImageOperationTerminalClass::Failed)
561        } else {
562            ImageOperationTerminalClass::Failed
563        }
564    }
565
566    fn gemini_structured_error_terminal(value: &Value) -> Option<ImageOperationTerminalClass> {
567        let error = value.get("error").unwrap_or(value);
568        if let Some(terminal) = error
569            .get("status")
570            .and_then(Value::as_str)
571            .and_then(Self::gemini_structured_error_code_terminal)
572        {
573            return Some(terminal);
574        }
575        if let Some(terminal) = error
576            .get("reason")
577            .and_then(Value::as_str)
578            .and_then(Self::gemini_structured_error_code_terminal)
579        {
580            return Some(terminal);
581        }
582        error
583            .get("details")
584            .and_then(Value::as_array)
585            .and_then(|details| {
586                details.iter().find_map(|detail| {
587                    detail
588                        .get("reason")
589                        .and_then(Value::as_str)
590                        .or_else(|| detail.get("status").and_then(Value::as_str))
591                        .and_then(Self::gemini_structured_error_code_terminal)
592                })
593            })
594    }
595
596    fn gemini_structured_error_code_terminal(code: &str) -> Option<ImageOperationTerminalClass> {
597        match code {
598            "BLOCKLIST" | "IMAGE_SAFETY" | "PROHIBITED_CONTENT" | "RECITATION" | "SAFETY"
599            | "SPII" => Some(ImageOperationTerminalClass::SafetyFiltered),
600            "MODEL_REFUSAL" => Some(ImageOperationTerminalClass::RefusedByProvider),
601            "DEADLINE_EXCEEDED" => Some(ImageOperationTerminalClass::Timeout),
602            _ => None,
603        }
604    }
605
606    async fn execute_native_image(
607        &self,
608        request: ProviderImageGenerationRequest,
609        plan: GeminiImageTurnPlan,
610    ) -> Result<ProviderImageGenerationOutput, LlmError> {
611        let body = self.build_image_request_body(&request, &plan.output)?;
612        let url = format!(
613            "{}/v1beta/models/{}:generateContent",
614            self.base_url, request.model
615        );
616        let response = self.post_gemini_json(&url, &body).await?;
617        let status_code = response.status().as_u16();
618        let text = response.text().await.unwrap_or_default();
619        if !(200..=299).contains(&status_code) {
620            return Ok(ProviderImageGenerationOutput {
621                operation_id: request.operation_id,
622                terminal: Self::gemini_error_terminal(status_code, &text),
623                images: Vec::new(),
624                provider_text: None,
625                revised_prompt: meerkat_core::RevisedPromptDisposition::UnsupportedByBackend,
626                native_metadata: ProviderImageMetadata::Gemini(GeminiImageMetadata {
627                    target_model: request.model,
628                    response_id: None,
629                    continuity_ref: None,
630                }),
631                warnings: Vec::new(),
632            });
633        }
634
635        let parsed: GenerateContentResponse =
636            serde_json::from_str(&text).map_err(|e| LlmError::StreamParseError {
637                message: format!("invalid Gemini image response JSON: {e}"),
638            })?;
639        let (width, height) = dimensions_from_size_preference(&request.generate_request.size);
640        let mut images = Vec::new();
641        let mut provider_text = Vec::new();
642        let mut finish_reason = None;
643        if let Some(candidates) = parsed.candidates {
644            for cand in candidates {
645                finish_reason = cand.finish_reason.clone().or(finish_reason);
646                if let Some(content) = cand.content
647                    && let Some(parts) = content.parts
648                {
649                    for part in parts {
650                        if let Some(text) = part.text
651                            && !text.is_empty()
652                            && !part.thought.unwrap_or(false)
653                        {
654                            provider_text.push(text);
655                        }
656                        if let Some(inline_data) = part.inline_data {
657                            images.push(ProviderGeneratedImage {
658                                media_type: meerkat_core::MediaType::new(inline_data.mime_type),
659                                base64_data: normalize_base64_image_data(&inline_data.data),
660                                width,
661                                height,
662                            });
663                        }
664                    }
665                }
666            }
667        }
668        let terminal = if images.is_empty() {
669            let prompt_block_reason = parsed
670                .prompt_feedback
671                .as_ref()
672                .and_then(|feedback| feedback.block_reason.as_deref());
673            match finish_reason
674                .as_deref()
675                .and_then(Self::gemini_structured_error_code_terminal)
676                .or_else(|| {
677                    prompt_block_reason.and_then(Self::gemini_structured_error_code_terminal)
678                }) {
679                Some(terminal) => terminal,
680                None => ImageOperationTerminalClass::EmptyResult {
681                    provider_text: if provider_text.is_empty() {
682                        ProviderTextDisposition::NotEmitted
683                    } else {
684                        ProviderTextDisposition::EmittedButNotStored
685                    },
686                },
687            }
688        } else {
689            ImageOperationTerminalClass::Generated
690        };
691        let warnings = if let Some(returned) = std::num::NonZeroU32::new(images.len() as u32) {
692            if returned < request.generate_request.count {
693                vec![
694                    meerkat_core::ImageGenerationWarning::ProviderReturnedFewerImages {
695                        requested: request.generate_request.count,
696                        returned,
697                    },
698                ]
699            } else {
700                Vec::new()
701            }
702        } else {
703            Vec::new()
704        };
705        Ok(ProviderImageGenerationOutput {
706            operation_id: request.operation_id,
707            terminal,
708            images,
709            provider_text: if provider_text.is_empty() {
710                None
711            } else {
712                Some(provider_text.join("\n"))
713            },
714            revised_prompt: meerkat_core::RevisedPromptDisposition::UnsupportedByBackend,
715            native_metadata: ProviderImageMetadata::Gemini(GeminiImageMetadata {
716                target_model: request.model,
717                response_id: parsed.response_id,
718                continuity_ref: None,
719            }),
720            warnings,
721        })
722    }
723
724    /// Compile an output schema for Gemini structured outputs.
725    ///
726    /// Uses `responseJsonSchema` without destructive lowering so schema
727    /// semantics are preserved.
728    fn compile_schema_for_gemini(
729        output_schema: &OutputSchema,
730    ) -> Result<CompiledSchema, SchemaError> {
731        let schema = output_schema.schema.as_value().clone();
732        let warnings = validate_gemini_response_json_schema(&schema, Provider::Gemini);
733
734        if output_schema.compat == SchemaCompat::Strict && !warnings.is_empty() {
735            return Err(SchemaError::UnsupportedFeatures {
736                provider: Provider::Gemini,
737                warnings,
738            });
739        }
740
741        Ok(CompiledSchema { schema, warnings })
742    }
743}
744
745fn gemini_image_config(output: &GeminiImageOutputOptions) -> serde_json::Value {
746    let mut config = serde_json::Map::new();
747    config.insert(
748        "aspectRatio".to_string(),
749        Value::String(output.aspect_ratio.as_wire_value().to_string()),
750    );
751    if let Some(image_size) = output.image_size {
752        config.insert(
753            "imageSize".to_string(),
754            Value::String(image_size.as_wire_value().to_string()),
755        );
756    }
757    Value::Object(config)
758}
759
760#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
761#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
762impl ImageGenerationExecutor for GeminiClient {
763    async fn execute_image_generation(
764        &self,
765        request: ProviderImageGenerationRequest,
766    ) -> Result<ProviderImageGenerationOutput, LlmError> {
767        match request.execution_plan.clone() {
768            plan if plan.provider.0 == "gemini" || plan.provider.0 == "google" => {
769                if plan.backend != meerkat_core::ImageGenerationBackendKind::NativeModel {
770                    return Err(LlmError::InvalidRequest {
771                        message: format!(
772                            "Gemini image executor cannot run backend {:?}",
773                            plan.backend
774                        ),
775                    });
776                }
777                let provider_plan: GeminiImageTurnPlan = serde_json::from_value(plan.provider_plan)
778                    .map_err(|err| LlmError::InvalidRequest {
779                        message: format!("invalid Gemini image plan: {err}"),
780                    })?;
781                self.execute_native_image(request, provider_plan).await
782            }
783            other => Err(LlmError::InvalidRequest {
784                message: format!("Gemini image executor cannot run plan {other:?}"),
785            }),
786        }
787    }
788}
789
790fn normalize_gemini_function_parameters_schema(schema: &Value) -> Result<Value, LlmError> {
791    let mut unresolved_refs = Vec::new();
792    let mut normalized =
793        inline_local_schema_refs(schema, schema, &mut Vec::new(), 0, &mut unresolved_refs);
794    if !unresolved_refs.is_empty() {
795        unresolved_refs.sort();
796        unresolved_refs.dedup();
797        return Err(LlmError::InvalidRequest {
798            message: format!(
799                "Gemini function parameters schema contains unresolved $ref values: {}. \
800                 Only local refs (e.g. '#/$defs/...') are supported for inlining.",
801                unresolved_refs.join(", ")
802            ),
803        });
804    }
805    lower_gemini_function_parameters_schema(&mut normalized);
806    strip_gemini_function_parameters_unsupported_keywords(&mut normalized);
807    Ok(normalized)
808}
809
810fn lower_gemini_function_parameters_schema(value: &mut Value) {
811    match value {
812        Value::Object(obj) => {
813            collapse_single_literal_composition(obj, "oneOf");
814            collapse_single_literal_composition(obj, "anyOf");
815            normalize_const_keyword(obj);
816            normalize_type_array_keyword(obj);
817
818            for child in obj.values_mut() {
819                lower_gemini_function_parameters_schema(child);
820            }
821
822            normalize_nullable_composition(obj, "oneOf");
823            normalize_nullable_composition(obj, "anyOf");
824        }
825        Value::Array(items) => {
826            for item in items {
827                lower_gemini_function_parameters_schema(item);
828            }
829        }
830        _ => {}
831    }
832}
833
834fn collapse_single_literal_composition(obj: &mut Map<String, Value>, key: &str) {
835    let Some(variants) = obj.get(key).and_then(Value::as_array) else {
836        return;
837    };
838
839    let mut literals = Vec::new();
840    for variant in variants {
841        let Some(variant_obj) = variant.as_object() else {
842            return;
843        };
844        let Some(literal) = variant_obj.get("const").cloned() else {
845            return;
846        };
847        if variant_obj
848            .keys()
849            .any(|k| k != "const" && k != "title" && k != "description")
850        {
851            return;
852        }
853        literals.push(literal);
854    }
855
856    if literals.is_empty() {
857        return;
858    }
859
860    obj.remove(key);
861    obj.insert("enum".to_string(), Value::Array(literals.clone()));
862    if !obj.contains_key("type")
863        && let Some(common_type) = infer_common_literal_type(&literals)
864    {
865        obj.insert("type".to_string(), Value::String(common_type.to_string()));
866    }
867}
868
869fn infer_common_literal_type(values: &[Value]) -> Option<&'static str> {
870    let mut common: Option<&'static str> = None;
871    for value in values {
872        let value_type = infer_schema_type_from_literal(value)?;
873        match common {
874            Some(existing) if existing != value_type => return None,
875            Some(_) => {}
876            None => common = Some(value_type),
877        }
878    }
879    common
880}
881
882fn infer_schema_type_from_literal(value: &Value) -> Option<&'static str> {
883    match value {
884        Value::String(_) => Some("string"),
885        Value::Number(n) if n.is_i64() || n.is_u64() => Some("integer"),
886        Value::Number(_) => Some("number"),
887        Value::Bool(_) => Some("boolean"),
888        Value::Array(_) => Some("array"),
889        Value::Object(_) => Some("object"),
890        Value::Null => None,
891    }
892}
893
894fn normalize_const_keyword(obj: &mut Map<String, Value>) {
895    let Some(const_value) = obj.remove("const") else {
896        return;
897    };
898
899    if !obj.contains_key("enum") {
900        obj.insert("enum".to_string(), Value::Array(vec![const_value.clone()]));
901    }
902
903    if !obj.contains_key("type")
904        && let Some(value_type) = infer_schema_type_from_literal(&const_value)
905    {
906        obj.insert("type".to_string(), Value::String(value_type.to_string()));
907    }
908    if const_value.is_null() {
909        obj.insert("nullable".to_string(), Value::Bool(true));
910    }
911}
912
913fn normalize_type_array_keyword(obj: &mut Map<String, Value>) {
914    let Some(Value::Array(type_values)) = obj.get("type").cloned() else {
915        return;
916    };
917
918    let mut has_null = false;
919    let mut non_null_types: Vec<String> = Vec::new();
920    for value in type_values {
921        let Value::String(type_name) = value else {
922            return;
923        };
924        if type_name == "null" {
925            has_null = true;
926        } else if !non_null_types.iter().any(|t| t == &type_name) {
927            non_null_types.push(type_name);
928        }
929    }
930
931    if non_null_types.is_empty() {
932        obj.remove("type");
933        obj.insert("nullable".to_string(), Value::Bool(true));
934        return;
935    }
936
937    if non_null_types.len() == 1 {
938        obj.insert("type".to_string(), Value::String(non_null_types[0].clone()));
939        if has_null {
940            obj.insert("nullable".to_string(), Value::Bool(true));
941        }
942        return;
943    }
944
945    let mut variants = Vec::new();
946    for type_name in non_null_types {
947        let mut variant = Map::new();
948        variant.insert("type".to_string(), Value::String(type_name));
949        variants.push(Value::Object(variant));
950    }
951
952    obj.remove("type");
953    match obj.get_mut("anyOf") {
954        Some(Value::Array(existing)) => existing.extend(variants),
955        _ => {
956            obj.insert("anyOf".to_string(), Value::Array(variants));
957        }
958    }
959    if has_null {
960        obj.insert("nullable".to_string(), Value::Bool(true));
961    }
962}
963
964fn normalize_nullable_composition(obj: &mut Map<String, Value>, key: &str) {
965    let Some(Value::Array(variants)) = obj.get(key).cloned() else {
966        return;
967    };
968
969    let mut saw_null_branch = false;
970    let mut retained = Vec::new();
971    for variant in variants {
972        if is_null_schema(&variant) {
973            saw_null_branch = true;
974        } else {
975            retained.push(variant);
976        }
977    }
978
979    if !saw_null_branch {
980        return;
981    }
982
983    obj.insert("nullable".to_string(), Value::Bool(true));
984    if retained.is_empty() {
985        obj.remove(key);
986    } else {
987        obj.insert(key.to_string(), Value::Array(retained));
988    }
989}
990
991fn is_null_schema(value: &Value) -> bool {
992    let Value::Object(obj) = value else {
993        return false;
994    };
995    if matches!(obj.get("type"), Some(Value::String(t)) if t == "null") {
996        return true;
997    }
998    matches!(
999        obj.get("enum"),
1000        Some(Value::Array(values)) if values.len() == 1 && values[0].is_null()
1001    )
1002}
1003
1004fn inline_local_schema_refs(
1005    node: &Value,
1006    root: &Value,
1007    active_refs: &mut Vec<String>,
1008    depth: usize,
1009    unresolved_refs: &mut Vec<String>,
1010) -> Value {
1011    const MAX_REF_DEPTH: usize = 64;
1012    if depth > MAX_REF_DEPTH {
1013        return node.clone();
1014    }
1015
1016    match node {
1017        Value::Object(obj) => {
1018            if let Some(reference) = obj.get("$ref").and_then(Value::as_str)
1019                && let Some(resolved) = resolve_local_schema_ref(root, reference)
1020                && !active_refs.iter().any(|r| r == reference)
1021            {
1022                active_refs.push(reference.to_string());
1023                let mut inlined = inline_local_schema_refs(
1024                    resolved,
1025                    root,
1026                    active_refs,
1027                    depth + 1,
1028                    unresolved_refs,
1029                );
1030                active_refs.pop();
1031
1032                if let Value::Object(ref mut inlined_obj) = inlined {
1033                    for (key, value) in obj {
1034                        if key == "$ref" {
1035                            continue;
1036                        }
1037                        inlined_obj.insert(
1038                            key.clone(),
1039                            inline_local_schema_refs(
1040                                value,
1041                                root,
1042                                active_refs,
1043                                depth + 1,
1044                                unresolved_refs,
1045                            ),
1046                        );
1047                    }
1048                    return inlined;
1049                }
1050
1051                return inlined;
1052            }
1053            if let Some(reference) = obj.get("$ref").and_then(Value::as_str) {
1054                unresolved_refs.push(reference.to_string());
1055            }
1056
1057            let mut mapped = Map::new();
1058            for (key, value) in obj {
1059                mapped.insert(
1060                    key.clone(),
1061                    inline_local_schema_refs(value, root, active_refs, depth + 1, unresolved_refs),
1062                );
1063            }
1064            Value::Object(mapped)
1065        }
1066        Value::Array(items) => Value::Array(
1067            items
1068                .iter()
1069                .map(|item| {
1070                    inline_local_schema_refs(item, root, active_refs, depth + 1, unresolved_refs)
1071                })
1072                .collect(),
1073        ),
1074        _ => node.clone(),
1075    }
1076}
1077
1078fn resolve_local_schema_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
1079    if !reference.starts_with("#/") {
1080        return None;
1081    }
1082
1083    let mut cursor = root;
1084    for segment in reference.trim_start_matches("#/").split('/') {
1085        let key = segment.replace("~1", "/").replace("~0", "~");
1086        cursor = cursor.get(&key)?;
1087    }
1088
1089    Some(cursor)
1090}
1091
1092fn strip_gemini_function_parameters_unsupported_keywords(value: &mut Value) {
1093    match value {
1094        Value::Object(obj) => {
1095            obj.remove("$schema");
1096            obj.remove("$defs");
1097            obj.remove("defs");
1098            obj.remove("definitions");
1099            obj.remove("$ref");
1100            obj.remove("$id");
1101            obj.remove("$anchor");
1102            obj.remove("const");
1103            obj.remove("title");
1104            obj.remove("additionalProperties");
1105            obj.remove("if");
1106            obj.remove("then");
1107            obj.remove("else");
1108            obj.remove("dependentRequired");
1109            obj.remove("dependentSchemas");
1110            obj.remove("unevaluatedProperties");
1111
1112            for (key, child) in obj.iter_mut() {
1113                if key == "properties" {
1114                    // The `properties` value is a map of property_name → schema.
1115                    // Keys are user-defined field names (e.g. "title", "id"), NOT
1116                    // JSON Schema keywords. Only recurse into each property's
1117                    // schema without stripping keys from the map itself.
1118                    if let Value::Object(props) = child {
1119                        for prop_schema in props.values_mut() {
1120                            strip_gemini_function_parameters_unsupported_keywords(prop_schema);
1121                        }
1122                    }
1123                } else {
1124                    strip_gemini_function_parameters_unsupported_keywords(child);
1125                }
1126            }
1127        }
1128        Value::Array(items) => {
1129            for item in items {
1130                strip_gemini_function_parameters_unsupported_keywords(item);
1131            }
1132        }
1133        _ => {}
1134    }
1135}
1136
1137fn text_event_for_part(
1138    text: String,
1139    is_thought: bool,
1140    meta: Option<Box<meerkat_core::ProviderMeta>>,
1141) -> LlmEvent {
1142    if is_thought {
1143        LlmEvent::ReasoningDelta { delta: text }
1144    } else {
1145        LlmEvent::TextDelta { delta: text, meta }
1146    }
1147}
1148
1149fn validate_gemini_response_json_schema(schema: &Value, provider: Provider) -> Vec<SchemaWarning> {
1150    let mut warnings = Vec::new();
1151    inspect_gemini_json_schema_node(schema, "", provider, &mut warnings);
1152    warnings
1153}
1154
1155fn inspect_gemini_json_schema_node(
1156    value: &Value,
1157    path: &str,
1158    provider: Provider,
1159    warnings: &mut Vec<SchemaWarning>,
1160) {
1161    match value {
1162        Value::Object(obj) => {
1163            for key in obj.keys() {
1164                if !is_gemini_supported_schema_keyword(key) {
1165                    warnings.push(SchemaWarning {
1166                        provider,
1167                        path: join_path(path, key),
1168                        message: format!(
1169                            "Keyword '{key}' may be ignored by Gemini responseJsonSchema"
1170                        ),
1171                    });
1172                }
1173            }
1174
1175            for (key, child) in obj {
1176                match key.as_str() {
1177                    // Map values are schemas keyed by arbitrary names.
1178                    "properties" | "$defs" => {
1179                        inspect_schema_map(child, &join_path(path, key), provider, warnings);
1180                    }
1181                    _ => inspect_gemini_json_schema_node(
1182                        child,
1183                        &join_path(path, key),
1184                        provider,
1185                        warnings,
1186                    ),
1187                }
1188            }
1189        }
1190        Value::Array(items) => {
1191            for (index, item) in items.iter().enumerate() {
1192                inspect_gemini_json_schema_node(item, &join_index(path, index), provider, warnings);
1193            }
1194        }
1195        _ => {}
1196    }
1197}
1198
1199fn inspect_schema_map(
1200    value: &Value,
1201    path: &str,
1202    provider: Provider,
1203    warnings: &mut Vec<SchemaWarning>,
1204) {
1205    match value {
1206        Value::Object(map) => {
1207            for (name, child) in map {
1208                inspect_gemini_json_schema_node(child, &join_path(path, name), provider, warnings);
1209            }
1210        }
1211        other => inspect_gemini_json_schema_node(other, path, provider, warnings),
1212    }
1213}
1214
1215fn is_gemini_supported_schema_keyword(key: &str) -> bool {
1216    // Source: Gemini responseJsonSchema supported keyword subset in
1217    // https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/control-generated-output#supported-schema-fields
1218    // Verified against docs on 2026-02-27.
1219    matches!(
1220        key,
1221        "$id"
1222            | "$defs"
1223            | "$ref"
1224            | "$anchor"
1225            | "type"
1226            | "format"
1227            | "title"
1228            | "description"
1229            | "const"
1230            | "default"
1231            | "examples"
1232            | "enum"
1233            | "items"
1234            | "prefixItems"
1235            | "minItems"
1236            | "maxItems"
1237            | "minimum"
1238            | "maximum"
1239            | "anyOf"
1240            | "oneOf"
1241            | "properties"
1242            | "additionalProperties"
1243            | "required"
1244            | "propertyOrdering"
1245            | "nullable"
1246    )
1247}
1248
1249fn join_path(prefix: &str, key: &str) -> String {
1250    if prefix.is_empty() {
1251        format!("/{key}")
1252    } else {
1253        format!("{prefix}/{key}")
1254    }
1255}
1256
1257fn join_index(prefix: &str, index: usize) -> String {
1258    if prefix.is_empty() {
1259        format!("/{index}")
1260    } else {
1261        format!("{prefix}/{index}")
1262    }
1263}
1264
1265#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1266#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1267impl LlmClient for GeminiClient {
1268    fn stream<'a>(&'a self, request: &'a LlmRequest) -> LlmStream<'a> {
1269        let inner: LlmStream<'a> = Box::pin(async_stream::try_stream! {
1270            let body = self.build_stream_request_body(request)?;
1271            let url = self.stream_generate_content_url(&request.model);
1272
1273            // Auth path: if an authorizer is attached (Code Assist /
1274            // Vertex ADC Bearer flow), collect its headers via the
1275            // HttpAuthorizer trait; otherwise fall back to x-goog-api-key.
1276            let mut req = self.http
1277                .post(&url)
1278                .header("Content-Type", "application/json");
1279            if let Some(authorizer) = &self.authorizer {
1280                let mut extra: Vec<(String, String)> = Vec::new();
1281                let mut auth_req = meerkat_core::HttpAuthorizationRequest {
1282                    method: "POST",
1283                    url: &url,
1284                    headers: &mut extra,
1285                };
1286                authorizer
1287                    .authorize(&mut auth_req)
1288                    .await
1289                    .map_err(|e| LlmError::AuthenticationFailed {
1290                        message: format!("gemini authorizer failed: {e}"),
1291                    })?;
1292                for (name, value) in extra {
1293                    req = req.header(name, value);
1294                }
1295            } else {
1296                req = req.header("x-goog-api-key", &self.api_key);
1297            }
1298            let response = req
1299                .json(&body)
1300                .send()
1301                .await
1302                .map_err(|_| LlmError::NetworkTimeout {
1303                    duration_ms: 30000,
1304                })?;
1305
1306            let status_code = response.status().as_u16();
1307            let stream_result = if (200..=299).contains(&status_code) {
1308                Ok(response.bytes_stream())
1309            } else {
1310                let headers = response.headers().clone();
1311                let text = response.text().await.unwrap_or_default();
1312                Err(LlmError::from_http_response(status_code, text, &headers))
1313            };
1314            let mut stream = stream_result?;
1315            let mut buffer = String::with_capacity(512);
1316            let mut tool_call_index: u32 = 0;
1317
1318            while let Some(chunk) = stream.next().await {
1319                let chunk = chunk.map_err(|_| LlmError::ConnectionReset)?;
1320                buffer.push_str(&String::from_utf8_lossy(&chunk));
1321
1322                while let Some(newline_pos) = buffer.find('\n') {
1323                    let line = buffer[..newline_pos].trim();
1324                    let data = line.strip_prefix("data: ");
1325                    let parsed_response = if let Some(d) = data {
1326                        self.parse_stream_line_for_wire(d)
1327                    } else {
1328                        None
1329                    };
1330
1331                    buffer.drain(..=newline_pos);
1332
1333                    if let Some(resp) = parsed_response {
1334                        if let Some(usage) = resp.usage_metadata {
1335                            yield LlmEvent::UsageUpdate {
1336                                usage: Usage {
1337                                    input_tokens: usage.prompt_token_count.unwrap_or(0),
1338                                    output_tokens: usage.candidates_token_count.unwrap_or(0),
1339                                    cache_creation_tokens: None,
1340                                    cache_read_tokens: None,
1341                                }
1342                            };
1343                        }
1344
1345                        if let Some(candidates) = resp.candidates {
1346                            for cand in candidates {
1347                                if let Some(content) = cand.content {
1348                                    // Not collapsed: inner loop processes heterogeneous part types
1349                                    // (text, function_call, function_response) independently.
1350                                    #[allow(clippy::collapsible_if)]
1351                                    if let Some(parts) = content.parts {
1352                                        for part in parts {
1353                                            // Build meta from thoughtSignature if present
1354                                            let meta = part.thought_signature.as_ref().map(|sig| {
1355                                                Box::new(meerkat_core::ProviderMeta::Gemini {
1356                                                    thought_signature: sig.clone(),
1357                                                })
1358                                            });
1359
1360                                            if let Some(text) = part.text {
1361                                                yield text_event_for_part(
1362                                                    text,
1363                                                    part.thought.unwrap_or(false),
1364                                                    meta.clone(),
1365                                                );
1366                                            }
1367                                            if let Some(fc) = part.function_call {
1368                                                let id = format!("fc_{tool_call_index}");
1369                                                tool_call_index += 1;
1370                                                yield LlmEvent::ToolCallComplete {
1371                                                    id,
1372                                                    name: fc.name,
1373                                                    args: fc.args.unwrap_or(json!({})),
1374                                                    meta,
1375                                                };
1376                                            }
1377                                        }
1378                                    }
1379                                }
1380
1381                                if let Some(reason) = cand.finish_reason {
1382                                    let stop = match reason.as_str() {
1383                                        "MAX_TOKENS" => StopReason::MaxTokens,
1384                                        "SAFETY" | "RECITATION" => StopReason::ContentFilter,
1385                                        // Gemini uses various names for tool calls
1386                                        "TOOL_CALL" | "FUNCTION_CALL" => StopReason::ToolUse,
1387                                        // "STOP" and any unrecognized reason default to EndTurn
1388                                        _ => StopReason::EndTurn,
1389                                    };
1390                                    yield LlmEvent::Done {
1391                                        outcome: LlmDoneOutcome::Success { stop_reason: stop },
1392                                    };
1393                                }
1394                            }
1395                        }
1396                    }
1397                }
1398            }
1399        });
1400
1401        streaming::ensure_terminal_done(inner)
1402    }
1403
1404    fn provider(&self) -> &'static str {
1405        "gemini"
1406    }
1407
1408    async fn health_check(&self) -> Result<(), LlmError> {
1409        Ok(())
1410    }
1411
1412    fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
1413        GeminiClient::compile_schema_for_gemini(output_schema)
1414    }
1415}
1416
1417#[derive(Debug, Deserialize)]
1418#[serde(rename_all = "camelCase")]
1419struct GenerateContentResponse {
1420    candidates: Option<Vec<Candidate>>,
1421    usage_metadata: Option<GeminiUsage>,
1422    response_id: Option<String>,
1423    prompt_feedback: Option<PromptFeedback>,
1424}
1425
1426#[derive(Debug, Deserialize)]
1427#[serde(rename_all = "camelCase")]
1428struct CodeAssistGenerateContentResponse {
1429    response: Option<GenerateContentResponse>,
1430    trace_id: Option<String>,
1431}
1432
1433#[derive(Debug, Deserialize)]
1434#[serde(rename_all = "camelCase")]
1435struct PromptFeedback {
1436    block_reason: Option<String>,
1437}
1438
1439#[derive(Debug, Deserialize)]
1440#[serde(rename_all = "camelCase")]
1441struct Candidate {
1442    content: Option<CandidateContent>,
1443    finish_reason: Option<String>,
1444}
1445
1446#[derive(Debug, Deserialize)]
1447struct CandidateContent {
1448    parts: Option<Vec<Part>>,
1449}
1450
1451#[derive(Debug, Deserialize)]
1452#[serde(rename_all = "camelCase")]
1453struct Part {
1454    text: Option<String>,
1455    function_call: Option<FunctionCall>,
1456    inline_data: Option<InlineData>,
1457    thought: Option<bool>,
1458    thought_signature: Option<String>,
1459}
1460
1461#[derive(Debug, Deserialize)]
1462#[serde(rename_all = "camelCase")]
1463struct InlineData {
1464    mime_type: String,
1465    data: String,
1466}
1467
1468#[derive(Debug, Deserialize)]
1469struct FunctionCall {
1470    name: String,
1471    args: Option<Value>,
1472}
1473
1474#[derive(Debug, Deserialize)]
1475#[serde(rename_all = "camelCase")]
1476struct GeminiUsage {
1477    prompt_token_count: Option<u64>,
1478    candidates_token_count: Option<u64>,
1479}
1480
1481#[cfg(test)]
1482#[allow(
1483    clippy::unwrap_used,
1484    clippy::expect_used,
1485    clippy::explicit_counter_loop,
1486    clippy::panic
1487)]
1488mod tests {
1489    use super::*;
1490    use axum::{Json, Router, extract::State, response::IntoResponse, routing::post};
1491    use meerkat_core::lifecycle::run_primitive::GeminiThinkingLevel;
1492    use meerkat_core::{
1493        AssistantBlock, BlockAssistantMessage, ContentBlock, ProviderMeta, UserMessage,
1494    };
1495    use meerkat_llm_core::ImageGenerationExecutor;
1496    use std::sync::{Arc, Mutex};
1497    use tokio::net::TcpListener;
1498
1499    #[derive(Clone)]
1500    struct GeminiImageStubState {
1501        response: Value,
1502        seen: Arc<Mutex<Vec<Value>>>,
1503    }
1504
1505    #[derive(Clone)]
1506    struct GeminiStreamStubState {
1507        payload: String,
1508        seen: Arc<Mutex<Vec<Value>>>,
1509    }
1510
1511    async fn gemini_image_stub(
1512        State(state): State<GeminiImageStubState>,
1513        Json(body): Json<Value>,
1514    ) -> impl IntoResponse {
1515        state.seen.lock().expect("seen mutex").push(body);
1516        Json(state.response)
1517    }
1518
1519    async fn gemini_stream_stub(
1520        State(state): State<GeminiStreamStubState>,
1521        Json(body): Json<Value>,
1522    ) -> impl IntoResponse {
1523        state.seen.lock().expect("seen mutex").push(body);
1524        ([("content-type", "text/event-stream")], state.payload)
1525    }
1526
1527    async fn spawn_gemini_image_stub(
1528        response: Value,
1529        seen: Arc<Mutex<Vec<Value>>>,
1530    ) -> (String, tokio::task::JoinHandle<()>) {
1531        let app = Router::new()
1532            .route(
1533                "/v1beta/models/gemini-2.5-flash-image:generateContent",
1534                post(gemini_image_stub),
1535            )
1536            .route(
1537                "/v1beta/models/gemini-3.1-flash-image-preview:generateContent",
1538                post(gemini_image_stub),
1539            )
1540            .with_state(GeminiImageStubState { response, seen });
1541        let listener = TcpListener::bind("127.0.0.1:0")
1542            .await
1543            .expect("bind test server");
1544        let addr = listener.local_addr().expect("local addr");
1545        let handle = tokio::spawn(async move {
1546            axum::serve(listener, app).await.expect("serve test server");
1547        });
1548        (format!("http://{addr}"), handle)
1549    }
1550
1551    async fn spawn_code_assist_stream_stub(
1552        payload: String,
1553        seen: Arc<Mutex<Vec<Value>>>,
1554    ) -> (String, tokio::task::JoinHandle<()>) {
1555        let app = Router::new()
1556            .route(
1557                "/v1internal:streamGenerateContent",
1558                post(gemini_stream_stub),
1559            )
1560            .with_state(GeminiStreamStubState { payload, seen });
1561        let listener = TcpListener::bind("127.0.0.1:0")
1562            .await
1563            .expect("bind test server");
1564        let addr = listener.local_addr().expect("local addr");
1565        let handle = tokio::spawn(async move {
1566            axum::serve(listener, app).await.expect("serve test server");
1567        });
1568        (format!("http://{addr}"), handle)
1569    }
1570
1571    fn gemini_image_executor_request_json() -> ProviderImageGenerationRequest {
1572        serde_json::from_value(serde_json::json!({
1573            "operation_id": "00000000-0000-0000-0000-000000000201",
1574            "model": "gemini-2.5-flash-image",
1575            "generate_request": {
1576                "intent": {
1577                    "intent": "generate",
1578                    "prompt": {"content": "draw a small blue boat"},
1579                    "prompt_source": {
1580                        "source": "user_provided",
1581                        "message_id": "00000000-0000-0000-0000-000000000202"
1582                    },
1583                    "reference_images": []
1584                },
1585                "target": {"target": "auto"},
1586                "size": {"size": "landscape1536x1024"},
1587                "quality": "auto",
1588                "format": "auto",
1589                "count": 1
1590            },
1591            "execution_plan": {
1592                "provider": "gemini",
1593                "backend": "native_model",
1594                "max_count": 1,
1595                "capabilities": {
1596                    "hosted_image_generation_tool": false,
1597                    "native_image_output": true,
1598                    "custom_tools": true,
1599                    "image_search_grounding": false,
1600                    "image_continuity_tokens": "same_provider_only"
1601                },
1602                "requires_scoped_override": true,
1603                "provider_plan": {
1604                    "projection_snapshot_id": "00000000-0000-0000-0000-000000000203",
1605                    "output": {
1606                        "aspect_ratio": "landscape16x9",
1607                        "image_size": null
1608                    }
1609                }
1610            },
1611            "projected_messages": []
1612        }))
1613        .expect("gemini image executor request")
1614    }
1615
1616    fn assert_no_const_or_type_arrays(value: &Value) {
1617        match value {
1618            Value::Object(obj) => {
1619                assert!(
1620                    obj.get("const").is_none(),
1621                    "const must be lowered/removed for Gemini function parameters: {value:?}"
1622                );
1623                if let Some(schema_type) = obj.get("type") {
1624                    assert!(
1625                        !schema_type.is_array(),
1626                        "type must be scalar in Gemini function parameters: {value:?}"
1627                    );
1628                }
1629                for child in obj.values() {
1630                    assert_no_const_or_type_arrays(child);
1631                }
1632            }
1633            Value::Array(items) => {
1634                for item in items {
1635                    assert_no_const_or_type_arrays(item);
1636                }
1637            }
1638            _ => {}
1639        }
1640    }
1641
1642    #[tokio::test]
1643    async fn code_assist_stream_uses_internal_endpoint_and_wrapper()
1644    -> Result<(), Box<dyn std::error::Error>> {
1645        let seen = Arc::new(Mutex::new(Vec::new()));
1646        let payload = [
1647            r#"data: {"response":{"candidates":[{"content":{"parts":[{"text":"hello from code assist"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":4}},"traceId":"trace-123"}"#,
1648            "",
1649        ]
1650        .join("\n");
1651        let (base_url, handle) = spawn_code_assist_stream_stub(payload, seen.clone()).await;
1652        let client =
1653            GeminiClient::new_with_base_url(String::new(), base_url).with_code_assist_wire();
1654        let request = LlmRequest::new(
1655            "gemini-3.1-flash-lite",
1656            vec![Message::User(UserMessage::text("hello".to_string()))],
1657        );
1658
1659        let mut stream = client.stream(&request);
1660        let mut deltas = Vec::new();
1661        while let Some(event) = stream.next().await {
1662            match event? {
1663                LlmEvent::TextDelta { delta, .. } => deltas.push(delta),
1664                LlmEvent::Done { .. } => break,
1665                _ => {}
1666            }
1667        }
1668        handle.abort();
1669
1670        assert_eq!(deltas, vec!["hello from code assist"]);
1671        let bodies = seen.lock().expect("seen mutex");
1672        let body = bodies.first().expect("captured Code Assist request");
1673        assert_eq!(body["model"], "gemini-2.5-flash");
1674        assert!(
1675            body.get("user_prompt_id").and_then(Value::as_str).is_some(),
1676            "Code Assist requires a user_prompt_id on generate requests",
1677        );
1678        assert!(
1679            body.get("request")
1680                .and_then(|request| request.get("contents"))
1681                .is_some(),
1682            "Code Assist request must wrap public generateContent payload under `request`",
1683        );
1684        assert!(
1685            body.get("contents").is_none(),
1686            "Code Assist must not send public generateContent fields at top level",
1687        );
1688        Ok(())
1689    }
1690
1691    #[test]
1692    fn gemini_image_error_terminal_uses_structured_error_reason() {
1693        let safety = serde_json::json!({
1694            "error": {
1695                "code": 400,
1696                "status": "INVALID_ARGUMENT",
1697                "details": [{
1698                    "@type": "type.googleapis.com/google.rpc.ErrorInfo",
1699                    "reason": "SAFETY",
1700                    "domain": "generativelanguage.googleapis.com"
1701                }]
1702            }
1703        });
1704
1705        assert_eq!(
1706            GeminiClient::gemini_error_terminal(400, &safety.to_string()),
1707            ImageOperationTerminalClass::SafetyFiltered
1708        );
1709
1710        let refusal = serde_json::json!({
1711            "error": {
1712                "code": 400,
1713                "status": "FAILED_PRECONDITION",
1714                "details": [{
1715                    "@type": "type.googleapis.com/google.rpc.ErrorInfo",
1716                    "reason": "MODEL_REFUSAL",
1717                    "domain": "generativelanguage.googleapis.com"
1718                }]
1719            }
1720        });
1721
1722        assert_eq!(
1723            GeminiClient::gemini_error_terminal(400, &refusal.to_string()),
1724            ImageOperationTerminalClass::RefusedByProvider
1725        );
1726    }
1727
1728    #[test]
1729    fn gemini_image_error_terminal_does_not_parse_message_text() {
1730        let message_only = serde_json::json!({
1731            "error": {
1732                "code": 400,
1733                "status": "INVALID_ARGUMENT",
1734                "message": "diagnostic text mentions blocked, safety, refusal, and refused"
1735            }
1736        });
1737
1738        assert_eq!(
1739            GeminiClient::gemini_error_terminal(400, &message_only.to_string()),
1740            ImageOperationTerminalClass::Failed
1741        );
1742        assert_eq!(
1743            GeminiClient::gemini_error_terminal(
1744                503,
1745                r#"{"error":{"status":"UNAVAILABLE","message":"safety backend unavailable"}}"#
1746            ),
1747            ImageOperationTerminalClass::Failed
1748        );
1749    }
1750
1751    #[test]
1752    fn gemini_image_error_terminal_uses_transport_status_for_timeout() {
1753        assert_eq!(
1754            GeminiClient::gemini_error_terminal(408, "request timed out"),
1755            ImageOperationTerminalClass::Timeout
1756        );
1757        assert_eq!(
1758            GeminiClient::gemini_error_terminal(504, "gateway timeout"),
1759            ImageOperationTerminalClass::Timeout
1760        );
1761    }
1762
1763    #[tokio::test]
1764    async fn gemini_native_image_executor_requests_text_and_image_modalities()
1765    -> Result<(), Box<dyn std::error::Error>> {
1766        let seen = Arc::new(Mutex::new(Vec::new()));
1767        let response = serde_json::json!({
1768            "responseId": "gem_resp_1",
1769            "candidates": [{
1770                "content": {
1771                    "parts": [
1772                        {"text": "Here is a blue boat."},
1773                        {
1774                            "inlineData": {
1775                                "mimeType": "image/png",
1776                                "data": "data:image/png;base64,aGVsbG8="
1777                            }
1778                        }
1779                    ]
1780                },
1781                "finishReason": "STOP"
1782            }]
1783        });
1784        let (base_url, handle) = spawn_gemini_image_stub(response, seen.clone()).await;
1785        let client = GeminiClient::new_with_base_url("test-key".to_string(), base_url);
1786
1787        let output = client
1788            .execute_image_generation(gemini_image_executor_request_json())
1789            .await?;
1790
1791        assert!(matches!(
1792            output.terminal,
1793            ImageOperationTerminalClass::Generated
1794        ));
1795        assert_eq!(output.images.len(), 1);
1796        assert_eq!(output.images[0].base64_data, "aGVsbG8=");
1797        assert_eq!(output.images[0].media_type.as_str(), "image/png");
1798        assert_eq!(
1799            (output.images[0].width, output.images[0].height),
1800            (1536, 1024)
1801        );
1802        assert_eq!(
1803            output.provider_text.as_deref(),
1804            Some("Here is a blue boat.")
1805        );
1806        assert!(matches!(
1807            output.native_metadata,
1808            ProviderImageMetadata::Gemini(GeminiImageMetadata {
1809                response_id: Some(_),
1810                ..
1811            })
1812        ));
1813
1814        let bodies = seen.lock().expect("seen mutex");
1815        let body = bodies.first().expect("captured Gemini image request");
1816        assert_eq!(
1817            body["generationConfig"]["responseModalities"],
1818            serde_json::json!(["IMAGE"])
1819        );
1820        assert_eq!(
1821            body["generationConfig"]["imageConfig"],
1822            serde_json::json!({
1823                "aspectRatio": "16:9"
1824            })
1825        );
1826        assert_eq!(
1827            body["contents"][0]["parts"][0]["text"],
1828            "draw a small blue boat"
1829        );
1830
1831        handle.abort();
1832        Ok(())
1833    }
1834
1835    #[tokio::test]
1836    async fn gemini_native_image_executor_maps_prompt_feedback_safety()
1837    -> Result<(), Box<dyn std::error::Error>> {
1838        let seen = Arc::new(Mutex::new(Vec::new()));
1839        let response = serde_json::json!({
1840            "responseId": "gem_resp_blocked",
1841            "promptFeedback": {
1842                "blockReason": "SAFETY"
1843            }
1844        });
1845        let (base_url, handle) = spawn_gemini_image_stub(response, seen).await;
1846        let client = GeminiClient::new_with_base_url("test-key".to_string(), base_url);
1847
1848        let output = client
1849            .execute_image_generation(gemini_image_executor_request_json())
1850            .await?;
1851
1852        assert_eq!(output.terminal, ImageOperationTerminalClass::SafetyFiltered);
1853        assert!(output.images.is_empty());
1854
1855        handle.abort();
1856        Ok(())
1857    }
1858
1859    #[tokio::test]
1860    async fn gemini_preview_image_executor_includes_image_size()
1861    -> Result<(), Box<dyn std::error::Error>> {
1862        let seen = Arc::new(Mutex::new(Vec::new()));
1863        let response = serde_json::json!({
1864            "responseId": "gem_resp_1",
1865            "candidates": [{
1866                "content": {
1867                    "parts": [{
1868                        "inlineData": {
1869                            "mimeType": "image/png",
1870                            "data": "data:image/png;base64,aGVsbG8="
1871                        }
1872                    }]
1873                },
1874                "finishReason": "STOP"
1875            }]
1876        });
1877        let (base_url, handle) = spawn_gemini_image_stub(response, seen.clone()).await;
1878        let client = GeminiClient::new_with_base_url("test-key".to_string(), base_url);
1879        let mut request = gemini_image_executor_request_json();
1880        request.model = "gemini-3.1-flash-image-preview".to_string();
1881        request.generate_request.size = meerkat_core::ImageSizePreference::Portrait1024x1536;
1882        let mut plan: crate::image_generation::GeminiImageTurnPlan =
1883            serde_json::from_value(request.execution_plan.provider_plan.clone())?;
1884        plan.output = crate::image_generation::GeminiImageOutputOptions {
1885            aspect_ratio: crate::image_generation::GeminiImageAspectRatio::Portrait9x16,
1886            image_size: Some(crate::image_generation::GeminiImageSize::OneK),
1887        };
1888        request.execution_plan.provider_plan = serde_json::to_value(plan)?;
1889
1890        let output = client.execute_image_generation(request).await?;
1891        assert!(matches!(
1892            output.terminal,
1893            ImageOperationTerminalClass::Generated
1894        ));
1895
1896        let bodies = seen.lock().expect("seen mutex");
1897        let body = bodies.first().expect("captured Gemini image request");
1898        assert_eq!(
1899            body["generationConfig"]["imageConfig"],
1900            serde_json::json!({
1901                "aspectRatio": "9:16",
1902                "imageSize": "1K"
1903            })
1904        );
1905
1906        handle.abort();
1907        Ok(())
1908    }
1909
1910    #[test]
1911    fn test_build_request_body_with_thinking_budget() -> Result<(), Box<dyn std::error::Error>> {
1912        let client = GeminiClient::new("test-key".to_string());
1913        let request = LlmRequest::new(
1914            "gemini-1.5-pro",
1915            vec![Message::User(UserMessage::text("test".to_string()))],
1916        )
1917        .with_gemini_tag_merge(|t| t.thinking_budget = Some(10000));
1918
1919        let body = client.build_request_body(&request)?;
1920
1921        let generation_config = body.get("generationConfig").ok_or("missing config")?;
1922        let thinking_config = generation_config
1923            .get("thinkingConfig")
1924            .ok_or("missing thinking")?;
1925        let thinking_budget = thinking_config
1926            .get("thinkingBudget")
1927            .ok_or("missing budget")?;
1928
1929        assert_eq!(thinking_budget.as_i64(), Some(10000));
1930        Ok(())
1931    }
1932
1933    #[test]
1934    fn test_build_request_body_with_thinking_level() -> Result<(), Box<dyn std::error::Error>> {
1935        let client = GeminiClient::new("test-key".to_string());
1936        let request = LlmRequest::new(
1937            "gemini-3-flash-preview",
1938            vec![Message::User(UserMessage::text("test".to_string()))],
1939        )
1940        .with_gemini_tag_merge(|t| t.thinking_level = Some(GeminiThinkingLevel::Low));
1941
1942        let body = client.build_request_body(&request)?;
1943
1944        let thinking_config = body
1945            .get("generationConfig")
1946            .and_then(|config| config.get("thinkingConfig"))
1947            .ok_or("missing thinking")?;
1948        assert_eq!(
1949            thinking_config
1950                .get("thinkingLevel")
1951                .and_then(serde_json::Value::as_str),
1952            Some("low")
1953        );
1954        assert!(thinking_config.get("thinkingBudget").is_none());
1955        Ok(())
1956    }
1957
1958    #[test]
1959    fn test_build_request_body_with_top_k() -> Result<(), Box<dyn std::error::Error>> {
1960        let client = GeminiClient::new("test-key".to_string());
1961        let request = LlmRequest::new(
1962            "gemini-1.5-pro",
1963            vec![Message::User(UserMessage::text("test".to_string()))],
1964        )
1965        .with_gemini_tag_merge(|t| t.top_k = Some(40));
1966
1967        let body = client.build_request_body(&request)?;
1968        let generation_config = body.get("generationConfig").ok_or("missing config")?;
1969        let top_k = generation_config.get("topK").ok_or("missing top_k")?;
1970
1971        assert_eq!(top_k.as_i64(), Some(40));
1972        Ok(())
1973    }
1974
1975    #[test]
1976    fn test_build_request_body_with_multiple_provider_params()
1977    -> Result<(), Box<dyn std::error::Error>> {
1978        let client = GeminiClient::new("test-key".to_string());
1979        let request = LlmRequest::new(
1980            "gemini-1.5-pro",
1981            vec![Message::User(UserMessage::text("test".to_string()))],
1982        )
1983        .with_gemini_tag_merge(|t| t.top_k = Some(50))
1984        .with_gemini_tag_merge(|t| t.thinking_budget = Some(5000));
1985
1986        let body = client.build_request_body(&request)?;
1987        let generation_config = body.get("generationConfig").ok_or("missing config")?;
1988
1989        let top_k = generation_config.get("topK").ok_or("missing top_k")?;
1990        assert_eq!(top_k.as_i64(), Some(50));
1991
1992        let thinking_config = generation_config
1993            .get("thinkingConfig")
1994            .ok_or("missing thinking")?;
1995        let thinking_budget = thinking_config
1996            .get("thinkingBudget")
1997            .ok_or("missing budget")?;
1998        assert_eq!(thinking_budget.as_i64(), Some(5000));
1999        Ok(())
2000    }
2001
2002    #[test]
2003    fn test_build_request_body_no_provider_params() -> Result<(), Box<dyn std::error::Error>> {
2004        let client = GeminiClient::new("test-key".to_string());
2005        let request = LlmRequest::new(
2006            "gemini-1.5-pro",
2007            vec![Message::User(UserMessage::text("test".to_string()))],
2008        );
2009
2010        let body = client.build_request_body(&request)?;
2011        let generation_config = body.get("generationConfig").ok_or("missing config")?;
2012
2013        assert!(generation_config.get("thinkingConfig").is_none());
2014        assert!(generation_config.get("topK").is_none());
2015        Ok(())
2016    }
2017
2018    /// Test that functionCall has thoughtSignature but functionResponse does NOT
2019    /// Per spec section 2.3: Signatures on functionCall, NEVER on functionResponse
2020    /// Uses BlockAssistant with ProviderMeta::Gemini for thoughtSignature.
2021    #[test]
2022    fn test_tool_response_uses_function_name_no_signature() -> Result<(), Box<dyn std::error::Error>>
2023    {
2024        use serde_json::value::RawValue;
2025        let client = GeminiClient::new("test-key".to_string());
2026        let args_raw = RawValue::from_string(json!({"city": "Tokyo"}).to_string()).unwrap();
2027        let request = LlmRequest::new(
2028            "gemini-1.5-pro",
2029            vec![
2030                Message::User(UserMessage::text("test".to_string())),
2031                Message::BlockAssistant(BlockAssistantMessage {
2032                    blocks: vec![AssistantBlock::ToolUse {
2033                        id: "call_1".to_string(),
2034                        name: "get_weather".into(),
2035                        args: args_raw,
2036                        meta: Some(Box::new(ProviderMeta::Gemini {
2037                            thought_signature: "sig_123".to_string(),
2038                        })),
2039                    }],
2040                    stop_reason: StopReason::ToolUse,
2041                    created_at: meerkat_core::types::message_timestamp_now(),
2042                }),
2043                Message::ToolResults {
2044                    results: vec![meerkat_core::ToolResult::new(
2045                        "call_1".to_string(),
2046                        "Sunny".to_string(),
2047                        false,
2048                    )],
2049                    created_at: meerkat_core::types::message_timestamp_now(),
2050                },
2051            ],
2052        );
2053
2054        let body = client.build_request_body(&request)?;
2055        let contents = body
2056            .get("contents")
2057            .and_then(|c| c.as_array())
2058            .ok_or("missing contents")?;
2059
2060        // Find the assistant message (role: "model") - functionCall SHOULD have signature
2061        let model_content = contents
2062            .iter()
2063            .find(|c| c.get("role").and_then(|r| r.as_str()) == Some("model"))
2064            .ok_or("missing model content")?;
2065        let model_parts = model_content
2066            .get("parts")
2067            .and_then(|p| p.as_array())
2068            .ok_or("missing model parts")?;
2069        let fc_part = model_parts
2070            .iter()
2071            .find(|p| p.get("functionCall").is_some())
2072            .ok_or("missing functionCall part")?;
2073        assert_eq!(
2074            fc_part["thoughtSignature"], "sig_123",
2075            "functionCall SHOULD have signature"
2076        );
2077
2078        // Find the tool result (last message) - functionResponse MUST NOT have signature
2079        let tool_result_parts = contents
2080            .last()
2081            .and_then(|c| c.get("parts"))
2082            .and_then(|p| p.as_array())
2083            .ok_or("missing parts")?;
2084
2085        let function_response = &tool_result_parts[0]["functionResponse"];
2086        assert_eq!(function_response["name"], "get_weather");
2087        // IMPORTANT: functionResponse MUST NOT have thoughtSignature (spec section 2.3)
2088        assert!(
2089            tool_result_parts[0].get("thoughtSignature").is_none(),
2090            "functionResponse MUST NOT have thoughtSignature"
2091        );
2092        Ok(())
2093    }
2094
2095    #[test]
2096    fn test_parse_stream_line_valid_response() -> Result<(), Box<dyn std::error::Error>> {
2097        let line =
2098            r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}]},"finishReason":"STOP"}]}"#;
2099        let response = GeminiClient::parse_stream_line(line);
2100        assert!(response.is_some());
2101        let response = response.ok_or("missing response")?;
2102        assert!(response.candidates.is_some());
2103        let candidates = response.candidates.ok_or("missing candidates")?;
2104        assert_eq!(candidates.len(), 1);
2105        Ok(())
2106    }
2107
2108    #[test]
2109    fn test_parse_stream_line_with_usage() -> Result<(), Box<dyn std::error::Error>> {
2110        let line = r#"{"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}"#;
2111        let response = GeminiClient::parse_stream_line(line);
2112        assert!(response.is_some());
2113        let response = response.ok_or("missing response")?;
2114        assert!(response.usage_metadata.is_some());
2115        let usage = response.usage_metadata.ok_or("missing usage")?;
2116        assert_eq!(usage.prompt_token_count, Some(10));
2117        Ok(())
2118    }
2119
2120    #[test]
2121    fn test_parse_stream_line_function_call() -> Result<(), Box<dyn std::error::Error>> {
2122        let line = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"city":"Tokyo"}}}]}}]}"#;
2123        let response = GeminiClient::parse_stream_line(line);
2124        assert!(response.is_some());
2125        let response = response.ok_or("missing response")?;
2126        let candidates = response.candidates.as_ref().ok_or("missing candidates")?;
2127        let parts = candidates[0]
2128            .content
2129            .as_ref()
2130            .ok_or("missing content")?
2131            .parts
2132            .as_ref()
2133            .ok_or("missing parts")?;
2134        let fc = parts[0].function_call.as_ref().ok_or("missing fc")?;
2135        assert_eq!(fc.name, "get_weather");
2136        assert_eq!(fc.args.as_ref().ok_or("missing args")?["city"], "Tokyo");
2137        Ok(())
2138    }
2139
2140    #[test]
2141    fn test_parse_stream_line_empty() {
2142        let line = "";
2143        let response = GeminiClient::parse_stream_line(line);
2144        assert!(response.is_none());
2145    }
2146
2147    #[test]
2148    fn test_parse_stream_line_invalid_json() {
2149        let line = "{invalid}";
2150        let response = GeminiClient::parse_stream_line(line);
2151        assert!(response.is_none());
2152    }
2153
2154    /// Regression: Gemini tool-call finish reasons must map to ToolUse.
2155    /// Previously TOOL_CALL and FUNCTION_CALL mapped to EndTurn incorrectly.
2156    #[test]
2157    fn test_regression_gemini_finish_reason_tool_call_maps_to_tool_use() {
2158        // Test the finish reason mapping logic directly
2159        let finish_reasons = ["TOOL_CALL", "FUNCTION_CALL"];
2160
2161        for reason in finish_reasons {
2162            let stop = match reason {
2163                "MAX_TOKENS" => StopReason::MaxTokens,
2164                "SAFETY" | "RECITATION" => StopReason::ContentFilter,
2165                "TOOL_CALL" | "FUNCTION_CALL" => StopReason::ToolUse,
2166                // "STOP" and any unrecognized reason default to EndTurn
2167                _ => StopReason::EndTurn,
2168            };
2169            assert_eq!(
2170                stop,
2171                StopReason::ToolUse,
2172                "finish_reason '{reason}' should map to ToolUse"
2173            );
2174        }
2175    }
2176
2177    /// Regression: Gemini RECITATION finish reason must map to ContentFilter.
2178    #[test]
2179    fn test_regression_gemini_finish_reason_recitation_maps_to_content_filter() {
2180        let reason = "RECITATION";
2181        let stop = match reason {
2182            "MAX_TOKENS" => StopReason::MaxTokens,
2183            "SAFETY" | "RECITATION" => StopReason::ContentFilter,
2184            "TOOL_CALL" | "FUNCTION_CALL" => StopReason::ToolUse,
2185            // "STOP" and any unrecognized reason default to EndTurn
2186            _ => StopReason::EndTurn,
2187        };
2188        assert_eq!(stop, StopReason::ContentFilter);
2189    }
2190
2191    /// Regression: Multiple tool calls to the same tool must get unique IDs.
2192    /// Previously, IDs were set to the tool name, causing collisions when
2193    /// the same tool was called multiple times (e.g., two "search" calls
2194    /// both got id="search", breaking tool-result correlation).
2195    #[test]
2196    fn test_regression_gemini_tool_call_ids_must_be_unique() {
2197        // Simulate the ID generation logic from streaming
2198        let mut tool_call_index: u32 = 0;
2199
2200        // Simulate 3 calls to "search" tool
2201        let tool_names = ["search", "search", "search"];
2202        let mut generated_ids = Vec::new();
2203
2204        for _name in tool_names {
2205            let id = format!("fc_{tool_call_index}");
2206            tool_call_index += 1;
2207            generated_ids.push(id);
2208        }
2209
2210        // All IDs must be unique
2211        assert_eq!(generated_ids[0], "fc_0");
2212        assert_eq!(generated_ids[1], "fc_1");
2213        assert_eq!(generated_ids[2], "fc_2");
2214
2215        // Verify no duplicates
2216        let mut seen = std::collections::HashSet::new();
2217        for id in &generated_ids {
2218            assert!(
2219                seen.insert(id.clone()),
2220                "Duplicate tool call ID found: {id}"
2221            );
2222        }
2223    }
2224
2225    /// Regression: Tool call IDs must be unique across mixed tool types.
2226    #[test]
2227    fn test_regression_gemini_tool_call_ids_unique_across_different_tools() {
2228        let mut tool_call_index: u32 = 0;
2229
2230        // Simulate mixed tool calls
2231        let tool_names = ["search", "write_file", "search", "read_file"];
2232        let mut id_to_name = Vec::new();
2233
2234        for name in tool_names {
2235            let id = format!("fc_{tool_call_index}");
2236            tool_call_index += 1;
2237            id_to_name.push((id, name));
2238        }
2239
2240        // Each call gets a unique ID regardless of tool name
2241        assert_eq!(id_to_name[0], ("fc_0".to_string(), "search"));
2242        assert_eq!(id_to_name[1], ("fc_1".to_string(), "write_file"));
2243        assert_eq!(id_to_name[2], ("fc_2".to_string(), "search")); // Second search gets fc_2
2244        assert_eq!(id_to_name[3], ("fc_3".to_string(), "read_file"));
2245    }
2246
2247    #[test]
2248    fn test_build_request_body_with_structured_output() -> Result<(), Box<dyn std::error::Error>> {
2249        let client = GeminiClient::new("test-key".to_string());
2250
2251        let schema = serde_json::json!({
2252            "type": "object",
2253            "properties": {
2254                "name": {"type": "string"},
2255                "age": {"type": "integer"}
2256            },
2257            "required": ["name", "age"]
2258        });
2259
2260        let request = LlmRequest::new(
2261            "gemini-3-pro-preview",
2262            vec![Message::User(UserMessage::text("test".to_string()))],
2263        )
2264        .with_gemini_tag_merge(|t| {
2265            t.structured_output = serde_json::from_value::<OutputSchema>(serde_json::json!({
2266                "schema": schema,
2267                "name": "person",
2268                "strict": true
2269            }))
2270            .ok();
2271        });
2272
2273        let body = client.build_request_body(&request)?;
2274
2275        let gen_config = body
2276            .get("generationConfig")
2277            .ok_or("missing generationConfig")?;
2278        assert_eq!(gen_config["responseMimeType"], "application/json");
2279        assert!(gen_config.get("responseJsonSchema").is_some());
2280
2281        let response_schema = &gen_config["responseJsonSchema"];
2282        assert_eq!(response_schema["type"], "object");
2283        assert!(response_schema.get("properties").is_some());
2284        Ok(())
2285    }
2286
2287    #[test]
2288    fn test_build_request_body_serializes_inline_video_user_content()
2289    -> Result<(), Box<dyn std::error::Error>> {
2290        let client = GeminiClient::new("test-key".to_string());
2291        let request = LlmRequest::new(
2292            "gemini-3-pro-preview",
2293            vec![Message::User(UserMessage::with_blocks(vec![
2294                ContentBlock::Video {
2295                    media_type: "video/mp4".to_string(),
2296                    duration_ms: 12_000,
2297                    data: meerkat_core::VideoData::Inline {
2298                        data: "AAAA".to_string(),
2299                    },
2300                },
2301            ]))],
2302        );
2303
2304        let body = client.build_request_body(&request)?;
2305        let contents = body["contents"].as_array().ok_or("missing contents")?;
2306        let parts = contents[0]["parts"].as_array().ok_or("missing parts")?;
2307        assert_eq!(parts[0]["inlineData"]["mimeType"], "video/mp4");
2308        assert_eq!(parts[0]["inlineData"]["data"], "AAAA");
2309        Ok(())
2310    }
2311
2312    #[test]
2313    fn test_build_request_body_rejects_video_tool_results() {
2314        let client = GeminiClient::new("test-key".to_string());
2315        let request = LlmRequest::new(
2316            "gemini-3-pro-preview",
2317            vec![Message::ToolResults {
2318                results: vec![meerkat_core::ToolResult::with_blocks(
2319                    "tool_1".to_string(),
2320                    vec![ContentBlock::Video {
2321                        media_type: "video/mp4".to_string(),
2322                        duration_ms: 12_000,
2323                        data: meerkat_core::VideoData::Inline {
2324                            data: "AAAA".to_string(),
2325                        },
2326                    }],
2327                    false,
2328                )],
2329                created_at: meerkat_core::types::message_timestamp_now(),
2330            }],
2331        );
2332
2333        let err = client
2334            .build_request_body(&request)
2335            .expect_err("video tool results should be rejected");
2336        match err {
2337            LlmError::InvalidRequest { message } => {
2338                assert!(message.contains("video blocks are not supported"));
2339            }
2340            other => panic!("unexpected error: {other:?}"),
2341        }
2342    }
2343
2344    #[test]
2345    fn test_build_request_body_with_structured_output_preserves_schema_keywords()
2346    -> Result<(), Box<dyn std::error::Error>> {
2347        let client = GeminiClient::new("test-key".to_string());
2348
2349        // Schema keywords supported by Gemini responseJsonSchema should be preserved.
2350        let schema = serde_json::json!({
2351            "type": "object",
2352            "$defs": {
2353                "Address": {"type": "object"}
2354            },
2355            "$ref": "#/$defs/Address",
2356            "anyOf": [
2357                {"type": "object"},
2358                {"type": "null"}
2359            ],
2360            "properties": {
2361                "name": {"type": "string"}
2362            },
2363            "additionalProperties": false
2364        });
2365
2366        let request = LlmRequest::new(
2367            "gemini-3-pro-preview",
2368            vec![Message::User(UserMessage::text("test".to_string()))],
2369        )
2370        .with_gemini_tag_merge(|t| {
2371            t.structured_output =
2372                serde_json::from_value::<OutputSchema>(serde_json::json!({"schema": schema})).ok();
2373        });
2374
2375        let body = client.build_request_body(&request)?;
2376
2377        let gen_config = body
2378            .get("generationConfig")
2379            .ok_or("missing generationConfig")?;
2380        let response_schema = &gen_config["responseJsonSchema"];
2381
2382        // These should be preserved
2383        assert!(response_schema.get("$defs").is_some());
2384        assert_eq!(response_schema["$ref"], "#/$defs/Address");
2385        assert!(response_schema.get("anyOf").is_some());
2386        assert_eq!(response_schema["additionalProperties"], false);
2387        assert_eq!(response_schema["type"], "object");
2388        assert!(response_schema.get("properties").is_some());
2389        Ok(())
2390    }
2391
2392    #[test]
2393    fn test_compile_schema_for_gemini_strict_errors_on_unsupported_keywords() {
2394        let schema = serde_json::json!({
2395            "type": "object",
2396            "allOf": [
2397                {"type": "object"}
2398            ]
2399        });
2400
2401        let output_schema = OutputSchema::new(schema)
2402            .expect("valid schema")
2403            .with_compat(SchemaCompat::Strict);
2404        let err = GeminiClient::compile_schema_for_gemini(&output_schema)
2405            .expect_err("strict mode should reject unsupported keywords");
2406
2407        match err {
2408            SchemaError::UnsupportedFeatures { provider, warnings } => {
2409                assert_eq!(provider, Provider::Gemini);
2410                assert!(!warnings.is_empty());
2411                assert!(
2412                    warnings.iter().any(|w| w.path.contains("/allOf")),
2413                    "expected warning path to include /allOf, got: {warnings:?}"
2414                );
2415            }
2416            other => panic!("expected UnsupportedFeatures, got: {other:?}"),
2417        }
2418    }
2419
2420    #[test]
2421    fn test_compile_schema_for_gemini_lossy_keeps_schema_and_emits_warnings() {
2422        let schema = serde_json::json!({
2423            "type": "object",
2424            "allOf": [{"type": "object"}],
2425            "properties": {
2426                "name": {
2427                    "type": "string",
2428                    "pattern": "^[a-z]+$"
2429                }
2430            }
2431        });
2432        let output_schema = OutputSchema::new(schema).expect("valid schema");
2433        let expected = output_schema.schema.as_value().clone();
2434        let compiled = GeminiClient::compile_schema_for_gemini(&output_schema)
2435            .expect("lossy mode should not error");
2436
2437        assert_eq!(compiled.schema, expected);
2438        assert!(!compiled.warnings.is_empty());
2439        assert!(
2440            compiled.warnings.iter().any(|w| w.path.contains("/allOf")),
2441            "expected /allOf warning"
2442        );
2443        assert!(
2444            compiled
2445                .warnings
2446                .iter()
2447                .any(|w| w.path.contains("/properties/name/pattern")),
2448            "expected /properties/name/pattern warning"
2449        );
2450    }
2451
2452    #[test]
2453    fn test_compile_schema_for_gemini_strict_accepts_supported_keywords() {
2454        let schema = serde_json::json!({
2455            "type": "object",
2456            "properties": {
2457                "items": {
2458                    "type": "array",
2459                    "items": {
2460                        "type": "object",
2461                        "properties": {
2462                            "id": {"type": "string"},
2463                            "score": {"type": "number", "minimum": 0.0, "maximum": 1.0}
2464                        },
2465                        "required": ["id", "score"],
2466                        "additionalProperties": false
2467                    }
2468                },
2469                "choice": {
2470                    "oneOf": [
2471                        {"type": "string"},
2472                        {"type": "null"}
2473                    ]
2474                }
2475            },
2476            "required": ["items", "choice"],
2477            "additionalProperties": false
2478        });
2479        let output_schema = OutputSchema::new(schema)
2480            .expect("valid schema")
2481            .with_compat(SchemaCompat::Strict);
2482        let compiled = GeminiClient::compile_schema_for_gemini(&output_schema)
2483            .expect("strict mode should accept supported keywords");
2484
2485        assert!(compiled.warnings.is_empty());
2486        assert_eq!(compiled.schema["type"], "object");
2487    }
2488
2489    #[test]
2490    fn test_compile_schema_for_gemini_warns_nested_unsupported_paths() {
2491        let schema = serde_json::json!({
2492            "type": "object",
2493            "properties": {
2494                "nested": {
2495                    "type": "object",
2496                    "allOf": [
2497                        {"type": "object", "properties": {"x": {"type": "string"}}}
2498                    ],
2499                    "properties": {
2500                        "payload": {
2501                            "type": "array",
2502                            "items": {
2503                                "type": "object",
2504                                "patternProperties": {
2505                                    "^k_": {"type": "integer"}
2506                                }
2507                            }
2508                        }
2509                    }
2510                }
2511            }
2512        });
2513        let output_schema = OutputSchema::new(schema).expect("valid schema");
2514        let compiled = GeminiClient::compile_schema_for_gemini(&output_schema)
2515            .expect("lossy mode should still compile");
2516
2517        let paths: Vec<String> = compiled.warnings.iter().map(|w| w.path.clone()).collect();
2518        assert!(
2519            paths.iter().any(|p| p.contains("/properties/nested/allOf")),
2520            "expected warning at /properties/nested/allOf, got: {paths:?}"
2521        );
2522        assert!(
2523            paths.iter().any(|p| {
2524                p.contains("/properties/nested/properties/payload/items/patternProperties")
2525            }),
2526            "expected warning at nested patternProperties path, got: {paths:?}"
2527        );
2528    }
2529
2530    #[test]
2531    fn test_build_request_body_strict_compat_rejects_unsupported_schema() {
2532        let client = GeminiClient::new("test-key".to_string());
2533        let request = LlmRequest::new(
2534            "gemini-3-pro-preview",
2535            vec![Message::User(UserMessage::text("test".to_string()))],
2536        )
2537        .with_gemini_tag_merge(|t| {
2538            t.structured_output = serde_json::from_value::<OutputSchema>(serde_json::json!({
2539                "schema": {
2540                    "type": "object",
2541                    "allOf": [{"type": "object"}]
2542                },
2543                "compat": "strict"
2544            }))
2545            .ok();
2546        });
2547
2548        let err = client
2549            .build_request_body(&request)
2550            .expect_err("strict compat should reject unsupported schema keywords");
2551
2552        match err {
2553            LlmError::InvalidRequest { message } => {
2554                assert!(
2555                    message.contains("unsupported"),
2556                    "unexpected message: {message}"
2557                );
2558                assert!(message.contains("Gemini"), "unexpected message: {message}");
2559            }
2560            other => panic!("expected InvalidRequest, got {other:?}"),
2561        }
2562    }
2563
2564    #[test]
2565    fn test_build_request_body_without_structured_output() -> Result<(), Box<dyn std::error::Error>>
2566    {
2567        let client = GeminiClient::new("test-key".to_string());
2568
2569        let request = LlmRequest::new(
2570            "gemini-3-pro-preview",
2571            vec![Message::User(UserMessage::text("test".to_string()))],
2572        );
2573
2574        let body = client.build_request_body(&request)?;
2575
2576        let gen_config = body
2577            .get("generationConfig")
2578            .ok_or("missing generationConfig")?;
2579        assert!(
2580            gen_config.get("responseMimeType").is_none(),
2581            "responseMimeType should not be present"
2582        );
2583        assert!(
2584            gen_config.get("responseJsonSchema").is_none(),
2585            "responseJsonSchema should not be present"
2586        );
2587        Ok(())
2588    }
2589
2590    /// Regression: type arrays must be lowered for functionDeclaration parameters.
2591    #[test]
2592    fn test_tool_schema_lowers_type_arrays() -> Result<(), Box<dyn std::error::Error>> {
2593        use meerkat_core::ToolDef;
2594        use std::sync::Arc;
2595
2596        let schema = serde_json::json!({
2597            "type": "object",
2598            "properties": {
2599                "name": {"type": "string"},
2600                "age": {"type": ["integer", "null"]},
2601                "email": {"type": ["string", "null"]},
2602                "score": {"type": ["string", "number"]}
2603            }
2604        });
2605
2606        let client = GeminiClient::new("test-key".to_string());
2607        let request = LlmRequest::new(
2608            "gemini-3-pro-preview",
2609            vec![Message::User(UserMessage::text("test".to_string()))],
2610        )
2611        .with_tools(vec![Arc::new(ToolDef {
2612            name: "test_tool".into(),
2613            description: "test".to_string(),
2614            input_schema: schema,
2615            provenance: None,
2616        })]);
2617        let body = client.build_request_body(&request)?;
2618        let lowered = &body["tools"][0]["functionDeclarations"][0]["parameters"];
2619
2620        assert_eq!(
2621            lowered["properties"]["age"]["type"], "integer",
2622            "['integer', 'null'] should lower to scalar type"
2623        );
2624        assert_eq!(
2625            lowered["properties"]["age"]["nullable"],
2626            serde_json::json!(true),
2627            "null in type array should map to nullable=true"
2628        );
2629        assert_eq!(
2630            lowered["properties"]["email"]["type"], "string",
2631            "['string', 'null'] should lower to scalar type"
2632        );
2633        assert_eq!(
2634            lowered["properties"]["email"]["nullable"],
2635            serde_json::json!(true),
2636            "null in type array should map to nullable=true"
2637        );
2638        assert!(
2639            lowered["properties"]["score"].get("type").is_none(),
2640            "multi-type union should move from type array to anyOf variants"
2641        );
2642        assert!(
2643            lowered["properties"]["score"].get("anyOf").is_some(),
2644            "multi-type union should become anyOf"
2645        );
2646        assert_eq!(
2647            lowered["properties"]["name"]["type"], "string",
2648            "'string' should remain 'string'"
2649        );
2650        assert_no_const_or_type_arrays(lowered);
2651        Ok(())
2652    }
2653
2654    /// Regression: const-based enum encodings must be lowered for Gemini tool schemas.
2655    #[test]
2656    fn test_tool_schema_lowers_const_compositions() -> Result<(), Box<dyn std::error::Error>> {
2657        use meerkat_core::ToolDef;
2658        use std::sync::Arc;
2659
2660        let schema = serde_json::json!({
2661            "type": "object",
2662            "properties": {
2663                "status": {
2664                    "oneOf": [
2665                        {"const": "active"},
2666                        {"const": "inactive"}
2667                    ]
2668                },
2669                "category": {
2670                    "anyOf": [
2671                        {
2672                            "oneOf": [
2673                                {"const": "alpha"},
2674                                {"const": "beta"},
2675                                {"const": "gamma"}
2676                            ]
2677                        },
2678                        {"type": "null"}
2679                    ]
2680                },
2681                "value": {
2682                    "anyOf": [
2683                        {"type": "string"},
2684                        {"type": "number"}
2685                    ]
2686                }
2687            },
2688            "allOf": [
2689                {"required": ["status"]}
2690            ]
2691        });
2692
2693        let client = GeminiClient::new("test-key".to_string());
2694        let request = LlmRequest::new(
2695            "gemini-3-pro-preview",
2696            vec![Message::User(UserMessage::text("test".to_string()))],
2697        )
2698        .with_tools(vec![Arc::new(ToolDef {
2699            name: "test_tool".into(),
2700            description: "test".to_string(),
2701            input_schema: schema,
2702            provenance: None,
2703        })]);
2704        let body = client.build_request_body(&request)?;
2705        let lowered = &body["tools"][0]["functionDeclarations"][0]["parameters"];
2706
2707        assert!(
2708            lowered["properties"]["status"].get("enum").is_some(),
2709            "const oneOf branches should collapse into enum"
2710        );
2711        assert_eq!(
2712            lowered["properties"]["status"]["enum"],
2713            serde_json::json!(["active", "inactive"])
2714        );
2715        assert_eq!(
2716            lowered["properties"]["status"]["type"], "string",
2717            "collapsed const enum should infer string type"
2718        );
2719        assert!(
2720            lowered["properties"]["category"]["nullable"] == serde_json::json!(true),
2721            "null composition branch should set nullable=true"
2722        );
2723        assert!(
2724            lowered["properties"]["value"].get("anyOf").is_some(),
2725            "anyOf should be preserved"
2726        );
2727        assert!(lowered.get("allOf").is_some(), "allOf should be preserved");
2728        assert_no_const_or_type_arrays(lowered);
2729        Ok(())
2730    }
2731
2732    #[test]
2733    fn test_tool_schema_parameters_inlines_ref_and_strips_unsupported_keywords()
2734    -> Result<(), Box<dyn std::error::Error>> {
2735        use meerkat_core::ToolDef;
2736        use std::sync::Arc;
2737
2738        let schema = serde_json::json!({
2739            "$schema": "https://json-schema.org/draft/2020-12/schema",
2740            "title": "RootParameters",
2741            "type": "object",
2742            "properties": {
2743                "payload": {
2744                    "$ref": "#/$defs/Payload"
2745                }
2746            },
2747            "required": ["payload"],
2748            "$defs": {
2749                "Payload": {
2750                    "title": "Payload",
2751                    "type": "object",
2752                    "properties": {
2753                        "message": {
2754                            "title": "Message",
2755                            "type": "string"
2756                        }
2757                    },
2758                    "required": ["message"],
2759                    "additionalProperties": false
2760                }
2761            },
2762            "additionalProperties": false
2763        });
2764
2765        let client = GeminiClient::new("test-key".to_string());
2766        let request = LlmRequest::new(
2767            "gemini-3-pro-preview",
2768            vec![Message::User(UserMessage::text("test".to_string()))],
2769        )
2770        .with_tools(vec![Arc::new(ToolDef {
2771            name: "test_tool".into(),
2772            description: "test".to_string(),
2773            input_schema: schema,
2774            provenance: None,
2775        })]);
2776        let body = client.build_request_body(&request)?;
2777        let parameters = &body["tools"][0]["functionDeclarations"][0]["parameters"];
2778
2779        assert!(
2780            parameters.get("$schema").is_none(),
2781            "tool parameters should not include $schema"
2782        );
2783        assert!(
2784            parameters.get("$defs").is_none(),
2785            "tool parameters should not include $defs"
2786        );
2787        assert!(
2788            parameters.get("title").is_none(),
2789            "tool parameters should not include title"
2790        );
2791        assert!(
2792            parameters.get("additionalProperties").is_none(),
2793            "tool parameters should strip root additionalProperties for Gemini"
2794        );
2795        assert!(
2796            parameters["properties"]["payload"].get("$ref").is_none(),
2797            "tool parameters should inline $ref targets"
2798        );
2799        assert!(
2800            parameters["properties"]["payload"].get("title").is_none(),
2801            "inlined payload should strip title"
2802        );
2803        assert_eq!(
2804            parameters["properties"]["payload"]["type"], "object",
2805            "inlined payload should preserve referenced type"
2806        );
2807        assert_eq!(
2808            parameters["properties"]["payload"]["properties"]["message"]["type"], "string",
2809            "inlined payload should preserve nested properties"
2810        );
2811        assert!(
2812            parameters["properties"]["payload"]["properties"]["message"]
2813                .get("title")
2814                .is_none(),
2815            "nested title should be stripped"
2816        );
2817        assert!(
2818            parameters["properties"]["payload"]
2819                .get("additionalProperties")
2820                .is_none(),
2821            "inlined payload should strip additionalProperties"
2822        );
2823        Ok(())
2824    }
2825
2826    #[test]
2827    fn test_tool_schema_parameters_strip_conditionals() -> Result<(), Box<dyn std::error::Error>> {
2828        use meerkat_core::ToolDef;
2829        use std::sync::Arc;
2830
2831        let schema = serde_json::json!({
2832            "type": "object",
2833            "properties": {
2834                "mode": {"type": "string"},
2835                "payload": {"type": "object"}
2836            },
2837            "allOf": [
2838                {
2839                    "if": {"properties": {"mode": {"const": "shell"}}},
2840                    "then": {"required": ["payload"]},
2841                    "else": {"required": ["mode"]}
2842                }
2843            ],
2844            "additionalProperties": false
2845        });
2846
2847        let client = GeminiClient::new("test-key".to_string());
2848        let request = LlmRequest::new(
2849            "gemini-3-pro-preview",
2850            vec![Message::User(UserMessage::text("test".to_string()))],
2851        )
2852        .with_tools(vec![Arc::new(ToolDef {
2853            name: "conditional_tool".into(),
2854            description: "test".to_string(),
2855            input_schema: schema,
2856            provenance: None,
2857        })]);
2858        let body = client.build_request_body(&request)?;
2859        let parameters = &body["tools"][0]["functionDeclarations"][0]["parameters"];
2860        let rendered = serde_json::to_string(parameters)?;
2861
2862        assert!(!rendered.contains("\"if\""));
2863        assert!(!rendered.contains("\"then\""));
2864        assert!(!rendered.contains("\"else\""));
2865        assert!(!rendered.contains("additionalProperties"));
2866        Ok(())
2867    }
2868
2869    #[test]
2870    fn test_tool_schema_parameters_strip_conditionals_inside_compositions()
2871    -> Result<(), Box<dyn std::error::Error>> {
2872        use meerkat_core::ToolDef;
2873        use std::sync::Arc;
2874
2875        let schema = serde_json::json!({
2876            "type": "object",
2877            "properties": {
2878                "target": {
2879                    "oneOf": [
2880                        {
2881                            "allOf": [
2882                                {
2883                                    "if": {"properties": {"kind": {"const": "model"}}},
2884                                    "then": {"required": ["model"]},
2885                                    "else": {"required": ["provider"]}
2886                                }
2887                            ]
2888                        }
2889                    ]
2890                }
2891            },
2892            "additionalProperties": false
2893        });
2894
2895        let client = GeminiClient::new("test-key".to_string());
2896        let request = LlmRequest::new(
2897            "gemini-3-pro-preview",
2898            vec![Message::User(UserMessage::text("test".to_string()))],
2899        )
2900        .with_tools(vec![Arc::new(ToolDef {
2901            name: "test_tool".into(),
2902            description: "test".to_string(),
2903            input_schema: schema,
2904            provenance: None,
2905        })]);
2906        let body = client.build_request_body(&request)?;
2907        let parameters = &body["tools"][0]["functionDeclarations"][0]["parameters"];
2908
2909        assert!(
2910            !serde_json::to_string(parameters)?.contains("\"additionalProperties\""),
2911            "Gemini function parameters should strip additionalProperties at every depth"
2912        );
2913        assert!(
2914            !serde_json::to_string(parameters)?.contains("\"if\""),
2915            "Gemini function parameters should strip conditional schemas"
2916        );
2917        assert!(
2918            !serde_json::to_string(parameters)?.contains("\"then\""),
2919            "Gemini function parameters should strip conditional schemas"
2920        );
2921        assert!(
2922            !serde_json::to_string(parameters)?.contains("\"else\""),
2923            "Gemini function parameters should strip conditional schemas"
2924        );
2925        Ok(())
2926    }
2927
2928    #[test]
2929    fn test_tool_schema_parameters_inline_ref_inside_anyof()
2930    -> Result<(), Box<dyn std::error::Error>> {
2931        use meerkat_core::ToolDef;
2932        use std::sync::Arc;
2933
2934        let schema = serde_json::json!({
2935            "type": "object",
2936            "properties": {
2937                "context": {
2938                    "anyOf": [
2939                        {"$ref": "#/$defs/Context"},
2940                        {"type": "null"}
2941                    ]
2942                }
2943            },
2944            "$defs": {
2945                "Context": {
2946                    "type": "object",
2947                    "properties": {
2948                        "ticket": {"type": "string"}
2949                    },
2950                    "required": ["ticket"],
2951                    "additionalProperties": false
2952                }
2953            }
2954        });
2955
2956        let client = GeminiClient::new("test-key".to_string());
2957        let request = LlmRequest::new(
2958            "gemini-3-pro-preview",
2959            vec![Message::User(UserMessage::text("test".to_string()))],
2960        )
2961        .with_tools(vec![Arc::new(ToolDef {
2962            name: "test_tool".into(),
2963            description: "test".to_string(),
2964            input_schema: schema,
2965            provenance: None,
2966        })]);
2967
2968        let body = client.build_request_body(&request)?;
2969        let parameters = &body["tools"][0]["functionDeclarations"][0]["parameters"];
2970        let context_schema = &parameters["properties"]["context"];
2971        let any_of = context_schema["anyOf"]
2972            .as_array()
2973            .ok_or("missing anyOf array")?;
2974        let object_branch = any_of
2975            .iter()
2976            .find(|item| item["type"] == "object")
2977            .ok_or("missing inlined object branch")?;
2978
2979        assert!(
2980            object_branch.get("$ref").is_none(),
2981            "inlined anyOf branch should not keep $ref"
2982        );
2983        assert!(
2984            object_branch.get("additionalProperties").is_none(),
2985            "additionalProperties should be stripped from inlined branch"
2986        );
2987        assert_eq!(object_branch["properties"]["ticket"]["type"], "string");
2988        Ok(())
2989    }
2990
2991    #[test]
2992    fn test_tool_schema_parameters_rejects_unresolved_external_ref() {
2993        use meerkat_core::ToolDef;
2994        use std::sync::Arc;
2995
2996        let schema = serde_json::json!({
2997            "type": "object",
2998            "properties": {
2999                "payload": {
3000                    "$ref": "https://example.com/schemas/Payload.json"
3001                }
3002            }
3003        });
3004
3005        let client = GeminiClient::new("test-key".to_string());
3006        let request = LlmRequest::new(
3007            "gemini-3-pro-preview",
3008            vec![Message::User(UserMessage::text("test".to_string()))],
3009        )
3010        .with_tools(vec![Arc::new(ToolDef {
3011            name: "test_tool".into(),
3012            description: "test".to_string(),
3013            input_schema: schema,
3014            provenance: None,
3015        })]);
3016
3017        let err = client
3018            .build_request_body(&request)
3019            .expect_err("external refs should fail fast for function parameters");
3020
3021        match err {
3022            LlmError::InvalidRequest { message } => {
3023                assert!(
3024                    message.contains("unresolved $ref"),
3025                    "unexpected message: {message}"
3026                );
3027                assert!(
3028                    message.contains("https://example.com/schemas/Payload.json"),
3029                    "unexpected message: {message}"
3030                );
3031            }
3032            other => panic!("expected InvalidRequest, got {other:?}"),
3033        }
3034    }
3035
3036    #[test]
3037    fn test_tool_schema_preserves_property_named_title() -> Result<(), Box<dyn std::error::Error>> {
3038        use meerkat_core::ToolDef;
3039        use std::sync::Arc;
3040
3041        let schema = serde_json::json!({
3042            "type": "object",
3043            "properties": {
3044                "id": { "type": "string" },
3045                "title": { "type": "string", "description": "Human readable title" },
3046                "summary": { "type": "string" }
3047            },
3048            "required": ["id", "title", "summary"]
3049        });
3050
3051        let client = GeminiClient::new("test-key".to_string());
3052        let request = LlmRequest::new(
3053            "gemini-3-pro-preview",
3054            vec![Message::User(UserMessage::text("test".to_string()))],
3055        )
3056        .with_tools(vec![Arc::new(ToolDef {
3057            name: "upsert_record".into(),
3058            description: "test".to_string(),
3059            input_schema: schema,
3060            provenance: None,
3061        })]);
3062        let body = client.build_request_body(&request)?;
3063        let parameters = &body["tools"][0]["functionDeclarations"][0]["parameters"];
3064        let props = parameters["properties"]
3065            .as_object()
3066            .ok_or("missing properties")?;
3067
3068        assert!(
3069            props.contains_key("title"),
3070            "property named 'title' must not be stripped — it's a user field, not a schema keyword"
3071        );
3072        assert!(
3073            props.contains_key("id"),
3074            "property 'id' should be preserved"
3075        );
3076        assert!(
3077            props.contains_key("summary"),
3078            "property 'summary' should be preserved"
3079        );
3080
3081        // The JSON Schema keyword `title` at the root level should still be stripped
3082        assert!(
3083            parameters.get("title").is_none(),
3084            "root-level title keyword should be stripped"
3085        );
3086
3087        Ok(())
3088    }
3089
3090    // =========================================================================
3091    // Thought Signature Tests (Spec Section 3.5)
3092    // =========================================================================
3093
3094    /// Parse thoughtSignature from functionCall parts into ProviderMeta::Gemini
3095    #[test]
3096    fn test_parse_function_call_with_thought_signature() -> Result<(), Box<dyn std::error::Error>> {
3097        let line = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"city":"Tokyo"}},"thoughtSignature":"sig_abc123"}]}}]}"#;
3098        let response = GeminiClient::parse_stream_line(line).ok_or("missing response")?;
3099        let candidates = response.candidates.as_ref().ok_or("missing candidates")?;
3100        let parts = candidates[0]
3101            .content
3102            .as_ref()
3103            .ok_or("missing content")?
3104            .parts
3105            .as_ref()
3106            .ok_or("missing parts")?;
3107
3108        assert!(
3109            parts[0].function_call.is_some(),
3110            "should have function_call"
3111        );
3112        assert_eq!(
3113            parts[0].thought_signature.as_deref(),
3114            Some("sig_abc123"),
3115            "should have thoughtSignature"
3116        );
3117        Ok(())
3118    }
3119
3120    /// Parse thoughtSignature from text parts into ProviderMeta::Gemini
3121    #[test]
3122    fn test_parse_text_with_thought_signature() -> Result<(), Box<dyn std::error::Error>> {
3123        let line = r#"{"candidates":[{"content":{"parts":[{"text":"Hello world","thoughtSignature":"sig_text_456"}]}}]}"#;
3124        let response = GeminiClient::parse_stream_line(line).ok_or("missing response")?;
3125        let candidates = response.candidates.as_ref().ok_or("missing candidates")?;
3126        let parts = candidates[0]
3127            .content
3128            .as_ref()
3129            .ok_or("missing content")?
3130            .parts
3131            .as_ref()
3132            .ok_or("missing parts")?;
3133
3134        assert_eq!(parts[0].text.as_deref(), Some("Hello world"));
3135        assert_eq!(
3136            parts[0].thought_signature.as_deref(),
3137            Some("sig_text_456"),
3138            "text parts can have thoughtSignature for continuity"
3139        );
3140        Ok(())
3141    }
3142
3143    #[test]
3144    fn test_parse_text_with_thought_flag() -> Result<(), Box<dyn std::error::Error>> {
3145        let line =
3146            r#"{"candidates":[{"content":{"parts":[{"text":"thinking...","thought":true}]}}]}"#;
3147        let response = GeminiClient::parse_stream_line(line).ok_or("missing response")?;
3148        let candidates = response.candidates.as_ref().ok_or("missing candidates")?;
3149        let parts = candidates[0]
3150            .content
3151            .as_ref()
3152            .ok_or("missing content")?
3153            .parts
3154            .as_ref()
3155            .ok_or("missing parts")?;
3156
3157        assert_eq!(parts[0].text.as_deref(), Some("thinking..."));
3158        assert_eq!(parts[0].thought, Some(true));
3159        Ok(())
3160    }
3161
3162    /// Parallel tool calls: only FIRST call has signature per spec section 2.3
3163    #[test]
3164    fn test_parallel_calls_only_first_has_signature() -> Result<(), Box<dyn std::error::Error>> {
3165        // Simulating 3 parallel function calls from Gemini - only first has signature
3166        let line = r#"{"candidates":[{"content":{"parts":[
3167            {"functionCall":{"name":"get_weather","args":{"city":"Tokyo"}},"thoughtSignature":"sig_first"},
3168            {"functionCall":{"name":"get_time","args":{"tz":"JST"}}},
3169            {"functionCall":{"name":"get_population","args":{"city":"Tokyo"}}}
3170        ]}}]}"#;
3171
3172        let response = GeminiClient::parse_stream_line(line).ok_or("missing response")?;
3173        let candidates = response.candidates.ok_or("missing candidates")?;
3174        let parts = candidates[0]
3175            .content
3176            .as_ref()
3177            .ok_or("missing content")?
3178            .parts
3179            .as_ref()
3180            .ok_or("missing parts")?;
3181
3182        assert_eq!(parts.len(), 3);
3183        assert_eq!(
3184            parts[0].thought_signature.as_deref(),
3185            Some("sig_first"),
3186            "first parallel call MUST have signature"
3187        );
3188        assert!(
3189            parts[1].thought_signature.is_none(),
3190            "second parallel call must NOT have signature"
3191        );
3192        assert!(
3193            parts[2].thought_signature.is_none(),
3194            "third parallel call must NOT have signature"
3195        );
3196        Ok(())
3197    }
3198
3199    /// Request building: thoughtSignature on functionCall via ProviderMeta, NEVER on functionResponse
3200    #[test]
3201    fn test_request_building_no_signature_on_function_response()
3202    -> Result<(), Box<dyn std::error::Error>> {
3203        use serde_json::value::RawValue;
3204        let client = GeminiClient::new("test-key".to_string());
3205
3206        let args_raw = RawValue::from_string(json!({"city": "Tokyo"}).to_string()).unwrap();
3207        let request = LlmRequest::new(
3208            "gemini-3-pro-preview",
3209            vec![
3210                Message::User(UserMessage::text("What's the weather?".to_string())),
3211                Message::BlockAssistant(BlockAssistantMessage {
3212                    blocks: vec![AssistantBlock::ToolUse {
3213                        id: "call_1".to_string(),
3214                        name: "get_weather".into(),
3215                        args: args_raw,
3216                        meta: Some(Box::new(ProviderMeta::Gemini {
3217                            thought_signature: "sig_123".to_string(),
3218                        })),
3219                    }],
3220                    stop_reason: StopReason::ToolUse,
3221                    created_at: meerkat_core::types::message_timestamp_now(),
3222                }),
3223                Message::ToolResults {
3224                    results: vec![meerkat_core::ToolResult::new(
3225                        "call_1".to_string(),
3226                        "Sunny, 25C".to_string(),
3227                        false,
3228                    )],
3229                    created_at: meerkat_core::types::message_timestamp_now(),
3230                },
3231            ],
3232        );
3233
3234        let body = client.build_request_body(&request)?;
3235        let contents = body
3236            .get("contents")
3237            .and_then(|c| c.as_array())
3238            .ok_or("missing contents")?;
3239
3240        // Find the assistant message (role: "model")
3241        let assistant_content = contents
3242            .iter()
3243            .find(|c| c.get("role").and_then(|r| r.as_str()) == Some("model"))
3244            .ok_or("missing model content")?;
3245        let assistant_parts = assistant_content
3246            .get("parts")
3247            .and_then(|p| p.as_array())
3248            .ok_or("missing parts")?;
3249
3250        // Assistant's functionCall SHOULD have thoughtSignature
3251        let fc_part = assistant_parts
3252            .iter()
3253            .find(|p| p.get("functionCall").is_some())
3254            .ok_or("missing functionCall part")?;
3255        assert!(
3256            fc_part.get("thoughtSignature").is_some(),
3257            "functionCall part SHOULD have thoughtSignature"
3258        );
3259
3260        // Find the tool results message (role: "user" with functionResponse)
3261        let tool_results_content = contents.last().ok_or("missing last content")?;
3262        let tool_result_parts = tool_results_content
3263            .get("parts")
3264            .and_then(|p| p.as_array())
3265            .ok_or("missing tool result parts")?;
3266
3267        // Tool result's functionResponse MUST NOT have thoughtSignature
3268        let fr_part = tool_result_parts
3269            .iter()
3270            .find(|p| p.get("functionResponse").is_some())
3271            .ok_or("missing functionResponse part")?;
3272        assert!(
3273            fr_part.get("thoughtSignature").is_none(),
3274            "functionResponse MUST NOT have thoughtSignature"
3275        );
3276
3277        Ok(())
3278    }
3279
3280    /// ToolCallComplete event should use ProviderMeta::Gemini instead of deprecated thought_signature field
3281    #[test]
3282    fn test_tool_call_complete_uses_provider_meta() {
3283        use meerkat_core::ProviderMeta;
3284
3285        // This test verifies the LlmEvent::ToolCallComplete uses the `meta` field
3286        // with ProviderMeta::Gemini variant instead of the deprecated `thought_signature` field
3287        let meta = Some(Box::new(ProviderMeta::Gemini {
3288            thought_signature: "sig_test".to_string(),
3289        }));
3290
3291        let event = LlmEvent::ToolCallComplete {
3292            id: "fc_0".to_string(),
3293            name: "test_tool".into(),
3294            args: json!({}),
3295            meta, // new field
3296        };
3297
3298        if let LlmEvent::ToolCallComplete { meta: m, .. } = event {
3299            assert!(m.is_some(), "meta should be Some");
3300            match *m.unwrap() {
3301                ProviderMeta::Gemini { thought_signature } => {
3302                    assert_eq!(thought_signature, "sig_test");
3303                }
3304                _ => panic!("expected Gemini variant"),
3305            }
3306        }
3307    }
3308
3309    /// TextDelta event should include meta for Gemini text parts with thoughtSignature
3310    #[test]
3311    fn test_text_delta_uses_provider_meta() {
3312        use meerkat_core::ProviderMeta;
3313
3314        let meta = Some(Box::new(ProviderMeta::Gemini {
3315            thought_signature: "sig_text".to_string(),
3316        }));
3317
3318        let event = LlmEvent::TextDelta {
3319            delta: "Hello".to_string(),
3320            meta,
3321        };
3322
3323        if let LlmEvent::TextDelta { meta: m, .. } = event {
3324            assert!(m.is_some());
3325            match *m.unwrap() {
3326                ProviderMeta::Gemini { thought_signature } => {
3327                    assert_eq!(thought_signature, "sig_text");
3328                }
3329                _ => panic!("expected Gemini variant"),
3330            }
3331        }
3332    }
3333
3334    #[test]
3335    fn test_text_event_for_part_emits_reasoning_delta_when_thought() {
3336        let event = text_event_for_part("plan step".to_string(), true, None);
3337        match event {
3338            LlmEvent::ReasoningDelta { delta } => assert_eq!(delta, "plan step"),
3339            _ => panic!("expected ReasoningDelta"),
3340        }
3341    }
3342
3343    #[test]
3344    fn test_text_event_for_part_emits_text_delta_when_not_thought() {
3345        use meerkat_core::ProviderMeta;
3346
3347        let event = text_event_for_part(
3348            "final answer".to_string(),
3349            false,
3350            Some(Box::new(ProviderMeta::Gemini {
3351                thought_signature: "sig_text".to_string(),
3352            })),
3353        );
3354        match event {
3355            LlmEvent::TextDelta { delta, meta } => {
3356                assert_eq!(delta, "final answer");
3357                let meta = meta.expect("meta");
3358                match meta.as_ref() {
3359                    ProviderMeta::Gemini { thought_signature } => {
3360                        assert_eq!(thought_signature, "sig_text");
3361                    }
3362                    _ => panic!("expected Gemini meta"),
3363                }
3364            }
3365            _ => panic!("expected TextDelta"),
3366        }
3367    }
3368
3369    // =========================================================================
3370    // Multimodal content (ContentBlock::Image) serialization tests
3371    // =========================================================================
3372
3373    #[test]
3374    fn gemini_user_message_with_image_inline_data() -> Result<(), Box<dyn std::error::Error>> {
3375        let client = GeminiClient::new("test-key".to_string());
3376        let request = LlmRequest::new(
3377            "gemini-3.1-pro-preview",
3378            vec![Message::User(UserMessage::with_blocks(vec![
3379                ContentBlock::Text {
3380                    text: "describe this".to_string(),
3381                },
3382                ContentBlock::Image {
3383                    media_type: "image/png".to_string(),
3384                    data: "iVBOR...".into(),
3385                },
3386            ]))],
3387        );
3388
3389        let body = client.build_request_body(&request)?;
3390        let contents = body["contents"].as_array().ok_or("missing contents")?;
3391        let user_content = &contents[0];
3392
3393        assert_eq!(user_content["role"], "user");
3394
3395        let parts = user_content["parts"].as_array().ok_or("missing parts")?;
3396        assert_eq!(parts.len(), 2);
3397
3398        assert_eq!(parts[0]["text"], "describe this");
3399
3400        assert_eq!(parts[1]["inlineData"]["mimeType"], "image/png");
3401        assert_eq!(parts[1]["inlineData"]["data"], "iVBOR...");
3402
3403        // source_path must NOT leak
3404        let body_str = serde_json::to_string(&body)?;
3405        assert!(
3406            !body_str.contains("source_path"),
3407            "source_path must never appear in provider payload"
3408        );
3409        assert!(
3410            !body_str.contains("/tmp/img.png"),
3411            "source_path value must never appear in provider payload"
3412        );
3413        Ok(())
3414    }
3415
3416    #[test]
3417    fn gemini_text_only_user_message_stays_simple() -> Result<(), Box<dyn std::error::Error>> {
3418        let client = GeminiClient::new("test-key".to_string());
3419        let request = LlmRequest::new(
3420            "gemini-3.1-pro-preview",
3421            vec![Message::User(UserMessage::text("just text"))],
3422        );
3423
3424        let body = client.build_request_body(&request)?;
3425        let contents = body["contents"].as_array().ok_or("missing contents")?;
3426        let parts = contents[0]["parts"].as_array().ok_or("missing parts")?;
3427
3428        // Text-only should use simple single-part format
3429        assert_eq!(parts.len(), 1);
3430        assert_eq!(parts[0]["text"], "just text");
3431        assert!(
3432            parts[0].get("inlineData").is_none(),
3433            "text-only should not have inlineData"
3434        );
3435        Ok(())
3436    }
3437
3438    #[test]
3439    fn gemini_tool_result_with_image_preserves_inline_data()
3440    -> Result<(), Box<dyn std::error::Error>> {
3441        use serde_json::value::RawValue;
3442        let client = GeminiClient::new("test-key".to_string());
3443        let args_raw = RawValue::from_string(json!({"url": "http://example.com"}).to_string())?;
3444
3445        let request = LlmRequest::new(
3446            "gemini-3.1-pro-preview",
3447            vec![
3448                Message::User(UserMessage::text("take a screenshot")),
3449                Message::BlockAssistant(BlockAssistantMessage {
3450                    blocks: vec![AssistantBlock::ToolUse {
3451                        id: "call_1".to_string(),
3452                        name: "screenshot".into(),
3453                        args: args_raw,
3454                        meta: None,
3455                    }],
3456                    stop_reason: StopReason::ToolUse,
3457                    created_at: meerkat_core::types::message_timestamp_now(),
3458                }),
3459                Message::ToolResults {
3460                    results: vec![meerkat_core::ToolResult::with_blocks(
3461                        "call_1".to_string(),
3462                        vec![
3463                            ContentBlock::Text {
3464                                text: "captured".to_string(),
3465                            },
3466                            ContentBlock::Image {
3467                                media_type: "image/png".to_string(),
3468                                data: "iVBOR...".into(),
3469                            },
3470                        ],
3471                        false,
3472                    )],
3473                    created_at: meerkat_core::types::message_timestamp_now(),
3474                },
3475            ],
3476        );
3477
3478        let body = client.build_request_body(&request)?;
3479        let contents = body["contents"].as_array().ok_or("missing contents")?;
3480
3481        // Last content entry is the tool result
3482        let tool_result_content = contents.last().ok_or("no last content")?;
3483        let parts = tool_result_content["parts"]
3484            .as_array()
3485            .ok_or("missing parts")?;
3486
3487        // First part: functionResponse with text content
3488        let response = &parts[0]["functionResponse"]["response"];
3489        let content_str = response["content"].as_str().ok_or("content not string")?;
3490        assert!(
3491            content_str.contains("captured"),
3492            "text content should be preserved in functionResponse"
3493        );
3494
3495        // Second part: inlineData with the actual image
3496        assert!(
3497            parts.len() >= 2,
3498            "should have functionResponse + inlineData parts, got {} parts",
3499            parts.len()
3500        );
3501        let inline_data = &parts[1]["inlineData"];
3502        assert_eq!(
3503            inline_data["mimeType"].as_str(),
3504            Some("image/png"),
3505            "image mimeType should be preserved"
3506        );
3507        assert_eq!(
3508            inline_data["data"].as_str(),
3509            Some("iVBOR..."),
3510            "image base64 data should be preserved"
3511        );
3512        Ok(())
3513    }
3514
3515    // =========================================================================
3516    // Google search tool injection tests
3517    // =========================================================================
3518
3519    #[test]
3520    fn test_google_search_alongside_functions() -> Result<(), Box<dyn std::error::Error>> {
3521        use meerkat_core::ToolDef;
3522        use std::sync::Arc;
3523
3524        let client = GeminiClient::new("test-key".to_string());
3525        let request = LlmRequest::new(
3526            "gemini-1.5-pro",
3527            vec![Message::User(UserMessage::text("test".to_string()))],
3528        )
3529        .with_tools(vec![Arc::new(ToolDef::new(
3530            "my_tool",
3531            "A test tool",
3532            serde_json::json!({"type": "object", "properties": {}}),
3533        ))])
3534        .with_gemini_tag_merge(|t| {
3535            t.google_search = Some(
3536                meerkat_core::lifecycle::run_primitive::OpaqueProviderBody::from_value(
3537                    &serde_json::json!({}),
3538                ),
3539            );
3540        });
3541        let body = client.build_request_body(&request)?;
3542        let tools = body["tools"].as_array().expect("tools should be array");
3543        assert_eq!(
3544            tools.len(),
3545            2,
3546            "should have functionDeclarations + google_search"
3547        );
3548        assert!(
3549            tools[0].get("functionDeclarations").is_some(),
3550            "first element should be functionDeclarations"
3551        );
3552        assert!(
3553            tools[1].get("google_search").is_some(),
3554            "second element should be google_search"
3555        );
3556        assert_eq!(
3557            body["toolConfig"]["includeServerSideToolInvocations"].as_bool(),
3558            Some(true),
3559            "mixed built-in + function tools must opt into server-side tool invocations"
3560        );
3561        Ok(())
3562    }
3563
3564    #[test]
3565    fn test_google_search_alone() -> Result<(), Box<dyn std::error::Error>> {
3566        let client = GeminiClient::new("test-key".to_string());
3567        let request = LlmRequest::new(
3568            "gemini-1.5-pro",
3569            vec![Message::User(UserMessage::text("test".to_string()))],
3570        )
3571        .with_gemini_tag_merge(|t| {
3572            t.google_search = Some(
3573                meerkat_core::lifecycle::run_primitive::OpaqueProviderBody::from_value(
3574                    &serde_json::json!({}),
3575                ),
3576            );
3577        });
3578        let body = client.build_request_body(&request)?;
3579        let tools = body["tools"].as_array().expect("tools should be array");
3580        assert_eq!(tools.len(), 1, "should have only google_search");
3581        assert!(tools[0].get("google_search").is_some());
3582        assert!(
3583            body.get("toolConfig").is_none() || body["toolConfig"].is_null(),
3584            "google_search alone should not force toolConfig"
3585        );
3586        Ok(())
3587    }
3588
3589    #[test]
3590    fn test_no_google_search_when_absent() -> Result<(), Box<dyn std::error::Error>> {
3591        use meerkat_core::ToolDef;
3592        use std::sync::Arc;
3593
3594        let client = GeminiClient::new("test-key".to_string());
3595        let request = LlmRequest::new(
3596            "gemini-1.5-pro",
3597            vec![Message::User(UserMessage::text("test".to_string()))],
3598        )
3599        .with_tools(vec![Arc::new(ToolDef::new(
3600            "my_tool",
3601            "A test tool",
3602            serde_json::json!({"type": "object", "properties": {}}),
3603        ))]);
3604        let body = client.build_request_body(&request)?;
3605        let tools = body["tools"].as_array().expect("tools should be array");
3606        assert_eq!(tools.len(), 1, "should only have functionDeclarations");
3607        assert!(tools[0].get("functionDeclarations").is_some());
3608        assert!(
3609            body.get("toolConfig").is_none() || body["toolConfig"].is_null(),
3610            "functionDeclarations alone should not force toolConfig"
3611        );
3612        Ok(())
3613    }
3614}