gemini_rust/
client.rs

1use crate::{
2    batch::{BatchBuilder, BatchHandle},
3    cache::{CacheBuilder, CachedContentHandle},
4    embedding::{
5        BatchContentEmbeddingResponse, BatchEmbedContentsRequest, ContentEmbeddingResponse,
6        EmbedBuilder, EmbedContentRequest,
7    },
8    files::{
9        handle::FileHandle,
10        model::{File, ListFilesResponse},
11    },
12    generation::{ContentBuilder, GenerateContentRequest, GenerationResponse},
13};
14use eventsource_stream::{EventStreamError, Eventsource};
15use futures::{Stream, StreamExt, TryStreamExt};
16use mime::Mime;
17use reqwest::{
18    header::{HeaderMap, HeaderName, HeaderValue, InvalidHeaderValue},
19    Client, ClientBuilder, RequestBuilder, Response,
20};
21use serde::{Deserialize, Serialize};
22use serde_json::json;
23use snafu::{OptionExt, ResultExt, Snafu};
24use std::{
25    fmt::{self, Formatter},
26    pin::Pin,
27    sync::{Arc, LazyLock},
28};
29use tracing::{instrument, Level, Span};
30use url::Url;
31
32use crate::batch::model::*;
33use crate::cache::model::*;
34
35/// Type alias for streaming generation responses
36///
37/// A pinned, boxed stream that yields `GenerationResponse` chunks as they arrive
38/// from the API. Used for streaming content generation to receive partial results
39/// before the complete response is ready.
40pub type GenerationStream = Pin<Box<dyn Stream<Item = Result<GenerationResponse, Error>> + Send>>;
41
42static DEFAULT_BASE_URL: LazyLock<Url> = LazyLock::new(|| {
43    Url::parse("https://generativelanguage.googleapis.com/v1beta/")
44        .expect("unreachable error: failed to parse default base URL")
45});
46
47#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
48pub enum Model {
49    #[default]
50    #[serde(rename = "models/gemini-2.5-flash")]
51    Gemini25Flash,
52    #[serde(rename = "models/gemini-2.5-flash-lite")]
53    Gemini25FlashLite,
54    #[serde(rename = "models/gemini-2.5-flash-image")]
55    Gemini25FlashImage,
56    #[serde(rename = "models/gemini-2.5-pro")]
57    Gemini25Pro,
58    #[serde(rename = "models/gemini-3-flash-preview")]
59    Gemini3Flash,
60    #[serde(rename = "models/gemini-3-pro-preview")]
61    Gemini3Pro,
62    #[serde(rename = "models/gemini-3-pro-image-preview")]
63    Gemini3ProImage,
64    #[serde(rename = "models/text-embedding-004")]
65    TextEmbedding004,
66    #[serde(untagged)]
67    Custom(String),
68}
69
70impl Model {
71    pub fn as_str(&self) -> &str {
72        match self {
73            Model::Gemini25Flash => "models/gemini-2.5-flash",
74            Model::Gemini25FlashLite => "models/gemini-2.5-flash-lite",
75            Model::Gemini25FlashImage => "models/gemini-2.5-flash-image",
76            Model::Gemini25Pro => "models/gemini-2.5-pro",
77            Model::Gemini3Flash => "models/gemini-3-flash-preview",
78            Model::Gemini3Pro => "models/gemini-3-pro-preview",
79            Model::Gemini3ProImage => "models/gemini-3-pro-image-preview",
80            Model::TextEmbedding004 => "models/text-embedding-004",
81            Model::Custom(model) => model,
82        }
83    }
84}
85
86impl From<String> for Model {
87    fn from(model: String) -> Self {
88        Self::Custom(model)
89    }
90}
91
92impl fmt::Display for Model {
93    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
94        match self {
95            Model::Gemini25Flash => write!(f, "models/gemini-2.5-flash"),
96            Model::Gemini25FlashLite => write!(f, "models/gemini-2.5-flash-lite"),
97            Model::Gemini25FlashImage => write!(f, "models/gemini-2.5-flash-image"),
98            Model::Gemini25Pro => write!(f, "models/gemini-2.5-pro"),
99            Model::Gemini3Flash => write!(f, "models/gemini-3-flash-preview"),
100            Model::Gemini3Pro => write!(f, "models/gemini-3-pro-preview"),
101            Model::Gemini3ProImage => write!(f, "models/gemini-3-pro-image-preview"),
102            Model::TextEmbedding004 => write!(f, "models/text-embedding-004"),
103            Model::Custom(model) => write!(f, "{model}"),
104        }
105    }
106}
107
108#[derive(Debug, Snafu)]
109#[snafu(visibility(pub))]
110pub enum Error {
111    #[snafu(display("failed to parse API key"))]
112    InvalidApiKey {
113        source: InvalidHeaderValue,
114    },
115
116    #[snafu(display("failed to construct URL (probably incorrect model name): {suffix}"))]
117    ConstructUrl {
118        source: url::ParseError,
119        suffix: String,
120    },
121
122    PerformRequestNew {
123        source: reqwest::Error,
124    },
125
126    #[snafu(display("failed to perform request to '{url}'"))]
127    PerformRequest {
128        source: reqwest::Error,
129        url: Url,
130    },
131
132    #[snafu(display(
133        "bad response from server; code {code}; description: {}",
134        description.as_deref().unwrap_or("none")
135    ))]
136    BadResponse {
137        /// HTTP status code
138        code: u16,
139        /// HTTP error description
140        description: Option<String>,
141    },
142
143    MissingResponseHeader {
144        header: String,
145    },
146
147    #[snafu(display("failed to obtain stream SSE part"))]
148    BadPart {
149        source: EventStreamError<reqwest::Error>,
150    },
151
152    #[snafu(display("failed to deserialize JSON response"))]
153    Deserialize {
154        source: serde_json::Error,
155    },
156
157    #[snafu(display("failed to generate content"))]
158    DecodeResponse {
159        source: reqwest::Error,
160    },
161
162    #[snafu(display("failed to parse URL"))]
163    UrlParse {
164        source: url::ParseError,
165    },
166
167    #[snafu(display("I/O error during file operations"))]
168    Io {
169        source: std::io::Error,
170    },
171
172    #[snafu(display("operation timed out: {name}"))]
173    OperationTimeout {
174        name: String,
175    },
176
177    #[snafu(display("operation failed: {name}, code: {code}, message: {message}"))]
178    OperationFailed {
179        name: String,
180        code: i32,
181        message: String,
182    },
183
184    #[snafu(display("invalid resource name: {name}"))]
185    InvalidResourceName {
186        name: String,
187    },
188}
189
190/// Internal client for making requests to the Gemini API
191#[derive(Debug)]
192pub struct GeminiClient {
193    http_client: Client,
194    pub model: Model,
195    base_url: Url,
196}
197
198impl GeminiClient {
199    /// Create a new client with custom base URL
200    fn with_base_url<K: AsRef<str>, M: Into<Model>>(
201        client_builder: ClientBuilder,
202        api_key: K,
203        model: M,
204        base_url: Url,
205    ) -> Result<Self, Error> {
206        let headers = HeaderMap::from_iter([(
207            HeaderName::from_static("x-goog-api-key"),
208            HeaderValue::from_str(api_key.as_ref()).context(InvalidApiKeySnafu)?,
209        )]);
210
211        let http_client = client_builder
212            .default_headers(headers)
213            .build()
214            .expect("all parameters must be valid");
215
216        Ok(Self {
217            http_client,
218            model: model.into(),
219            base_url,
220        })
221    }
222
223    /// Check the response status code and return an error if it is not successful
224    #[tracing::instrument(skip_all, err)]
225    async fn check_response(response: Response) -> Result<Response, Error> {
226        let status = response.status();
227        if !status.is_success() {
228            let description = response.text().await.ok();
229            BadResponseSnafu {
230                code: status.as_u16(),
231                description,
232            }
233            .fail()
234        } else {
235            Ok(response)
236        }
237    }
238
239    /// Performs an HTTP request to the Gemini API with standardized error handling.
240    ///
241    /// This method provides a generic way to make HTTP requests to the Gemini API with
242    /// consistent error handling, response checking, and deserialization. It handles:
243    /// - Building the HTTP request using a provided builder function
244    /// - Sending the request and handling network errors
245    /// - Checking the response status code for errors
246    /// - Deserializing the response using a provided deserializer function
247    ///
248    /// # Type Parameters
249    /// * `B` - A function that takes a `&Client` and returns a `RequestBuilder`
250    /// * `D` - An async function that takes ownership of a `Response` and returns a `Result<T, Error>`
251    /// * `T` - The type of the deserialized response
252    ///
253    /// # Note
254    /// The `AsyncFn` trait is a standard Rust feature (stabilized in v1.85) and does not
255    /// require any additional imports or feature flags.
256    ///
257    /// # Parameters
258    /// * `builder` - A function that constructs the HTTP request using the client
259    /// * `deserializer` - An async function that processes the response into the desired type
260    ///
261    /// # Examples
262    ///
263    /// Basic HTTP operations:
264    /// ```no_run
265    /// # use gemini_rust::client::*;
266    /// # use reqwest::Response;
267    /// # use url::Url;
268    /// # use serde_json::Value;
269    /// # use snafu::ResultExt;
270    /// # async fn examples(client: &GeminiClient) -> Result<(), Box<dyn std::error::Error>> {
271    /// # let url: Url = "https://example.com".parse()?;
272    /// # let request = Value::Null;
273    ///
274    /// // POST request with JSON payload
275    /// let _response : () = client
276    ///     .perform_request(
277    ///         |c| c.post(url.clone()).json(&request),
278    ///         async |r| r.json().await.context(DecodeResponseSnafu),
279    ///     )
280    ///     .await?;
281    ///
282    /// // GET request with JSON response
283    /// let _response : () = client
284    ///     .perform_request(
285    ///         |c| c.get(url.clone()),
286    ///         async |r| r.json().await.context(DecodeResponseSnafu),
287    ///     )
288    ///     .await?;
289    ///
290    /// // DELETE request with no response body
291    /// let _response = client
292    ///     .perform_request(|c| c.delete(url), async |_r| Ok(()))
293    ///     .await?;
294    /// # Ok(())
295    /// # }
296    /// ```
297    ///
298    /// Request returning a stream:
299    /// ```no_run
300    /// # use gemini_rust::client::*;
301    /// # use reqwest::Response;
302    /// # use url::Url;
303    /// # use serde_json::Value;
304    /// # async fn example(client: &GeminiClient) -> Result<(), Box<dyn std::error::Error>> {
305    /// # let url: Url = "https://example.com".parse()?;
306    /// # let request = Value::Null;
307    /// let _stream = client
308    ///     .perform_request(
309    ///         |c| c.post(url).json(&request),
310    ///         async |r| Ok(r.bytes_stream()),
311    ///     )
312    ///     .await?;
313    /// # Ok(())
314    /// # }
315    /// ```
316    #[tracing::instrument(skip_all)]
317    #[doc(hidden)]
318    pub async fn perform_request<
319        B: FnOnce(&Client) -> RequestBuilder,
320        D: AsyncFn(Response) -> Result<T, Error>,
321        T,
322    >(
323        &self,
324        builder: B,
325        deserializer: D,
326    ) -> Result<T, Error> {
327        let request = builder(&self.http_client);
328        tracing::debug!("request built successfully");
329        let response = request.send().await.context(PerformRequestNewSnafu)?;
330        tracing::debug!("response received successfully");
331        let response = Self::check_response(response).await?;
332        tracing::debug!("response ok");
333        deserializer(response).await
334    }
335
336    /// Perform a GET request and deserialize the JSON response.
337    ///
338    /// This is a convenience wrapper around [`perform_request`](Self::perform_request).
339    #[tracing::instrument(skip(self), fields(request.type = "get", request.url = %url))]
340    async fn get_json<T: serde::de::DeserializeOwned>(&self, url: Url) -> Result<T, Error> {
341        self.perform_request(
342            |c| c.get(url),
343            async |r| r.json().await.context(DecodeResponseSnafu),
344        )
345        .await
346    }
347
348    /// Perform a POST request with JSON body and deserialize the JSON response.
349    ///
350    /// This is a convenience wrapper around [`perform_request`](Self::perform_request).
351    #[tracing::instrument(skip(self, body), fields(request.type = "post", request.url = %url))]
352    async fn post_json<Req: serde::Serialize, Res: serde::de::DeserializeOwned>(
353        &self,
354        url: Url,
355        body: &Req,
356    ) -> Result<Res, Error> {
357        self.perform_request(
358            |c| c.post(url).json(body),
359            async |r| r.json().await.context(DecodeResponseSnafu),
360        )
361        .await
362    }
363
364    /// Generate content
365    #[instrument(skip_all, fields(
366        model,
367        messages.parts.count = request.contents.len(),
368        tools.present = request.tools.is_some(),
369        system.instruction.present = request.system_instruction.is_some(),
370        cached.content.present = request.cached_content.is_some(),
371        usage.prompt_tokens,
372        usage.candidates_tokens,
373        usage.thoughts_tokens,
374        usage.cached_content_tokens,
375        usage.total_tokens,
376    ), ret(level = Level::TRACE), err)]
377    pub(crate) async fn generate_content_raw(
378        &self,
379        request: GenerateContentRequest,
380    ) -> Result<GenerationResponse, Error> {
381        let url = self.build_url("generateContent")?;
382        let response: GenerationResponse = self.post_json(url, &request).await?;
383
384        // Record usage metadata
385        if let Some(usage) = &response.usage_metadata {
386            #[rustfmt::skip]
387            Span::current()
388                .record("usage.prompt_tokens", usage.prompt_token_count)
389                .record("usage.candidates_tokens", usage.candidates_token_count)
390                .record("usage.thoughts_tokens", usage.thoughts_token_count)
391                .record("usage.cached_content_tokens", usage.cached_content_token_count)
392                .record("usage.total_tokens", usage.total_token_count);
393
394            tracing::debug!("generation usage evaluated");
395        }
396
397        Ok(response)
398    }
399
400    /// Generate content with streaming
401    #[instrument(skip_all, fields(
402        model,
403        messages.parts.count = request.contents.len(),
404        tools.present = request.tools.is_some(),
405        system.instruction.present = request.system_instruction.is_some(),
406        cached.content.present = request.cached_content.is_some(),
407    ), err)]
408    pub(crate) async fn generate_content_stream(
409        &self,
410        request: GenerateContentRequest,
411    ) -> Result<GenerationStream, Error> {
412        let mut url = self.build_url("streamGenerateContent")?;
413        url.query_pairs_mut().append_pair("alt", "sse");
414
415        let stream = self
416            .perform_request(
417                |c| c.post(url).json(&request),
418                async |r| Ok(r.bytes_stream()),
419            )
420            .await?;
421
422        Ok(Box::pin(
423            stream
424                .eventsource()
425                .map(|event| event.context(BadPartSnafu))
426                .and_then(|event| async move {
427                    serde_json::from_str::<GenerationResponse>(&event.data)
428                        .context(DeserializeSnafu)
429                }),
430        ))
431    }
432
433    /// Count tokens for content
434    #[instrument(skip_all, fields(
435        model,
436        messages.parts.count = request.contents.len(),
437    ))]
438    pub(crate) async fn count_tokens(
439        &self,
440        request: GenerateContentRequest,
441    ) -> Result<crate::generation::CountTokensResponse, Error> {
442        let url = self.build_url("countTokens")?;
443        // Wrap the request in a "generateContentRequest" field and explicitly add the model.
444        // The countTokens API requires the model to be specified within generateContentRequest.
445        let body = json!({
446            "generateContentRequest": {
447                "model": self.model.as_str(),
448                "contents": request.contents,
449                "generationConfig": request.generation_config,
450                "safetySettings": request.safety_settings,
451                "tools": request.tools,
452                "toolConfig": request.tool_config,
453                "systemInstruction": request.system_instruction,
454                "cachedContent": request.cached_content,
455            }
456        });
457        self.post_json(url, &body).await
458    }
459
460    /// Embed content
461    #[instrument(skip_all, fields(
462        model,
463        task.type = request.task_type.as_ref().map(|t| format!("{t:?}")),
464        task.title = request.title,
465        task.output.dimensionality = request.output_dimensionality,
466    ))]
467    pub(crate) async fn embed_content(
468        &self,
469        request: EmbedContentRequest,
470    ) -> Result<ContentEmbeddingResponse, Error> {
471        let url = self.build_url("embedContent")?;
472        self.post_json(url, &request).await
473    }
474
475    /// Batch Embed content
476    #[instrument(skip_all, fields(batch.size = request.requests.len()))]
477    pub(crate) async fn embed_content_batch(
478        &self,
479        request: BatchEmbedContentsRequest,
480    ) -> Result<BatchContentEmbeddingResponse, Error> {
481        let url = self.build_url("batchEmbedContents")?;
482        self.post_json(url, &request).await
483    }
484
485    /// Batch generate content (synchronous API that returns results immediately)
486    #[instrument(skip_all, fields(
487        batch.display_name = request.batch.display_name,
488        batch.size = request.batch.input_config.batch_size(),
489    ))]
490    pub(crate) async fn batch_generate_content(
491        &self,
492        request: BatchGenerateContentRequest,
493    ) -> Result<BatchGenerateContentResponse, Error> {
494        let url = self.build_url("batchGenerateContent")?;
495        self.post_json(url, &request).await
496    }
497
498    /// Get a batch operation
499    #[instrument(skip_all, fields(
500        operation.name = name,
501    ))]
502    pub(crate) async fn get_batch_operation<T: serde::de::DeserializeOwned>(
503        &self,
504        name: &str,
505    ) -> Result<T, Error> {
506        let url = self.build_batch_url(name, None)?;
507        self.get_json(url).await
508    }
509
510    /// List batch operations
511    #[instrument(skip_all, fields(
512        page.size = page_size,
513        page.token.present = page_token.is_some(),
514    ))]
515    pub(crate) async fn list_batch_operations(
516        &self,
517        page_size: Option<u32>,
518        page_token: Option<String>,
519    ) -> Result<ListBatchesResponse, Error> {
520        let mut url = self.build_batch_url("batches", None)?;
521
522        if let Some(size) = page_size {
523            url.query_pairs_mut()
524                .append_pair("pageSize", &size.to_string());
525        }
526        if let Some(token) = page_token {
527            url.query_pairs_mut().append_pair("pageToken", &token);
528        }
529
530        self.get_json(url).await
531    }
532
533    /// List files
534    #[instrument(skip_all, fields(
535        page.size = page_size,
536        page.token.present = page_token.is_some(),
537    ))]
538    pub(crate) async fn list_files(
539        &self,
540        page_size: Option<u32>,
541        page_token: Option<String>,
542    ) -> Result<ListFilesResponse, Error> {
543        let mut url = self.build_files_url(None)?;
544
545        if let Some(size) = page_size {
546            url.query_pairs_mut()
547                .append_pair("pageSize", &size.to_string());
548        }
549        if let Some(token) = page_token {
550            url.query_pairs_mut().append_pair("pageToken", &token);
551        }
552
553        self.get_json(url).await
554    }
555
556    /// Cancel a batch operation
557    #[instrument(skip_all, fields(
558        operation.name = name,
559    ))]
560    pub(crate) async fn cancel_batch_operation(&self, name: &str) -> Result<(), Error> {
561        let url = self.build_batch_url(name, Some("cancel"))?;
562        self.perform_request(|c| c.post(url).json(&json!({})), async |_r| Ok(()))
563            .await
564    }
565
566    /// Delete a batch operation
567    #[instrument(skip_all, fields(
568        operation.name = name,
569    ))]
570    pub(crate) async fn delete_batch_operation(&self, name: &str) -> Result<(), Error> {
571        let url = self.build_batch_url(name, None)?;
572        self.perform_request(|c| c.delete(url), async |_r| Ok(()))
573            .await
574    }
575
576    async fn create_upload(
577        &self,
578        bytes: usize,
579        display_name: Option<String>,
580        mime_type: Mime,
581    ) -> Result<Url, Error> {
582        let url = self
583            .base_url
584            .join("/upload/v1beta/files")
585            .context(ConstructUrlSnafu {
586                suffix: "/upload/v1beta/files".to_string(),
587            })?;
588
589        self.perform_request(
590            |c| {
591                c.post(url)
592                    .header("X-Goog-Upload-Protocol", "resumable")
593                    .header("X-Goog-Upload-Command", "start")
594                    .header("X-Goog-Upload-Content-Length", bytes.to_string())
595                    .header("X-Goog-Upload-Header-Content-Type", mime_type.to_string())
596                    .json(&json!({"file": {"displayName": display_name}}))
597            },
598            async |r| {
599                r.headers()
600                    .get("X-Goog-Upload-URL")
601                    .context(MissingResponseHeaderSnafu {
602                        header: "X-Goog-Upload-URL",
603                    })
604                    .and_then(|upload_url| {
605                        upload_url
606                            .to_str()
607                            .map(str::to_string)
608                            .map_err(|_| Error::BadResponse {
609                                code: 500,
610                                description: Some("Missing upload URL in response".to_string()),
611                            })
612                    })
613                    .and_then(|url| Url::parse(&url).context(UrlParseSnafu))
614            },
615        )
616        .await
617    }
618
619    /// Upload a file using the resumable upload protocol.
620    #[instrument(skip_all, fields(
621        file.size = file_bytes.len(),
622        mime.type = mime_type.to_string(),
623        file.display_name = display_name.as_deref(),
624    ))]
625    pub(crate) async fn upload_file(
626        &self,
627        display_name: Option<String>,
628        file_bytes: Vec<u8>,
629        mime_type: Mime,
630    ) -> Result<File, Error> {
631        // Step 1: Create resumable upload session
632        let upload_url = self
633            .create_upload(file_bytes.len(), display_name, mime_type)
634            .await?;
635
636        // Step 2: Upload file content
637        let upload_response = self
638            .http_client
639            .post(upload_url.clone())
640            .header("X-Goog-Upload-Command", "upload, finalize")
641            .header("X-Goog-Upload-Offset", "0")
642            .body(file_bytes)
643            .send()
644            .await
645            .map_err(|e| Error::PerformRequest {
646                source: e,
647                url: upload_url,
648            })?;
649
650        let final_response = Self::check_response(upload_response).await?;
651
652        #[derive(serde::Deserialize)]
653        struct UploadResponse {
654            file: File,
655        }
656
657        let upload_response: UploadResponse =
658            final_response.json().await.context(DecodeResponseSnafu)?;
659        Ok(upload_response.file)
660    }
661
662    /// Get a file resource
663    #[instrument(skip_all, fields(
664        file.name = name,
665    ))]
666    pub(crate) async fn get_file(&self, name: &str) -> Result<File, Error> {
667        let url = self.build_files_url(Some(name))?;
668        self.get_json(url).await
669    }
670
671    /// Delete a file resource
672    #[instrument(skip_all, fields(
673        file.name = name,
674    ))]
675    pub(crate) async fn delete_file(&self, name: &str) -> Result<(), Error> {
676        let url = self.build_files_url(Some(name))?;
677        self.perform_request(|c| c.delete(url), async |_r| Ok(()))
678            .await
679    }
680
681    /// Download a file resource
682    #[instrument(skip_all, fields(
683        file.name = name,
684    ))]
685    pub(crate) async fn download_file(&self, name: &str) -> Result<Vec<u8>, Error> {
686        let mut url = self
687            .base_url
688            .join(&format!("/download/v1beta/{name}:download"))
689            .context(ConstructUrlSnafu {
690                suffix: format!("/download/v1beta/{name}:download"),
691            })?;
692        url.query_pairs_mut().append_pair("alt", "media");
693
694        self.perform_request(
695            |c| c.get(url),
696            async |r| {
697                r.bytes()
698                    .await
699                    .context(DecodeResponseSnafu)
700                    .map(|bytes| bytes.to_vec())
701            },
702        )
703        .await
704    }
705
706    /// Create cached content
707    pub(crate) async fn create_cached_content(
708        &self,
709        cached_content: CreateCachedContentRequest,
710    ) -> Result<CachedContent, Error> {
711        let url = self.build_cache_url(None)?;
712        self.post_json(url, &cached_content).await
713    }
714
715    /// Get cached content
716    pub(crate) async fn get_cached_content(&self, name: &str) -> Result<CachedContent, Error> {
717        let url = self.build_cache_url(Some(name))?;
718        self.get_json(url).await
719    }
720
721    /// Update cached content (typically to update TTL)
722    pub(crate) async fn update_cached_content(
723        &self,
724        name: &str,
725        expiration: CacheExpirationRequest,
726    ) -> Result<CachedContent, Error> {
727        let url = self.build_cache_url(Some(name))?;
728
729        // Create a minimal update payload with just the expiration
730        let update_payload = match expiration {
731            CacheExpirationRequest::Ttl { ttl } => json!({ "ttl": ttl }),
732            CacheExpirationRequest::ExpireTime { expire_time } => {
733                json!({ "expireTime": expire_time.format(&time::format_description::well_known::Rfc3339).unwrap() })
734            }
735        };
736
737        self.perform_request(
738            |c| c.patch(url.clone()).json(&update_payload),
739            async |r| r.json().await.context(DecodeResponseSnafu),
740        )
741        .await
742    }
743
744    /// Delete cached content
745    pub(crate) async fn delete_cached_content(&self, name: &str) -> Result<(), Error> {
746        let url = self.build_cache_url(Some(name))?;
747        self.perform_request(|c| c.delete(url.clone()), async |_r| Ok(()))
748            .await
749    }
750
751    /// List cached contents
752    pub(crate) async fn list_cached_contents(
753        &self,
754        page_size: Option<i32>,
755        page_token: Option<String>,
756    ) -> Result<ListCachedContentsResponse, Error> {
757        let mut url = self.build_cache_url(None)?;
758
759        if let Some(size) = page_size {
760            url.query_pairs_mut()
761                .append_pair("pageSize", &size.to_string());
762        }
763        if let Some(token) = page_token {
764            url.query_pairs_mut().append_pair("pageToken", &token);
765        }
766
767        self.get_json(url).await
768    }
769
770    /// Build a URL with the given suffix
771    #[tracing::instrument(skip(self), ret(level = Level::DEBUG))]
772    fn build_url_with_suffix(&self, suffix: &str) -> Result<Url, Error> {
773        self.base_url.join(suffix).context(ConstructUrlSnafu {
774            suffix: suffix.to_string(),
775        })
776    }
777
778    /// Build a URL for the API
779    #[tracing::instrument(skip(self), ret(level = Level::DEBUG))]
780    fn build_url(&self, endpoint: &str) -> Result<Url, Error> {
781        let suffix = format!("{}:{endpoint}", self.model);
782        self.build_url_with_suffix(&suffix)
783    }
784
785    /// Build a URL for a batch operation
786    fn build_batch_url(&self, name: &str, action: Option<&str>) -> Result<Url, Error> {
787        let suffix = action
788            .map(|a| format!("{name}:{a}"))
789            .unwrap_or_else(|| name.to_string());
790        self.build_url_with_suffix(&suffix)
791    }
792
793    /// Build a URL for file operations
794    fn build_files_url(&self, name: Option<&str>) -> Result<Url, Error> {
795        let suffix = name
796            .map(|n| format!("files/{}", n.strip_prefix("files/").unwrap_or(n)))
797            .unwrap_or_else(|| "files".to_string());
798        self.build_url_with_suffix(&suffix)
799    }
800
801    /// Build a URL for cache operations
802    fn build_cache_url(&self, name: Option<&str>) -> Result<Url, Error> {
803        let suffix = name
804            .map(|n| {
805                if n.starts_with("cachedContents/") {
806                    n.to_string()
807                } else {
808                    format!("cachedContents/{n}")
809                }
810            })
811            .unwrap_or_else(|| "cachedContents".to_string());
812        self.build_url_with_suffix(&suffix)
813    }
814
815    // File Search Store operations
816
817    #[instrument(skip_all, fields(display_name = request.display_name.as_deref()))]
818    pub async fn create_file_search_store(
819        &self,
820        request: crate::file_search::CreateFileSearchStoreRequest,
821    ) -> Result<crate::file_search::FileSearchStore, Error> {
822        let url = self.build_url_with_suffix("fileSearchStores")?;
823        self.post_json(url, &request).await
824    }
825
826    #[instrument(skip_all, fields(store.name = %name))]
827    pub async fn get_file_search_store(
828        &self,
829        name: &str,
830    ) -> Result<crate::file_search::FileSearchStore, Error> {
831        let url = self.build_url_with_suffix(name)?;
832        self.get_json(url).await
833    }
834
835    #[instrument(skip_all, fields(
836        page.size = page_size,
837        page.token.present = page_token.is_some(),
838    ))]
839    pub async fn list_file_search_stores(
840        &self,
841        page_size: Option<u32>,
842        page_token: Option<&str>,
843    ) -> Result<crate::file_search::ListFileSearchStoresResponse, Error> {
844        let mut url = self.build_url_with_suffix("fileSearchStores")?;
845        if let Some(size) = page_size {
846            url.query_pairs_mut()
847                .append_pair("pageSize", &size.to_string());
848        }
849        if let Some(token) = page_token {
850            url.query_pairs_mut().append_pair("pageToken", token);
851        }
852        self.get_json(url).await
853    }
854
855    #[instrument(skip_all, fields(store.name = %name, force))]
856    pub async fn delete_file_search_store(&self, name: &str, force: bool) -> Result<(), Error> {
857        let mut url = self.build_url_with_suffix(name)?;
858        if force {
859            url.query_pairs_mut().append_pair("force", "true");
860        }
861        self.perform_request(|c| c.delete(url.clone()), async |_r| Ok(()))
862            .await
863    }
864
865    // Upload operation (resumable protocol)
866
867    #[instrument(skip_all, fields(
868        store.name = %store_name,
869        file.size = file_data.len(),
870        display_name = display_name.as_deref(),
871        mime.type = mime_type.as_ref().map(|m| m.to_string()),
872    ))]
873    pub async fn upload_to_file_search_store(
874        &self,
875        store_name: &str,
876        file_data: Vec<u8>,
877        display_name: Option<String>,
878        mime_type: Option<mime::Mime>,
879        custom_metadata: Option<Vec<crate::file_search::CustomMetadata>>,
880        chunking_config: Option<crate::file_search::ChunkingConfig>,
881    ) -> Result<crate::file_search::Operation, Error> {
882        use crate::file_search::UploadToFileSearchStoreRequest;
883
884        let metadata_request = UploadToFileSearchStoreRequest {
885            display_name,
886            custom_metadata,
887            chunking_config,
888            mime_type: mime_type.clone(),
889        };
890
891        let mime = mime_type.unwrap_or(mime::APPLICATION_OCTET_STREAM);
892
893        let init_url = format!("/upload/v1beta/{}:uploadToFileSearchStore", store_name);
894        let upload_url = self
895            .initiate_resumable_upload(&init_url, file_data.len(), &mime, Some(&metadata_request))
896            .await?;
897
898        let operation: crate::file_search::Operation =
899            self.upload_file_data(&upload_url, file_data).await?;
900        Ok(operation)
901    }
902
903    // Import operation
904
905    #[instrument(skip_all, fields(
906        store.name = %store_name,
907        file.name = %request.file_name,
908    ))]
909    pub async fn import_file_to_search_store(
910        &self,
911        store_name: &str,
912        request: crate::file_search::ImportFileRequest,
913    ) -> Result<crate::file_search::Operation, Error> {
914        let url = self.build_url_with_suffix(&format!("{}:importFile", store_name))?;
915        self.post_json(url, &request).await
916    }
917
918    // Document operations
919
920    #[instrument(skip_all, fields(
921        store.name = %store_name,
922        document.id = %document_id,
923    ))]
924    pub async fn get_document(
925        &self,
926        store_name: &str,
927        document_id: &str,
928    ) -> Result<crate::file_search::Document, Error> {
929        let url =
930            self.build_url_with_suffix(&format!("{}/documents/{}", store_name, document_id))?;
931        self.get_json(url).await
932    }
933
934    #[instrument(skip_all, fields(
935        store.name = %store_name,
936        page.size = page_size,
937        page.token.present = page_token.is_some(),
938    ))]
939    pub async fn list_documents(
940        &self,
941        store_name: &str,
942        page_size: Option<u32>,
943        page_token: Option<&str>,
944    ) -> Result<crate::file_search::ListDocumentsResponse, Error> {
945        let mut url = self.build_url_with_suffix(&format!("{}/documents", store_name))?;
946        if let Some(size) = page_size {
947            url.query_pairs_mut()
948                .append_pair("pageSize", &size.to_string());
949        }
950        if let Some(token) = page_token {
951            url.query_pairs_mut().append_pair("pageToken", token);
952        }
953        self.get_json(url).await
954    }
955
956    #[instrument(skip_all, fields(
957        store.name = %store_name,
958        document.id = %document_id,
959        force,
960    ))]
961    pub async fn delete_document(
962        &self,
963        store_name: &str,
964        document_id: &str,
965        force: bool,
966    ) -> Result<(), Error> {
967        let mut url =
968            self.build_url_with_suffix(&format!("{}/documents/{}", store_name, document_id))?;
969        if force {
970            url.query_pairs_mut().append_pair("force", "true");
971        }
972        self.perform_request(|c| c.delete(url.clone()), async |_r| Ok(()))
973            .await
974    }
975
976    // Operation operations
977
978    #[instrument(skip_all, fields(operation.name = %name))]
979    pub async fn get_operation(&self, name: &str) -> Result<crate::file_search::Operation, Error> {
980        let url = self.build_url_with_suffix(name)?;
981        self.get_json(url).await
982    }
983
984    // Resumable upload helpers
985
986    #[instrument(skip(self, metadata))]
987    async fn initiate_resumable_upload<T: Serialize>(
988        &self,
989        path: &str,
990        total_bytes: usize,
991        mime_type: &Mime,
992        metadata: Option<&T>,
993    ) -> Result<String, Error> {
994        let url = self.build_url_with_suffix(path)?;
995
996        tracing::debug!("initiating resumable upload to {}", url);
997
998        let mut request = self
999            .http_client
1000            .post(url.clone())
1001            .header("X-Goog-Upload-Protocol", "resumable")
1002            .header("X-Goog-Upload-Command", "start")
1003            .header(
1004                "X-Goog-Upload-Header-Content-Length",
1005                total_bytes.to_string(),
1006            )
1007            .header("X-Goog-Upload-Header-Content-Type", mime_type.to_string())
1008            .header("Content-Type", "application/json");
1009
1010        // Always send metadata as JSON body, even if it's empty
1011        if let Some(metadata) = metadata {
1012            request = request.json(metadata);
1013        } else {
1014            request = request.body("{}");
1015        }
1016
1017        let response = request.send().await.context(PerformRequestNewSnafu)?;
1018
1019        // Check response status
1020        let response = Self::check_response(response).await?;
1021
1022        let upload_url = response
1023            .headers()
1024            .get("x-goog-upload-url")
1025            .and_then(|v| v.to_str().ok())
1026            .ok_or(Error::MissingResponseHeader {
1027                header: "x-goog-upload-url".to_string(),
1028            })?;
1029
1030        tracing::debug!("received upload url: {}", upload_url);
1031        Ok(upload_url.to_string())
1032    }
1033
1034    #[instrument(skip(self, data), fields(data.len = data.len()))]
1035    async fn upload_file_data<T: serde::de::DeserializeOwned>(
1036        &self,
1037        upload_url: &str,
1038        data: Vec<u8>,
1039    ) -> Result<T, Error> {
1040        tracing::debug!("uploading file data to {}", upload_url);
1041
1042        let data_len = data.len();
1043        let response = self
1044            .http_client
1045            .post(upload_url)
1046            .header("Content-Length", data_len.to_string())
1047            .header("X-Goog-Upload-Offset", "0")
1048            .header("X-Goog-Upload-Command", "upload, finalize")
1049            .body(data)
1050            .send()
1051            .await
1052            .context(PerformRequestNewSnafu)?;
1053
1054        tracing::debug!("upload response status: {}", response.status());
1055        let response = Self::check_response(response).await?;
1056
1057        // The finalize response contains the result
1058        response.json().await.context(DecodeResponseSnafu)
1059    }
1060}
1061
1062/// A builder for the `Gemini` client.
1063///
1064/// # Examples
1065///
1066/// ## Basic usage
1067///
1068/// ```no_run
1069/// use gemini_rust::{GeminiBuilder, Model};
1070///
1071/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
1072/// let gemini = GeminiBuilder::new("YOUR_API_KEY")
1073///     .with_model(Model::Gemini25Pro)
1074///     .build()?;
1075/// # Ok(())
1076/// # }
1077/// ```
1078///
1079/// ## With proxy configuration
1080///
1081/// ```no_run
1082/// use gemini_rust::{GeminiBuilder, Model};
1083/// use reqwest::{ClientBuilder, Proxy};
1084///
1085/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
1086/// let proxy = Proxy::https("https://my.proxy")?;
1087/// let http_client = ClientBuilder::new().proxy(proxy);
1088///
1089/// let gemini = GeminiBuilder::new("YOUR_API_KEY")
1090///     .with_http_client(http_client)
1091///     .build()?;
1092/// # Ok(())
1093/// # }
1094/// ```
1095pub struct GeminiBuilder {
1096    key: String,
1097    model: Model,
1098    client_builder: ClientBuilder,
1099    base_url: Url,
1100}
1101
1102impl GeminiBuilder {
1103    /// Creates a new `GeminiBuilder` with the given API key.
1104    pub fn new<K: Into<String>>(key: K) -> Self {
1105        Self {
1106            key: key.into(),
1107            model: Model::default(),
1108            client_builder: ClientBuilder::default(),
1109            base_url: DEFAULT_BASE_URL.clone(),
1110        }
1111    }
1112
1113    /// Sets the model for the client.
1114    pub fn with_model<M: Into<Model>>(mut self, model: M) -> Self {
1115        self.model = model.into();
1116        self
1117    }
1118
1119    /// Sets a custom `reqwest::ClientBuilder`.
1120    pub fn with_http_client(mut self, client_builder: ClientBuilder) -> Self {
1121        self.client_builder = client_builder;
1122        self
1123    }
1124
1125    /// Sets a custom base URL for the API.
1126    pub fn with_base_url(mut self, base_url: Url) -> Self {
1127        self.base_url = base_url;
1128        self
1129    }
1130
1131    /// Builds the `Gemini` client.
1132    pub fn build(self) -> Result<Gemini, Error> {
1133        Ok(Gemini {
1134            client: Arc::new(GeminiClient::with_base_url(
1135                self.client_builder,
1136                self.key,
1137                self.model,
1138                self.base_url,
1139            )?),
1140        })
1141    }
1142}
1143
1144/// Client for the Gemini API
1145#[derive(Clone)]
1146pub struct Gemini {
1147    client: Arc<GeminiClient>,
1148}
1149
1150impl Gemini {
1151    /// Create a new client with the specified API key
1152    pub fn new<K: AsRef<str>>(api_key: K) -> Result<Self, Error> {
1153        Self::with_model(api_key, Model::default())
1154    }
1155
1156    /// Create a new client for the Gemini Pro model
1157    pub fn pro<K: AsRef<str>>(api_key: K) -> Result<Self, Error> {
1158        Self::with_model(api_key, Model::Gemini25Pro)
1159    }
1160
1161    /// Create a new client for the Gemini Pro 3 image model
1162    pub fn pro_image<K: AsRef<str>>(api_key: K) -> Result<Self, Error> {
1163        Self::with_model(api_key, Model::Gemini3ProImage)
1164    }
1165
1166    /// Create a new client with the specified API key and model
1167    pub fn with_model<K: AsRef<str>, M: Into<Model>>(api_key: K, model: M) -> Result<Self, Error> {
1168        Self::with_model_and_base_url(api_key, model, DEFAULT_BASE_URL.clone())
1169    }
1170
1171    /// Create a new client with custom base URL
1172    pub fn with_base_url<K: AsRef<str>>(api_key: K, base_url: Url) -> Result<Self, Error> {
1173        Self::with_model_and_base_url(api_key, Model::default(), base_url)
1174    }
1175
1176    /// Create a new client with the specified API key, model, and base URL
1177    pub fn with_model_and_base_url<K: AsRef<str>, M: Into<Model>>(
1178        api_key: K,
1179        model: M,
1180        base_url: Url,
1181    ) -> Result<Self, Error> {
1182        let client =
1183            GeminiClient::with_base_url(Default::default(), api_key, model.into(), base_url)?;
1184        Ok(Self {
1185            client: Arc::new(client),
1186        })
1187    }
1188
1189    /// Start building a content generation request
1190    pub fn generate_content(&self) -> ContentBuilder {
1191        ContentBuilder::new(self.client.clone())
1192    }
1193
1194    /// Start building a content embedding request
1195    pub fn embed_content(&self) -> EmbedBuilder {
1196        EmbedBuilder::new(self.client.clone())
1197    }
1198
1199    /// Start building a batch content generation request
1200    pub fn batch_generate_content(&self) -> BatchBuilder {
1201        BatchBuilder::new(self.client.clone())
1202    }
1203
1204    /// Get a handle to a batch operation by its name.
1205    pub fn get_batch(&self, name: &str) -> BatchHandle {
1206        BatchHandle::new(name.to_string(), self.client.clone())
1207    }
1208
1209    /// Lists batch operations.
1210    ///
1211    /// This method returns a stream that handles pagination automatically.
1212    pub fn list_batches(
1213        &self,
1214        page_size: impl Into<Option<u32>>,
1215    ) -> impl Stream<Item = Result<BatchOperation, Error>> + Send {
1216        let client = self.client.clone();
1217        let page_size = page_size.into();
1218        async_stream::try_stream! {
1219            let mut page_token: Option<String> = None;
1220            loop {
1221                let response = client
1222                    .list_batch_operations(page_size, page_token.clone())
1223                    .await?;
1224
1225                for operation in response.operations {
1226                    yield operation;
1227                }
1228
1229                if let Some(next_page_token) = response.next_page_token {
1230                    page_token = Some(next_page_token);
1231                } else {
1232                    break;
1233                }
1234            }
1235        }
1236    }
1237
1238    /// Create cached content with a fluent API.
1239    pub fn create_cache(&self) -> CacheBuilder {
1240        CacheBuilder::new(self.client.clone())
1241    }
1242
1243    /// Get a handle to cached content by its name.
1244    pub fn get_cached_content(&self, name: &str) -> CachedContentHandle {
1245        CachedContentHandle::new(name.to_string(), self.client.clone())
1246    }
1247
1248    /// Lists cached contents.
1249    ///
1250    /// This method returns a stream that handles pagination automatically.
1251    pub fn list_cached_contents(
1252        &self,
1253        page_size: impl Into<Option<i32>>,
1254    ) -> impl Stream<Item = Result<CachedContentSummary, Error>> + Send {
1255        let client = self.client.clone();
1256        let page_size = page_size.into();
1257        async_stream::try_stream! {
1258            let mut page_token: Option<String> = None;
1259            loop {
1260                let response = client
1261                    .list_cached_contents(page_size, page_token.clone())
1262                    .await?;
1263
1264                for cached_content in response.cached_contents {
1265                    yield cached_content;
1266                }
1267
1268                if let Some(next_page_token) = response.next_page_token {
1269                    page_token = Some(next_page_token);
1270                } else {
1271                    break;
1272                }
1273            }
1274        }
1275    }
1276
1277    /// Start building a file resource
1278    pub fn create_file<B: Into<Vec<u8>>>(&self, bytes: B) -> crate::files::builder::FileBuilder {
1279        crate::files::builder::FileBuilder::new(self.client.clone(), bytes)
1280    }
1281
1282    /// Get a handle to a file by its name.
1283    pub async fn get_file(&self, name: &str) -> Result<FileHandle, Error> {
1284        let file = self.client.get_file(name).await?;
1285        Ok(FileHandle::new(self.client.clone(), file))
1286    }
1287
1288    /// Lists files.
1289    ///
1290    /// This method returns a stream that handles pagination automatically.
1291    pub fn list_files(
1292        &self,
1293        page_size: impl Into<Option<u32>>,
1294    ) -> impl Stream<Item = Result<FileHandle, Error>> + Send {
1295        let client = self.client.clone();
1296        let page_size = page_size.into();
1297        async_stream::try_stream! {
1298            let mut page_token: Option<String> = None;
1299            loop {
1300                let response = client
1301                    .list_files(page_size, page_token.clone())
1302                    .await?;
1303
1304                for file in response.files {
1305                    yield FileHandle::new(client.clone(), file);
1306                }
1307
1308                if let Some(next_page_token) = response.next_page_token {
1309                    page_token = Some(next_page_token);
1310                } else {
1311                    break;
1312                }
1313            }
1314        }
1315    }
1316
1317    /// Start building a file search store
1318    pub fn create_file_search_store(&self) -> crate::file_search::FileSearchStoreBuilder {
1319        crate::file_search::FileSearchStoreBuilder {
1320            client: self.client.clone(),
1321            display_name: None,
1322        }
1323    }
1324
1325    /// Get a handle to a file search store by its name.
1326    pub async fn get_file_search_store(
1327        &self,
1328        name: &str,
1329    ) -> Result<crate::file_search::FileSearchStoreHandle, Error> {
1330        let store = self.client.get_file_search_store(name).await?;
1331        Ok(crate::file_search::FileSearchStoreHandle::new(
1332            self.client.clone(),
1333            store,
1334        ))
1335    }
1336
1337    /// Lists file search stores.
1338    ///
1339    /// This method returns a stream that handles pagination automatically.
1340    pub fn list_file_search_stores(
1341        &self,
1342        page_size: impl Into<Option<u32>>,
1343    ) -> impl Stream<Item = Result<crate::file_search::FileSearchStoreHandle, Error>> + Send {
1344        let client = self.client.clone();
1345        let page_size = page_size.into();
1346        async_stream::try_stream! {
1347            let mut page_token: Option<String> = None;
1348            loop {
1349                let response = client
1350                    .list_file_search_stores(page_size, page_token.as_deref())
1351                    .await?;
1352
1353                for store in response.file_search_stores {
1354                    yield crate::file_search::FileSearchStoreHandle::new(client.clone(), store);
1355                }
1356
1357                if let Some(next_page_token) = response.next_page_token {
1358                    page_token = Some(next_page_token);
1359                } else {
1360                    break;
1361                }
1362            }
1363        }
1364    }
1365}