1use 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
27fn gemini_tag(request: &LlmRequest) -> Option<&GeminiProviderTag> {
29 match request.provider_params.as_ref()? {
30 ProviderTag::Gemini(t) => Some(t),
31 _ => None,
32 }
33}
34
35pub 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 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 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 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 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 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 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 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 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 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 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 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 _ => {} }
234 }
235
236 contents.push(serde_json::json!({
237 "role": "model",
238 "parts": parts
239 }));
240 }
241 Message::ToolResults { results, .. } => {
242 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 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 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 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 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 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 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 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 "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 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 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 #[allow(clippy::collapsible_if)]
1351 if let Some(parts) = content.parts {
1352 for part in parts {
1353 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 "TOOL_CALL" | "FUNCTION_CALL" => StopReason::ToolUse,
1387 _ => 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]
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 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 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 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 #[test]
2157 fn test_regression_gemini_finish_reason_tool_call_maps_to_tool_use() {
2158 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 _ => StopReason::EndTurn,
2168 };
2169 assert_eq!(
2170 stop,
2171 StopReason::ToolUse,
2172 "finish_reason '{reason}' should map to ToolUse"
2173 );
2174 }
2175 }
2176
2177 #[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 _ => StopReason::EndTurn,
2187 };
2188 assert_eq!(stop, StopReason::ContentFilter);
2189 }
2190
2191 #[test]
2196 fn test_regression_gemini_tool_call_ids_must_be_unique() {
2197 let mut tool_call_index: u32 = 0;
2199
2200 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 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 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 #[test]
2227 fn test_regression_gemini_tool_call_ids_unique_across_different_tools() {
2228 let mut tool_call_index: u32 = 0;
2229
2230 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 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")); 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 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 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 #[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 #[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 = ¶meters["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 assert!(
3083 parameters.get("title").is_none(),
3084 "root-level title keyword should be stripped"
3085 );
3086
3087 Ok(())
3088 }
3089
3090 #[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 #[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 #[test]
3164 fn test_parallel_calls_only_first_has_signature() -> Result<(), Box<dyn std::error::Error>> {
3165 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 #[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 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 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 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 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 #[test]
3282 fn test_tool_call_complete_uses_provider_meta() {
3283 use meerkat_core::ProviderMeta;
3284
3285 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, };
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 #[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 #[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 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 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 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 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 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 #[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}