1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::Duration;
7
8use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION};
9use reqwest::{Client as HttpClient, Proxy};
10use tokio::sync::OnceCell;
11
12use crate::auth::OAuthTokenProvider;
13use crate::error::{Error, Result};
14use google_cloud_auth::credentials::{
15 Builder as AuthBuilder, CacheableResource, Credentials as GoogleCredentials,
16};
17use http::Extensions;
18
19#[derive(Clone)]
21pub struct Client {
22 inner: Arc<ClientInner>,
23}
24
25pub(crate) struct ClientInner {
26 pub http: HttpClient,
27 pub config: ClientConfig,
28 pub api_client: ApiClient,
29 pub(crate) auth_provider: Option<AuthProvider>,
30}
31
32#[derive(Debug, Clone)]
34pub struct ClientConfig {
35 pub api_key: Option<String>,
37 pub backend: Backend,
39 pub vertex_config: Option<VertexConfig>,
41 pub http_options: HttpOptions,
43 pub credentials: Credentials,
45 pub auth_scopes: Vec<String>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Backend {
52 GeminiApi,
53 VertexAi,
54}
55
56#[derive(Debug, Clone)]
58pub enum Credentials {
59 ApiKey(String),
61 OAuth {
63 client_secret_path: PathBuf,
64 token_cache_path: Option<PathBuf>,
65 },
66 ApplicationDefault,
68}
69
70#[derive(Debug, Clone)]
72pub struct VertexConfig {
73 pub project: String,
74 pub location: String,
75 pub credentials: Option<VertexCredentials>,
76}
77
78#[derive(Debug, Clone)]
80pub struct VertexCredentials {
81 pub access_token: Option<String>,
82}
83
84#[derive(Debug, Clone, Default)]
86pub struct HttpOptions {
87 pub timeout: Option<u64>,
88 pub proxy: Option<String>,
89 pub headers: HashMap<String, String>,
90 pub base_url: Option<String>,
91 pub api_version: Option<String>,
92}
93
94impl Client {
95 pub fn new(api_key: impl Into<String>) -> Result<Self> {
100 Self::builder()
101 .api_key(api_key)
102 .backend(Backend::GeminiApi)
103 .build()
104 }
105
106 pub fn from_env() -> Result<Self> {
111 let api_key = std::env::var("GEMINI_API_KEY")
112 .or_else(|_| std::env::var("GOOGLE_API_KEY"))
113 .map_err(|_| Error::InvalidConfig {
114 message: "GEMINI_API_KEY or GOOGLE_API_KEY not found".into(),
115 })?;
116 let mut builder = Self::builder().api_key(api_key);
117 if let Ok(base_url) =
118 std::env::var("GENAI_BASE_URL").or_else(|_| std::env::var("GEMINI_BASE_URL"))
119 {
120 if !base_url.trim().is_empty() {
121 builder = builder.base_url(base_url);
122 }
123 }
124 if let Ok(api_version) = std::env::var("GENAI_API_VERSION") {
125 if !api_version.trim().is_empty() {
126 builder = builder.api_version(api_version);
127 }
128 }
129 builder.build()
130 }
131
132 pub fn new_vertex(project: impl Into<String>, location: impl Into<String>) -> Result<Self> {
137 Self::builder()
138 .backend(Backend::VertexAi)
139 .vertex_project(project)
140 .vertex_location(location)
141 .build()
142 }
143
144 pub fn with_oauth(client_secret_path: impl AsRef<Path>) -> Result<Self> {
149 Self::builder()
150 .credentials(Credentials::OAuth {
151 client_secret_path: client_secret_path.as_ref().to_path_buf(),
152 token_cache_path: None,
153 })
154 .build()
155 }
156
157 pub fn with_adc() -> Result<Self> {
162 Self::builder()
163 .credentials(Credentials::ApplicationDefault)
164 .build()
165 }
166
167 #[must_use]
169 pub fn builder() -> ClientBuilder {
170 ClientBuilder::default()
171 }
172
173 #[must_use]
175 pub fn models(&self) -> crate::models::Models {
176 crate::models::Models::new(self.inner.clone())
177 }
178
179 #[must_use]
181 pub fn chats(&self) -> crate::chats::Chats {
182 crate::chats::Chats::new(self.inner.clone())
183 }
184
185 #[must_use]
187 pub fn files(&self) -> crate::files::Files {
188 crate::files::Files::new(self.inner.clone())
189 }
190
191 #[must_use]
193 pub fn file_search_stores(&self) -> crate::file_search_stores::FileSearchStores {
194 crate::file_search_stores::FileSearchStores::new(self.inner.clone())
195 }
196
197 #[must_use]
199 pub fn documents(&self) -> crate::documents::Documents {
200 crate::documents::Documents::new(self.inner.clone())
201 }
202
203 #[must_use]
205 pub fn live(&self) -> crate::live::Live {
206 crate::live::Live::new(self.inner.clone())
207 }
208
209 #[must_use]
211 pub fn live_music(&self) -> crate::live_music::LiveMusic {
212 crate::live_music::LiveMusic::new(self.inner.clone())
213 }
214
215 #[must_use]
217 pub fn caches(&self) -> crate::caches::Caches {
218 crate::caches::Caches::new(self.inner.clone())
219 }
220
221 #[must_use]
223 pub fn batches(&self) -> crate::batches::Batches {
224 crate::batches::Batches::new(self.inner.clone())
225 }
226
227 #[must_use]
229 pub fn tunings(&self) -> crate::tunings::Tunings {
230 crate::tunings::Tunings::new(self.inner.clone())
231 }
232
233 #[must_use]
235 pub fn operations(&self) -> crate::operations::Operations {
236 crate::operations::Operations::new(self.inner.clone())
237 }
238
239 #[must_use]
241 pub fn auth_tokens(&self) -> crate::tokens::AuthTokens {
242 crate::tokens::AuthTokens::new(self.inner.clone())
243 }
244
245 #[must_use]
247 pub fn interactions(&self) -> crate::interactions::Interactions {
248 crate::interactions::Interactions::new(self.inner.clone())
249 }
250
251 #[must_use]
253 pub fn deep_research(&self) -> crate::deep_research::DeepResearch {
254 crate::deep_research::DeepResearch::new(self.inner.clone())
255 }
256}
257
258#[derive(Default)]
260pub struct ClientBuilder {
261 api_key: Option<String>,
262 credentials: Option<Credentials>,
263 backend: Option<Backend>,
264 vertex_project: Option<String>,
265 vertex_location: Option<String>,
266 http_options: HttpOptions,
267 auth_scopes: Option<Vec<String>>,
268}
269
270impl ClientBuilder {
271 #[must_use]
273 pub fn api_key(mut self, key: impl Into<String>) -> Self {
274 self.api_key = Some(key.into());
275 self
276 }
277
278 #[must_use]
280 pub fn credentials(mut self, credentials: Credentials) -> Self {
281 self.credentials = Some(credentials);
282 self
283 }
284
285 #[must_use]
287 pub const fn backend(mut self, backend: Backend) -> Self {
288 self.backend = Some(backend);
289 self
290 }
291
292 #[must_use]
294 pub fn vertex_project(mut self, project: impl Into<String>) -> Self {
295 self.vertex_project = Some(project.into());
296 self
297 }
298
299 #[must_use]
301 pub fn vertex_location(mut self, location: impl Into<String>) -> Self {
302 self.vertex_location = Some(location.into());
303 self
304 }
305
306 #[must_use]
308 pub const fn timeout(mut self, secs: u64) -> Self {
309 self.http_options.timeout = Some(secs);
310 self
311 }
312
313 #[must_use]
315 pub fn proxy(mut self, url: impl Into<String>) -> Self {
316 self.http_options.proxy = Some(url.into());
317 self
318 }
319
320 #[must_use]
322 pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
323 self.http_options.headers.insert(key.into(), value.into());
324 self
325 }
326
327 #[must_use]
329 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
330 self.http_options.base_url = Some(base_url.into());
331 self
332 }
333
334 #[must_use]
336 pub fn api_version(mut self, api_version: impl Into<String>) -> Self {
337 self.http_options.api_version = Some(api_version.into());
338 self
339 }
340
341 #[must_use]
343 pub fn auth_scopes(mut self, scopes: Vec<String>) -> Self {
344 self.auth_scopes = Some(scopes);
345 self
346 }
347
348 pub fn build(self) -> Result<Client> {
353 let Self {
354 api_key,
355 credentials,
356 backend,
357 vertex_project,
358 vertex_location,
359 http_options,
360 auth_scopes,
361 } = self;
362
363 let backend = Self::resolve_backend(
364 backend,
365 vertex_project.as_deref(),
366 vertex_location.as_deref(),
367 );
368 Self::validate_vertex_config(
369 backend,
370 vertex_project.as_deref(),
371 vertex_location.as_deref(),
372 )?;
373 let credentials = Self::resolve_credentials(backend, api_key.as_deref(), credentials)?;
374 let headers = Self::build_headers(&http_options, backend, &credentials)?;
375 let http = Self::build_http_client(&http_options, headers)?;
376
377 let auth_scopes = auth_scopes.unwrap_or_else(|| default_auth_scopes(backend));
378 let api_key = match &credentials {
379 Credentials::ApiKey(key) => Some(key.clone()),
380 _ => None,
381 };
382 let vertex_config = Self::build_vertex_config(backend, vertex_project, vertex_location)?;
383 let config = ClientConfig {
384 api_key,
385 backend,
386 vertex_config,
387 http_options,
388 credentials: credentials.clone(),
389 auth_scopes,
390 };
391
392 let auth_provider = build_auth_provider(&credentials)?;
393 let api_client = ApiClient::new(&config);
394
395 Ok(Client {
396 inner: Arc::new(ClientInner {
397 http,
398 config,
399 api_client,
400 auth_provider,
401 }),
402 })
403 }
404
405 fn resolve_backend(
406 backend: Option<Backend>,
407 vertex_project: Option<&str>,
408 vertex_location: Option<&str>,
409 ) -> Backend {
410 backend.unwrap_or_else(|| {
411 if vertex_project.is_some() || vertex_location.is_some() {
412 Backend::VertexAi
413 } else {
414 Backend::GeminiApi
415 }
416 })
417 }
418
419 fn validate_vertex_config(
420 backend: Backend,
421 vertex_project: Option<&str>,
422 vertex_location: Option<&str>,
423 ) -> Result<()> {
424 if backend == Backend::VertexAi && (vertex_project.is_none() || vertex_location.is_none()) {
425 return Err(Error::InvalidConfig {
426 message: "Project and location required for Vertex AI".into(),
427 });
428 }
429 Ok(())
430 }
431
432 fn resolve_credentials(
433 backend: Backend,
434 api_key: Option<&str>,
435 credentials: Option<Credentials>,
436 ) -> Result<Credentials> {
437 if credentials.is_some()
438 && api_key.is_some()
439 && !matches!(credentials, Some(Credentials::ApiKey(_)))
440 {
441 return Err(Error::InvalidConfig {
442 message: "API key cannot be combined with OAuth/ADC credentials".into(),
443 });
444 }
445
446 let credentials = match credentials {
447 Some(credentials) => credentials,
448 None => {
449 if let Some(api_key) = api_key {
450 Credentials::ApiKey(api_key.to_string())
451 } else if backend == Backend::VertexAi {
452 Credentials::ApplicationDefault
453 } else {
454 return Err(Error::InvalidConfig {
455 message: "API key or OAuth credentials required for Gemini API".into(),
456 });
457 }
458 }
459 };
460
461 if backend == Backend::VertexAi && matches!(credentials, Credentials::ApiKey(_)) {
462 return Err(Error::InvalidConfig {
463 message: "Vertex AI does not support API key authentication".into(),
464 });
465 }
466
467 Ok(credentials)
468 }
469
470 fn build_headers(
471 http_options: &HttpOptions,
472 backend: Backend,
473 credentials: &Credentials,
474 ) -> Result<HeaderMap> {
475 let mut headers = HeaderMap::new();
476 for (key, value) in &http_options.headers {
477 let name =
478 HeaderName::from_bytes(key.as_bytes()).map_err(|_| Error::InvalidConfig {
479 message: format!("Invalid header name: {key}"),
480 })?;
481 let value = HeaderValue::from_str(value).map_err(|_| Error::InvalidConfig {
482 message: format!("Invalid header value for {key}"),
483 })?;
484 headers.insert(name, value);
485 }
486
487 if backend == Backend::GeminiApi {
488 let api_key = match credentials {
489 Credentials::ApiKey(key) => key.as_str(),
490 _ => "",
491 };
492 let header_name = HeaderName::from_static("x-goog-api-key");
493 if !api_key.is_empty() && !headers.contains_key(&header_name) {
494 let mut header_value =
495 HeaderValue::from_str(api_key).map_err(|_| Error::InvalidConfig {
496 message: "Invalid API key value".into(),
497 })?;
498 header_value.set_sensitive(true);
499 headers.insert(header_name, header_value);
500 }
501 }
502
503 Ok(headers)
504 }
505
506 fn build_http_client(http_options: &HttpOptions, headers: HeaderMap) -> Result<HttpClient> {
507 let mut http_builder = HttpClient::builder();
508 if let Some(timeout) = http_options.timeout {
509 http_builder = http_builder.timeout(Duration::from_secs(timeout));
510 }
511
512 if let Some(proxy_url) = &http_options.proxy {
513 let proxy = Proxy::all(proxy_url).map_err(|e| Error::InvalidConfig {
514 message: format!("Invalid proxy: {e}"),
515 })?;
516 http_builder = http_builder.proxy(proxy);
517 }
518
519 if !headers.is_empty() {
520 http_builder = http_builder.default_headers(headers);
521 }
522
523 Ok(http_builder.build()?)
524 }
525
526 fn build_vertex_config(
527 backend: Backend,
528 vertex_project: Option<String>,
529 vertex_location: Option<String>,
530 ) -> Result<Option<VertexConfig>> {
531 if backend != Backend::VertexAi {
532 return Ok(None);
533 }
534 let project = vertex_project.ok_or_else(|| Error::InvalidConfig {
535 message: "Project and location required for Vertex AI".into(),
536 })?;
537 let location = vertex_location.ok_or_else(|| Error::InvalidConfig {
538 message: "Project and location required for Vertex AI".into(),
539 })?;
540 Ok(Some(VertexConfig {
541 project,
542 location,
543 credentials: None,
544 }))
545 }
546}
547
548fn build_auth_provider(credentials: &Credentials) -> Result<Option<AuthProvider>> {
549 match credentials {
550 Credentials::ApiKey(_) => Ok(None),
551 Credentials::OAuth {
552 client_secret_path,
553 token_cache_path,
554 } => Ok(Some(AuthProvider::OAuth(Arc::new(
555 OAuthTokenProvider::from_paths(client_secret_path.clone(), token_cache_path.clone())?,
556 )))),
557 Credentials::ApplicationDefault => Ok(Some(AuthProvider::ApplicationDefault(Arc::new(
558 OnceCell::new(),
559 )))),
560 }
561}
562
563#[derive(Clone)]
564pub(crate) enum AuthProvider {
565 OAuth(Arc<OAuthTokenProvider>),
566 ApplicationDefault(Arc<OnceCell<Arc<GoogleCredentials>>>),
567}
568
569impl AuthProvider {
570 async fn headers(&self, scopes: &[&str]) -> Result<HeaderMap> {
571 match self {
572 Self::OAuth(provider) => {
573 let token = provider.token().await?;
574 let mut header =
575 HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| Error::Auth {
576 message: "Invalid OAuth access token".into(),
577 })?;
578 header.set_sensitive(true);
579 let mut headers = HeaderMap::new();
580 headers.insert(AUTHORIZATION, header);
581 Ok(headers)
582 }
583 Self::ApplicationDefault(cell) => {
584 let credentials = cell
585 .get_or_try_init(|| async {
586 AuthBuilder::default()
587 .with_scopes(scopes.iter().copied())
588 .build()
589 .map(Arc::new)
590 .map_err(|err| Error::Auth {
591 message: format!("ADC init failed: {err}"),
592 })
593 })
594 .await?;
595 let headers = credentials
596 .headers(Extensions::new())
597 .await
598 .map_err(|err| Error::Auth {
599 message: format!("ADC header fetch failed: {err}"),
600 })?;
601 match headers {
602 CacheableResource::New { data, .. } => Ok(data),
603 CacheableResource::NotModified => Err(Error::Auth {
604 message: "ADC header fetch returned NotModified without cached headers"
605 .into(),
606 }),
607 }
608 }
609 }
610 }
611}
612
613impl ClientInner {
614 pub async fn send(&self, request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
619 let mut request = request.build()?;
620 if let Some(headers) = self.auth_headers().await? {
621 for (name, value) in &headers {
622 if request.headers().contains_key(name) {
623 continue;
624 }
625 let mut value = value.clone();
626 if name == AUTHORIZATION {
627 value.set_sensitive(true);
628 }
629 request.headers_mut().insert(name.clone(), value);
630 }
631 }
632 #[cfg(feature = "mcp")]
633 crate::mcp::append_mcp_usage_header(request.headers_mut())?;
634 Ok(self.http.execute(request).await?)
635 }
636
637 async fn auth_headers(&self) -> Result<Option<HeaderMap>> {
638 let Some(provider) = &self.auth_provider else {
639 return Ok(None);
640 };
641
642 let scopes: Vec<&str> = self.config.auth_scopes.iter().map(String::as_str).collect();
643 let headers = provider.headers(&scopes).await?;
644 Ok(Some(headers))
645 }
646}
647
648fn default_auth_scopes(backend: Backend) -> Vec<String> {
649 match backend {
650 Backend::VertexAi => vec!["https://www.googleapis.com/auth/cloud-platform".into()],
651 Backend::GeminiApi => vec![
652 "https://www.googleapis.com/auth/generative-language".into(),
653 "https://www.googleapis.com/auth/generative-language.retriever".into(),
654 ],
655 }
656}
657
658pub(crate) struct ApiClient {
659 pub base_url: String,
660 pub api_version: String,
661}
662
663impl ApiClient {
664 pub fn new(config: &ClientConfig) -> Self {
666 let base_url = config.http_options.base_url.as_deref().map_or_else(
667 || match config.backend {
668 Backend::VertexAi => {
669 let location = config
670 .vertex_config
671 .as_ref()
672 .map_or("", |cfg| cfg.location.as_str());
673 if location.is_empty() {
674 "https://aiplatform.googleapis.com/".to_string()
675 } else {
676 format!("https://{location}-aiplatform.googleapis.com/")
677 }
678 }
679 Backend::GeminiApi => "https://generativelanguage.googleapis.com/".to_string(),
680 },
681 normalize_base_url,
682 );
683
684 let api_version =
685 config
686 .http_options
687 .api_version
688 .clone()
689 .unwrap_or_else(|| match config.backend {
690 Backend::VertexAi => "v1beta1".to_string(),
691 Backend::GeminiApi => "v1beta".to_string(),
692 });
693
694 Self {
695 base_url,
696 api_version,
697 }
698 }
699}
700
701fn normalize_base_url(base_url: &str) -> String {
702 let mut value = base_url.trim().to_string();
703 if !value.ends_with('/') {
704 value.push('/');
705 }
706 value
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use crate::test_support::with_env;
713 use std::path::PathBuf;
714 use tempfile::tempdir;
715
716 #[test]
717 fn test_client_from_api_key() {
718 let client = Client::new("test-api-key").unwrap();
719 assert_eq!(client.inner.config.backend, Backend::GeminiApi);
720 }
721
722 #[test]
723 fn test_client_builder() {
724 let client = Client::builder()
725 .api_key("test-key")
726 .timeout(30)
727 .build()
728 .unwrap();
729 assert!(client.inner.config.api_key.is_some());
730 }
731
732 #[test]
733 fn test_vertex_ai_config() {
734 let client = Client::new_vertex("my-project", "us-central1").unwrap();
735 assert_eq!(client.inner.config.backend, Backend::VertexAi);
736 assert_eq!(
737 client.inner.api_client.base_url,
738 "https://us-central1-aiplatform.googleapis.com/"
739 );
740 }
741
742 #[test]
743 fn test_base_url_normalization() {
744 let client = Client::builder()
745 .api_key("test-key")
746 .base_url("https://example.com")
747 .build()
748 .unwrap();
749 assert_eq!(client.inner.api_client.base_url, "https://example.com/");
750 }
751
752 #[test]
753 fn test_from_env_reads_overrides() {
754 with_env(
755 &[
756 ("GEMINI_API_KEY", Some("env-key")),
757 ("GENAI_BASE_URL", Some("https://env.example.com")),
758 ("GENAI_API_VERSION", Some("v99")),
759 ("GOOGLE_API_KEY", None),
760 ],
761 || {
762 let client = Client::from_env().unwrap();
763 assert_eq!(client.inner.api_client.base_url, "https://env.example.com/");
764 assert_eq!(client.inner.api_client.api_version, "v99");
765 },
766 );
767 }
768
769 #[test]
770 fn test_from_env_ignores_empty_overrides() {
771 with_env(
772 &[
773 ("GEMINI_API_KEY", Some("env-key")),
774 ("GENAI_BASE_URL", Some(" ")),
775 ("GENAI_API_VERSION", Some("")),
776 ("GOOGLE_API_KEY", None),
777 ],
778 || {
779 let client = Client::from_env().unwrap();
780 assert_eq!(
781 client.inner.api_client.base_url,
782 "https://generativelanguage.googleapis.com/"
783 );
784 assert_eq!(client.inner.api_client.api_version, "v1beta");
785 },
786 );
787 }
788
789 #[test]
790 fn test_from_env_missing_key_errors() {
791 with_env(
792 &[
793 ("GEMINI_API_KEY", None),
794 ("GOOGLE_API_KEY", None),
795 ("GENAI_BASE_URL", None),
796 ],
797 || {
798 let result = Client::from_env();
799 assert!(result.is_err());
800 },
801 );
802 }
803
804 #[test]
805 fn test_from_env_google_api_key_fallback() {
806 with_env(
807 &[
808 ("GEMINI_API_KEY", None),
809 ("GOOGLE_API_KEY", Some("google-key")),
810 ],
811 || {
812 let client = Client::from_env().unwrap();
813 assert_eq!(client.inner.config.api_key.as_deref(), Some("google-key"));
814 },
815 );
816 }
817
818 #[test]
819 fn test_with_oauth_missing_client_secret_errors() {
820 let dir = tempdir().unwrap();
821 let secret_path = dir.path().join("missing_client_secret.json");
822 let err = Client::with_oauth(&secret_path).err().unwrap();
823 assert!(matches!(err, Error::InvalidConfig { .. }));
824 }
825
826 #[test]
827 fn test_with_adc_builds_client() {
828 let client = Client::with_adc().unwrap();
829 assert!(matches!(
830 client.inner.config.credentials,
831 Credentials::ApplicationDefault
832 ));
833 }
834
835 #[test]
836 fn test_builder_defaults_to_vertex_when_project_set() {
837 let client = Client::builder()
838 .vertex_project("proj")
839 .vertex_location("loc")
840 .build()
841 .unwrap();
842 assert_eq!(client.inner.config.backend, Backend::VertexAi);
843 assert!(matches!(
844 client.inner.config.credentials,
845 Credentials::ApplicationDefault
846 ));
847 }
848
849 #[test]
850 fn test_valid_proxy_is_accepted() {
851 let client = Client::builder()
852 .api_key("test-key")
853 .proxy("http://127.0.0.1:8888")
854 .build();
855 assert!(client.is_ok());
856 }
857
858 #[test]
859 fn test_vertex_requires_project_and_location() {
860 let result = Client::builder().backend(Backend::VertexAi).build();
861 assert!(result.is_err());
862 }
863
864 #[test]
865 fn test_api_key_with_oauth_is_invalid() {
866 let result = Client::builder()
867 .api_key("test-key")
868 .credentials(Credentials::OAuth {
869 client_secret_path: PathBuf::from("client_secret.json"),
870 token_cache_path: None,
871 })
872 .build();
873 assert!(result.is_err());
874 }
875
876 #[test]
877 fn test_missing_api_key_for_gemini_errors() {
878 let result = Client::builder().backend(Backend::GeminiApi).build();
879 assert!(result.is_err());
880 }
881
882 #[test]
883 fn test_invalid_header_name_is_rejected() {
884 let result = Client::builder()
885 .api_key("test-key")
886 .header("bad header", "value")
887 .build();
888 assert!(result.is_err());
889 }
890
891 #[test]
892 fn test_invalid_header_value_is_rejected() {
893 let result = Client::builder()
894 .api_key("test-key")
895 .header("x-test", "bad\nvalue")
896 .build();
897 assert!(result.is_err());
898 }
899
900 #[test]
901 fn test_invalid_api_key_value_is_rejected() {
902 let err = Client::builder().api_key("bad\nkey").build().err().unwrap();
903 assert!(
904 matches!(err, Error::InvalidConfig { message } if message.contains("Invalid API key value"))
905 );
906 }
907
908 #[test]
909 fn test_invalid_proxy_is_rejected() {
910 let result = Client::builder()
911 .api_key("test-key")
912 .proxy("not a url")
913 .build();
914 assert!(result.is_err());
915 }
916
917 #[test]
918 fn test_vertex_api_key_is_rejected() {
919 let result = Client::builder()
920 .backend(Backend::VertexAi)
921 .vertex_project("proj")
922 .vertex_location("loc")
923 .credentials(Credentials::ApiKey("key".into()))
924 .build();
925 assert!(result.is_err());
926 }
927
928 #[test]
929 fn test_default_auth_scopes() {
930 let gemini = default_auth_scopes(Backend::GeminiApi);
931 assert!(gemini.iter().any(|s| s.contains("generative-language")));
932
933 let vertex = default_auth_scopes(Backend::VertexAi);
934 assert!(vertex.iter().any(|s| s.contains("cloud-platform")));
935 }
936
937 #[test]
938 fn test_custom_auth_scopes_override_default() {
939 let client = Client::builder()
940 .api_key("test-key")
941 .auth_scopes(vec!["scope-1".to_string()])
942 .build()
943 .unwrap();
944 assert_eq!(client.inner.config.auth_scopes, vec!["scope-1".to_string()]);
945 }
946}