1use std::time::Duration;
57
58use async_trait::async_trait;
59use futures::StreamExt;
60use reqwest::Client;
61use serde::Deserialize;
62use serde_json::{Value, json};
63
64use crate::constants::MAX_RESPONSE_CHARS;
65use crate::models::ModelCapabilities;
66use crate::models::config::ModelConfig;
67use crate::models::error::{BackendError, ModelError, Result};
68use crate::models::reasoning::{
69 ReasoningCapability, ReasoningChunk, ReasoningLevel, nearest_effort,
70};
71use crate::models::stream::{StreamCallback, StreamEvent};
72use crate::models::tool_call::{FunctionCall, ToolCall};
73use crate::models::traits::Model;
74use crate::models::types::{
75 ChatMessage, FinishReason, MessageAudience, MessageRole, ModelResponse, TokenUsage,
76};
77use crate::utils::drain_sse_events;
78
79use super::ModelLimits;
80
81const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: response exceeded size limit]";
82
83fn push_capped(buf: &mut String, chunk: &str, truncated: &mut bool, cap: usize) {
87 if *truncated {
88 return;
89 }
90 buf.push_str(chunk);
91 if buf.len() > cap {
92 let end = buf.floor_char_boundary(cap);
93 buf.truncate(end);
94 buf.push_str(TRUNCATION_MARKER);
95 *truncated = true;
96 }
97}
98
99fn gemini_empty_is_benign(reason: &str) -> bool {
108 matches!(reason, "STOP" | "MAX_TOKENS" | "FINISH_REASON_UNSPECIFIED")
109}
110
111fn map_gemini_finish_reason(s: &str) -> FinishReason {
112 match s {
113 "STOP" => FinishReason::Stop,
114 "MAX_TOKENS" => FinishReason::Length,
115 "SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" => {
116 FinishReason::ContentFilter
117 },
118 other => FinishReason::Other(other.to_string()),
119 }
120}
121
122fn stream_closed_abnormally(finish_reason: Option<&FinishReason>) -> bool {
132 finish_reason.is_none()
133}
134
135fn thinking_budget_for(level: ReasoningLevel) -> i32 {
141 match level {
142 ReasoningLevel::None => 0,
143 ReasoningLevel::Minimal => 512,
144 ReasoningLevel::Low => 2048,
145 ReasoningLevel::Medium => 8192,
146 ReasoningLevel::High => 24576,
147 ReasoningLevel::Max | ReasoningLevel::XHigh => -1,
150 }
151}
152
153fn thinking_level_for(level: ReasoningLevel) -> &'static str {
159 match level {
160 ReasoningLevel::None | ReasoningLevel::Minimal => "minimal",
161 ReasoningLevel::Low => "low",
162 ReasoningLevel::Medium => "medium",
163 ReasoningLevel::High | ReasoningLevel::Max | ReasoningLevel::XHigh => "high",
164 }
165}
166
167fn to_gemini_tools(openai_tools: &[&Value]) -> Vec<Value> {
180 let declarations: Vec<Value> = openai_tools
181 .iter()
182 .filter_map(|tool| {
183 let function = tool.get("function")?;
184 let name = function.get("name")?.as_str()?;
185 let description = function
186 .get("description")
187 .and_then(|d| d.as_str())
188 .unwrap_or("");
189 let parameters = function.get("parameters").cloned().unwrap_or(json!({
190 "type": "object",
191 "properties": {}
192 }));
193 Some(json!({
194 "name": name,
195 "description": description,
196 "parameters": parameters,
197 }))
198 })
199 .collect();
200
201 if declarations.is_empty() {
202 Vec::new()
203 } else {
204 vec![json!({"functionDeclarations": declarations})]
205 }
206}
207
208fn push_system_reminder(out: &mut Vec<Value>, text: &str) {
212 out.push(json!({
213 "role": "user",
214 "parts": [{"text": format!("<system-reminder>\n{text}\n</system-reminder>")}],
215 }));
216}
217
218fn coalesce_consecutive_roles(msgs: Vec<Value>) -> Vec<Value> {
224 let mut out: Vec<Value> = Vec::with_capacity(msgs.len());
225 for msg in msgs {
226 let same_role = out.last().is_some_and(|prev| prev["role"] == msg["role"]);
227 if same_role
228 && let Some(incoming) = msg["parts"].as_array()
229 && let Some(prev) = out.last_mut()
230 && let Some(parts) = prev["parts"].as_array_mut()
231 {
232 parts.extend(incoming.iter().cloned());
233 continue;
234 }
235 out.push(msg);
236 }
237 out
238}
239
240fn convert_messages(messages: &[ChatMessage]) -> (Option<Value>, Vec<Value>) {
256 let mut system: Option<Value> = None;
257 let mut out: Vec<Value> = Vec::new();
258
259 let mut i = 0;
260 while i < messages.len() {
261 let msg = &messages[i];
262 match msg.role {
263 MessageRole::System
264 if msg.kind.audience() == MessageAudience::ModelDirected
265 && !msg.content.is_empty() =>
266 {
267 push_system_reminder(&mut out, &msg.content);
273 i += 1;
274 },
275 MessageRole::System => {
276 if system.is_none() && !msg.content.is_empty() {
277 system = Some(json!({
278 "parts": [{"text": msg.content}],
279 }));
280 }
281 i += 1;
282 },
283 MessageRole::User => {
284 let mut parts: Vec<Value> = Vec::new();
285 if !msg.content.is_empty() {
286 parts.push(json!({"text": msg.content}));
287 }
288 if let Some(ref images) = msg.images {
289 for data in images {
290 parts.push(json!({
291 "inlineData": {
292 "mimeType": "image/png",
293 "data": data,
294 }
295 }));
296 }
297 }
298 if parts.is_empty() {
299 parts.push(json!({"text": ""}));
300 }
301 out.push(json!({"role": "user", "parts": parts}));
302 i += 1;
303 },
304 MessageRole::Assistant => {
305 let mut parts: Vec<Value> = Vec::new();
306 if let Some(ref thinking) = msg.thinking
307 && !thinking.is_empty()
308 {
309 parts.push(json!({
312 "text": thinking,
313 "thought": true,
314 }));
315 }
316 if !msg.content.is_empty() {
317 parts.push(json!({"text": msg.content}));
318 }
319 if let Some(ref tool_calls) = msg.tool_calls {
320 for tc in tool_calls {
321 parts.push(json!({
322 "functionCall": {
323 "name": tc.function.name,
324 "args": tc.function.arguments,
325 }
326 }));
327 }
328 }
329 if parts.is_empty() {
330 i += 1;
333 continue;
334 }
335 out.push(json!({"role": "model", "parts": parts}));
336 i += 1;
337 },
338 MessageRole::Tool => {
339 let mut parts: Vec<Value> = Vec::new();
342 while i < messages.len() && messages[i].role == MessageRole::Tool {
343 let t = &messages[i];
344 let name = t
345 .tool_name
346 .clone()
347 .unwrap_or_else(|| "unknown_tool".to_string());
348 parts.push(json!({
352 "functionResponse": {
353 "name": name,
354 "response": {"result": t.content},
355 }
356 }));
357 i += 1;
358 }
359 out.push(json!({"role": "user", "parts": parts}));
360 },
361 }
362 }
363
364 (system, coalesce_consecutive_roles(out))
365}
366
367pub struct GeminiAdapter {
369 client: Client,
370 api_key: String,
371 base_url: String,
372 model_name: String,
373 capabilities: ModelCapabilities,
374}
375
376impl GeminiAdapter {
377 pub fn new(api_key: String, model_name: String, base_url: String) -> Result<Self> {
380 let client = Client::builder()
381 .pool_max_idle_per_host(10)
382 .pool_idle_timeout(Duration::from_secs(90))
383 .tcp_keepalive(Duration::from_secs(60))
384 .connect_timeout(Duration::from_secs(10))
385 .build()
386 .map_err(|e| {
387 ModelError::Backend(BackendError::ConnectionFailed {
388 backend: "gemini".to_string(),
389 url: base_url.clone(),
390 reason: e.to_string(),
391 })
392 })?;
393
394 let capabilities = ModelCapabilities {
401 supports_tools: true,
402 supports_vision: true,
403 supports_reasoning: ReasoningCapability::Levels(vec![
404 ReasoningLevel::None,
405 ReasoningLevel::Minimal,
406 ReasoningLevel::Low,
407 ReasoningLevel::Medium,
408 ReasoningLevel::High,
409 ReasoningLevel::Max,
410 ReasoningLevel::XHigh,
411 ]),
412 max_context_tokens: None,
413 max_output_tokens: None,
414 };
415
416 Ok(Self {
417 client,
418 api_key,
419 base_url,
420 model_name,
421 capabilities,
422 })
423 }
424
425 fn build_request_body(&self, messages: &[ChatMessage], config: &ModelConfig) -> Value {
429 let (system_from_msgs, gemini_contents) = convert_messages(messages);
430 let system = match (config.combined_system_prompt(), system_from_msgs) {
436 (Some(s), _) if !s.is_empty() => Some(json!({
437 "parts": [{"text": s}],
438 })),
439 (_, Some(v)) => Some(v),
440 _ => None,
441 };
442
443 let mut body = json!({
444 "contents": gemini_contents,
445 });
446 if let Some(s) = system {
447 body["systemInstruction"] = s;
448 }
449
450 let mut gen_config = json!({});
452 gen_config["temperature"] = json!(config.temperature.clamp(0.0, 2.0));
455 if config.max_tokens > 0 {
456 gen_config["maxOutputTokens"] = json!(config.max_tokens);
457 }
458
459 let effective_reasoning = match &self.capabilities.supports_reasoning {
463 ReasoningCapability::Levels(supported) => {
464 nearest_effort(config.reasoning, supported).unwrap_or(ReasoningLevel::None)
465 },
466 _ => config.reasoning,
467 };
468
469 match crate::models::catalog::lookup(&self.model_name).thinking {
474 crate::models::catalog::ThinkingShape::GeminiLevel => {
475 let level_str = thinking_level_for(effective_reasoning);
476 gen_config["thinkingConfig"] = json!({
477 "thinkingLevel": level_str,
478 "includeThoughts": effective_reasoning != ReasoningLevel::None,
479 });
480 },
481 crate::models::catalog::ThinkingShape::GeminiBudget { min, can_disable } => {
482 let raw = thinking_budget_for(effective_reasoning);
483 let budget = if effective_reasoning == ReasoningLevel::None {
484 if can_disable { 0 } else { min }
488 } else if raw < 0 {
489 -1
491 } else {
492 raw.max(min)
494 };
495 gen_config["thinkingConfig"] = json!({
496 "thinkingBudget": budget,
497 "includeThoughts": budget != 0,
498 });
499 },
500 _ => {},
503 }
504 if let Some(schema) = &config.output_schema {
508 gen_config["responseMimeType"] = json!("application/json");
509 gen_config["responseJsonSchema"] = schema.clone();
510 }
511 body["generationConfig"] = gen_config;
512
513 let registered: Vec<&Value> = config.tools.iter().collect();
516 let gemini_tools = to_gemini_tools(®istered);
517 if !gemini_tools.is_empty() {
518 body["tools"] = json!(gemini_tools);
519 }
520
521 body
522 }
523
524 async fn send_chat(&self, body: &Value, stream: bool) -> Result<reqwest::Response> {
529 let method = if stream {
530 "streamGenerateContent?alt=sse"
531 } else {
532 "generateContent"
533 };
534 let url = format!(
535 "{}/models/{}:{}",
536 self.base_url.trim_end_matches('/'),
537 self.model_name,
538 method
539 );
540 crate::models::retry::retry_transient_http(|| async {
541 self.client
542 .post(&url)
543 .header("x-goog-api-key", &self.api_key)
544 .header("content-type", "application/json")
545 .json(body)
546 .send()
547 .await
548 .map_err(|e| {
549 ModelError::Backend(BackendError::ConnectionFailed {
550 backend: "gemini".to_string(),
551 url: url.clone(),
552 reason: e.to_string(),
553 })
554 })
555 })
556 .await
557 }
558
559 pub async fn fetch_model_limits(&self) -> Result<ModelLimits> {
566 let url = format!(
567 "{}/models/{}",
568 self.base_url.trim_end_matches('/'),
569 self.model_name
570 );
571 let response = self
572 .client
573 .get(&url)
574 .header("x-goog-api-key", &self.api_key)
575 .send()
576 .await
577 .map_err(|e| {
578 ModelError::Backend(BackendError::ConnectionFailed {
579 backend: "gemini".to_string(),
580 url: url.clone(),
581 reason: e.to_string(),
582 })
583 })?;
584 if response.status() == reqwest::StatusCode::NOT_FOUND {
585 return Ok(ModelLimits::default());
586 }
587 if !response.status().is_success() {
588 return Err(http_error_from_response(response).await);
589 }
590 let info: GeminiModelInfo = response.json().await.map_err(|e| ModelError::ParseError {
591 message: format!("Failed to parse Gemini model info: {}", e),
592 raw: None,
593 })?;
594 Ok(info.into())
595 }
596
597 async fn decode_non_streaming(&self, response: reqwest::Response) -> Result<ModelResponse> {
599 if !response.status().is_success() {
600 return Err(http_error_from_response(response).await);
601 }
602
603 let json: GeminiResponse = response.json().await.map_err(|e| ModelError::ParseError {
604 message: format!("Failed to parse Gemini response: {}", e),
605 raw: None,
606 })?;
607
608 if json.candidates.is_empty()
615 && let Some(reason) = json
616 .prompt_feedback
617 .as_ref()
618 .and_then(|pf| pf.block_reason.as_deref())
619 {
620 return Err(ModelError::Backend(BackendError::ProviderError {
621 provider: "gemini".to_string(),
622 code: Some(reason.to_string()),
623 message: format!("Gemini blocked the prompt (blockReason={reason})"),
624 debug: crate::models::error::ResponseDebugContext::default(),
625 }));
626 }
627
628 let mut text_acc = String::new();
629 let mut thinking_acc = String::new();
630 let mut tool_calls: Vec<ToolCall> = Vec::new();
631 let mut stop_reason: Option<FinishReason> = None;
632
633 if let Some(candidate) = json.candidates.into_iter().next() {
634 stop_reason = candidate
635 .finish_reason
636 .as_deref()
637 .map(map_gemini_finish_reason);
638 match candidate.content {
639 Some(content) => {
640 for part in content.parts {
641 if let Some(text) = part.text {
642 if part.thought.unwrap_or(false) {
643 thinking_acc.push_str(&text);
644 } else {
645 text_acc.push_str(&text);
646 }
647 } else if let Some(fc) = part.function_call {
648 let id = format!("call_{}", tool_calls.len());
649 tool_calls.push(ToolCall {
650 id: Some(id),
651 function: FunctionCall {
652 name: fc.name,
653 arguments: fc.args,
654 },
655 });
656 }
657 }
658 },
659 None => {
660 let reason = candidate.finish_reason.as_deref().unwrap_or("unknown");
664 if !gemini_empty_is_benign(reason) {
665 return Err(ModelError::Backend(BackendError::ProviderError {
666 provider: "gemini".to_string(),
667 code: Some(reason.to_string()),
668 message: format!("Gemini returned no content (finishReason={reason})"),
669 debug: crate::models::error::ResponseDebugContext::default(),
670 }));
671 }
672 },
673 }
674 }
675
676 let raw_prompt_tokens = json.usage_metadata.prompt_token_count.unwrap_or(0);
677 let cached_tokens = json.usage_metadata.cached_content_token_count.unwrap_or(0);
678 let prompt_tokens = raw_prompt_tokens.saturating_sub(cached_tokens);
682 let completion_tokens = json.usage_metadata.candidates_token_count.unwrap_or(0);
683 let reasoning_tokens = json.usage_metadata.thoughts_token_count.unwrap_or(0);
684 let usage = TokenUsage::provider(prompt_tokens, completion_tokens)
685 .with_cached_input(cached_tokens)
686 .with_reasoning_output(reasoning_tokens);
687
688 Ok(ModelResponse {
689 content: text_acc,
690 usage: Some(usage),
691 model_name: self.model_name.clone(),
692 stop_reason,
693 thinking: if thinking_acc.is_empty() {
694 None
695 } else {
696 Some(thinking_acc)
697 },
698 tool_calls: if tool_calls.is_empty() {
699 None
700 } else {
701 Some(tool_calls)
702 },
703 provider_continuation: None,
705 })
706 }
707
708 async fn handle_stream(
717 &self,
718 response: reqwest::Response,
719 callback: StreamCallback,
720 hide_reasoning_trace: bool,
721 ) -> Result<ModelResponse> {
722 if !response.status().is_success() {
723 return Err(http_error_from_response(response).await);
724 }
725
726 let mut stream = response.bytes_stream();
727 let mut buf: Vec<u8> = Vec::new();
728 let mut state = StreamState::default();
729
730 while let Some(chunk_result) = stream.next().await {
731 let chunk = chunk_result.map_err(|e| ModelError::StreamError(e.to_string()))?;
732 if buf.len() > crate::constants::MAX_SSE_BUFFER_BYTES {
738 return Err(ModelError::StreamError(format!(
739 "SSE stream exceeded {} byte reassembly cap without a complete event",
740 crate::constants::MAX_SSE_BUFFER_BYTES
741 )));
742 }
743 buf.extend_from_slice(&chunk);
744
745 for payload in drain_sse_events(&mut buf) {
746 process_chunk_payload(&payload, &mut state, &callback, hide_reasoning_trace)?;
747 }
748 }
749
750 if stream_closed_abnormally(state.finish_reason.as_ref()) {
756 return Err(ModelError::StreamError(
757 "Gemini stream closed before a terminal finishReason; the \
758 connection was likely dropped mid-response"
759 .to_string(),
760 ));
761 }
762
763 let usage = state.usage();
767
768 Ok(ModelResponse {
769 content: state.text_acc,
770 usage,
771 model_name: self.model_name.clone(),
772 stop_reason: state.finish_reason,
773 thinking: if state.thinking_acc.is_empty() {
774 None
775 } else {
776 Some(state.thinking_acc)
777 },
778 tool_calls: if state.tool_calls_done.is_empty() {
779 None
780 } else {
781 Some(state.tool_calls_done)
782 },
783 provider_continuation: None,
784 })
785 }
786}
787
788#[derive(Debug, Default)]
792struct StreamState {
793 text_acc: String,
794 thinking_acc: String,
795 tool_calls_done: Vec<ToolCall>,
796 truncated: bool,
797 prompt_tokens: usize,
798 completion_tokens: usize,
799 cached_input_tokens: usize,
800 reasoning_output_tokens: usize,
801 saw_usage: bool,
804 finish_reason: Option<FinishReason>,
805}
806
807impl StreamState {
808 fn usage(&self) -> Option<TokenUsage> {
813 self.saw_usage.then(|| {
814 let fresh_prompt = self.prompt_tokens.saturating_sub(self.cached_input_tokens);
815 TokenUsage::provider(fresh_prompt, self.completion_tokens)
816 .with_cached_input(self.cached_input_tokens)
817 .with_reasoning_output(self.reasoning_output_tokens)
818 })
819 }
820}
821
822fn process_chunk_payload(
826 payload: &str,
827 state: &mut StreamState,
828 callback: &StreamCallback,
829 hide_reasoning_trace: bool,
830) -> Result<()> {
831 let parsed: Value = serde_json::from_str(payload).map_err(|e| ModelError::ParseError {
832 message: format!("Failed to parse Gemini stream chunk: {}", e),
833 raw: Some(payload.to_string()),
834 })?;
835
836 if let Some(err) = parsed.get("error") {
838 let code = err
839 .get("status")
840 .and_then(|v| v.as_str())
841 .unwrap_or("UNKNOWN");
842 let msg = err
843 .get("message")
844 .and_then(|v| v.as_str())
845 .unwrap_or("Gemini stream error");
846 return Err(ModelError::Backend(BackendError::ProviderError {
847 provider: "gemini".to_string(),
848 code: Some(code.to_string()),
849 message: msg.to_string(),
850 debug: crate::models::error::ResponseDebugContext::default(),
851 }));
852 }
853
854 if let Some(reason) = parsed
860 .pointer("/promptFeedback/blockReason")
861 .and_then(|v| v.as_str())
862 {
863 return Err(ModelError::Backend(BackendError::ProviderError {
864 provider: "gemini".to_string(),
865 code: Some(reason.to_string()),
866 message: format!("Gemini blocked the prompt (blockReason={reason})"),
867 debug: crate::models::error::ResponseDebugContext::default(),
868 }));
869 }
870
871 if let Some(usage) = parsed.get("usageMetadata") {
873 state.saw_usage = true;
874 if let Some(p) = usage.get("promptTokenCount").and_then(|v| v.as_u64()) {
875 state.prompt_tokens = p as usize;
876 }
877 if let Some(c) = usage.get("candidatesTokenCount").and_then(|v| v.as_u64()) {
878 state.completion_tokens = c as usize;
879 }
880 if let Some(cached) = usage
881 .get("cachedContentTokenCount")
882 .and_then(|v| v.as_u64())
883 {
884 state.cached_input_tokens = cached as usize;
885 }
886 if let Some(thoughts) = usage.get("thoughtsTokenCount").and_then(|v| v.as_u64()) {
887 state.reasoning_output_tokens = thoughts as usize;
888 }
889 }
890
891 let finish_reason = parsed
894 .pointer("/candidates/0/finishReason")
895 .and_then(|v| v.as_str());
896 if let Some(fr) = finish_reason {
897 state.finish_reason = Some(map_gemini_finish_reason(fr));
898 }
899
900 let Some(parts_arr) = parsed
904 .pointer("/candidates/0/content/parts")
905 .and_then(|v| v.as_array())
906 else {
907 if let Some(fr) = finish_reason
912 && !gemini_empty_is_benign(fr)
913 && state.text_acc.is_empty()
914 && state.tool_calls_done.is_empty()
915 {
916 return Err(ModelError::Backend(BackendError::ProviderError {
917 provider: "gemini".to_string(),
918 code: Some(fr.to_string()),
919 message: format!("Gemini returned no content (finishReason={fr})"),
920 debug: crate::models::error::ResponseDebugContext::default(),
921 }));
922 }
923 return Ok(());
924 };
925
926 for part in parts_arr {
927 if let Some(fc) = part.get("functionCall") {
930 let name = fc
931 .get("name")
932 .and_then(|v| v.as_str())
933 .unwrap_or("")
934 .to_string();
935 let args = fc.get("args").cloned().unwrap_or_else(|| json!({}));
936 if name.is_empty() {
937 continue;
938 }
939 let id = format!("call_{}", state.tool_calls_done.len());
940 let tc = ToolCall {
941 id: Some(id),
942 function: FunctionCall {
943 name,
944 arguments: args,
945 },
946 };
947 callback(StreamEvent::ToolCall(tc.clone()));
948 state.tool_calls_done.push(tc);
949 continue;
950 }
951
952 let Some(text) = part.get("text").and_then(|v| v.as_str()) else {
954 continue;
955 };
956 if text.is_empty() || state.truncated {
957 continue;
958 }
959 let is_thought = part
960 .get("thought")
961 .and_then(|v| v.as_bool())
962 .unwrap_or(false);
963 if is_thought {
964 if !hide_reasoning_trace {
965 callback(StreamEvent::Reasoning(ReasoningChunk {
966 text: text.to_string(),
967 signature: None,
968 }));
969 }
970 push_capped(
971 &mut state.thinking_acc,
972 text,
973 &mut state.truncated,
974 MAX_RESPONSE_CHARS,
975 );
976 } else {
977 callback(StreamEvent::Text(text.to_string()));
978 push_capped(
979 &mut state.text_acc,
980 text,
981 &mut state.truncated,
982 MAX_RESPONSE_CHARS,
983 );
984 }
985 }
986 Ok(())
987}
988
989#[async_trait]
990impl Model for GeminiAdapter {
991 fn name(&self) -> &str {
992 &self.model_name
993 }
994
995 fn capabilities(&self) -> &ModelCapabilities {
996 &self.capabilities
997 }
998
999 async fn list_models(&self) -> Result<Vec<String>> {
1004 Err(ModelError::Unsupported {
1005 feature: "list_models (gemini)".to_string(),
1006 })
1007 }
1008
1009 async fn chat(
1010 &self,
1011 messages: &[ChatMessage],
1012 config: &ModelConfig,
1013 callback: Option<StreamCallback>,
1014 ) -> Result<ModelResponse> {
1015 let body = self.build_request_body(messages, config);
1016 let stream = callback.is_some();
1017 let response = self.send_chat(&body, stream).await?;
1018 if let Some(cb) = callback {
1019 self.handle_stream(response, cb, config.hide_reasoning_trace)
1020 .await
1021 } else {
1022 self.decode_non_streaming(response).await
1023 }
1024 }
1025}
1026
1027#[derive(Debug, Default, Deserialize)]
1033struct GeminiModelInfo {
1034 #[serde(default, rename = "inputTokenLimit")]
1035 input_token_limit: Option<usize>,
1036 #[serde(default, rename = "outputTokenLimit")]
1037 output_token_limit: Option<usize>,
1038}
1039
1040impl From<GeminiModelInfo> for ModelLimits {
1041 fn from(info: GeminiModelInfo) -> Self {
1042 ModelLimits {
1043 max_context_tokens: info.input_token_limit,
1044 max_output_tokens: info.output_token_limit,
1045 }
1046 }
1047}
1048
1049#[derive(Debug, Deserialize)]
1050struct GeminiResponse {
1051 #[serde(default)]
1052 candidates: Vec<Candidate>,
1053 #[serde(default, rename = "usageMetadata")]
1054 usage_metadata: UsageMetadata,
1055 #[serde(default, rename = "promptFeedback")]
1059 prompt_feedback: Option<PromptFeedback>,
1060}
1061
1062#[derive(Debug, Deserialize)]
1066struct PromptFeedback {
1067 #[serde(default, rename = "blockReason")]
1068 block_reason: Option<String>,
1069}
1070
1071#[derive(Debug, Deserialize)]
1072struct Candidate {
1073 #[serde(default)]
1078 content: Option<CandidateContent>,
1079 #[serde(default, rename = "finishReason")]
1080 finish_reason: Option<String>,
1081}
1082
1083#[derive(Debug, Deserialize)]
1084struct CandidateContent {
1085 #[serde(default)]
1086 parts: Vec<ResponsePart>,
1087}
1088
1089#[derive(Debug, Deserialize)]
1094struct ResponsePart {
1095 #[serde(default)]
1096 text: Option<String>,
1097 #[serde(default)]
1098 thought: Option<bool>,
1099 #[serde(default, rename = "functionCall")]
1100 function_call: Option<FunctionCallOut>,
1101}
1102
1103#[derive(Debug, Deserialize)]
1104struct FunctionCallOut {
1105 name: String,
1106 #[serde(default)]
1107 args: Value,
1108}
1109
1110#[derive(Debug, Default, Deserialize)]
1111struct UsageMetadata {
1112 #[serde(default, rename = "promptTokenCount")]
1113 prompt_token_count: Option<usize>,
1114 #[serde(default, rename = "candidatesTokenCount")]
1115 candidates_token_count: Option<usize>,
1116 #[serde(default, rename = "cachedContentTokenCount")]
1117 cached_content_token_count: Option<usize>,
1118 #[serde(default, rename = "thoughtsTokenCount")]
1119 thoughts_token_count: Option<usize>,
1120}
1121
1122async fn http_error_from_response(response: reqwest::Response) -> ModelError {
1124 let status = response.status().as_u16();
1125 let debug = crate::models::error::ResponseDebugContext::from_headers(response.headers());
1126 let body = response
1127 .text()
1128 .await
1129 .unwrap_or_else(|_| "Unknown error".to_string());
1130 if let Ok(parsed) = serde_json::from_str::<Value>(&body)
1131 && let Some(err) = parsed.get("error")
1132 {
1133 let code = err.get("status").and_then(|v| v.as_str()).map(String::from);
1134 let msg = err
1135 .get("message")
1136 .and_then(|v| v.as_str())
1137 .unwrap_or(&body)
1138 .to_string();
1139 let suffix = if code.as_deref() == Some("PERMISSION_DENIED") {
1142 " (check that GOOGLE_API_KEY is valid and the Generative Language API is enabled)"
1143 } else if code.as_deref() == Some("INVALID_ARGUMENT")
1144 && msg.to_lowercase().contains("thinkingbudget")
1145 {
1146 " (thinkingBudget out of range for this model — file an issue at github.com/noahsabaj/mermaid)"
1147 } else {
1148 ""
1149 };
1150 return ModelError::Backend(BackendError::ProviderError {
1151 provider: "gemini".to_string(),
1152 code,
1153 message: format!("{}{}", msg, suffix),
1154 debug: debug.clone(),
1155 });
1156 }
1157 ModelError::Backend(BackendError::HttpError {
1158 status,
1159 message: body,
1160 debug,
1161 })
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 use super::*;
1167 use crate::models::tool_call::{FunctionCall, ToolCall};
1168
1169 #[test]
1170 fn model_info_parses_documented_limit_fields() {
1171 let body = r#"{
1174 "name": "models/gemini-2.5-pro",
1175 "displayName": "Gemini 2.5 Pro",
1176 "inputTokenLimit": 1048576,
1177 "outputTokenLimit": 65536,
1178 "supportedGenerationMethods": ["generateContent"]
1179 }"#;
1180 let info: GeminiModelInfo = serde_json::from_str(body).expect("parse");
1181 let limits: ModelLimits = info.into();
1182 assert_eq!(limits.max_context_tokens, Some(1_048_576));
1183 assert_eq!(limits.max_output_tokens, Some(65_536));
1184 }
1185
1186 #[test]
1187 fn model_info_missing_limit_fields_degrade_to_none() {
1188 let body = r#"{"name": "models/gemini-2.5-pro"}"#;
1189 let info: GeminiModelInfo = serde_json::from_str(body).expect("parse");
1190 let limits: ModelLimits = info.into();
1191 assert_eq!(limits.max_context_tokens, None);
1192 assert_eq!(limits.max_output_tokens, None);
1193 }
1194
1195 #[test]
1196 fn candidate_without_content_deserializes() {
1197 let json = serde_json::json!({
1200 "candidates": [{ "finishReason": "SAFETY" }],
1201 "usageMetadata": {}
1202 });
1203 let resp: GeminiResponse = serde_json::from_value(json).expect("blocked response parses");
1204 assert_eq!(resp.candidates.len(), 1);
1205 assert!(resp.candidates[0].content.is_none());
1206 assert_eq!(resp.candidates[0].finish_reason.as_deref(), Some("SAFETY"));
1207 }
1208
1209 #[test]
1210 fn empty_response_benign_only_for_non_blocks() {
1211 assert!(gemini_empty_is_benign("STOP"));
1215 assert!(gemini_empty_is_benign("MAX_TOKENS"));
1216 assert!(gemini_empty_is_benign("FINISH_REASON_UNSPECIFIED"));
1217 assert!(!gemini_empty_is_benign("SAFETY"));
1218 assert!(!gemini_empty_is_benign("RECITATION"));
1219 }
1220
1221 #[test]
1222 fn prompt_block_response_parses_with_no_candidates() {
1223 let json = serde_json::json!({
1227 "promptFeedback": { "blockReason": "SAFETY" }
1228 });
1229 let resp: GeminiResponse = serde_json::from_value(json).expect("prompt block parses");
1230 assert!(resp.candidates.is_empty());
1231 assert_eq!(
1232 resp.prompt_feedback
1233 .as_ref()
1234 .and_then(|pf| pf.block_reason.as_deref()),
1235 Some("SAFETY")
1236 );
1237 }
1238
1239 #[test]
1240 fn stream_prompt_block_surfaces_provider_error() {
1241 let mut state = StreamState::default();
1245 let cb: StreamCallback = std::sync::Arc::new(|_ev| {});
1246 let payload = r#"{"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"}}"#;
1247 let err = process_chunk_payload(payload, &mut state, &cb, false)
1248 .expect_err("prompt block must error");
1249 match err {
1250 ModelError::Backend(BackendError::ProviderError {
1251 provider,
1252 code,
1253 message,
1254 ..
1255 }) => {
1256 assert_eq!(provider, "gemini");
1257 assert_eq!(code.as_deref(), Some("PROHIBITED_CONTENT"));
1258 assert!(message.contains("PROHIBITED_CONTENT"), "{message}");
1259 },
1260 other => panic!("expected ProviderError, got {other:?}"),
1261 }
1262 }
1263
1264 #[test]
1265 fn stream_prompt_feedback_without_block_reason_is_not_an_error() {
1266 let mut state = StreamState::default();
1270 let cb: StreamCallback = std::sync::Arc::new(|_ev| {});
1271 let payload = r#"{"promptFeedback":{"safetyRatings":[]},"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}"#;
1272 process_chunk_payload(payload, &mut state, &cb, false).expect("benign feedback is ok");
1273 assert_eq!(state.text_acc, "hello");
1274 }
1275
1276 fn test_adapter() -> GeminiAdapter {
1277 GeminiAdapter::new(
1278 "test-key".to_string(),
1279 "gemini-3-pro".to_string(),
1280 "https://generativelanguage.googleapis.com/v1beta".to_string(),
1281 )
1282 .expect("adapter constructs")
1283 }
1284
1285 #[test]
1288 fn thinking_budget_per_level() {
1289 assert_eq!(thinking_budget_for(ReasoningLevel::None), 0);
1290 assert_eq!(thinking_budget_for(ReasoningLevel::Minimal), 512);
1291 assert_eq!(thinking_budget_for(ReasoningLevel::Low), 2048);
1292 assert_eq!(thinking_budget_for(ReasoningLevel::Medium), 8192);
1293 assert_eq!(thinking_budget_for(ReasoningLevel::High), 24576);
1294 assert_eq!(thinking_budget_for(ReasoningLevel::Max), -1);
1295 }
1296
1297 #[test]
1300 fn tool_translation_groups_into_function_declarations() {
1301 let openai = [
1302 json!({
1303 "type": "function",
1304 "function": {
1305 "name": "read_file",
1306 "description": "Read a file",
1307 "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}
1308 }
1309 }),
1310 json!({
1311 "type": "function",
1312 "function": {
1313 "name": "write_file",
1314 "description": "Write a file",
1315 "parameters": {"type": "object", "properties": {}}
1316 }
1317 }),
1318 ];
1319 let refs: Vec<&Value> = openai.iter().collect();
1320 let gemini = to_gemini_tools(&refs);
1321 assert_eq!(gemini.len(), 1);
1322 let decls = gemini[0]["functionDeclarations"].as_array().unwrap();
1323 assert_eq!(decls.len(), 2);
1324 assert_eq!(decls[0]["name"], "read_file");
1325 assert_eq!(decls[0]["description"], "Read a file");
1326 assert!(decls[0].get("parameters").is_some());
1327 assert!(decls[0].get("function").is_none());
1329 assert!(decls[0].get("type").is_none());
1330 }
1331
1332 #[test]
1333 fn tool_translation_handles_missing_description() {
1334 let openai = [json!({
1335 "type": "function",
1336 "function": {
1337 "name": "no_desc",
1338 "parameters": {"type": "object", "properties": {}}
1339 }
1340 })];
1341 let refs: Vec<&Value> = openai.iter().collect();
1342 let gemini = to_gemini_tools(&refs);
1343 let decls = gemini[0]["functionDeclarations"].as_array().unwrap();
1344 assert_eq!(decls[0]["description"], "");
1345 }
1346
1347 #[test]
1348 fn tool_translation_empty_returns_empty() {
1349 let gemini = to_gemini_tools(&[]);
1350 assert!(gemini.is_empty());
1351 }
1352
1353 #[test]
1356 fn convert_messages_extracts_system_first() {
1357 let messages = vec![
1358 ChatMessage::system("You are helpful."),
1359 ChatMessage::user("hi"),
1360 ChatMessage::system("ignored second system"),
1361 ];
1362 let (system, contents) = convert_messages(&messages);
1363 let sys = system.expect("system extracted");
1364 assert_eq!(sys["parts"][0]["text"], "You are helpful.");
1365 assert_eq!(contents.len(), 1);
1367 assert_eq!(contents[0]["role"], "user");
1368 }
1369
1370 #[test]
1374 fn model_directed_system_messages_reach_the_wire_as_tagged_user_parts() {
1375 use crate::models::ChatMessageKind;
1376 let mut nudge = ChatMessage::system("Reminder: plan mode is active.");
1377 nudge.kind = ChatMessageKind::RecoveryNudge;
1378 let messages = vec![ChatMessage::user("ok"), nudge];
1379
1380 let (_system, contents) = convert_messages(&messages);
1381 assert_eq!(contents.len(), 1, "merged into the adjacent user turn");
1382 let parts = contents[0]["parts"].as_array().expect("parts array");
1383 assert_eq!(parts.len(), 2);
1384 let tagged = parts[1]["text"].as_str().unwrap();
1385 assert!(
1386 tagged.contains("<system-reminder>") && tagged.contains("plan mode is active"),
1387 "steering must be delivered and tagged: {tagged}",
1388 );
1389 }
1390
1391 #[test]
1397 fn convert_messages_never_emits_consecutive_same_role_turns() {
1398 use crate::models::ChatMessageKind;
1399 let steering = || {
1400 let mut m = ChatMessage::system("Reminder: plan mode is active.");
1401 m.kind = ChatMessageKind::ContextMarker;
1402 m
1403 };
1404 let tool_call = || {
1405 let mut m = ChatMessage::assistant("");
1406 m.tool_calls = Some(vec![ToolCall {
1407 id: Some("c1".to_string()),
1408 function: FunctionCall {
1409 name: "read_file".into(),
1410 arguments: json!({"path": "a.txt"}),
1411 },
1412 }]);
1413 m
1414 };
1415
1416 let shapes: Vec<(&str, Vec<ChatMessage>)> = vec![
1417 (
1418 "steering between two user turns",
1419 vec![
1420 ChatMessage::user("first"),
1421 steering(),
1422 ChatMessage::user("second"),
1423 ],
1424 ),
1425 (
1426 "two user turns in a row",
1427 vec![ChatMessage::user("first"), ChatMessage::user("second")],
1428 ),
1429 (
1430 "user types while tool results are pending",
1431 vec![
1432 ChatMessage::user("read it"),
1433 tool_call(),
1434 ChatMessage::tool("c1", "read_file", "contents"),
1435 ChatMessage::user("actually, stop"),
1436 ],
1437 ),
1438 (
1439 "two assistant turns from an interrupted continuation",
1440 vec![
1441 ChatMessage::user("go"),
1442 ChatMessage::assistant("part one"),
1443 ChatMessage::assistant("part two"),
1444 ],
1445 ),
1446 (
1447 "back-to-back steering",
1448 vec![ChatMessage::user("go"), steering(), steering()],
1449 ),
1450 (
1451 "steering with no user turn to attach to",
1452 vec![ChatMessage::assistant("partial"), steering()],
1453 ),
1454 ];
1455
1456 for (name, messages) in shapes {
1457 let (_system, contents) = convert_messages(&messages);
1458 assert!(!contents.is_empty(), "{name}: the history must not vanish");
1459 for pair in contents.windows(2) {
1460 assert_ne!(
1461 pair[0]["role"], pair[1]["role"],
1462 "{name}: emitted consecutive {} turns, which Gemini rejects: {contents:#?}",
1463 pair[0]["role"],
1464 );
1465 }
1466 }
1467 }
1468
1469 #[test]
1472 fn coalescing_two_user_turns_keeps_both_texts() {
1473 let messages = vec![ChatMessage::user("first"), ChatMessage::user("second")];
1474 let (_system, contents) = convert_messages(&messages);
1475 assert_eq!(contents.len(), 1);
1476 let parts = contents[0]["parts"].as_array().expect("parts array");
1477 assert_eq!(parts.len(), 2, "both texts survive: {parts:#?}");
1478 assert_eq!(parts[0]["text"], "first");
1479 assert_eq!(parts[1]["text"], "second");
1480 }
1481
1482 #[test]
1483 fn convert_messages_renames_assistant_to_model() {
1484 let messages = vec![ChatMessage::user("hi"), ChatMessage::assistant("hello")];
1485 let (_system, contents) = convert_messages(&messages);
1486 assert_eq!(contents[0]["role"], "user");
1487 assert_eq!(contents[1]["role"], "model");
1488 assert_eq!(contents[1]["parts"][0]["text"], "hello");
1489 }
1490
1491 #[test]
1492 fn convert_messages_merges_consecutive_tool_messages() {
1493 let mut call = ChatMessage::assistant("");
1498 call.tool_calls = Some(vec![
1499 ToolCall {
1500 id: Some("call_0".to_string()),
1501 function: FunctionCall {
1502 name: "read_file".into(),
1503 arguments: json!({"path": "a.txt"}),
1504 },
1505 },
1506 ToolCall {
1507 id: Some("call_1".to_string()),
1508 function: FunctionCall {
1509 name: "read_file".into(),
1510 arguments: json!({"path": "b.txt"}),
1511 },
1512 },
1513 ]);
1514 let messages = vec![
1515 ChatMessage::user("read two files"),
1516 call,
1517 ChatMessage::tool("call_0", "read_file", "contents of A"),
1518 ChatMessage::tool("call_1", "read_file", "contents of B"),
1519 ];
1520 let (_, contents) = convert_messages(&messages);
1521 assert_eq!(contents.len(), 3);
1523 assert_eq!(contents[1]["role"], "model");
1524 assert_eq!(contents[2]["role"], "user");
1525 let parts = contents[2]["parts"].as_array().unwrap();
1526 assert_eq!(parts.len(), 2, "two functionResponse parts");
1527 assert_eq!(parts[0]["functionResponse"]["name"], "read_file");
1528 assert_eq!(
1529 parts[0]["functionResponse"]["response"]["result"],
1530 "contents of A"
1531 );
1532 assert_eq!(
1533 parts[1]["functionResponse"]["response"]["result"],
1534 "contents of B"
1535 );
1536 }
1537
1538 #[test]
1539 fn convert_messages_emits_function_call_part_for_assistant_tool_call() {
1540 let mut msg = ChatMessage::assistant("");
1541 msg.tool_calls = Some(vec![ToolCall {
1542 id: Some("call_0".to_string()),
1543 function: FunctionCall {
1544 name: "read_file".to_string(),
1545 arguments: json!({"path": "Cargo.toml"}),
1546 },
1547 }]);
1548 let messages = vec![ChatMessage::user("read it"), msg];
1549 let (_, contents) = convert_messages(&messages);
1550 assert_eq!(contents[1]["role"], "model");
1551 let parts = contents[1]["parts"].as_array().unwrap();
1552 assert_eq!(parts.len(), 1);
1553 let fc = &parts[0]["functionCall"];
1554 assert_eq!(fc["name"], "read_file");
1555 assert_eq!(fc["args"]["path"], "Cargo.toml");
1556 }
1557
1558 #[test]
1559 fn convert_messages_emits_inline_data_for_user_images() {
1560 let msg = ChatMessage::user("look at this").with_images(vec!["base64data".to_string()]);
1561 let (_, contents) = convert_messages(&[msg]);
1562 let parts = contents[0]["parts"].as_array().unwrap();
1563 assert_eq!(parts.len(), 2);
1564 assert_eq!(parts[0]["text"], "look at this");
1565 assert_eq!(parts[1]["inlineData"]["mimeType"], "image/png");
1566 assert_eq!(parts[1]["inlineData"]["data"], "base64data");
1567 }
1568
1569 #[test]
1570 fn convert_messages_emits_thought_part_for_assistant_thinking() {
1571 let mut msg = ChatMessage::assistant("the answer is 42");
1572 msg.thinking = Some("step 1: think hard".to_string());
1573 let messages = vec![ChatMessage::user("compute"), msg];
1574 let (_, contents) = convert_messages(&messages);
1575 let parts = contents[1]["parts"].as_array().unwrap();
1576 assert_eq!(parts.len(), 2);
1577 assert_eq!(parts[0]["text"], "step 1: think hard");
1578 assert_eq!(parts[0]["thought"], true);
1579 assert_eq!(parts[1]["text"], "the answer is 42");
1580 assert!(parts[1].get("thought").is_none());
1582 }
1583
1584 #[test]
1587 fn capabilities_advertise_full_reasoning_levels_and_vision() {
1588 let adapter = test_adapter();
1589 let caps = adapter.capabilities();
1590 assert!(caps.supports_tools);
1591 assert!(caps.supports_vision);
1592 match &caps.supports_reasoning {
1593 ReasoningCapability::Levels(levels) => {
1594 assert!(levels.contains(&ReasoningLevel::None));
1595 assert!(levels.contains(&ReasoningLevel::Minimal));
1596 assert!(levels.contains(&ReasoningLevel::Max));
1597 },
1598 other => panic!("expected Levels, got {:?}", other),
1599 }
1600 }
1601
1602 #[test]
1603 fn name_returns_model_id() {
1604 let adapter = test_adapter();
1605 assert_eq!(adapter.name(), "gemini-3-pro");
1606 }
1607
1608 #[test]
1611 fn build_request_body_includes_required_fields() {
1612 let adapter = test_adapter();
1613 let messages = vec![ChatMessage::user("hi")];
1614 let config = ModelConfig::default();
1615 let body = adapter.build_request_body(&messages, &config);
1616 assert!(body.get("model").is_none());
1618 assert!(body["contents"].is_array());
1619 let contents = body["contents"].as_array().unwrap();
1620 assert_eq!(contents[0]["role"], "user");
1621 assert_eq!(contents[0]["parts"][0]["text"], "hi");
1622 assert!(body["generationConfig"].is_object());
1623 }
1624
1625 #[test]
1626 fn build_request_body_maps_output_schema_to_response_json_schema() {
1627 let adapter = test_adapter();
1628 let messages = vec![ChatMessage::user("format it")];
1629 let config = ModelConfig {
1630 output_schema: Some(serde_json::json!({"type": "object"})),
1631 ..Default::default()
1632 };
1633 let body = adapter.build_request_body(&messages, &config);
1634 assert_eq!(
1635 body["generationConfig"]["responseMimeType"],
1636 "application/json"
1637 );
1638 assert_eq!(
1639 body["generationConfig"]["responseJsonSchema"]["type"],
1640 "object"
1641 );
1642 let body = adapter.build_request_body(&messages, &ModelConfig::default());
1644 assert!(body["generationConfig"].get("responseMimeType").is_none());
1645 assert!(body["generationConfig"].get("responseJsonSchema").is_none());
1646 }
1647
1648 #[test]
1649 fn auto_max_tokens_omits_max_output_tokens() {
1650 let adapter = test_adapter();
1653 let config = ModelConfig {
1654 max_tokens: 0,
1655 ..Default::default()
1656 };
1657 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config);
1658 assert!(body["generationConfig"].get("maxOutputTokens").is_none());
1659 }
1660
1661 #[test]
1662 fn build_request_body_wraps_system_in_content_object() {
1663 let adapter = test_adapter();
1664 let messages = vec![ChatMessage::user("hi")];
1665 let config = ModelConfig {
1666 system_prompt: Some("You are helpful.".to_string()),
1667 ..Default::default()
1668 };
1669 let body = adapter.build_request_body(&messages, &config);
1670 let sys = &body["systemInstruction"];
1671 assert!(sys.is_object());
1672 assert_eq!(sys["parts"][0]["text"], "You are helpful.");
1673 }
1674
1675 #[test]
1680 fn build_request_body_concats_dynamic_suffix_to_system_instruction() {
1681 let adapter = test_adapter();
1682 let messages = vec![ChatMessage::user("hi")];
1683 let config = ModelConfig {
1684 system_prompt: Some("You are Mermaid.".to_string()),
1685 dynamic_system_suffix: Some("Project rule: always snake_case.".to_string()),
1686 ..Default::default()
1687 };
1688 let body = adapter.build_request_body(&messages, &config);
1689 let text = body["systemInstruction"]["parts"][0]["text"]
1690 .as_str()
1691 .expect("systemInstruction text");
1692 assert!(text.contains("You are Mermaid."));
1693 assert!(text.contains("Project rule: always snake_case."));
1694 assert!(text.contains("---"));
1695 }
1696
1697 #[test]
1701 fn build_request_body_thinking_level_for_medium_on_gemini_3() {
1702 let adapter = test_adapter(); let messages = vec![ChatMessage::user("hi")];
1704 let config = ModelConfig {
1705 reasoning: ReasoningLevel::Medium,
1706 ..Default::default()
1707 };
1708 let body = adapter.build_request_body(&messages, &config);
1709 let tc = &body["generationConfig"]["thinkingConfig"];
1710 assert_eq!(tc["thinkingLevel"], "medium");
1711 assert_eq!(tc["includeThoughts"], true);
1712 assert!(tc.get("thinkingBudget").is_none());
1714 }
1715
1716 #[test]
1718 fn build_request_body_thinking_level_for_max_collapses_to_high_on_gemini_3() {
1719 let adapter = test_adapter(); let messages = vec![ChatMessage::user("hi")];
1721 let config = ModelConfig {
1722 reasoning: ReasoningLevel::Max,
1723 ..Default::default()
1724 };
1725 let body = adapter.build_request_body(&messages, &config);
1726 assert_eq!(
1727 body["generationConfig"]["thinkingConfig"]["thinkingLevel"],
1728 "high"
1729 );
1730 }
1731
1732 #[test]
1735 fn build_request_body_thinking_level_minimal_for_none_on_gemini_3() {
1736 let adapter = test_adapter(); let messages = vec![ChatMessage::user("hi")];
1738 let config = ModelConfig {
1739 reasoning: ReasoningLevel::None,
1740 ..Default::default()
1741 };
1742 let body = adapter.build_request_body(&messages, &config);
1743 let tc = &body["generationConfig"]["thinkingConfig"];
1744 assert_eq!(tc["thinkingLevel"], "minimal");
1745 assert_eq!(tc["includeThoughts"], false);
1747 }
1748
1749 #[test]
1752 fn build_request_body_thinking_budget_clamps_to_min_128_on_gemini_2_5_pro_for_none() {
1753 let adapter = GeminiAdapter::new(
1754 "test-key".to_string(),
1755 "gemini-2.5-pro".to_string(),
1756 "https://generativelanguage.googleapis.com/v1beta".to_string(),
1757 )
1758 .expect("adapter constructs");
1759 let messages = vec![ChatMessage::user("hi")];
1760 let config = ModelConfig {
1761 reasoning: ReasoningLevel::None,
1762 ..Default::default()
1763 };
1764 let body = adapter.build_request_body(&messages, &config);
1765 let tc = &body["generationConfig"]["thinkingConfig"];
1766 assert_eq!(tc["thinkingBudget"], 128);
1768 assert_eq!(tc["includeThoughts"], true);
1770 }
1771
1772 #[test]
1774 fn build_request_body_thinking_budget_zero_for_none_on_gemini_2_5_flash() {
1775 let adapter = GeminiAdapter::new(
1776 "test-key".to_string(),
1777 "gemini-2.5-flash".to_string(),
1778 "https://generativelanguage.googleapis.com/v1beta".to_string(),
1779 )
1780 .expect("adapter constructs");
1781 let messages = vec![ChatMessage::user("hi")];
1782 let config = ModelConfig {
1783 reasoning: ReasoningLevel::None,
1784 ..Default::default()
1785 };
1786 let body = adapter.build_request_body(&messages, &config);
1787 let tc = &body["generationConfig"]["thinkingConfig"];
1788 assert_eq!(tc["thinkingBudget"], 0);
1789 assert_eq!(tc["includeThoughts"], false);
1790 }
1791
1792 #[test]
1794 fn build_request_body_thinking_budget_adaptive_for_max_on_gemini_2_5_flash() {
1795 let adapter = GeminiAdapter::new(
1796 "test-key".to_string(),
1797 "gemini-2.5-flash".to_string(),
1798 "https://generativelanguage.googleapis.com/v1beta".to_string(),
1799 )
1800 .expect("adapter constructs");
1801 let messages = vec![ChatMessage::user("hi")];
1802 let config = ModelConfig {
1803 reasoning: ReasoningLevel::Max,
1804 ..Default::default()
1805 };
1806 let body = adapter.build_request_body(&messages, &config);
1807 assert_eq!(
1808 body["generationConfig"]["thinkingConfig"]["thinkingBudget"],
1809 -1
1810 );
1811 }
1812
1813 #[test]
1817 fn build_request_body_omits_thinking_config_on_gemini_2_0() {
1818 let adapter = GeminiAdapter::new(
1819 "test-key".to_string(),
1820 "gemini-2.0-flash".to_string(),
1821 "https://generativelanguage.googleapis.com/v1beta".to_string(),
1822 )
1823 .expect("adapter constructs");
1824 let messages = vec![ChatMessage::user("hi")];
1825 let config = ModelConfig {
1826 reasoning: ReasoningLevel::Medium,
1827 ..Default::default()
1828 };
1829 let body = adapter.build_request_body(&messages, &config);
1830 assert!(
1832 body["generationConfig"].get("thinkingConfig").is_none(),
1833 "legacy Gemini models must NOT receive thinkingConfig"
1834 );
1835 }
1836
1837 #[test]
1840 fn dispatch_is_level_for_gemini_3_models() {
1841 use crate::models::catalog::{ThinkingShape, lookup};
1842 for m in ["gemini-3-pro", "gemini-3-flash", "gemini-3-flash-lite"] {
1843 assert_eq!(lookup(m).thinking, ThinkingShape::GeminiLevel, "{m}");
1844 }
1845 }
1846
1847 #[test]
1848 fn dispatch_budgets_pin_per_model_floors_and_disable_rules() {
1849 use crate::models::catalog::{ThinkingShape, lookup};
1850 assert_eq!(
1851 lookup("gemini-2.5-pro").thinking,
1852 ThinkingShape::GeminiBudget {
1853 min: 128,
1854 can_disable: false
1855 }
1856 );
1857 assert_eq!(
1858 lookup("gemini-2.5-flash-lite").thinking,
1859 ThinkingShape::GeminiBudget {
1860 min: 512,
1861 can_disable: true
1862 }
1863 );
1864 assert_eq!(
1865 lookup("gemini-2.5-flash").thinking,
1866 ThinkingShape::GeminiBudget {
1867 min: 0,
1868 can_disable: true
1869 }
1870 );
1871 }
1872
1873 #[test]
1874 fn dispatch_is_provider_default_for_legacy_gemini_models() {
1875 use crate::models::catalog::{ThinkingShape, lookup};
1876 assert_eq!(
1879 lookup("gemini-2.0-flash").thinking,
1880 ThinkingShape::ProviderDefault
1881 );
1882 assert_eq!(
1883 lookup("gemini-1.5-pro").thinking,
1884 ThinkingShape::ProviderDefault
1885 );
1886 }
1887
1888 #[test]
1891 fn thinking_level_per_reasoning_level() {
1892 assert_eq!(thinking_level_for(ReasoningLevel::None), "minimal");
1893 assert_eq!(thinking_level_for(ReasoningLevel::Minimal), "minimal");
1894 assert_eq!(thinking_level_for(ReasoningLevel::Low), "low");
1895 assert_eq!(thinking_level_for(ReasoningLevel::Medium), "medium");
1896 assert_eq!(thinking_level_for(ReasoningLevel::High), "high");
1897 assert_eq!(thinking_level_for(ReasoningLevel::Max), "high");
1899 assert_eq!(thinking_level_for(ReasoningLevel::XHigh), "high");
1900 }
1901
1902 #[test]
1903 fn thinking_budget_for_xhigh_matches_max_adaptive_sentinel() {
1904 assert_eq!(thinking_budget_for(ReasoningLevel::Max), -1);
1907 assert_eq!(thinking_budget_for(ReasoningLevel::XHigh), -1);
1908 }
1909
1910 #[test]
1911 fn build_request_body_includes_tools_in_function_declarations_shape() {
1912 let adapter = test_adapter();
1913 let messages = vec![ChatMessage::user("hi")];
1914 let config = ModelConfig {
1918 tools: (0..5)
1919 .map(|i| {
1920 serde_json::json!({
1921 "type": "function",
1922 "function": {
1923 "name": format!("tool_{}", i),
1924 "description": "a test tool",
1925 "parameters": {"type": "object"}
1926 }
1927 })
1928 })
1929 .collect(),
1930 ..Default::default()
1931 };
1932 let body = adapter.build_request_body(&messages, &config);
1933 let tools = body["tools"].as_array().expect("tools array");
1934 assert!(!tools.is_empty());
1935 assert!(tools[0]["functionDeclarations"].is_array());
1936 let decls = tools[0]["functionDeclarations"].as_array().unwrap();
1937 assert_eq!(decls.len(), 5);
1938 }
1939
1940 #[test]
1941 fn build_request_body_preserves_registry_selected_web_tools() {
1942 let adapter = test_adapter();
1943 let config = ModelConfig {
1944 tools: ["web_fetch", "web_search"]
1945 .into_iter()
1946 .map(|name| {
1947 serde_json::json!({
1948 "type": "function",
1949 "function": {
1950 "name": name,
1951 "description": "registered web tool",
1952 "parameters": {"type": "object"}
1953 }
1954 })
1955 })
1956 .collect(),
1957 ..Default::default()
1958 };
1959
1960 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config);
1961 let names: Vec<&str> = body["tools"][0]["functionDeclarations"]
1962 .as_array()
1963 .expect("function declarations")
1964 .iter()
1965 .filter_map(|tool| tool.get("name").and_then(Value::as_str))
1966 .collect();
1967 assert_eq!(names, ["web_fetch", "web_search"]);
1968 }
1969
1970 #[test]
1971 fn build_request_body_clamps_temperature() {
1972 let adapter = test_adapter();
1973 let messages = vec![ChatMessage::user("hi")];
1974 let config = ModelConfig {
1975 temperature: 5.0, ..Default::default()
1977 };
1978 let body = adapter.build_request_body(&messages, &config);
1979 let temp = body["generationConfig"]["temperature"].as_f64().unwrap();
1980 assert!(temp <= 2.0);
1981 }
1982
1983 use std::sync::Arc;
1986 use std::sync::Mutex;
1987
1988 fn record_callback() -> (StreamCallback, Arc<Mutex<Vec<StreamEvent>>>) {
1991 let events: Arc<Mutex<Vec<StreamEvent>>> = Arc::new(Mutex::new(Vec::new()));
1992 let clone = Arc::clone(&events);
1993 let cb: StreamCallback = Arc::new(move |evt| {
1994 clone.lock().unwrap().push(evt);
1995 });
1996 (cb, events)
1997 }
1998
1999 fn count_text(events: &[StreamEvent]) -> usize {
2000 events
2001 .iter()
2002 .filter(|e| matches!(e, StreamEvent::Text(_)))
2003 .count()
2004 }
2005
2006 fn count_reasoning(events: &[StreamEvent]) -> usize {
2007 events
2008 .iter()
2009 .filter(|e| matches!(e, StreamEvent::Reasoning(_)))
2010 .count()
2011 }
2012
2013 fn count_tool_calls(events: &[StreamEvent]) -> usize {
2014 events
2015 .iter()
2016 .filter(|e| matches!(e, StreamEvent::ToolCall(_)))
2017 .count()
2018 }
2019
2020 #[test]
2021 fn stream_text_only_multi_chunk() {
2022 let (cb, events) = record_callback();
2023 let mut state = StreamState::default();
2024
2025 let chunk1 = json!({
2027 "candidates": [{
2028 "content": {"parts": [{"text": "Hello, "}]}
2029 }]
2030 })
2031 .to_string();
2032 process_chunk_payload(&chunk1, &mut state, &cb, false).unwrap();
2033
2034 let chunk2 = json!({
2036 "candidates": [{
2037 "content": {"parts": [{"text": "world!"}]}
2038 }],
2039 "usageMetadata": {
2040 "promptTokenCount": 5,
2041 "candidatesTokenCount": 3,
2042 "totalTokenCount": 8
2043 }
2044 })
2045 .to_string();
2046 process_chunk_payload(&chunk2, &mut state, &cb, false).unwrap();
2047
2048 assert_eq!(state.text_acc, "Hello, world!");
2049 assert_eq!(state.prompt_tokens, 5);
2050 assert_eq!(state.completion_tokens, 3);
2051 assert_eq!(state.usage().expect("usage present").total_tokens(), 8);
2052
2053 let evts = events.lock().unwrap();
2054 assert_eq!(count_text(&evts), 2);
2055 assert_eq!(count_reasoning(&evts), 0);
2056 assert_eq!(count_tool_calls(&evts), 0);
2057 }
2058
2059 #[test]
2060 fn stream_usage_is_none_without_usage_metadata() {
2061 let (cb, _events) = record_callback();
2064 let mut state = StreamState::default();
2065 let chunk =
2066 json!({ "candidates": [{ "content": {"parts": [{"text": "hi"}]} }] }).to_string();
2067 process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2068 assert!(!state.saw_usage);
2069 assert!(state.usage().is_none());
2070 }
2071
2072 #[test]
2073 fn stream_usage_does_not_double_count_cached_input() {
2074 let (cb, _events) = record_callback();
2077 let mut state = StreamState::default();
2078 let chunk = json!({
2079 "candidates": [{ "content": {"parts": [{"text": "hi"}]} }],
2080 "usageMetadata": {
2081 "promptTokenCount": 1000,
2082 "cachedContentTokenCount": 300,
2083 "candidatesTokenCount": 50,
2084 "totalTokenCount": 1050
2085 }
2086 })
2087 .to_string();
2088 process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2089 assert!(state.saw_usage);
2090 let usage = state.usage().expect("usage present");
2091 assert_eq!(
2092 usage.input_total_tokens(),
2093 1000,
2094 "cached input must not be double-counted"
2095 );
2096 }
2097
2098 #[test]
2099 fn stream_thought_then_text() {
2100 let (cb, events) = record_callback();
2101 let mut state = StreamState::default();
2102
2103 let chunk1 = json!({
2104 "candidates": [{
2105 "content": {"parts": [{"text": "let me think...", "thought": true}]}
2106 }]
2107 })
2108 .to_string();
2109 process_chunk_payload(&chunk1, &mut state, &cb, false).unwrap();
2110
2111 let chunk2 = json!({
2112 "candidates": [{
2113 "content": {"parts": [{"text": "the answer is 42"}]}
2114 }]
2115 })
2116 .to_string();
2117 process_chunk_payload(&chunk2, &mut state, &cb, false).unwrap();
2118
2119 assert_eq!(state.thinking_acc, "let me think...");
2120 assert_eq!(state.text_acc, "the answer is 42");
2121
2122 let evts = events.lock().unwrap();
2123 assert_eq!(count_reasoning(&evts), 1);
2124 assert_eq!(count_text(&evts), 1);
2125 }
2126
2127 #[test]
2128 fn stream_function_call_emits_tool_call_event() {
2129 let (cb, events) = record_callback();
2130 let mut state = StreamState::default();
2131
2132 let chunk = json!({
2133 "candidates": [{
2134 "content": {
2135 "parts": [
2136 {"functionCall": {"name": "read_file", "args": {"path": "Cargo.toml"}}}
2137 ]
2138 }
2139 }]
2140 })
2141 .to_string();
2142 process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2143
2144 assert_eq!(state.tool_calls_done.len(), 1);
2145 let tc = &state.tool_calls_done[0];
2146 assert_eq!(tc.function.name, "read_file");
2147 assert_eq!(tc.function.arguments["path"], "Cargo.toml");
2148 assert_eq!(tc.id.as_deref(), Some("call_0"));
2149
2150 let evts = events.lock().unwrap();
2151 assert_eq!(count_tool_calls(&evts), 1);
2152 }
2153
2154 #[test]
2155 fn stream_thought_text_and_tool_call_in_one_chunk() {
2156 let (cb, events) = record_callback();
2157 let mut state = StreamState::default();
2158
2159 let chunk = json!({
2160 "candidates": [{
2161 "content": {
2162 "parts": [
2163 {"text": "thinking...", "thought": true},
2164 {"text": "calling tool now"},
2165 {"functionCall": {"name": "list_dir", "args": {"path": "."}}}
2166 ]
2167 }
2168 }]
2169 })
2170 .to_string();
2171 process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2172
2173 assert_eq!(state.thinking_acc, "thinking...");
2174 assert_eq!(state.text_acc, "calling tool now");
2175 assert_eq!(state.tool_calls_done.len(), 1);
2176
2177 let evts = events.lock().unwrap();
2178 assert_eq!(count_reasoning(&evts), 1);
2179 assert_eq!(count_text(&evts), 1);
2180 assert_eq!(count_tool_calls(&evts), 1);
2181 }
2182
2183 #[test]
2184 fn stream_hide_reasoning_trace_suppresses_event_but_accumulates() {
2185 let (cb, events) = record_callback();
2186 let mut state = StreamState::default();
2187
2188 let chunk = json!({
2189 "candidates": [{
2190 "content": {"parts": [{"text": "hidden thoughts", "thought": true}]}
2191 }]
2192 })
2193 .to_string();
2194 process_chunk_payload(&chunk, &mut state, &cb, true).unwrap();
2196
2197 assert_eq!(state.thinking_acc, "hidden thoughts");
2200 let evts = events.lock().unwrap();
2201 assert_eq!(count_reasoning(&evts), 0);
2202 }
2203
2204 #[test]
2205 fn stream_mid_stream_error_returns_error() {
2206 let (cb, _events) = record_callback();
2207 let mut state = StreamState::default();
2208
2209 let chunk = json!({
2210 "error": {
2211 "code": 429,
2212 "message": "Resource exhausted",
2213 "status": "RESOURCE_EXHAUSTED"
2214 }
2215 })
2216 .to_string();
2217 let result = process_chunk_payload(&chunk, &mut state, &cb, false);
2218 assert!(result.is_err());
2219 match result {
2220 Err(ModelError::Backend(BackendError::ProviderError { code, message, .. })) => {
2221 assert_eq!(code.as_deref(), Some("RESOURCE_EXHAUSTED"));
2222 assert!(message.contains("Resource exhausted"));
2223 },
2224 other => panic!("expected ProviderError, got {:?}", other),
2225 }
2226 }
2227
2228 #[test]
2229 fn stream_tool_call_ids_are_synthesized_in_sequence() {
2230 let (cb, _events) = record_callback();
2231 let mut state = StreamState::default();
2232
2233 let chunk = json!({
2234 "candidates": [{
2235 "content": {
2236 "parts": [
2237 {"functionCall": {"name": "tool_a", "args": {}}},
2238 {"functionCall": {"name": "tool_b", "args": {}}}
2239 ]
2240 }
2241 }]
2242 })
2243 .to_string();
2244 process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2245
2246 assert_eq!(state.tool_calls_done.len(), 2);
2247 assert_eq!(state.tool_calls_done[0].id.as_deref(), Some("call_0"));
2248 assert_eq!(state.tool_calls_done[1].id.as_deref(), Some("call_1"));
2249 }
2250
2251 #[test]
2252 fn stream_safety_block_with_no_parts_errors() {
2253 let (cb, _events) = record_callback();
2254 let mut state = StreamState::default();
2255 let chunk = json!({ "candidates": [{ "finishReason": "SAFETY" }] }).to_string();
2257 assert!(process_chunk_payload(&chunk, &mut state, &cb, false).is_err());
2258 }
2259
2260 #[test]
2261 fn stream_max_tokens_keeps_partial_and_sets_length() {
2262 let (cb, _events) = record_callback();
2263 let mut state = StreamState::default();
2264 let chunk = json!({
2265 "candidates": [{
2266 "content": {"parts": [{"text": "partial"}]},
2267 "finishReason": "MAX_TOKENS"
2268 }]
2269 })
2270 .to_string();
2271 process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2272 assert_eq!(state.finish_reason, Some(FinishReason::Length));
2273 assert_eq!(state.text_acc, "partial");
2274 }
2275
2276 #[test]
2277 fn stream_closed_abnormally_when_no_finish_reason_observed() {
2278 let (cb, _events) = record_callback();
2282 let mut state = StreamState::default();
2283 let chunk =
2284 json!({ "candidates": [{ "content": {"parts": [{"text": "partial answer"}]} }] })
2285 .to_string();
2286 process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2287 assert!(
2288 stream_closed_abnormally(state.finish_reason.as_ref()),
2289 "no finishReason observed yet → abnormal if the stream ends here"
2290 );
2291
2292 let final_chunk = json!({
2294 "candidates": [{ "content": {"parts": [{"text": "."}]}, "finishReason": "STOP" }]
2295 })
2296 .to_string();
2297 process_chunk_payload(&final_chunk, &mut state, &cb, false).unwrap();
2298 assert!(!stream_closed_abnormally(state.finish_reason.as_ref()));
2299 }
2300
2301 #[test]
2302 fn stream_closed_abnormally_preserves_max_tokens_truncation() {
2303 assert!(stream_closed_abnormally(None));
2306 assert!(!stream_closed_abnormally(Some(&FinishReason::Length)));
2307 assert!(!stream_closed_abnormally(Some(&FinishReason::Stop)));
2308 }
2309
2310 #[test]
2311 fn parallel_same_tool_calls_keep_call_order() {
2312 let (cb, _events) = record_callback();
2317 let mut state = StreamState::default();
2318 let chunk = json!({
2319 "candidates": [{
2320 "content": {"parts": [
2321 {"functionCall": {"name": "read_file", "args": {"path": "a"}}},
2322 {"functionCall": {"name": "read_file", "args": {"path": "b"}}}
2323 ]}
2324 }]
2325 })
2326 .to_string();
2327 process_chunk_payload(&chunk, &mut state, &cb, false).unwrap();
2328 assert_eq!(state.tool_calls_done.len(), 2);
2329 assert_eq!(state.tool_calls_done[0].id.as_deref(), Some("call_0"));
2330 assert_eq!(state.tool_calls_done[1].id.as_deref(), Some("call_1"));
2331 assert_eq!(state.tool_calls_done[0].function.arguments["path"], "a");
2332 assert_eq!(state.tool_calls_done[1].function.arguments["path"], "b");
2333 }
2334}