genai_rs/client.rs
1use crate::GenaiError;
2use reqwest::Client as ReqwestClient;
3use std::time::Duration;
4
5/// Logs a request body at debug level, preferring JSON format when possible.
6fn log_request_body<T: std::fmt::Debug + serde::Serialize>(body: &T) {
7 match serde_json::to_string_pretty(body) {
8 Ok(json) => tracing::debug!("Request Body (JSON):\n{json}"),
9 Err(_) => tracing::debug!("Request Body: {body:#?}"),
10 }
11}
12
13/// Logs a response body at debug level, preferring JSON format when possible.
14fn log_response_body<T: std::fmt::Debug + serde::Serialize>(body: &T) {
15 match serde_json::to_string_pretty(body) {
16 Ok(json) => tracing::debug!("Response Body (JSON):\n{json}"),
17 Err(_) => tracing::debug!("Response Body: {body:#?}"),
18 }
19}
20
21/// The main client for interacting with the Google Generative AI API.
22#[derive(Clone)]
23pub struct Client {
24 pub(crate) api_key: String,
25 #[allow(clippy::struct_field_names)]
26 pub(crate) http_client: ReqwestClient,
27}
28
29// Custom Debug implementation that redacts the API key for security.
30// This prevents accidental exposure of credentials in logs, error messages, or debug output.
31impl std::fmt::Debug for Client {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 f.debug_struct("Client")
34 .field("api_key", &"[REDACTED]")
35 .field("http_client", &self.http_client)
36 .finish()
37 }
38}
39
40/// Builder for `Client` instances.
41///
42/// # Example
43///
44/// ```
45/// use genai_rs::Client;
46/// use std::time::Duration;
47///
48/// let client = Client::builder("api_key".to_string())
49/// .with_timeout(Duration::from_secs(120))
50/// .with_connect_timeout(Duration::from_secs(10))
51/// .build()?;
52/// # Ok::<(), genai_rs::GenaiError>(())
53/// ```
54pub struct ClientBuilder {
55 api_key: String,
56 timeout: Option<Duration>,
57 connect_timeout: Option<Duration>,
58}
59
60// Custom Debug implementation that redacts the API key for security.
61impl std::fmt::Debug for ClientBuilder {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.debug_struct("ClientBuilder")
64 .field("api_key", &"[REDACTED]")
65 .field("timeout", &self.timeout)
66 .field("connect_timeout", &self.connect_timeout)
67 .finish()
68 }
69}
70
71impl ClientBuilder {
72 /// Sets the total request timeout.
73 ///
74 /// This is the maximum time a request can take from start to finish,
75 /// including connection time, sending the request, and receiving the response.
76 ///
77 /// For LLM requests that may take a long time to generate responses,
78 /// consider setting a longer timeout (e.g., 120-300 seconds).
79 ///
80 /// If not set, requests will wait indefinitely (no timeout).
81 /// Connection-level timeouts like TCP keepalive may still apply at the OS level.
82 ///
83 /// # Example
84 ///
85 /// ```
86 /// use genai_rs::Client;
87 /// use std::time::Duration;
88 ///
89 /// let client = Client::builder("api_key".to_string())
90 /// .with_timeout(Duration::from_secs(120))
91 /// .build()?;
92 /// # Ok::<(), genai_rs::GenaiError>(())
93 /// ```
94 #[must_use]
95 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
96 self.timeout = Some(timeout);
97 self
98 }
99
100 /// Sets the connection timeout.
101 ///
102 /// This is the maximum time to wait for establishing a connection to the server.
103 /// A shorter timeout here can help fail fast if the network is unavailable.
104 ///
105 /// If not set, the connection phase will wait indefinitely (no timeout).
106 ///
107 /// # Example
108 ///
109 /// ```
110 /// use genai_rs::Client;
111 /// use std::time::Duration;
112 ///
113 /// let client = Client::builder("api_key".to_string())
114 /// .with_connect_timeout(Duration::from_secs(10))
115 /// .build()?;
116 /// # Ok::<(), genai_rs::GenaiError>(())
117 /// ```
118 #[must_use]
119 pub const fn with_connect_timeout(mut self, timeout: Duration) -> Self {
120 self.connect_timeout = Some(timeout);
121 self
122 }
123
124 /// Builds the `Client`.
125 ///
126 /// # Errors
127 ///
128 /// Returns an error if the underlying HTTP client cannot be constructed. This should only
129 /// happen in exceptional circumstances such as TLS backend initialization failures.
130 pub fn build(self) -> Result<Client, GenaiError> {
131 let mut builder = ReqwestClient::builder();
132
133 if let Some(timeout) = self.timeout {
134 builder = builder.timeout(timeout);
135 }
136
137 if let Some(connect_timeout) = self.connect_timeout {
138 builder = builder.connect_timeout(connect_timeout);
139 }
140
141 let http_client = builder
142 .build()
143 .map_err(|e| GenaiError::ClientBuild(e.to_string()))?;
144
145 Ok(Client {
146 api_key: self.api_key,
147 http_client,
148 })
149 }
150}
151
152impl Client {
153 /// Creates a new builder for `Client` instances.
154 ///
155 /// # Arguments
156 ///
157 /// * `api_key` - Your Google AI API key.
158 #[must_use]
159 pub const fn builder(api_key: String) -> ClientBuilder {
160 ClientBuilder {
161 api_key,
162 timeout: None,
163 connect_timeout: None,
164 }
165 }
166
167 /// Creates a new `GenAI` client.
168 ///
169 /// # Arguments
170 ///
171 /// * `api_key` - Your Google AI API key.
172 #[must_use]
173 pub fn new(api_key: String) -> Self {
174 Self {
175 api_key,
176 http_client: ReqwestClient::new(),
177 }
178 }
179
180 // --- Interactions API methods ---
181
182 /// Creates a builder for constructing an interaction request.
183 ///
184 /// This provides a fluent interface for building interactions with models or agents.
185 /// Use this method for a more ergonomic API compared to manually constructing
186 /// `InteractionRequest`.
187 ///
188 /// # Examples
189 ///
190 /// ```no_run
191 /// # use genai_rs::Client;
192 /// # #[tokio::main]
193 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
194 /// let client = Client::builder("api_key".to_string()).build()?;
195 ///
196 /// // Simple interaction
197 /// let response = client.interaction()
198 /// .with_model("gemini-3-flash-preview")
199 /// .with_text("Hello, world!")
200 /// .create()
201 /// .await?;
202 ///
203 /// // Stateful conversation (requires stored interaction)
204 /// let response2 = client.interaction()
205 /// .with_model("gemini-3-flash-preview")
206 /// .with_text("What did I just say?")
207 /// .with_previous_interaction(response.id.as_ref().expect("stored interaction has id"))
208 /// .create()
209 /// .await?;
210 /// # Ok(())
211 /// # }
212 /// ```
213 #[must_use]
214 pub fn interaction(&self) -> crate::request_builder::InteractionBuilder<'_> {
215 crate::request_builder::InteractionBuilder::new(self)
216 }
217
218 /// Creates a new interaction using the Gemini Interactions API.
219 ///
220 /// The Interactions API provides a unified interface for working with models and agents,
221 /// with built-in support for stateful conversations, function calling, and long-running tasks.
222 ///
223 /// # Arguments
224 ///
225 /// * `request` - The interaction request with model/agent, input, and optional configuration.
226 ///
227 /// # Errors
228 ///
229 /// Returns an error if:
230 /// - The HTTP request fails
231 /// - Response parsing fails
232 /// - The API returns an error
233 ///
234 /// # Example
235 ///
236 /// ```no_run
237 /// use genai_rs::Client;
238 /// use genai_rs::{InteractionRequest, InteractionInput};
239 ///
240 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
241 /// let client = Client::new("your-api-key".to_string());
242 ///
243 /// let request = InteractionRequest {
244 /// model: Some("gemini-3-flash-preview".to_string()),
245 /// agent: None,
246 /// agent_config: None,
247 /// input: InteractionInput::Text("Hello, world!".to_string()),
248 /// previous_interaction_id: None,
249 /// tools: None,
250 /// response_modalities: None,
251 /// response_format: None,
252 /// response_mime_type: None,
253 /// generation_config: None,
254 /// stream: None,
255 /// background: None,
256 /// store: None,
257 /// system_instruction: None,
258 /// };
259 ///
260 /// let response = client.execute(request).await?;
261 /// println!("Interaction ID: {:?}", response.id);
262 /// # Ok(())
263 /// # }
264 /// ```
265 ///
266 /// # Streaming Example
267 ///
268 /// ```no_run
269 /// use genai_rs::{Client, StreamChunk};
270 /// use genai_rs::{InteractionRequest, InteractionInput};
271 /// use futures_util::StreamExt;
272 ///
273 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
274 /// let client = Client::builder("api_key".to_string()).build()?;
275 /// let request = InteractionRequest {
276 /// model: Some("gemini-3-flash-preview".to_string()),
277 /// agent: None,
278 /// agent_config: None,
279 /// input: InteractionInput::Text("Count to 5".to_string()),
280 /// previous_interaction_id: None,
281 /// tools: None,
282 /// response_modalities: None,
283 /// response_format: None,
284 /// response_mime_type: None,
285 /// generation_config: None,
286 /// stream: Some(true),
287 /// background: None,
288 /// store: None,
289 /// system_instruction: None,
290 /// };
291 ///
292 /// let mut last_event_id = None;
293 /// let mut stream = client.execute_stream(request);
294 /// while let Some(result) = stream.next().await {
295 /// let event = result?;
296 /// last_event_id = event.event_id.clone(); // Track for resume
297 /// match event.chunk {
298 /// StreamChunk::Delta(delta) => {
299 /// if let Some(text) = delta.as_text() {
300 /// print!("{}", text);
301 /// }
302 /// }
303 /// StreamChunk::Complete(response) => {
304 /// println!("\nDone! ID: {:?}", response.id);
305 /// }
306 /// _ => {} // Handle unknown future variants
307 /// }
308 /// }
309 /// # Ok(())
310 /// # }
311 /// ```
312 ///
313 /// # Retry Example
314 ///
315 /// ```no_run
316 /// use genai_rs::Client;
317 /// use std::time::Duration;
318 ///
319 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
320 /// let client = Client::new("api_key".to_string());
321 /// let request = client.interaction()
322 /// .with_model("gemini-3-flash-preview")
323 /// .with_text("Hello!")
324 /// .build()?;
325 ///
326 /// // Retry loop with exponential backoff
327 /// let mut attempts = 0;
328 /// let response = loop {
329 /// match client.execute(request.clone()).await {
330 /// Ok(r) => break r,
331 /// Err(e) if e.is_retryable() && attempts < 3 => {
332 /// attempts += 1;
333 /// tokio::time::sleep(Duration::from_millis(100 * 2u64.pow(attempts))).await;
334 /// }
335 /// Err(e) => return Err(e.into()),
336 /// }
337 /// };
338 /// # Ok(())
339 /// # }
340 /// ```
341 #[tracing::instrument(skip(self), fields(model = ?request.model, agent = ?request.agent))]
342 pub async fn execute(
343 &self,
344 request: crate::InteractionRequest,
345 ) -> Result<crate::InteractionResponse, GenaiError> {
346 tracing::debug!("Creating interaction");
347 log_request_body(&request);
348
349 let response = crate::http::interactions::create_interaction(
350 &self.http_client,
351 &self.api_key,
352 request,
353 )
354 .await?;
355
356 log_response_body(&response);
357 tracing::debug!("Interaction created: ID={:?}", response.id);
358
359 Ok(response)
360 }
361
362 /// Executes a pre-built interaction request with streaming.
363 ///
364 /// This is the streaming variant of [`execute()`](Self::execute).
365 ///
366 /// Returns a stream of [`StreamEvent`](crate::StreamEvent) items as they arrive.
367 /// Each event contains:
368 /// - `chunk`: The content (delta or complete response)
369 /// - `event_id`: Optional ID for resuming interrupted streams
370 ///
371 /// # Example
372 ///
373 /// ```no_run
374 /// use genai_rs::{Client, StreamChunk};
375 /// use futures_util::StreamExt;
376 ///
377 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
378 /// let client = Client::new("api_key".to_string());
379 ///
380 /// let request = client.interaction()
381 /// .with_model("gemini-3-flash-preview")
382 /// .with_text("Count to 5")
383 /// .build()?;
384 ///
385 /// let mut stream = client.execute_stream(request);
386 /// while let Some(result) = stream.next().await {
387 /// let event = result?;
388 /// match event.chunk {
389 /// StreamChunk::Delta(delta) => {
390 /// if let Some(text) = delta.as_text() {
391 /// print!("{}", text);
392 /// }
393 /// }
394 /// StreamChunk::Complete(response) => {
395 /// println!("\nDone!");
396 /// }
397 /// _ => {}
398 /// }
399 /// }
400 /// # Ok(())
401 /// # }
402 /// ```
403 #[tracing::instrument(skip(self), fields(model = ?request.model, agent = ?request.agent))]
404 pub fn execute_stream(
405 &self,
406 request: crate::InteractionRequest,
407 ) -> futures_util::stream::BoxStream<'_, Result<crate::StreamEvent, GenaiError>> {
408 use futures_util::StreamExt;
409
410 tracing::debug!("Creating streaming interaction");
411 log_request_body(&request);
412
413 let stream = crate::http::interactions::create_interaction_stream(
414 &self.http_client,
415 &self.api_key,
416 request,
417 );
418
419 stream
420 .map(move |result| {
421 result.inspect(|event| {
422 tracing::debug!(
423 "Received stream event: chunk={:?}, event_id={:?}",
424 event.chunk,
425 event.event_id
426 );
427 })
428 })
429 .boxed()
430 }
431
432 /// Retrieves an existing interaction by its ID.
433 ///
434 /// Useful for checking the status of long-running interactions or agents,
435 /// or for retrieving the full conversation history.
436 ///
437 /// # Arguments
438 ///
439 /// * `interaction_id` - The unique identifier of the interaction to retrieve.
440 ///
441 /// # Errors
442 ///
443 /// Returns an error if:
444 /// - The HTTP request fails
445 /// - Response parsing fails
446 /// - The API returns an error
447 pub async fn get_interaction(
448 &self,
449 interaction_id: &str,
450 ) -> Result<crate::InteractionResponse, GenaiError> {
451 tracing::debug!("Getting interaction: ID={interaction_id}");
452
453 let response = crate::http::interactions::get_interaction(
454 &self.http_client,
455 &self.api_key,
456 interaction_id,
457 )
458 .await?;
459
460 log_response_body(&response);
461 tracing::debug!("Retrieved interaction: status={:?}", response.status);
462
463 Ok(response)
464 }
465
466 /// Retrieves an existing interaction by its ID with streaming.
467 ///
468 /// Returns a stream of events for the interaction. This is useful for:
469 /// - Resuming an interrupted stream using `last_event_id`
470 /// - Streaming a long-running interaction's progress (e.g., deep research)
471 ///
472 /// Each event includes an `event_id` that can be used to resume the stream
473 /// from that point if the connection is interrupted.
474 ///
475 /// # Arguments
476 ///
477 /// * `interaction_id` - The unique identifier of the interaction to stream.
478 /// * `last_event_id` - Optional event ID to resume from. Pass the last received
479 /// event's `event_id` to continue from where you left off.
480 ///
481 /// # Returns
482 /// A boxed stream that yields `StreamEvent` items.
483 ///
484 /// # Example
485 /// ```no_run
486 /// use genai_rs::{Client, StreamChunk};
487 /// use futures_util::StreamExt;
488 ///
489 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
490 /// let client = Client::builder("api_key".to_string()).build()?;
491 /// let interaction_id = "some-interaction-id";
492 ///
493 /// // Resume a stream from a previous event
494 /// let last_event_id = Some("evt_abc123");
495 /// let mut stream = client.get_interaction_stream(interaction_id, last_event_id);
496 ///
497 /// while let Some(result) = stream.next().await {
498 /// let event = result?;
499 /// println!("Event ID: {:?}", event.event_id);
500 /// match event.chunk {
501 /// StreamChunk::Delta(delta) => {
502 /// if let Some(text) = delta.as_text() {
503 /// print!("{}", text);
504 /// }
505 /// }
506 /// StreamChunk::Complete(response) => {
507 /// println!("\nDone! Status: {:?}", response.status);
508 /// }
509 /// _ => {}
510 /// }
511 /// }
512 /// # Ok(())
513 /// # }
514 /// ```
515 pub fn get_interaction_stream<'a>(
516 &'a self,
517 interaction_id: &'a str,
518 last_event_id: Option<&'a str>,
519 ) -> futures_util::stream::BoxStream<'a, Result<crate::StreamEvent, GenaiError>> {
520 use futures_util::StreamExt;
521
522 tracing::debug!(
523 "Getting interaction stream: ID={}, resume_from={:?}",
524 interaction_id,
525 last_event_id
526 );
527
528 let stream = crate::http::interactions::get_interaction_stream(
529 &self.http_client,
530 &self.api_key,
531 interaction_id,
532 last_event_id,
533 );
534
535 stream
536 .map(move |result| {
537 result.inspect(|event| {
538 tracing::debug!(
539 "Received stream event: chunk={:?}, event_id={:?}",
540 event.chunk,
541 event.event_id
542 );
543 })
544 })
545 .boxed()
546 }
547
548 /// Deletes an interaction by its ID.
549 ///
550 /// Removes the interaction from the server, freeing up storage and making it
551 /// unavailable for future reference via `previous_interaction_id`.
552 ///
553 /// # Arguments
554 ///
555 /// * `interaction_id` - The unique identifier of the interaction to delete.
556 ///
557 /// # Errors
558 ///
559 /// Returns an error if:
560 /// - The HTTP request fails
561 /// - The API returns an error
562 pub async fn delete_interaction(&self, interaction_id: &str) -> Result<(), GenaiError> {
563 tracing::debug!("Deleting interaction: ID={interaction_id}");
564
565 crate::http::interactions::delete_interaction(
566 &self.http_client,
567 &self.api_key,
568 interaction_id,
569 )
570 .await?;
571
572 tracing::debug!("Interaction deleted successfully");
573
574 Ok(())
575 }
576
577 /// Cancels an in-progress background interaction.
578 ///
579 /// Only applicable to interactions created with `background: true` that are
580 /// still in `InProgress` status. Returns the updated interaction with
581 /// status `Cancelled`.
582 ///
583 /// This is useful for:
584 /// - Halting long-running agent tasks (e.g., deep-research) when requirements change
585 /// - Cost control by stopping interactions consuming significant tokens
586 /// - Implementing timeout handling in application logic
587 /// - Supporting user-initiated cancellation in UIs
588 ///
589 /// # Arguments
590 ///
591 /// * `interaction_id` - The unique identifier of the interaction to cancel.
592 ///
593 /// # Errors
594 ///
595 /// Returns an error if:
596 /// - The interaction doesn't exist
597 /// - The interaction is not in a cancellable state (not background or already complete)
598 /// - The HTTP request fails
599 /// - The API returns an error
600 ///
601 /// # Example
602 ///
603 /// ```no_run
604 /// use genai_rs::{Client, InteractionStatus};
605 ///
606 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
607 /// let client = Client::new("your-api-key".to_string());
608 ///
609 /// // Start a background agent interaction
610 /// let response = client.interaction()
611 /// .with_agent("deep-research-pro-preview-12-2025")
612 /// .with_text("Research AI safety")
613 /// .with_background(true)
614 /// .with_store_enabled()
615 /// .create()
616 /// .await?;
617 ///
618 /// let interaction_id = response.id.as_ref().expect("stored interaction has id");
619 ///
620 /// // Later, cancel if still in progress
621 /// if response.status == InteractionStatus::InProgress {
622 /// let cancelled = client.cancel_interaction(interaction_id).await?;
623 /// assert_eq!(cancelled.status, InteractionStatus::Cancelled);
624 /// println!("Interaction cancelled");
625 /// }
626 /// # Ok(())
627 /// # }
628 /// ```
629 pub async fn cancel_interaction(
630 &self,
631 interaction_id: &str,
632 ) -> Result<crate::InteractionResponse, GenaiError> {
633 tracing::debug!("Cancelling interaction: ID={interaction_id}");
634
635 let response = crate::http::interactions::cancel_interaction(
636 &self.http_client,
637 &self.api_key,
638 interaction_id,
639 )
640 .await?;
641
642 log_response_body(&response);
643 tracing::debug!("Interaction cancelled: status={:?}", response.status);
644
645 Ok(response)
646 }
647
648 // --- Files API methods ---
649
650 /// Uploads a file from a path to the Files API.
651 ///
652 /// Files are stored for 48 hours and can be referenced in interactions by their URI.
653 /// This is more efficient than inline base64 encoding for large files or files
654 /// that will be used across multiple interactions.
655 ///
656 /// # Arguments
657 ///
658 /// * `path` - Path to the file to upload
659 ///
660 /// # Errors
661 ///
662 /// Returns an error if:
663 /// - The file cannot be read
664 /// - The MIME type cannot be determined
665 /// - The upload fails
666 ///
667 /// # Example
668 ///
669 /// ```no_run
670 /// use genai_rs::{Client, Content};
671 ///
672 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
673 /// let client = Client::new("api-key".to_string());
674 ///
675 /// // Upload a video file
676 /// let file = client.upload_file("video.mp4").await?;
677 /// println!("Uploaded: {} -> {}", file.name, file.uri);
678 ///
679 /// // Use in interaction
680 /// let response = client.interaction()
681 /// .with_model("gemini-3-flash-preview")
682 /// .with_content(vec![
683 /// Content::text("Describe this video"),
684 /// Content::from_file(&file),
685 /// ])
686 /// .create()
687 /// .await?;
688 /// # Ok(())
689 /// # }
690 /// ```
691 pub async fn upload_file(
692 &self,
693 path: impl AsRef<std::path::Path>,
694 ) -> Result<crate::FileMetadata, GenaiError> {
695 let path = path.as_ref();
696
697 // Read file contents
698 let file_data = tokio::fs::read(path).await.map_err(|e| {
699 tracing::warn!("Failed to read file '{}': {}", path.display(), e);
700 GenaiError::InvalidInput(format!("Failed to read file '{}': {}", path.display(), e))
701 })?;
702
703 // Detect MIME type from extension
704 let mime_type = crate::multimodal::detect_mime_type(path).ok_or_else(|| {
705 tracing::warn!(
706 "Could not determine MIME type for '{}' - unknown extension",
707 path.display()
708 );
709 GenaiError::InvalidInput(format!(
710 "Could not determine MIME type for '{}'. Please use upload_file_with_mime() to specify explicitly.",
711 path.display()
712 ))
713 })?;
714
715 // Use filename as display name
716 let display_name = path
717 .file_name()
718 .and_then(|s| s.to_str())
719 .map(|s| s.to_string());
720
721 tracing::debug!(
722 "Uploading file: path={}, size={} bytes, mime_type={}",
723 path.display(),
724 file_data.len(),
725 mime_type
726 );
727
728 crate::http::files::upload_file(
729 &self.http_client,
730 &self.api_key,
731 file_data,
732 mime_type,
733 display_name.as_deref(),
734 )
735 .await
736 }
737
738 /// Uploads a file with an explicit MIME type.
739 ///
740 /// Use this when automatic MIME type detection isn't suitable.
741 ///
742 /// # Arguments
743 ///
744 /// * `path` - Path to the file to upload
745 /// * `mime_type` - MIME type of the file (e.g., "video/mp4")
746 ///
747 /// # Example
748 ///
749 /// ```no_run
750 /// use genai_rs::Client;
751 ///
752 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
753 /// let client = Client::new("api-key".to_string());
754 ///
755 /// let file = client.upload_file_with_mime("data.bin", "application/octet-stream").await?;
756 /// # Ok(())
757 /// # }
758 /// ```
759 pub async fn upload_file_with_mime(
760 &self,
761 path: impl AsRef<std::path::Path>,
762 mime_type: &str,
763 ) -> Result<crate::FileMetadata, GenaiError> {
764 let path = path.as_ref();
765
766 let file_data = tokio::fs::read(path).await.map_err(|e| {
767 tracing::warn!("Failed to read file '{}': {}", path.display(), e);
768 GenaiError::InvalidInput(format!("Failed to read file '{}': {}", path.display(), e))
769 })?;
770
771 let display_name = path
772 .file_name()
773 .and_then(|s| s.to_str())
774 .map(|s| s.to_string());
775
776 tracing::debug!(
777 "Uploading file: path={}, size={} bytes, mime_type={}",
778 path.display(),
779 file_data.len(),
780 mime_type
781 );
782
783 crate::http::files::upload_file(
784 &self.http_client,
785 &self.api_key,
786 file_data,
787 mime_type,
788 display_name.as_deref(),
789 )
790 .await
791 }
792
793 /// Uploads file bytes directly with a specified MIME type.
794 ///
795 /// Use this when you already have file contents in memory.
796 ///
797 /// # Arguments
798 ///
799 /// * `data` - File contents as bytes
800 /// * `mime_type` - MIME type of the file
801 /// * `display_name` - Optional display name for the file
802 ///
803 /// # Example
804 ///
805 /// ```no_run
806 /// use genai_rs::Client;
807 ///
808 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
809 /// let client = Client::new("api-key".to_string());
810 ///
811 /// // Upload bytes from memory
812 /// let video_bytes = std::fs::read("video.mp4")?;
813 /// let file = client.upload_file_bytes(video_bytes, "video/mp4", Some("my-video")).await?;
814 /// # Ok(())
815 /// # }
816 /// ```
817 pub async fn upload_file_bytes(
818 &self,
819 data: Vec<u8>,
820 mime_type: &str,
821 display_name: Option<&str>,
822 ) -> Result<crate::FileMetadata, GenaiError> {
823 tracing::debug!(
824 "Uploading file bytes: size={} bytes, mime_type={}, display_name={:?}",
825 data.len(),
826 mime_type,
827 display_name
828 );
829
830 crate::http::files::upload_file(
831 &self.http_client,
832 &self.api_key,
833 data,
834 mime_type,
835 display_name,
836 )
837 .await
838 }
839
840 /// Gets metadata for an uploaded file.
841 ///
842 /// Use this to check the processing status of a recently uploaded file.
843 ///
844 /// # Arguments
845 ///
846 /// * `file_name` - The resource name of the file (e.g., "files/abc123")
847 ///
848 /// # Example
849 ///
850 /// ```no_run
851 /// use genai_rs::Client;
852 ///
853 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
854 /// let client = Client::new("api-key".to_string());
855 ///
856 /// let file = client.get_file("files/abc123").await?;
857 /// if file.is_active() {
858 /// println!("File is ready to use");
859 /// } else if file.is_processing() {
860 /// println!("File is still processing...");
861 /// }
862 /// # Ok(())
863 /// # }
864 /// ```
865 pub async fn get_file(&self, file_name: &str) -> Result<crate::FileMetadata, GenaiError> {
866 crate::http::files::get_file(&self.http_client, &self.api_key, file_name).await
867 }
868
869 /// Lists all uploaded files.
870 ///
871 /// # Example
872 ///
873 /// ```no_run
874 /// use genai_rs::Client;
875 ///
876 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
877 /// let client = Client::new("api-key".to_string());
878 ///
879 /// let response = client.list_files(None, None).await?;
880 /// for file in response.files {
881 /// println!("{}: {} ({})", file.name, file.display_name.as_deref().unwrap_or(""), file.mime_type);
882 /// }
883 /// # Ok(())
884 /// # }
885 /// ```
886 pub async fn list_files(
887 &self,
888 page_size: Option<u32>,
889 page_token: Option<&str>,
890 ) -> Result<crate::ListFilesResponse, GenaiError> {
891 crate::http::files::list_files(&self.http_client, &self.api_key, page_size, page_token)
892 .await
893 }
894
895 /// Deletes an uploaded file.
896 ///
897 /// # Arguments
898 ///
899 /// * `file_name` - The resource name of the file to delete (e.g., "files/abc123")
900 ///
901 /// # Example
902 ///
903 /// ```no_run
904 /// use genai_rs::Client;
905 ///
906 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
907 /// let client = Client::new("api-key".to_string());
908 ///
909 /// // Upload, use, then delete
910 /// let file = client.upload_file("video.mp4").await?;
911 /// // ... use in interactions ...
912 /// client.delete_file(&file.name).await?;
913 /// # Ok(())
914 /// # }
915 /// ```
916 pub async fn delete_file(&self, file_name: &str) -> Result<(), GenaiError> {
917 crate::http::files::delete_file(&self.http_client, &self.api_key, file_name).await
918 }
919
920 /// Uploads a file using chunked transfer to minimize memory usage.
921 ///
922 /// Unlike `upload_file`, this method streams the file from disk in chunks,
923 /// never loading the entire file into memory. This is ideal for large files
924 /// (500MB-2GB) or memory-constrained environments.
925 ///
926 /// # Arguments
927 ///
928 /// * `path` - Path to the file to upload
929 ///
930 /// # Returns
931 ///
932 /// Returns a tuple of:
933 /// - `FileMetadata`: The uploaded file's metadata
934 /// - `ResumableUpload`: A handle that can be used to resume if the upload is interrupted
935 ///
936 /// # Memory Usage
937 ///
938 /// This method uses approximately 8MB of memory for buffering, regardless of
939 /// the file size. A 2GB file uses the same memory as a 10MB file.
940 ///
941 /// # Errors
942 ///
943 /// Returns an error if:
944 /// - The file cannot be read
945 /// - The MIME type cannot be determined
946 /// - The upload fails
947 ///
948 /// # Example
949 ///
950 /// ```no_run
951 /// use genai_rs::{Client, Content};
952 ///
953 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
954 /// let client = Client::new("api-key".to_string());
955 ///
956 /// // Upload a large video file without loading it all into memory
957 /// let (file, _upload_handle) = client.upload_file_chunked("large_video.mp4").await?;
958 /// println!("Uploaded: {} -> {}", file.name, file.uri);
959 ///
960 /// // Use in interaction
961 /// let response = client.interaction()
962 /// .with_model("gemini-3-flash-preview")
963 /// .with_content(vec![
964 /// Content::text("Describe this video"),
965 /// Content::from_file(&file),
966 /// ])
967 /// .create()
968 /// .await?;
969 /// # Ok(())
970 /// # }
971 /// ```
972 pub async fn upload_file_chunked(
973 &self,
974 path: impl AsRef<std::path::Path>,
975 ) -> Result<(crate::FileMetadata, crate::ResumableUpload), GenaiError> {
976 let path = path.as_ref();
977
978 // Detect MIME type from extension
979 let mime_type = crate::multimodal::detect_mime_type(path).ok_or_else(|| {
980 tracing::warn!(
981 "Could not determine MIME type for '{}' - unknown extension",
982 path.display()
983 );
984 GenaiError::InvalidInput(format!(
985 "Could not determine MIME type for '{}'. Please use upload_file_chunked_with_mime() to specify explicitly.",
986 path.display()
987 ))
988 })?;
989
990 // Use filename as display name
991 let display_name = path
992 .file_name()
993 .and_then(|s| s.to_str())
994 .map(|s| s.to_string());
995
996 tracing::debug!(
997 "Chunked upload: path={}, mime_type={}",
998 path.display(),
999 mime_type
1000 );
1001
1002 crate::http::files::upload_file_chunked(
1003 &self.http_client,
1004 &self.api_key,
1005 path,
1006 mime_type,
1007 display_name.as_deref(),
1008 )
1009 .await
1010 }
1011
1012 /// Uploads a file using chunked transfer with an explicit MIME type.
1013 ///
1014 /// Use this when automatic MIME type detection isn't suitable.
1015 ///
1016 /// # Arguments
1017 ///
1018 /// * `path` - Path to the file to upload
1019 /// * `mime_type` - MIME type of the file (e.g., "video/mp4")
1020 ///
1021 /// # Example
1022 ///
1023 /// ```no_run
1024 /// use genai_rs::Client;
1025 ///
1026 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1027 /// let client = Client::new("api-key".to_string());
1028 ///
1029 /// let (file, _) = client.upload_file_chunked_with_mime(
1030 /// "data.bin",
1031 /// "application/octet-stream"
1032 /// ).await?;
1033 /// # Ok(())
1034 /// # }
1035 /// ```
1036 pub async fn upload_file_chunked_with_mime(
1037 &self,
1038 path: impl AsRef<std::path::Path>,
1039 mime_type: &str,
1040 ) -> Result<(crate::FileMetadata, crate::ResumableUpload), GenaiError> {
1041 let path = path.as_ref();
1042
1043 let display_name = path
1044 .file_name()
1045 .and_then(|s| s.to_str())
1046 .map(|s| s.to_string());
1047
1048 tracing::debug!(
1049 "Chunked upload: path={}, mime_type={}",
1050 path.display(),
1051 mime_type
1052 );
1053
1054 crate::http::files::upload_file_chunked(
1055 &self.http_client,
1056 &self.api_key,
1057 path,
1058 mime_type,
1059 display_name.as_deref(),
1060 )
1061 .await
1062 }
1063
1064 /// Uploads a file using chunked transfer with a custom chunk size.
1065 ///
1066 /// This is the same as `upload_file_chunked_with_mime` but allows
1067 /// specifying the chunk size for streaming. Larger chunks are more
1068 /// efficient for fast networks, while smaller chunks use less memory.
1069 ///
1070 /// # Arguments
1071 ///
1072 /// * `path` - Path to the file to upload
1073 /// * `mime_type` - MIME type of the file
1074 /// * `chunk_size` - Size of chunks to stream in bytes (default: 8MB)
1075 ///
1076 /// # Example
1077 ///
1078 /// ```no_run
1079 /// use genai_rs::Client;
1080 ///
1081 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1082 /// let client = Client::new("api-key".to_string());
1083 ///
1084 /// // Use 16MB chunks for faster upload on a fast network
1085 /// let chunk_size = 16 * 1024 * 1024;
1086 /// let (file, _) = client.upload_file_chunked_with_options(
1087 /// "large_video.mp4",
1088 /// "video/mp4",
1089 /// chunk_size
1090 /// ).await?;
1091 /// # Ok(())
1092 /// # }
1093 /// ```
1094 pub async fn upload_file_chunked_with_options(
1095 &self,
1096 path: impl AsRef<std::path::Path>,
1097 mime_type: &str,
1098 chunk_size: usize,
1099 ) -> Result<(crate::FileMetadata, crate::ResumableUpload), GenaiError> {
1100 let path = path.as_ref();
1101
1102 let display_name = path
1103 .file_name()
1104 .and_then(|s| s.to_str())
1105 .map(|s| s.to_string());
1106
1107 tracing::debug!(
1108 "Chunked upload: path={}, mime_type={}, chunk_size={}",
1109 path.display(),
1110 mime_type,
1111 chunk_size
1112 );
1113
1114 crate::http::files::upload_file_chunked_with_chunk_size(
1115 &self.http_client,
1116 &self.api_key,
1117 path,
1118 mime_type,
1119 display_name.as_deref(),
1120 chunk_size,
1121 )
1122 .await
1123 }
1124
1125 /// Waits for a file to finish processing.
1126 ///
1127 /// Some files (especially videos) require processing before they can be used.
1128 /// This method polls the file status until it becomes active or fails.
1129 ///
1130 /// # Arguments
1131 ///
1132 /// * `file` - The file metadata to wait for
1133 /// * `poll_interval` - How often to check the status
1134 /// * `timeout` - Maximum time to wait
1135 ///
1136 /// # Returns
1137 ///
1138 /// Returns the updated file metadata when processing completes.
1139 ///
1140 /// # Errors
1141 ///
1142 /// Returns an error if:
1143 /// - The file processing fails
1144 /// - The timeout is exceeded
1145 ///
1146 /// # Example
1147 ///
1148 /// ```no_run
1149 /// use genai_rs::Client;
1150 /// use std::time::Duration;
1151 ///
1152 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1153 /// let client = Client::new("api-key".to_string());
1154 ///
1155 /// let file = client.upload_file("large_video.mp4").await?;
1156 ///
1157 /// // Wait for processing to complete
1158 /// let ready_file = client.wait_for_file_ready(
1159 /// &file,
1160 /// Duration::from_secs(2),
1161 /// Duration::from_secs(120)
1162 /// ).await?;
1163 ///
1164 /// println!("File ready: {}", ready_file.uri);
1165 /// # Ok(())
1166 /// # }
1167 /// ```
1168 pub async fn wait_for_file_ready(
1169 &self,
1170 file: &crate::FileMetadata,
1171 poll_interval: std::time::Duration,
1172 timeout: std::time::Duration,
1173 ) -> Result<crate::FileMetadata, GenaiError> {
1174 use std::time::Instant;
1175
1176 let start = Instant::now();
1177
1178 loop {
1179 let current = self.get_file(&file.name).await?;
1180
1181 if current.is_active() {
1182 return Ok(current);
1183 }
1184
1185 if current.is_failed() {
1186 let error_code = current.error.as_ref().and_then(|e| e.code);
1187 let error_msg = current
1188 .error
1189 .as_ref()
1190 .and_then(|e| e.message.as_deref())
1191 .unwrap_or("File processing failed without details");
1192
1193 tracing::error!(
1194 "File '{}' processing failed: code={:?}, message={}",
1195 file.name,
1196 error_code,
1197 error_msg
1198 );
1199
1200 // Use Api error since this is a server-side processing failure
1201 return Err(GenaiError::Api {
1202 status_code: error_code.map_or(500, |c| c as u16),
1203 message: format!("File processing failed: {}", error_msg),
1204 request_id: None,
1205 retry_after: None,
1206 });
1207 }
1208
1209 // Log unknown states per Evergreen logging strategy
1210 if let Some(state) = ¤t.state
1211 && state.is_unknown()
1212 {
1213 tracing::warn!(
1214 "File '{}' is in unknown state {:?}, continuing to poll. \
1215 This may indicate API evolution - consider updating genai-rs.",
1216 file.name,
1217 state
1218 );
1219 }
1220
1221 if start.elapsed() > timeout {
1222 // Use Internal error since this is an operational issue, not invalid input
1223 let state_info = current
1224 .state
1225 .as_ref()
1226 .map(|s| format!("{:?}", s))
1227 .unwrap_or_else(|| "unknown".to_string());
1228 return Err(GenaiError::Internal(format!(
1229 "Timeout waiting for file '{}' to be ready (waited {:?}, last state: {}). \
1230 The file may still be processing - try again with a longer timeout.",
1231 file.name,
1232 start.elapsed(),
1233 state_info
1234 )));
1235 }
1236
1237 tracing::debug!(
1238 "File '{}' still processing, waiting {:?}...",
1239 file.name,
1240 poll_interval
1241 );
1242 tokio::time::sleep(poll_interval).await;
1243 }
1244 }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249 use super::*;
1250
1251 #[test]
1252 fn test_client_builder_default() {
1253 let client = Client::builder("test_key".to_string()).build().unwrap();
1254 assert_eq!(client.api_key, "test_key");
1255 }
1256
1257 #[test]
1258 fn test_client_builder_with_timeout() {
1259 let client = Client::builder("test_key".to_string())
1260 .with_timeout(Duration::from_secs(120))
1261 .build()
1262 .unwrap();
1263 assert_eq!(client.api_key, "test_key");
1264 // Note: We can't easily inspect the reqwest client's timeout,
1265 // but this test verifies the builder chain works
1266 }
1267
1268 #[test]
1269 fn test_client_builder_with_connect_timeout() {
1270 let client = Client::builder("test_key".to_string())
1271 .with_connect_timeout(Duration::from_secs(10))
1272 .build()
1273 .unwrap();
1274 assert_eq!(client.api_key, "test_key");
1275 }
1276
1277 #[test]
1278 fn test_client_builder_with_both_timeouts() {
1279 let client = Client::builder("test_key".to_string())
1280 .with_timeout(Duration::from_secs(120))
1281 .with_connect_timeout(Duration::from_secs(10))
1282 .build()
1283 .unwrap();
1284 assert_eq!(client.api_key, "test_key");
1285 }
1286
1287 #[test]
1288 fn test_client_new() {
1289 let client = Client::new("test_key".to_string());
1290 assert_eq!(client.api_key, "test_key");
1291 }
1292
1293 #[test]
1294 fn test_client_debug_redacts_api_key() {
1295 let client = Client::new("super_secret_api_key_12345".to_string());
1296 let debug_output = format!("{:?}", client);
1297
1298 // API key should NOT appear in debug output
1299 assert!(
1300 !debug_output.contains("super_secret_api_key_12345"),
1301 "API key was exposed in debug output: {}",
1302 debug_output
1303 );
1304 // Should show [REDACTED] instead
1305 assert!(
1306 debug_output.contains("[REDACTED]"),
1307 "Debug output should contain [REDACTED]: {}",
1308 debug_output
1309 );
1310 }
1311
1312 #[test]
1313 fn test_client_builder_returns_result() {
1314 let result = Client::builder("test_key".to_string()).build();
1315 assert!(result.is_ok());
1316 }
1317
1318 #[test]
1319 fn test_client_builder_debug_redacts_api_key() {
1320 let builder = Client::builder("another_secret_key_67890".to_string())
1321 .with_timeout(Duration::from_secs(60));
1322 let debug_output = format!("{:?}", builder);
1323
1324 // API key should NOT appear in debug output
1325 assert!(
1326 !debug_output.contains("another_secret_key_67890"),
1327 "API key was exposed in builder debug output: {}",
1328 debug_output
1329 );
1330 // Should show [REDACTED] instead
1331 assert!(
1332 debug_output.contains("[REDACTED]"),
1333 "Builder debug output should contain [REDACTED]: {}",
1334 debug_output
1335 );
1336 }
1337
1338 #[tokio::test]
1339 async fn test_upload_file_unknown_extension_error() {
1340 let client = Client::new("test_key".to_string());
1341
1342 // Create a temp file with an unknown extension
1343 let temp_dir = tempfile::tempdir().unwrap();
1344 let file_path = temp_dir.path().join("data.xyz");
1345 std::fs::write(&file_path, b"test data").unwrap();
1346
1347 // upload_file should fail with InvalidInput for unknown MIME type
1348 let result = client.upload_file(&file_path).await;
1349 assert!(result.is_err(), "Should fail for unknown extension");
1350
1351 let err = result.unwrap_err();
1352 let err_string = err.to_string();
1353 assert!(
1354 err_string.contains("Could not determine MIME type"),
1355 "Error should mention MIME type issue: {}",
1356 err_string
1357 );
1358 assert!(
1359 err_string.contains("data.xyz"),
1360 "Error should include filename: {}",
1361 err_string
1362 );
1363 }
1364
1365 #[tokio::test]
1366 async fn test_upload_file_nonexistent_file_error() {
1367 let client = Client::new("test_key".to_string());
1368
1369 // Try to upload a file that doesn't exist
1370 let result = client.upload_file("/nonexistent/path/to/file.txt").await;
1371 assert!(result.is_err(), "Should fail for nonexistent file");
1372
1373 let err = result.unwrap_err();
1374 let err_string = err.to_string();
1375 assert!(
1376 err_string.contains("Failed to read file"),
1377 "Error should mention file read failure: {}",
1378 err_string
1379 );
1380 }
1381
1382 #[tokio::test]
1383 async fn test_upload_file_bytes_empty_file_error() {
1384 let client = Client::new("test_key".to_string());
1385
1386 // Try to upload empty bytes
1387 let result = client
1388 .upload_file_bytes(Vec::new(), "text/plain", Some("empty.txt"))
1389 .await;
1390 assert!(result.is_err(), "Should fail for empty file");
1391
1392 let err = result.unwrap_err();
1393 let err_string = err.to_string();
1394 assert!(
1395 err_string.contains("Cannot upload empty file"),
1396 "Error should mention empty file: {}",
1397 err_string
1398 );
1399 }
1400
1401 #[tokio::test]
1402 async fn test_upload_file_bytes_validates_before_network() {
1403 // This test verifies that validation happens before any network call
1404 // by using an invalid API key - if we reach the network, we'd get auth error
1405 let client = Client::new("invalid_key".to_string());
1406
1407 // Empty file should fail with validation error, not auth error
1408 let result = client
1409 .upload_file_bytes(Vec::new(), "text/plain", None)
1410 .await;
1411 assert!(result.is_err());
1412 let err_string = result.unwrap_err().to_string();
1413 assert!(
1414 err_string.contains("Cannot upload empty file"),
1415 "Should fail validation before hitting network: {}",
1416 err_string
1417 );
1418 }
1419}