1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, LazyLock};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
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;
18use rust_genai_types::http::HttpRetryOptions;
19
20const X_GOOG_API_CLIENT_HEADER: &str = "x-goog-api-client";
21const SDK_USAGE_HEADER_VALUE: &str = concat!(
22 "google-genai-sdk/",
23 env!("CARGO_PKG_VERSION"),
24 " gl-rust/unknown"
25);
26
27#[derive(Clone)]
29pub struct Client {
30 inner: Arc<ClientInner>,
31}
32
33pub(crate) struct ClientInner {
34 pub http: HttpClient,
35 pub config: ClientConfig,
36 pub api_client: ApiClient,
37 pub(crate) auth_provider: Option<AuthProvider>,
38}
39
40#[derive(Debug, Clone)]
42pub struct ClientConfig {
43 pub api_key: Option<String>,
45 pub backend: Backend,
47 pub vertex_config: Option<VertexConfig>,
49 pub http_options: HttpOptions,
51 pub credentials: Credentials,
53 pub auth_scopes: Vec<String>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Backend {
60 GeminiApi,
61 VertexAi,
62}
63
64#[derive(Debug, Clone)]
66pub enum Credentials {
67 ApiKey(String),
69 OAuth {
71 client_secret_path: PathBuf,
72 token_cache_path: Option<PathBuf>,
73 },
74 ApplicationDefault,
76}
77
78#[derive(Debug, Clone)]
80pub struct VertexConfig {
81 pub project: String,
82 pub location: String,
83 pub credentials: Option<VertexCredentials>,
84}
85
86#[derive(Debug, Clone)]
88pub struct VertexCredentials {
89 pub access_token: Option<String>,
90}
91
92#[derive(Debug, Clone, Default)]
94pub struct HttpOptions {
95 pub timeout: Option<u64>,
96 pub proxy: Option<String>,
97 pub headers: HashMap<String, String>,
98 pub base_url: Option<String>,
99 pub api_version: Option<String>,
100 pub retry_options: Option<HttpRetryOptions>,
101}
102
103impl Client {
104 pub fn new(api_key: impl Into<String>) -> Result<Self> {
109 Self::builder()
110 .api_key(api_key)
111 .backend(Backend::GeminiApi)
112 .build()
113 }
114
115 pub fn from_env() -> Result<Self> {
120 let vertex_override = env_flag("GOOGLE_GENAI_USE_VERTEXAI");
121 let vertex_project = first_nonempty_env(&["GOOGLE_CLOUD_PROJECT"]);
122 let vertex_location = first_nonempty_env(&["GOOGLE_CLOUD_LOCATION"]);
123 let api_key = first_nonempty_env(&["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
124 let has_complete_vertex_env = vertex_project.is_some() && vertex_location.is_some();
125 let use_vertex = match vertex_override {
126 Some(flag) => flag,
127 None => has_complete_vertex_env && api_key.is_none(),
128 };
129
130 let mut builder = if use_vertex {
131 let mut builder = Self::builder().backend(Backend::VertexAi);
132 if let Some(project) = vertex_project {
133 builder = builder.vertex_project(project);
134 }
135 if let Some(location) = vertex_location {
136 builder = builder.vertex_location(location);
137 }
138 builder
139 } else {
140 let api_key = api_key.ok_or_else(|| Error::InvalidConfig {
141 message: "GEMINI_API_KEY or GOOGLE_API_KEY not found".into(),
142 })?;
143 Self::builder().api_key(api_key).backend(Backend::GeminiApi)
144 };
145
146 let base_url_envs: &[&str] = if use_vertex {
147 &["GOOGLE_GENAI_BASE_URL", "GENAI_BASE_URL"]
148 } else {
149 &["GOOGLE_GENAI_BASE_URL", "GENAI_BASE_URL", "GEMINI_BASE_URL"]
150 };
151 if let Some(base_url) = first_nonempty_env(base_url_envs) {
152 builder = builder.base_url(base_url);
153 }
154 if let Some(api_version) =
155 first_nonempty_env(&["GOOGLE_GENAI_API_VERSION", "GENAI_API_VERSION"])
156 {
157 builder = builder.api_version(api_version);
158 }
159 builder.build()
160 }
161
162 pub fn new_vertex(project: impl Into<String>, location: impl Into<String>) -> Result<Self> {
167 Self::builder()
168 .backend(Backend::VertexAi)
169 .vertex_project(project)
170 .vertex_location(location)
171 .build()
172 }
173
174 pub fn with_oauth(client_secret_path: impl AsRef<Path>) -> Result<Self> {
179 Self::builder()
180 .credentials(Credentials::OAuth {
181 client_secret_path: client_secret_path.as_ref().to_path_buf(),
182 token_cache_path: None,
183 })
184 .build()
185 }
186
187 pub fn with_adc() -> Result<Self> {
192 Self::builder()
193 .credentials(Credentials::ApplicationDefault)
194 .build()
195 }
196
197 #[must_use]
199 pub fn builder() -> ClientBuilder {
200 ClientBuilder::default()
201 }
202
203 #[must_use]
205 pub fn models(&self) -> crate::models::Models {
206 crate::models::Models::new(self.inner.clone())
207 }
208
209 #[must_use]
211 pub fn chats(&self) -> crate::chats::Chats {
212 crate::chats::Chats::new(self.inner.clone())
213 }
214
215 #[must_use]
217 pub fn files(&self) -> crate::files::Files {
218 crate::files::Files::new(self.inner.clone())
219 }
220
221 #[must_use]
223 pub fn file_search_stores(&self) -> crate::file_search_stores::FileSearchStores {
224 crate::file_search_stores::FileSearchStores::new(self.inner.clone())
225 }
226
227 #[must_use]
229 pub fn documents(&self) -> crate::documents::Documents {
230 crate::documents::Documents::new(self.inner.clone())
231 }
232
233 #[must_use]
235 pub fn live(&self) -> crate::live::Live {
236 crate::live::Live::new(self.inner.clone())
237 }
238
239 #[must_use]
241 pub fn live_music(&self) -> crate::live_music::LiveMusic {
242 crate::live_music::LiveMusic::new(self.inner.clone())
243 }
244
245 #[must_use]
247 pub fn caches(&self) -> crate::caches::Caches {
248 crate::caches::Caches::new(self.inner.clone())
249 }
250
251 #[must_use]
253 pub fn batches(&self) -> crate::batches::Batches {
254 crate::batches::Batches::new(self.inner.clone())
255 }
256
257 #[must_use]
259 pub fn tunings(&self) -> crate::tunings::Tunings {
260 crate::tunings::Tunings::new(self.inner.clone())
261 }
262
263 #[must_use]
265 pub fn operations(&self) -> crate::operations::Operations {
266 crate::operations::Operations::new(self.inner.clone())
267 }
268
269 #[must_use]
271 pub fn auth_tokens(&self) -> crate::tokens::AuthTokens {
272 crate::tokens::AuthTokens::new(self.inner.clone())
273 }
274
275 #[must_use]
279 pub fn tokens(&self) -> crate::tokens::Tokens {
280 self.auth_tokens()
281 }
282
283 #[must_use]
285 pub fn interactions(&self) -> crate::interactions::Interactions {
286 crate::interactions::Interactions::new(self.inner.clone())
287 }
288
289 #[must_use]
291 pub fn webhooks(&self) -> crate::webhooks::Webhooks {
292 crate::webhooks::Webhooks::new(self.inner.clone())
293 }
294
295 #[must_use]
297 pub fn deep_research(&self) -> crate::deep_research::DeepResearch {
298 crate::deep_research::DeepResearch::new(self.inner.clone())
299 }
300}
301
302#[derive(Default)]
304pub struct ClientBuilder {
305 api_key: Option<String>,
306 credentials: Option<Credentials>,
307 backend: Option<Backend>,
308 vertex_project: Option<String>,
309 vertex_location: Option<String>,
310 http_options: HttpOptions,
311 auth_scopes: Option<Vec<String>>,
312}
313
314impl ClientBuilder {
315 #[must_use]
317 pub fn api_key(mut self, key: impl Into<String>) -> Self {
318 self.api_key = Some(key.into());
319 self
320 }
321
322 #[must_use]
324 pub fn credentials(mut self, credentials: Credentials) -> Self {
325 self.credentials = Some(credentials);
326 self
327 }
328
329 #[must_use]
331 pub const fn backend(mut self, backend: Backend) -> Self {
332 self.backend = Some(backend);
333 self
334 }
335
336 #[must_use]
338 pub fn vertex_project(mut self, project: impl Into<String>) -> Self {
339 self.vertex_project = Some(project.into());
340 self
341 }
342
343 #[must_use]
345 pub fn vertex_location(mut self, location: impl Into<String>) -> Self {
346 self.vertex_location = Some(location.into());
347 self
348 }
349
350 #[must_use]
352 pub const fn timeout(mut self, secs: u64) -> Self {
353 self.http_options.timeout = Some(secs);
354 self
355 }
356
357 #[must_use]
359 pub fn proxy(mut self, url: impl Into<String>) -> Self {
360 self.http_options.proxy = Some(url.into());
361 self
362 }
363
364 #[must_use]
366 pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
367 self.http_options.headers.insert(key.into(), value.into());
368 self
369 }
370
371 #[must_use]
373 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
374 self.http_options.base_url = Some(base_url.into());
375 self
376 }
377
378 #[must_use]
380 pub fn api_version(mut self, api_version: impl Into<String>) -> Self {
381 self.http_options.api_version = Some(api_version.into());
382 self
383 }
384
385 #[must_use]
387 pub fn retry_options(mut self, retry_options: HttpRetryOptions) -> Self {
388 self.http_options.retry_options = Some(retry_options);
389 self
390 }
391
392 #[must_use]
394 pub fn auth_scopes(mut self, scopes: Vec<String>) -> Self {
395 self.auth_scopes = Some(scopes);
396 self
397 }
398
399 pub fn build(self) -> Result<Client> {
404 let Self {
405 api_key,
406 credentials,
407 backend,
408 vertex_project,
409 vertex_location,
410 http_options,
411 auth_scopes,
412 } = self;
413
414 let backend = Self::resolve_backend(
415 backend,
416 vertex_project.as_deref(),
417 vertex_location.as_deref(),
418 );
419 Self::validate_vertex_config(
420 backend,
421 vertex_project.as_deref(),
422 vertex_location.as_deref(),
423 )?;
424 let credentials = Self::resolve_credentials(backend, api_key.as_deref(), credentials)?;
425 let headers = Self::build_headers(&http_options, backend, &credentials)?;
426 let http = Self::build_http_client(&http_options, headers)?;
427
428 let auth_scopes = auth_scopes.unwrap_or_else(|| default_auth_scopes(backend));
429 let api_key = match &credentials {
430 Credentials::ApiKey(key) => Some(key.clone()),
431 _ => None,
432 };
433 let vertex_config = Self::build_vertex_config(backend, vertex_project, vertex_location)?;
434 let config = ClientConfig {
435 api_key,
436 backend,
437 vertex_config,
438 http_options,
439 credentials: credentials.clone(),
440 auth_scopes,
441 };
442
443 let auth_provider = build_auth_provider(&credentials)?;
444 let api_client = ApiClient::new(&config);
445
446 Ok(Client {
447 inner: Arc::new(ClientInner {
448 http,
449 config,
450 api_client,
451 auth_provider,
452 }),
453 })
454 }
455
456 fn resolve_backend(
457 backend: Option<Backend>,
458 vertex_project: Option<&str>,
459 vertex_location: Option<&str>,
460 ) -> Backend {
461 backend.unwrap_or_else(|| {
462 if vertex_project.is_some() || vertex_location.is_some() {
463 Backend::VertexAi
464 } else {
465 Backend::GeminiApi
466 }
467 })
468 }
469
470 fn validate_vertex_config(
471 backend: Backend,
472 vertex_project: Option<&str>,
473 vertex_location: Option<&str>,
474 ) -> Result<()> {
475 if backend == Backend::VertexAi && (vertex_project.is_none() || vertex_location.is_none()) {
476 return Err(Error::InvalidConfig {
477 message: "Project and location required for Vertex AI".into(),
478 });
479 }
480 Ok(())
481 }
482
483 fn resolve_credentials(
484 backend: Backend,
485 api_key: Option<&str>,
486 credentials: Option<Credentials>,
487 ) -> Result<Credentials> {
488 if credentials.is_some()
489 && api_key.is_some()
490 && !matches!(credentials, Some(Credentials::ApiKey(_)))
491 {
492 return Err(Error::InvalidConfig {
493 message: "API key cannot be combined with OAuth/ADC credentials".into(),
494 });
495 }
496
497 let credentials = match credentials {
498 Some(credentials) => credentials,
499 None => {
500 if let Some(api_key) = api_key {
501 Credentials::ApiKey(api_key.to_string())
502 } else if backend == Backend::VertexAi {
503 Credentials::ApplicationDefault
504 } else {
505 return Err(Error::InvalidConfig {
506 message: "API key or OAuth credentials required for Gemini API".into(),
507 });
508 }
509 }
510 };
511
512 if backend == Backend::VertexAi && matches!(credentials, Credentials::ApiKey(_)) {
513 return Err(Error::InvalidConfig {
514 message: "Vertex AI does not support API key authentication".into(),
515 });
516 }
517
518 Ok(credentials)
519 }
520
521 fn build_headers(
522 http_options: &HttpOptions,
523 backend: Backend,
524 credentials: &Credentials,
525 ) -> Result<HeaderMap> {
526 let mut headers = HeaderMap::new();
527 for (key, value) in &http_options.headers {
528 let name =
529 HeaderName::from_bytes(key.as_bytes()).map_err(|_| Error::InvalidConfig {
530 message: format!("Invalid header name: {key}"),
531 })?;
532 let value = HeaderValue::from_str(value).map_err(|_| Error::InvalidConfig {
533 message: format!("Invalid header value for {key}"),
534 })?;
535 headers.insert(name, value);
536 }
537
538 if backend == Backend::GeminiApi {
539 let api_key = match credentials {
540 Credentials::ApiKey(key) => key.as_str(),
541 _ => "",
542 };
543 let header_name = HeaderName::from_static("x-goog-api-key");
544 if !api_key.is_empty() && !headers.contains_key(&header_name) {
545 let mut header_value =
546 HeaderValue::from_str(api_key).map_err(|_| Error::InvalidConfig {
547 message: "Invalid API key value".into(),
548 })?;
549 header_value.set_sensitive(true);
550 headers.insert(header_name, header_value);
551 }
552 }
553
554 Ok(headers)
555 }
556
557 fn build_http_client(http_options: &HttpOptions, headers: HeaderMap) -> Result<HttpClient> {
558 let mut http_builder = HttpClient::builder();
559 if let Some(timeout) = http_options.timeout {
560 http_builder = http_builder.timeout(Duration::from_secs(timeout));
561 }
562
563 if let Some(proxy_url) = &http_options.proxy {
564 let proxy = Proxy::all(proxy_url).map_err(|e| Error::InvalidConfig {
565 message: format!("Invalid proxy: {e}"),
566 })?;
567 http_builder = http_builder.proxy(proxy);
568 }
569
570 if !headers.is_empty() {
571 http_builder = http_builder.default_headers(headers);
572 }
573
574 Ok(http_builder.build()?)
575 }
576
577 fn build_vertex_config(
578 backend: Backend,
579 vertex_project: Option<String>,
580 vertex_location: Option<String>,
581 ) -> Result<Option<VertexConfig>> {
582 if backend != Backend::VertexAi {
583 return Ok(None);
584 }
585 let project = vertex_project.ok_or_else(|| Error::InvalidConfig {
586 message: "Project and location required for Vertex AI".into(),
587 })?;
588 let location = vertex_location.ok_or_else(|| Error::InvalidConfig {
589 message: "Project and location required for Vertex AI".into(),
590 })?;
591 Ok(Some(VertexConfig {
592 project,
593 location,
594 credentials: None,
595 }))
596 }
597}
598
599fn build_auth_provider(credentials: &Credentials) -> Result<Option<AuthProvider>> {
600 match credentials {
601 Credentials::ApiKey(_) => Ok(None),
602 Credentials::OAuth {
603 client_secret_path,
604 token_cache_path,
605 } => Ok(Some(AuthProvider::OAuth(Arc::new(
606 OAuthTokenProvider::from_paths(client_secret_path.clone(), token_cache_path.clone())?,
607 )))),
608 Credentials::ApplicationDefault => Ok(Some(AuthProvider::ApplicationDefault(Arc::new(
609 OnceCell::new(),
610 )))),
611 }
612}
613
614#[derive(Clone)]
615pub(crate) enum AuthProvider {
616 OAuth(Arc<OAuthTokenProvider>),
617 ApplicationDefault(Arc<OnceCell<Arc<GoogleCredentials>>>),
618}
619
620impl AuthProvider {
621 async fn headers(&self, scopes: &[&str]) -> Result<HeaderMap> {
622 match self {
623 Self::OAuth(provider) => {
624 let token = provider.token().await?;
625 let mut header =
626 HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| Error::Auth {
627 message: "Invalid OAuth access token".into(),
628 })?;
629 header.set_sensitive(true);
630 let mut headers = HeaderMap::new();
631 headers.insert(AUTHORIZATION, header);
632 Ok(headers)
633 }
634 Self::ApplicationDefault(cell) => {
635 let credentials = cell
636 .get_or_try_init(|| async {
637 AuthBuilder::default()
638 .with_scopes(scopes.iter().copied())
639 .build()
640 .map(Arc::new)
641 .map_err(|err| Error::Auth {
642 message: format!("ADC init failed: {err}"),
643 })
644 })
645 .await?;
646 let headers = credentials
647 .headers(Extensions::new())
648 .await
649 .map_err(|err| Error::Auth {
650 message: format!("ADC header fetch failed: {err}"),
651 })?;
652 match headers {
653 CacheableResource::New { data, .. } => Ok(data),
654 CacheableResource::NotModified => Err(Error::Auth {
655 message: "ADC header fetch returned NotModified without cached headers"
656 .into(),
657 }),
658 }
659 }
660 }
661 }
662}
663
664const DEFAULT_RETRY_ATTEMPTS: u32 = 5; const DEFAULT_RETRY_INITIAL_DELAY_SECS: f64 = 1.0;
666const DEFAULT_RETRY_MAX_DELAY_SECS: f64 = 60.0;
667const DEFAULT_RETRY_EXP_BASE: f64 = 2.0;
668const DEFAULT_RETRY_JITTER: f64 = 1.0;
669const DEFAULT_RETRY_HTTP_STATUS_CODES: [u16; 6] = [408, 429, 500, 502, 503, 504];
670static DEFAULT_HTTP_RETRY_OPTIONS: LazyLock<HttpRetryOptions> =
671 LazyLock::new(|| HttpRetryOptions {
672 attempts: Some(DEFAULT_RETRY_ATTEMPTS),
673 initial_delay: Some(DEFAULT_RETRY_INITIAL_DELAY_SECS),
674 max_delay: Some(DEFAULT_RETRY_MAX_DELAY_SECS),
675 exp_base: Some(DEFAULT_RETRY_EXP_BASE),
676 jitter: Some(DEFAULT_RETRY_JITTER),
677 http_status_codes: Some(DEFAULT_RETRY_HTTP_STATUS_CODES.to_vec()),
678 });
679
680#[derive(Debug, Clone, Copy)]
681pub(crate) struct RetryMetadata {
682 pub attempts: u32,
683 pub retryable: bool,
684}
685
686impl ClientInner {
687 pub async fn send(&self, request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
692 self.send_with_http_options(request, None).await
693 }
694
695 pub async fn send_with_http_options(
700 &self,
701 request: reqwest::RequestBuilder,
702 request_http_options: Option<&rust_genai_types::http::HttpOptions>,
703 ) -> Result<reqwest::Response> {
704 let retry_options = request_http_options
705 .and_then(|options| options.retry_options.as_ref())
706 .or(self.config.http_options.retry_options.as_ref())
707 .unwrap_or(&DEFAULT_HTTP_RETRY_OPTIONS);
708
709 let request_template = request.build()?;
710 self.execute_with_retry(request_template, retry_options)
711 .await
712 }
713
714 async fn execute_once(&self, mut request: reqwest::Request) -> Result<reqwest::Response> {
715 self.prepare_request(&mut request).await?;
716 Ok(self.http.execute(request).await?)
717 }
718
719 async fn execute_with_retry(
720 &self,
721 request_template: reqwest::Request,
722 retry_options: &HttpRetryOptions,
723 ) -> Result<reqwest::Response> {
724 let attempts = retry_options.attempts.unwrap_or(DEFAULT_RETRY_ATTEMPTS);
725 let retryable_codes: &[u16] = retry_options
726 .http_status_codes
727 .as_deref()
728 .unwrap_or(&DEFAULT_RETRY_HTTP_STATUS_CODES);
729 if attempts <= 1 {
730 let mut response = self.execute_once(request_template).await?;
731 if !response.status().is_success() {
732 attach_retry_metadata_for_codes(&mut response, 1, retryable_codes);
733 }
734 return Ok(response);
735 }
736
737 if request_template.try_clone().is_none() {
739 let mut response = self.execute_once(request_template).await?;
740 if !response.status().is_success() {
741 attach_retry_metadata_for_codes(&mut response, 1, retryable_codes);
742 }
743 return Ok(response);
744 }
745
746 for attempt in 0..attempts {
747 let request = request_template
748 .try_clone()
749 .expect("request_template is cloneable");
750 let response = self.execute_once(request).await?;
751
752 if response.status().is_success() {
753 return Ok(response);
754 }
755
756 let status = response.status().as_u16();
757 let should_retry = retryable_codes.contains(&status);
758 let is_last_attempt = attempt + 1 >= attempts;
759 if !should_retry || is_last_attempt {
760 let mut response = response;
761 attach_retry_metadata(&mut response, attempt + 1, should_retry);
762 return Ok(response);
763 }
764
765 let delay = bounded_retry_delay_secs(
766 retry_options,
767 attempt,
768 retry_after_delay_secs(response.headers()),
769 );
770 drop(response);
772 if delay > 0.0 {
773 tokio::time::sleep(Duration::from_secs_f64(delay)).await;
774 }
775 }
776
777 unreachable!("retry loop must return a response");
779 }
780
781 async fn prepare_request(&self, request: &mut reqwest::Request) -> Result<()> {
782 if let Some(headers) = self.auth_headers().await? {
783 for (name, value) in &headers {
784 if request.headers().contains_key(name) {
785 continue;
786 }
787 let mut value = value.clone();
788 if name == AUTHORIZATION {
789 value.set_sensitive(true);
790 }
791 request.headers_mut().insert(name.clone(), value);
792 }
793 }
794 if self.config.backend == Backend::GeminiApi {
795 append_sdk_usage_header(request.headers_mut())?;
796 }
797 #[cfg(feature = "mcp")]
798 crate::mcp::append_mcp_usage_header(request.headers_mut())?;
799 Ok(())
800 }
801
802 async fn auth_headers(&self) -> Result<Option<HeaderMap>> {
803 let Some(provider) = &self.auth_provider else {
804 return Ok(None);
805 };
806
807 let scopes: Vec<&str> = self.config.auth_scopes.iter().map(String::as_str).collect();
808 let headers = provider.headers(&scopes).await?;
809 Ok(Some(headers))
810 }
811}
812
813fn append_sdk_usage_header(headers: &mut HeaderMap) -> Result<()> {
814 let header_name = HeaderName::from_static(X_GOOG_API_CLIENT_HEADER);
815 let existing_values = headers
816 .get_all(&header_name)
817 .iter()
818 .map(|value| {
819 value
820 .to_str()
821 .map(str::trim)
822 .map(str::to_string)
823 .map_err(|_| Error::InvalidConfig {
824 message: "Invalid x-goog-api-client header value".into(),
825 })
826 })
827 .collect::<Result<Vec<_>>>()?;
828 let existing = existing_values
829 .into_iter()
830 .filter(|value| !value.is_empty())
831 .collect::<Vec<_>>()
832 .join(" ");
833 let combined = if existing.contains(SDK_USAGE_HEADER_VALUE) {
834 existing
835 } else if existing.is_empty() {
836 SDK_USAGE_HEADER_VALUE.to_string()
837 } else {
838 format!("{SDK_USAGE_HEADER_VALUE} {existing}")
839 };
840 let value = HeaderValue::from_str(&combined).map_err(|_| Error::InvalidConfig {
841 message: "Invalid x-goog-api-client header value".into(),
842 })?;
843 headers.insert(header_name, value);
844 Ok(())
845}
846
847fn first_nonempty_env(names: &[&str]) -> Option<String> {
848 names.iter().find_map(|name| {
849 std::env::var(name)
850 .ok()
851 .map(|value| value.trim().to_string())
852 .filter(|value| !value.is_empty())
853 })
854}
855
856fn env_flag(name: &str) -> Option<bool> {
857 let value = std::env::var(name).ok()?;
858 match value.trim().to_ascii_lowercase().as_str() {
859 "1" | "true" | "yes" | "on" => Some(true),
860 "0" | "false" | "no" | "off" => Some(false),
861 _ => None,
862 }
863}
864
865fn attach_retry_metadata(response: &mut reqwest::Response, attempts: u32, retryable: bool) {
866 response.extensions_mut().insert(RetryMetadata {
867 attempts,
868 retryable,
869 });
870}
871
872fn attach_retry_metadata_for_codes(
873 response: &mut reqwest::Response,
874 attempts: u32,
875 retryable_codes: &[u16],
876) {
877 let retryable = retryable_codes.contains(&response.status().as_u16());
878 attach_retry_metadata(response, attempts, retryable);
879}
880
881fn retry_after_delay_secs(headers: &HeaderMap) -> Option<f64> {
882 let retry_after = headers
883 .get(reqwest::header::RETRY_AFTER)
884 .and_then(|value| value.to_str().ok())
885 .map(str::trim)?;
886
887 retry_after
888 .parse::<f64>()
889 .ok()
890 .map(|delay| delay.max(0.0))
891 .or_else(|| {
892 httpdate::parse_http_date(retry_after).ok().map(|deadline| {
893 deadline
894 .duration_since(SystemTime::now())
895 .unwrap_or_default()
896 .as_secs_f64()
897 })
898 })
899}
900
901fn bounded_retry_delay_secs(
902 options: &HttpRetryOptions,
903 retry_index: u32,
904 retry_after_secs: Option<f64>,
905) -> f64 {
906 let delay = retry_after_secs.unwrap_or_else(|| retry_delay_secs(options, retry_index));
907 let max_delay = options
908 .max_delay
909 .unwrap_or(DEFAULT_RETRY_MAX_DELAY_SECS)
910 .max(0.0);
911 delay.min(max_delay)
912}
913
914fn retry_delay_secs(options: &HttpRetryOptions, retry_index: u32) -> f64 {
915 let initial = options
916 .initial_delay
917 .unwrap_or(DEFAULT_RETRY_INITIAL_DELAY_SECS)
918 .max(0.0);
919 let max_delay = options
920 .max_delay
921 .unwrap_or(DEFAULT_RETRY_MAX_DELAY_SECS)
922 .max(0.0);
923 let exp_base = options.exp_base.unwrap_or(DEFAULT_RETRY_EXP_BASE).max(0.0);
924 let jitter = options.jitter.unwrap_or(DEFAULT_RETRY_JITTER).max(0.0);
925
926 let exp_delay = if exp_base == 0.0 {
927 0.0
928 } else {
929 initial * exp_base.powf(retry_index as f64)
930 };
931 let base_delay = if max_delay > 0.0 {
932 exp_delay.min(max_delay)
933 } else {
934 exp_delay
935 };
936
937 let jitter_delay = if jitter > 0.0 {
938 let nanos = SystemTime::now()
940 .duration_since(UNIX_EPOCH)
941 .unwrap_or_default()
942 .subsec_nanos() as f64;
943 let frac = (nanos / 1_000_000_000.0).clamp(0.0, 1.0);
944 frac * jitter
945 } else {
946 0.0
947 };
948
949 let delay = base_delay + jitter_delay;
950 if max_delay > 0.0 {
951 delay.min(max_delay)
952 } else {
953 delay
954 }
955}
956
957fn default_auth_scopes(backend: Backend) -> Vec<String> {
958 match backend {
959 Backend::VertexAi => vec!["https://www.googleapis.com/auth/cloud-platform".into()],
960 Backend::GeminiApi => vec![
961 "https://www.googleapis.com/auth/generative-language".into(),
962 "https://www.googleapis.com/auth/generative-language.retriever".into(),
963 ],
964 }
965}
966
967pub(crate) struct ApiClient {
968 pub base_url: String,
969 pub api_version: String,
970}
971
972impl ApiClient {
973 pub fn new(config: &ClientConfig) -> Self {
975 let base_url = config.http_options.base_url.as_deref().map_or_else(
976 || match config.backend {
977 Backend::VertexAi => {
978 let location = config
979 .vertex_config
980 .as_ref()
981 .map_or("", |cfg| cfg.location.as_str());
982 if location.is_empty() {
983 "https://aiplatform.googleapis.com/".to_string()
984 } else {
985 format!("https://{location}-aiplatform.googleapis.com/")
986 }
987 }
988 Backend::GeminiApi => "https://generativelanguage.googleapis.com/".to_string(),
989 },
990 normalize_base_url,
991 );
992
993 let api_version =
994 config
995 .http_options
996 .api_version
997 .clone()
998 .unwrap_or_else(|| match config.backend {
999 Backend::VertexAi => "v1beta1".to_string(),
1000 Backend::GeminiApi => "v1beta".to_string(),
1001 });
1002
1003 Self {
1004 base_url,
1005 api_version,
1006 }
1007 }
1008}
1009
1010fn normalize_base_url(base_url: &str) -> String {
1011 let mut value = base_url.trim().to_string();
1012 if !value.ends_with('/') {
1013 value.push('/');
1014 }
1015 value
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021 use crate::test_support::with_env;
1022 use bytes::Bytes;
1023 use futures_util::stream;
1024 use std::path::PathBuf;
1025 use std::time::SystemTime;
1026 use tempfile::tempdir;
1027 use wiremock::matchers::{method, path};
1028 use wiremock::{Mock, MockServer, ResponseTemplate};
1029
1030 #[test]
1031 fn test_client_from_api_key() {
1032 let client = Client::new("test-api-key").unwrap();
1033 assert_eq!(client.inner.config.backend, Backend::GeminiApi);
1034 }
1035
1036 #[test]
1037 fn test_client_builder() {
1038 let client = Client::builder()
1039 .api_key("test-key")
1040 .timeout(30)
1041 .build()
1042 .unwrap();
1043 assert!(client.inner.config.api_key.is_some());
1044 }
1045
1046 #[test]
1047 fn test_vertex_ai_config() {
1048 let client = Client::new_vertex("my-project", "us-central1").unwrap();
1049 assert_eq!(client.inner.config.backend, Backend::VertexAi);
1050 assert_eq!(
1051 client.inner.api_client.base_url,
1052 "https://us-central1-aiplatform.googleapis.com/"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_base_url_normalization() {
1058 let client = Client::builder()
1059 .api_key("test-key")
1060 .base_url("https://example.com")
1061 .build()
1062 .unwrap();
1063 assert_eq!(client.inner.api_client.base_url, "https://example.com/");
1064 }
1065
1066 #[test]
1067 fn test_from_env_reads_overrides() {
1068 with_env(
1069 &[
1070 ("GEMINI_API_KEY", Some("env-key")),
1071 ("GENAI_BASE_URL", Some("https://env.example.com")),
1072 ("GENAI_API_VERSION", Some("v99")),
1073 ("GOOGLE_GENAI_API_VERSION", None),
1074 ("GOOGLE_API_KEY", None),
1075 ],
1076 || {
1077 let client = Client::from_env().unwrap();
1078 assert_eq!(client.inner.api_client.base_url, "https://env.example.com/");
1079 assert_eq!(client.inner.api_client.api_version, "v99");
1080 },
1081 );
1082 }
1083
1084 #[test]
1085 fn test_from_env_ignores_gemini_base_url_for_vertex() {
1086 with_env(
1087 &[
1088 ("GEMINI_API_KEY", None),
1089 ("GOOGLE_API_KEY", None),
1090 ("GOOGLE_GENAI_USE_VERTEXAI", Some("true")),
1091 ("GOOGLE_CLOUD_PROJECT", Some("vertex-project")),
1092 ("GOOGLE_CLOUD_LOCATION", Some("us-central1")),
1093 ("GEMINI_BASE_URL", Some("https://gemini-only.example.com")),
1094 ("GENAI_BASE_URL", None),
1095 ("GOOGLE_GENAI_BASE_URL", None),
1096 ],
1097 || {
1098 let client = Client::from_env().unwrap();
1099 assert_eq!(client.inner.config.backend, Backend::VertexAi);
1100 assert_eq!(
1101 client.inner.api_client.base_url,
1102 "https://us-central1-aiplatform.googleapis.com/"
1103 );
1104 },
1105 );
1106 }
1107
1108 #[test]
1109 fn test_from_env_ignores_empty_overrides() {
1110 with_env(
1111 &[
1112 ("GEMINI_API_KEY", Some("env-key")),
1113 ("GENAI_BASE_URL", Some(" ")),
1114 ("GENAI_API_VERSION", Some("")),
1115 ("GOOGLE_GENAI_API_VERSION", None),
1116 ("GOOGLE_API_KEY", None),
1117 ],
1118 || {
1119 let client = Client::from_env().unwrap();
1120 assert_eq!(
1121 client.inner.api_client.base_url,
1122 "https://generativelanguage.googleapis.com/"
1123 );
1124 assert_eq!(client.inner.api_client.api_version, "v1beta");
1125 },
1126 );
1127 }
1128
1129 #[test]
1130 fn test_from_env_missing_key_errors() {
1131 with_env(
1132 &[
1133 ("GEMINI_API_KEY", None),
1134 ("GOOGLE_API_KEY", None),
1135 ("GENAI_BASE_URL", None),
1136 ("GOOGLE_GENAI_USE_VERTEXAI", None),
1137 ("GOOGLE_CLOUD_PROJECT", None),
1138 ("GOOGLE_CLOUD_LOCATION", None),
1139 ],
1140 || {
1141 let result = Client::from_env();
1142 assert!(result.is_err());
1143 },
1144 );
1145 }
1146
1147 #[test]
1148 fn test_from_env_google_api_key_fallback() {
1149 with_env(
1150 &[
1151 ("GEMINI_API_KEY", None),
1152 ("GOOGLE_API_KEY", Some("google-key")),
1153 ("GOOGLE_GENAI_USE_VERTEXAI", None),
1154 ],
1155 || {
1156 let client = Client::from_env().unwrap();
1157 assert_eq!(client.inner.config.api_key.as_deref(), Some("google-key"));
1158 },
1159 );
1160 }
1161
1162 #[test]
1163 fn test_from_env_supports_official_vertex_envs() {
1164 with_env(
1165 &[
1166 ("GEMINI_API_KEY", Some("env-key")),
1167 ("GOOGLE_API_KEY", None),
1168 ("GOOGLE_GENAI_USE_VERTEXAI", Some("true")),
1169 ("GOOGLE_CLOUD_PROJECT", Some("vertex-project")),
1170 ("GOOGLE_CLOUD_LOCATION", Some("us-central1")),
1171 ("GOOGLE_GENAI_API_VERSION", Some("v1")),
1172 ("GENAI_API_VERSION", Some("v1beta")),
1173 ],
1174 || {
1175 let client = Client::from_env().unwrap();
1176 assert_eq!(client.inner.config.backend, Backend::VertexAi);
1177 assert!(matches!(
1178 client.inner.config.credentials,
1179 Credentials::ApplicationDefault
1180 ));
1181 assert_eq!(client.inner.config.api_key, None);
1182 assert_eq!(
1183 client.inner.api_client.base_url,
1184 "https://us-central1-aiplatform.googleapis.com/"
1185 );
1186 assert_eq!(client.inner.api_client.api_version, "v1");
1187 },
1188 );
1189 }
1190
1191 #[test]
1192 fn test_from_env_uses_complete_vertex_env_without_flag_when_api_key_is_absent() {
1193 with_env(
1194 &[
1195 ("GEMINI_API_KEY", None),
1196 ("GOOGLE_API_KEY", None),
1197 ("GOOGLE_GENAI_USE_VERTEXAI", None),
1198 ("GOOGLE_CLOUD_PROJECT", Some("vertex-project")),
1199 ("GOOGLE_CLOUD_LOCATION", Some("us-central1")),
1200 ],
1201 || {
1202 let client = Client::from_env().unwrap();
1203 assert_eq!(client.inner.config.backend, Backend::VertexAi);
1204 assert_eq!(client.inner.config.api_key, None);
1205 assert_eq!(
1206 client.inner.api_client.base_url,
1207 "https://us-central1-aiplatform.googleapis.com/"
1208 );
1209 },
1210 );
1211 }
1212
1213 #[test]
1214 fn test_from_env_prefers_gemini_when_api_key_and_complete_vertex_env_exist() {
1215 with_env(
1216 &[
1217 ("GEMINI_API_KEY", Some("env-key")),
1218 ("GOOGLE_API_KEY", None),
1219 ("GOOGLE_GENAI_USE_VERTEXAI", None),
1220 ("GOOGLE_CLOUD_PROJECT", Some("vertex-project")),
1221 ("GOOGLE_CLOUD_LOCATION", Some("us-central1")),
1222 ],
1223 || {
1224 let client = Client::from_env().unwrap();
1225 assert_eq!(client.inner.config.backend, Backend::GeminiApi);
1226 assert_eq!(client.inner.config.api_key.as_deref(), Some("env-key"));
1227 assert_eq!(
1228 client.inner.api_client.base_url,
1229 "https://generativelanguage.googleapis.com/"
1230 );
1231 },
1232 );
1233 }
1234
1235 #[test]
1236 fn test_from_env_explicit_false_prefers_gemini_even_with_complete_vertex_env() {
1237 with_env(
1238 &[
1239 ("GEMINI_API_KEY", Some("env-key")),
1240 ("GOOGLE_API_KEY", None),
1241 ("GOOGLE_GENAI_USE_VERTEXAI", Some("false")),
1242 ("GOOGLE_CLOUD_PROJECT", Some("vertex-project")),
1243 ("GOOGLE_CLOUD_LOCATION", Some("us-central1")),
1244 ],
1245 || {
1246 let client = Client::from_env().unwrap();
1247 assert_eq!(client.inner.config.backend, Backend::GeminiApi);
1248 assert_eq!(client.inner.config.api_key.as_deref(), Some("env-key"));
1249 assert_eq!(
1250 client.inner.api_client.base_url,
1251 "https://generativelanguage.googleapis.com/"
1252 );
1253 },
1254 );
1255 }
1256
1257 #[test]
1258 fn test_from_env_prefers_gemini_when_vertex_env_is_partial() {
1259 with_env(
1260 &[
1261 ("GEMINI_API_KEY", Some("env-key")),
1262 ("GOOGLE_API_KEY", None),
1263 ("GOOGLE_GENAI_USE_VERTEXAI", None),
1264 ("GOOGLE_CLOUD_PROJECT", Some("vertex-project")),
1265 ("GOOGLE_CLOUD_LOCATION", None),
1266 ],
1267 || {
1268 let client = Client::from_env().unwrap();
1269 assert_eq!(client.inner.config.backend, Backend::GeminiApi);
1270 assert_eq!(client.inner.config.api_key.as_deref(), Some("env-key"));
1271 assert_eq!(
1272 client.inner.api_client.base_url,
1273 "https://generativelanguage.googleapis.com/"
1274 );
1275 },
1276 );
1277 }
1278
1279 #[test]
1280 fn test_from_env_vertex_requires_project_and_location() {
1281 with_env(
1282 &[
1283 ("GOOGLE_GENAI_USE_VERTEXAI", Some("true")),
1284 ("GOOGLE_CLOUD_PROJECT", Some("vertex-project")),
1285 ("GOOGLE_CLOUD_LOCATION", None),
1286 ("GEMINI_API_KEY", None),
1287 ("GOOGLE_API_KEY", None),
1288 ],
1289 || {
1290 let result = Client::from_env();
1291 assert!(matches!(result, Err(Error::InvalidConfig { .. })));
1292 },
1293 );
1294 }
1295
1296 #[test]
1297 fn test_bounded_retry_delay_secs_prefers_retry_after_with_cap() {
1298 let options = HttpRetryOptions {
1299 max_delay: Some(2.0),
1300 ..Default::default()
1301 };
1302
1303 let delay = bounded_retry_delay_secs(&options, 0, Some(120.0));
1304 assert_eq!(delay, 2.0);
1305 }
1306
1307 #[test]
1308 fn test_bounded_retry_delay_secs_uses_retry_after_when_below_cap() {
1309 let options = HttpRetryOptions {
1310 max_delay: Some(5.0),
1311 ..Default::default()
1312 };
1313
1314 let delay = bounded_retry_delay_secs(&options, 0, Some(1.5));
1315 assert_eq!(delay, 1.5);
1316 }
1317
1318 #[test]
1319 fn test_bounded_retry_delay_secs_caps_retry_after_at_zero() {
1320 let options = HttpRetryOptions {
1321 max_delay: Some(0.0),
1322 ..Default::default()
1323 };
1324
1325 let delay = bounded_retry_delay_secs(&options, 0, Some(120.0));
1326 assert_eq!(delay, 0.0);
1327 }
1328
1329 #[test]
1330 fn test_bounded_retry_delay_secs_falls_back_to_backoff() {
1331 let options = HttpRetryOptions {
1332 initial_delay: Some(1.0),
1333 max_delay: Some(10.0),
1334 exp_base: Some(2.0),
1335 jitter: Some(0.0),
1336 ..Default::default()
1337 };
1338
1339 let delay = bounded_retry_delay_secs(&options, 2, None);
1340 assert_eq!(delay, 4.0);
1341 }
1342
1343 #[test]
1344 fn test_retry_after_delay_secs_parses_http_date() {
1345 let deadline = SystemTime::now() + Duration::from_secs(120);
1346 let mut headers = HeaderMap::new();
1347 headers.insert(
1348 reqwest::header::RETRY_AFTER,
1349 HeaderValue::from_str(&httpdate::fmt_http_date(deadline)).unwrap(),
1350 );
1351
1352 let delay = retry_after_delay_secs(&headers).unwrap();
1353 assert!((110.0..=120.0).contains(&delay));
1354 }
1355
1356 #[tokio::test]
1357 async fn test_send_with_http_options_preserves_custom_retry_metadata_without_retries() {
1358 let server = MockServer::start().await;
1359 Mock::given(method("POST"))
1360 .and(path("/retry-once"))
1361 .respond_with(ResponseTemplate::new(409).set_body_string("conflict"))
1362 .mount(&server)
1363 .await;
1364
1365 let client = Client::new("test-key").unwrap();
1366 let request = client
1367 .inner
1368 .http
1369 .post(format!("{}/retry-once", server.uri()))
1370 .body(reqwest::Body::wrap_stream(stream::once(async {
1371 Ok::<Bytes, std::io::Error>(Bytes::from_static(b"payload"))
1372 })));
1373 let http_options = rust_genai_types::http::HttpOptions {
1374 retry_options: Some(HttpRetryOptions {
1375 attempts: Some(2),
1376 http_status_codes: Some(vec![409]),
1377 initial_delay: Some(0.0),
1378 max_delay: Some(0.0),
1379 exp_base: Some(0.0),
1380 jitter: Some(0.0),
1381 }),
1382 ..Default::default()
1383 };
1384
1385 let response = client
1386 .inner
1387 .send_with_http_options(request, Some(&http_options))
1388 .await
1389 .unwrap();
1390 let retry_metadata = response
1391 .extensions()
1392 .get::<RetryMetadata>()
1393 .copied()
1394 .unwrap();
1395
1396 assert_eq!(response.status().as_u16(), 409);
1397 assert_eq!(retry_metadata.attempts, 1);
1398 assert!(retry_metadata.retryable);
1399 }
1400
1401 #[test]
1402 fn test_with_oauth_missing_client_secret_errors() {
1403 let dir = tempdir().unwrap();
1404 let secret_path = dir.path().join("missing_client_secret.json");
1405 let err = Client::with_oauth(&secret_path).err().unwrap();
1406 assert!(matches!(err, Error::InvalidConfig { .. }));
1407 }
1408
1409 #[test]
1410 fn test_with_adc_builds_client() {
1411 let client = Client::with_adc().unwrap();
1412 assert!(matches!(
1413 client.inner.config.credentials,
1414 Credentials::ApplicationDefault
1415 ));
1416 }
1417
1418 #[test]
1419 fn test_builder_defaults_to_vertex_when_project_set() {
1420 let client = Client::builder()
1421 .vertex_project("proj")
1422 .vertex_location("loc")
1423 .build()
1424 .unwrap();
1425 assert_eq!(client.inner.config.backend, Backend::VertexAi);
1426 assert!(matches!(
1427 client.inner.config.credentials,
1428 Credentials::ApplicationDefault
1429 ));
1430 }
1431
1432 #[test]
1433 fn test_valid_proxy_is_accepted() {
1434 let client = Client::builder()
1435 .api_key("test-key")
1436 .proxy("http://127.0.0.1:8888")
1437 .build();
1438 assert!(client.is_ok());
1439 }
1440
1441 #[test]
1442 fn test_vertex_requires_project_and_location() {
1443 let result = Client::builder().backend(Backend::VertexAi).build();
1444 assert!(result.is_err());
1445 }
1446
1447 #[test]
1448 fn test_api_key_with_oauth_is_invalid() {
1449 let result = Client::builder()
1450 .api_key("test-key")
1451 .credentials(Credentials::OAuth {
1452 client_secret_path: PathBuf::from("client_secret.json"),
1453 token_cache_path: None,
1454 })
1455 .build();
1456 assert!(result.is_err());
1457 }
1458
1459 #[test]
1460 fn test_missing_api_key_for_gemini_errors() {
1461 let result = Client::builder().backend(Backend::GeminiApi).build();
1462 assert!(result.is_err());
1463 }
1464
1465 #[test]
1466 fn test_invalid_header_name_is_rejected() {
1467 let result = Client::builder()
1468 .api_key("test-key")
1469 .header("bad header", "value")
1470 .build();
1471 assert!(result.is_err());
1472 }
1473
1474 #[test]
1475 fn test_invalid_header_value_is_rejected() {
1476 let result = Client::builder()
1477 .api_key("test-key")
1478 .header("x-test", "bad\nvalue")
1479 .build();
1480 assert!(result.is_err());
1481 }
1482
1483 #[test]
1484 fn test_invalid_api_key_value_is_rejected() {
1485 let err = Client::builder().api_key("bad\nkey").build().err().unwrap();
1486 assert!(
1487 matches!(err, Error::InvalidConfig { message } if message.contains("Invalid API key value"))
1488 );
1489 }
1490
1491 #[test]
1492 fn test_invalid_proxy_is_rejected() {
1493 let result = Client::builder()
1494 .api_key("test-key")
1495 .proxy("not a url")
1496 .build();
1497 assert!(result.is_err());
1498 }
1499
1500 #[test]
1501 fn test_vertex_api_key_is_rejected() {
1502 let result = Client::builder()
1503 .backend(Backend::VertexAi)
1504 .vertex_project("proj")
1505 .vertex_location("loc")
1506 .credentials(Credentials::ApiKey("key".into()))
1507 .build();
1508 assert!(result.is_err());
1509 }
1510
1511 #[test]
1512 fn test_default_auth_scopes() {
1513 let gemini = default_auth_scopes(Backend::GeminiApi);
1514 assert!(gemini.iter().any(|s| s.contains("generative-language")));
1515
1516 let vertex = default_auth_scopes(Backend::VertexAi);
1517 assert!(vertex.iter().any(|s| s.contains("cloud-platform")));
1518 }
1519
1520 #[test]
1521 fn test_custom_auth_scopes_override_default() {
1522 let client = Client::builder()
1523 .api_key("test-key")
1524 .auth_scopes(vec!["scope-1".to_string()])
1525 .build()
1526 .unwrap();
1527 assert_eq!(client.inner.config.auth_scopes, vec!["scope-1".to_string()]);
1528 }
1529
1530 #[test]
1531 fn test_append_sdk_usage_header() {
1532 let mut headers = HeaderMap::new();
1533 append_sdk_usage_header(&mut headers).unwrap();
1534 assert_eq!(
1535 headers
1536 .get(X_GOOG_API_CLIENT_HEADER)
1537 .and_then(|value| value.to_str().ok()),
1538 Some(SDK_USAGE_HEADER_VALUE)
1539 );
1540 }
1541
1542 #[test]
1543 fn test_append_sdk_usage_header_preserves_existing_value() {
1544 let mut headers = HeaderMap::new();
1545 headers.insert(
1546 HeaderName::from_static(X_GOOG_API_CLIENT_HEADER),
1547 HeaderValue::from_static("custom-client/1.0.0"),
1548 );
1549 append_sdk_usage_header(&mut headers).unwrap();
1550 append_sdk_usage_header(&mut headers).unwrap();
1551 assert_eq!(
1552 headers
1553 .get(X_GOOG_API_CLIENT_HEADER)
1554 .and_then(|value| value.to_str().ok()),
1555 Some(concat!(
1556 "google-genai-sdk/",
1557 env!("CARGO_PKG_VERSION"),
1558 " gl-rust/unknown custom-client/1.0.0"
1559 ))
1560 );
1561 }
1562}