1pub(crate) mod data;
7
8use crate::attachments::validate_request_attachments;
9use crate::provider::LlmProvider;
10use crate::streaming::{StreamBox, StreamDelta, StreamErrorKind, reqwest_error_delta};
11use agent_sdk_foundation::llm::{ChatOutcome, ChatRequest, ChatResponse, ThinkingConfig};
12use anyhow::Result;
13use async_trait::async_trait;
14use data::{
15 ApiContent, ApiFunctionCallingConfig, ApiGenerateContentRequest, ApiGenerateContentResponse,
16 ApiGenerationConfig, ApiPart, ApiUsageMetadata, build_api_contents, build_content_blocks,
17 convert_tools_to_config, gemini_response_schema, map_finish_reason, map_thinking_config,
18};
19use reqwest::StatusCode;
20
21const API_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
22
23const CONNECT_TIMEOUT_SECS: u64 = 30;
25const TCP_KEEPALIVE_SECS: u64 = 30;
27const CHAT_READ_TIMEOUT_SECS: u64 = 300;
31
32const MODELS_PAGE_SIZE: u32 = 1000;
34const MODELS_MAX_PAGES: usize = 100;
37
38fn build_http_client() -> reqwest::Client {
41 reqwest::Client::builder()
42 .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
43 .tcp_keepalive(std::time::Duration::from_secs(TCP_KEEPALIVE_SECS))
44 .build()
45 .unwrap_or_else(|error| {
46 log::warn!(
47 "failed to build Gemini HTTP client with timeouts ({error}); using default client"
48 );
49 reqwest::Client::new()
50 })
51}
52
53pub const MODEL_GEMINI_31_PRO: &str = "gemini-3.1-pro-preview";
55pub const MODEL_GEMINI_31_FLASH_LITE: &str = "gemini-3.1-flash-lite-preview";
56
57pub const MODEL_GEMINI_3_FLASH: &str = "gemini-3-flash-preview";
59
60pub const MODEL_GEMINI_3_PRO: &str = "gemini-3.0-pro";
62
63pub const MODEL_GEMINI_25_FLASH: &str = "gemini-2.5-flash";
65pub const MODEL_GEMINI_25_PRO: &str = "gemini-2.5-pro";
66
67pub const MODEL_GEMINI_2_FLASH: &str = "gemini-2.0-flash";
69pub const MODEL_GEMINI_2_FLASH_LITE: &str = "gemini-2.0-flash-lite";
70
71#[derive(Clone)]
73pub struct GeminiProvider {
74 client: reqwest::Client,
75 api_key: String,
76 model: String,
77 base_url: String,
78 thinking: Option<ThinkingConfig>,
79 use_header_auth: bool,
82 extra_headers: Vec<(String, String)>,
84}
85
86impl GeminiProvider {
87 pub const API_KEY_ENV: &'static str = "GEMINI_API_KEY";
89
90 #[must_use]
92 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
93 Self {
94 client: build_http_client(),
95 api_key: api_key.into(),
96 model: model.into(),
97 base_url: API_BASE_URL.to_owned(),
98 thinking: None,
99 use_header_auth: true,
100 extra_headers: Vec::new(),
101 }
102 }
103
104 fn effective_max_tokens(&self, request: &ChatRequest) -> u32 {
111 if request.max_tokens_explicit {
112 request.max_tokens
113 } else {
114 self.default_max_tokens()
115 }
116 }
117
118 #[must_use]
126 pub fn from_env() -> Self {
127 Self::try_from_env().unwrap_or_else(|e| panic!("{e}"))
128 }
129
130 pub fn try_from_env() -> Result<Self> {
137 let api_key = std::env::var(Self::API_KEY_ENV).map_err(|_| {
138 anyhow::anyhow!("environment variable `{}` is not set", Self::API_KEY_ENV)
139 })?;
140 Ok(Self::flash(api_key))
141 }
142
143 #[must_use]
145 pub fn flash(api_key: impl Into<String>) -> Self {
146 Self::new(api_key, MODEL_GEMINI_3_FLASH)
147 }
148
149 #[must_use]
151 pub fn flash_lite_31(api_key: String) -> Self {
152 Self::new(api_key, MODEL_GEMINI_31_FLASH_LITE.to_owned())
153 }
154
155 #[must_use]
157 pub fn flash_lite(api_key: String) -> Self {
158 Self::new(api_key, MODEL_GEMINI_2_FLASH_LITE.to_owned())
159 }
160
161 #[must_use]
163 pub fn pro_31(api_key: String) -> Self {
164 Self::new(api_key, MODEL_GEMINI_31_PRO.to_owned())
165 }
166
167 #[must_use]
169 pub fn pro(api_key: String) -> Self {
170 Self::new(api_key, MODEL_GEMINI_31_PRO.to_owned())
171 }
172
173 #[must_use]
175 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
176 self.thinking = Some(thinking);
177 self
178 }
179
180 #[must_use]
182 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
183 self.base_url = base_url.into();
184 self
185 }
186
187 #[must_use]
190 pub const fn with_header_auth(mut self) -> Self {
191 self.use_header_auth = true;
192 self
193 }
194
195 #[must_use]
197 pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
198 self.extra_headers = headers;
199 self
200 }
201
202 fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
205 let builder = if self.api_key.is_empty() {
206 builder
207 } else if self.use_header_auth {
208 builder.header("x-goog-api-key", &self.api_key)
209 } else {
210 builder.query(&[("key", &self.api_key)])
211 };
212 self.extra_headers
213 .iter()
214 .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
215 }
216}
217
218#[async_trait]
219#[allow(clippy::too_many_lines)]
220impl LlmProvider for GeminiProvider {
221 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
222 let thinking = match self.resolve_thinking_config(request.thinking.as_ref()) {
223 Ok(thinking) => thinking,
224 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
225 };
226 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
227 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
228 }
229 let contents = build_api_contents(&request.messages);
230 let tools = request
231 .tools
232 .as_ref()
233 .map(|t| convert_tools_to_config(t.clone()));
234 let tool_config = request
235 .tool_choice
236 .as_ref()
237 .map(ApiFunctionCallingConfig::from_tool_choice);
238 let system_instruction = if request.system.is_empty() {
239 None
240 } else {
241 Some(ApiContent {
242 role: None,
243 parts: vec![ApiPart::Text {
244 text: request.system.clone(),
245 thought_signature: None,
246 }],
247 })
248 };
249
250 let thinking_config = thinking.as_ref().map(map_thinking_config);
251 let (response_mime_type, response_schema) =
252 request.response_format.as_ref().map_or((None, None), |rf| {
253 (
254 Some("application/json"),
255 Some(gemini_response_schema(&rf.schema)),
256 )
257 });
258
259 let max_tokens = self.effective_max_tokens(&request);
260 let api_request = ApiGenerateContentRequest {
261 contents: &contents,
262 system_instruction: system_instruction.as_ref(),
263 tools: tools.as_ref().map(std::slice::from_ref),
264 tool_config,
265 generation_config: Some(ApiGenerationConfig {
266 max_output_tokens: Some(max_tokens),
267 thinking_config,
268 response_mime_type,
269 response_schema,
270 }),
271 cached_content: request.cached_content.as_deref(),
272 };
273
274 log::debug!(
275 "Gemini LLM request model={} max_tokens={}",
276 self.model,
277 max_tokens
278 );
279
280 let builder = self
281 .client
282 .post(format!(
283 "{}/models/{}:generateContent",
284 self.base_url, self.model
285 ))
286 .header("Content-Type", "application/json")
287 .timeout(std::time::Duration::from_secs(CHAT_READ_TIMEOUT_SECS));
288 let response = self
289 .apply_auth(builder)
290 .json(&api_request)
291 .send()
292 .await
293 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
294
295 let status = response.status();
296 let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
298 crate::http::retry_after_from_headers(response.headers())
299 } else {
300 None
301 };
302 let bytes = response
303 .bytes()
304 .await
305 .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
306
307 log::debug!(
308 "Gemini LLM response status={} body_len={}",
309 status,
310 bytes.len()
311 );
312
313 if status == StatusCode::TOO_MANY_REQUESTS {
314 let retry_after = retry_after.or_else(|| {
315 crate::retry_hints::google_retry_delay(&String::from_utf8_lossy(&bytes))
316 });
317 return Ok(ChatOutcome::RateLimited(retry_after));
318 }
319
320 if status.is_server_error() {
321 let body = String::from_utf8_lossy(&bytes);
322 log::error!("Gemini server error status={status} body={body}");
323 return Ok(ChatOutcome::ServerError(body.into_owned()));
324 }
325
326 if status.is_client_error() {
327 let body = String::from_utf8_lossy(&bytes);
328 log::warn!("Gemini client error status={status} body={body}");
329 return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
330 }
331
332 let api_response: ApiGenerateContentResponse = serde_json::from_slice(&bytes)
333 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
334
335 let candidate = api_response
336 .candidates
337 .into_iter()
338 .next()
339 .ok_or_else(|| anyhow::anyhow!("no candidates in response"))?;
340
341 let content = build_content_blocks(&candidate.content);
342
343 if content.is_empty() && !candidate.content.parts.is_empty() {
344 log::warn!(
345 "Gemini parts not converted to content blocks raw_parts={:?}",
346 candidate.content.parts
347 );
348 }
349
350 let has_tool_calls = content
351 .iter()
352 .any(|b| matches!(b, agent_sdk_foundation::llm::ContentBlock::ToolUse { .. }));
353
354 let stop_reason = candidate
355 .finish_reason
356 .as_ref()
357 .map(|r| map_finish_reason(r, has_tool_calls));
358
359 let usage = api_response
360 .usage_metadata
361 .unwrap_or(ApiUsageMetadata {
362 prompt: 0,
363 candidates: 0,
364 cached_content: 0,
365 })
366 .into_usage();
367
368 Ok(ChatOutcome::Success(ChatResponse {
369 id: String::new(),
370 content,
371 model: self.model.clone(),
372 stop_reason,
373 usage,
374 }))
375 }
376
377 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
378 Box::pin(async_stream::stream! {
379 let thinking = match self.resolve_thinking_config(request.thinking.as_ref()) {
380 Ok(thinking) => thinking,
381 Err(error) => {
382 yield Ok(StreamDelta::Error {
383 message: error.to_string(),
384 kind: StreamErrorKind::InvalidRequest,
385 });
386 return;
387 }
388 };
389 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
390 yield Ok(StreamDelta::Error {
391 message: error.to_string(),
392 kind: StreamErrorKind::InvalidRequest,
393 });
394 return;
395 }
396 let contents = build_api_contents(&request.messages);
397 let tools = request
398 .tools
399 .as_ref()
400 .map(|t| convert_tools_to_config(t.clone()));
401 let tool_config = request
402 .tool_choice
403 .as_ref()
404 .map(ApiFunctionCallingConfig::from_tool_choice);
405 let system_instruction = if request.system.is_empty() {
406 None
407 } else {
408 Some(ApiContent {
409 role: None,
410 parts: vec![ApiPart::Text {
411 text: request.system.clone(),
412 thought_signature: None,
413 }],
414 })
415 };
416
417 let thinking_config = thinking.as_ref().map(map_thinking_config);
418 let (response_mime_type, response_schema) = request
419 .response_format
420 .as_ref()
421 .map_or((None, None), |rf| {
422 (
423 Some("application/json"),
424 Some(gemini_response_schema(&rf.schema)),
425 )
426 });
427
428 let max_tokens = self.effective_max_tokens(&request);
429 let api_request = ApiGenerateContentRequest {
430 contents: &contents,
431 system_instruction: system_instruction.as_ref(),
432 tools: tools.as_ref().map(std::slice::from_ref),
433 tool_config,
434 generation_config: Some(ApiGenerationConfig {
435 max_output_tokens: Some(max_tokens),
436 thinking_config,
437 response_mime_type,
438 response_schema,
439 }),
440 cached_content: request.cached_content.as_deref(),
441 };
442
443 log::debug!(
444 "Gemini streaming LLM request model={} max_tokens={}",
445 self.model,
446 max_tokens
447 );
448
449 let stream_builder = self
450 .client
451 .post(format!(
452 "{}/models/{}:streamGenerateContent",
453 self.base_url, self.model
454 ))
455 .header("Content-Type", "application/json")
456 .query(&[("alt", "sse")]);
457 let response = match self
458 .apply_auth(stream_builder)
459 .json(&api_request)
460 .send()
461 .await
462 {
463 Ok(r) => r,
464 Err(error) => {
465 yield Ok(reqwest_error_delta("request failed", &error));
466 return;
467 }
468 };
469
470 let status = response.status();
471 if !status.is_success() {
472 let header_hint = crate::http::retry_after_from_headers(response.headers());
474 let body = response.text().await.unwrap_or_default();
475 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
476 StreamErrorKind::RateLimited(
477 header_hint.or_else(|| crate::retry_hints::google_retry_delay(&body)),
478 )
479 } else if status.is_server_error() {
480 StreamErrorKind::ServerError
481 } else {
482 StreamErrorKind::InvalidRequest
483 };
484 log::warn!("Gemini error status={status} body={body}");
485 yield Ok(StreamDelta::Error {
486 message: body,
487 kind,
488 });
489 return;
490 }
491
492 let mut inner = data::stream_gemini_response(response);
493 while let Some(item) = futures::StreamExt::next(&mut inner).await {
494 yield item;
495 }
496 })
497 }
498
499 async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
500 let mut rows: Vec<GeminiModelRow> = Vec::new();
505 let mut page_token: Option<String> = None;
506 for _ in 0..MODELS_MAX_PAGES {
507 let mut query: Vec<(&str, String)> = vec![("pageSize", MODELS_PAGE_SIZE.to_string())];
508 if let Some(token) = &page_token {
509 query.push(("pageToken", token.clone()));
510 }
511 let builder = self
512 .client
513 .get(format!("{}/models", self.base_url))
514 .header("Content-Type", "application/json")
515 .query(&query);
516 let builder = self.apply_auth(builder);
517 let body =
518 crate::impls::model_listing::fetch_model_list_body(builder, "Gemini").await?;
519 let page = parse_models_page(&body)?;
520 rows.extend(page.models);
521 match page.next_page_token {
522 Some(token) if !token.is_empty() => page_token = Some(token),
523 _ => break,
524 }
525 }
526 Ok(finalize_gemini_models(rows))
527 }
528
529 async fn probe_connectivity(&self) -> bool {
530 crate::provider::probe_http_reachability(&self.client, &self.base_url).await
531 }
532
533 fn model(&self) -> &str {
534 &self.model
535 }
536
537 fn provider(&self) -> &'static str {
538 "gemini"
539 }
540
541 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
542 self.thinking.as_ref()
543 }
544}
545
546#[derive(serde::Deserialize)]
550struct GeminiModelRow {
551 name: String,
552 #[serde(rename = "displayName", default)]
553 display_name: Option<String>,
554 #[serde(rename = "inputTokenLimit", default)]
555 input_token_limit: Option<u32>,
556 #[serde(rename = "outputTokenLimit", default)]
557 output_token_limit: Option<u32>,
558 #[serde(rename = "supportedGenerationMethods", default)]
559 supported_generation_methods: Vec<String>,
560}
561
562struct GeminiModelsPage {
565 models: Vec<GeminiModelRow>,
566 next_page_token: Option<String>,
567}
568
569fn parse_models_page(body: &str) -> Result<GeminiModelsPage> {
577 #[derive(serde::Deserialize)]
578 struct ListResponse {
579 #[serde(default)]
580 models: Vec<GeminiModelRow>,
581 #[serde(rename = "nextPageToken", default)]
582 next_page_token: Option<String>,
583 }
584 let parsed: ListResponse = serde_json::from_str(body)
585 .map_err(|e| anyhow::anyhow!("failed to parse Gemini models list: {e}"))?;
586 Ok(GeminiModelsPage {
587 models: parsed.models,
588 next_page_token: parsed.next_page_token,
589 })
590}
591
592fn finalize_gemini_models(rows: Vec<GeminiModelRow>) -> Vec<crate::provider::ModelInfo> {
600 rows.into_iter()
601 .filter(|row| {
602 row.supported_generation_methods.is_empty()
603 || row
604 .supported_generation_methods
605 .iter()
606 .any(|m| m == "generateContent")
607 })
608 .map(|row| crate::provider::ModelInfo {
609 id: match row.name.strip_prefix("models/") {
610 Some(stripped) => stripped.to_owned(),
611 None => row.name.clone(),
612 },
613 display_name: row.display_name,
614 context_window: row.input_token_limit,
615 max_output_tokens: row.output_token_limit,
616 })
617 .collect()
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 const GEMINI_MODELS_FIXTURE: &str = r#"{
625 "models": [
626 {
627 "name": "models/gemini-2.5-pro",
628 "displayName": "Gemini 2.5 Pro",
629 "inputTokenLimit": 1048576,
630 "outputTokenLimit": 65536,
631 "supportedGenerationMethods": ["generateContent", "countTokens"]
632 },
633 {
634 "name": "models/text-embedding-004",
635 "displayName": "Text Embedding 004",
636 "inputTokenLimit": 2048,
637 "outputTokenLimit": 1,
638 "supportedGenerationMethods": ["embedContent"]
639 }
640 ]
641 }"#;
642
643 #[test]
644 fn parse_models_page_strips_prefix_and_maps_limits() -> anyhow::Result<()> {
645 let page = parse_models_page(GEMINI_MODELS_FIXTURE)?;
646 let models = finalize_gemini_models(page.models);
647 assert_eq!(models.len(), 1);
649 let pro = &models[0];
650 assert_eq!(pro.id, "gemini-2.5-pro");
651 assert_eq!(pro.display_name.as_deref(), Some("Gemini 2.5 Pro"));
652 assert_eq!(pro.context_window, Some(1_048_576));
653 assert_eq!(pro.max_output_tokens, Some(65_536));
654 assert_eq!(page.next_page_token, None);
655 Ok(())
656 }
657
658 #[tokio::test]
659 async fn list_models_follows_pagination_and_filters_after_all_pages() -> anyhow::Result<()> {
660 use wiremock::matchers::{method, path, query_param, query_param_is_missing};
661 use wiremock::{Mock, MockServer, ResponseTemplate};
662
663 let server = MockServer::start().await;
664
665 Mock::given(method("GET"))
669 .and(path("/models"))
670 .and(query_param_is_missing("pageToken"))
671 .respond_with(ResponseTemplate::new(200).set_body_string(
672 r#"{
673 "models": [
674 {
675 "name": "models/gemini-2.5-pro",
676 "displayName": "Gemini 2.5 Pro",
677 "inputTokenLimit": 1048576,
678 "outputTokenLimit": 65536,
679 "supportedGenerationMethods": ["generateContent"]
680 },
681 {
682 "name": "models/text-embedding-004",
683 "displayName": "Embedding",
684 "supportedGenerationMethods": ["embedContent"]
685 }
686 ],
687 "nextPageToken": "page-2"
688 }"#,
689 ))
690 .mount(&server)
691 .await;
692
693 Mock::given(method("GET"))
695 .and(path("/models"))
696 .and(query_param("pageToken", "page-2"))
697 .respond_with(ResponseTemplate::new(200).set_body_string(
698 r#"{
699 "models": [
700 {
701 "name": "models/gemini-3-flash",
702 "displayName": "Gemini 3 Flash",
703 "inputTokenLimit": 1048576,
704 "outputTokenLimit": 65536,
705 "supportedGenerationMethods": ["generateContent"]
706 }
707 ]
708 }"#,
709 ))
710 .mount(&server)
711 .await;
712
713 let provider = GeminiProvider::new("test-key".to_string(), "gemini-test".to_string())
714 .with_base_url(server.uri());
715 let models = provider.list_models().await?;
716
717 let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect();
720 assert_eq!(ids, vec!["gemini-2.5-pro", "gemini-3-flash"]);
721 Ok(())
722 }
723
724 #[test]
725 fn test_new_creates_provider_with_custom_model() {
726 let provider = GeminiProvider::new("test-api-key".to_string(), "custom-model".to_string());
727
728 assert_eq!(provider.model(), "custom-model");
729 assert_eq!(provider.provider(), "gemini");
730 }
731
732 #[test]
733 fn test_flash_factory_creates_flash_provider() {
734 let provider = GeminiProvider::flash("test-api-key".to_string());
735
736 assert_eq!(provider.model(), MODEL_GEMINI_3_FLASH);
737 assert_eq!(provider.provider(), "gemini");
738 }
739
740 #[test]
741 fn test_flash_lite_factory_creates_flash_lite_provider() {
742 let provider = GeminiProvider::flash_lite("test-api-key".to_string());
743
744 assert_eq!(provider.model(), MODEL_GEMINI_2_FLASH_LITE);
745 assert_eq!(provider.provider(), "gemini");
746 }
747
748 #[test]
749 fn test_flash_lite_31_factory_creates_flash_lite_provider() {
750 let provider = GeminiProvider::flash_lite_31("test-api-key".to_string());
751
752 assert_eq!(provider.model(), MODEL_GEMINI_31_FLASH_LITE);
753 assert_eq!(provider.provider(), "gemini");
754 }
755
756 #[test]
757 fn test_pro_factory_creates_pro_provider() {
758 let provider = GeminiProvider::pro("test-api-key".to_string());
759
760 assert_eq!(provider.model(), MODEL_GEMINI_31_PRO);
761 assert_eq!(provider.provider(), "gemini");
762 }
763
764 #[test]
765 fn test_pro_31_factory_creates_pro_provider() {
766 let provider = GeminiProvider::pro_31("test-api-key".to_string());
767
768 assert_eq!(provider.model(), MODEL_GEMINI_31_PRO);
769 assert_eq!(provider.provider(), "gemini");
770 }
771
772 #[test]
773 fn test_model_constants_have_expected_values() {
774 assert_eq!(MODEL_GEMINI_31_PRO, "gemini-3.1-pro-preview");
775 assert_eq!(MODEL_GEMINI_31_FLASH_LITE, "gemini-3.1-flash-lite-preview");
776 assert_eq!(MODEL_GEMINI_3_FLASH, "gemini-3-flash-preview");
777 assert_eq!(MODEL_GEMINI_3_PRO, "gemini-3.0-pro");
778 assert_eq!(MODEL_GEMINI_25_FLASH, "gemini-2.5-flash");
779 assert_eq!(MODEL_GEMINI_25_PRO, "gemini-2.5-pro");
780 assert_eq!(MODEL_GEMINI_2_FLASH, "gemini-2.0-flash");
781 assert_eq!(MODEL_GEMINI_2_FLASH_LITE, "gemini-2.0-flash-lite");
782 }
783
784 #[test]
785 fn test_gemini_20_models_reject_thinking() {
786 let provider = GeminiProvider::flash_lite("test-api-key".to_string());
787 let error = provider
788 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
789 .unwrap_err();
790 assert!(error.to_string().contains("thinking is not supported"));
791 }
792
793 #[test]
794 fn test_default_uses_header_auth() {
795 let provider = GeminiProvider::new("test-key".to_string(), "model".to_string());
796 assert!(
797 provider.use_header_auth,
798 "Default should use header auth for security"
799 );
800 }
801
802 #[test]
803 fn test_provider_is_cloneable() {
804 let provider = GeminiProvider::new("test-api-key".to_string(), "test-model".to_string());
805 let cloned = provider.clone();
806
807 assert_eq!(provider.model(), cloned.model());
808 assert_eq!(provider.provider(), cloned.provider());
809 }
810
811 fn request_with_max_tokens(max_tokens: u32, explicit: bool) -> ChatRequest {
812 ChatRequest {
813 system: String::new(),
814 messages: vec![agent_sdk_foundation::llm::Message::user("hi")],
815 tools: None,
816 max_tokens,
817 max_tokens_explicit: explicit,
818 session_id: None,
819 cached_content: None,
820 thinking: None,
821 tool_choice: None,
822 response_format: None,
823 cache: None,
824 }
825 }
826
827 #[test]
828 fn test_effective_max_tokens_honors_explicit_budget() {
829 let provider = GeminiProvider::pro("test-api-key".to_string());
830 let request = request_with_max_tokens(123, true);
831 assert_eq!(provider.effective_max_tokens(&request), 123);
832 }
833
834 #[test]
835 fn test_effective_max_tokens_uses_default_when_implicit() {
836 let provider = GeminiProvider::pro("test-api-key".to_string());
839 let request = request_with_max_tokens(4096, false);
840 assert_eq!(
841 provider.effective_max_tokens(&request),
842 provider.default_max_tokens()
843 );
844 }
845}