gemini_genai_rs/generate/
mod.rs

1//! generateContent and streamGenerateContent REST API.
2//!
3//! This module provides typed request/response types and a client for the
4//! Gemini generateContent REST API. Feature-gated behind `generate`.
5//!
6//! # Usage
7//!
8//! ```no_run
9//! use gemini_genai_rs::{Client, prelude::ModelId};
10//!
11//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
12//! let client = Client::from_api_key("your-key").model(ModelId::FLASH_LATEST);
13//!
14//! let response = client.generate_content("What is Rust?").await?;
15//! println!("{}", response.text().unwrap_or_default());
16//! # Ok(())
17//! # }
18//! ```
19
20mod config;
21mod response;
22
23pub use config::GenerateContentConfig;
24pub use response::{BlockReason, Candidate, GenerateContentResponse, PromptFeedback};
25
26use crate::client::Client;
27use crate::client::http::HttpError;
28use crate::protocol::types::ModelId;
29use crate::transport::auth::ServiceEndpoint;
30
31impl Client {
32    /// Generate content from a text prompt using the default model.
33    pub async fn generate_content(
34        &self,
35        prompt: impl Into<String>,
36    ) -> Result<GenerateContentResponse, GenerateError> {
37        let config = GenerateContentConfig::from_text(prompt);
38        self.generate_content_with(config, None).await
39    }
40
41    /// Generate content with full configuration and optional model override.
42    pub async fn generate_content_with(
43        &self,
44        config: GenerateContentConfig,
45        model: Option<&ModelId>,
46    ) -> Result<GenerateContentResponse, GenerateError> {
47        let model = model.unwrap_or(self.default_model());
48        let url = self.rest_url_for(ServiceEndpoint::GenerateContent, model);
49        let headers = self.auth_headers().await?;
50
51        let body = config.to_request_body();
52        let json = self
53            .http_client()
54            .post_json(&url, headers, &body)
55            .await
56            .map_err(GenerateError::from)?;
57
58        let response: GenerateContentResponse = serde_json::from_value(json)?;
59        Ok(response)
60    }
61}
62
63impl Client {
64    /// Stream a generation: each item is one chunk of the reply, as the
65    /// server sends it (`streamGenerateContent?alt=sse`).
66    ///
67    /// Concatenating the chunks' text gives the full reply. Usage metadata,
68    /// when present, is cumulative, so the last chunk's is the call's total.
69    ///
70    /// ```no_run
71    /// use futures_util::StreamExt;
72    /// use gemini_genai_rs::{Client, generate::GenerateContentConfig};
73    ///
74    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
75    /// let client = Client::from_api_key("your-key");
76    /// let mut chunks = client
77    ///     .stream_generate_content_with(GenerateContentConfig::from_text("Tell a story"), None)
78    ///     .await?;
79    /// while let Some(chunk) = chunks.next().await {
80    ///     print!("{}", chunk?.text().unwrap_or_default());
81    /// }
82    /// # Ok(())
83    /// # }
84    /// ```
85    pub async fn stream_generate_content_with(
86        &self,
87        config: GenerateContentConfig,
88        model: Option<&ModelId>,
89    ) -> Result<
90        futures_util::stream::BoxStream<'static, Result<GenerateContentResponse, GenerateError>>,
91        GenerateError,
92    > {
93        use futures_util::StreamExt;
94
95        let model = model.unwrap_or(self.default_model());
96        let url = self.rest_url_for(ServiceEndpoint::StreamGenerateContent, model);
97        let separator = if url.contains('?') { '&' } else { '?' };
98        let url = format!("{url}{separator}alt=sse");
99        let headers = self.auth_headers().await?;
100
101        let body = config.to_request_body();
102        let events = self
103            .http_client()
104            .post_sse(&url, headers, &body)
105            .await
106            .map_err(GenerateError::from)?;
107        Ok(events
108            .map(|event| Ok(serde_json::from_value(event?)?))
109            .boxed())
110    }
111}
112
113/// Errors specific to the Generate API.
114#[derive(Debug, thiserror::Error)]
115pub enum GenerateError {
116    /// HTTP transport error.
117    #[error(transparent)]
118    Http(#[from] HttpError),
119
120    /// JSON deserialization error.
121    #[error("Failed to parse response: {0}")]
122    Parse(#[from] serde_json::Error),
123
124    /// Authentication error.
125    #[error("Auth error: {0}")]
126    Auth(#[from] crate::session::AuthError),
127
128    /// Content was blocked by safety filters.
129    #[error("Content blocked: {reason:?}")]
130    SafetyBlocked {
131        /// The reason the content was blocked.
132        reason: BlockReason,
133    },
134
135    /// Prompt was rejected.
136    #[error("Prompt blocked: {reason:?}")]
137    PromptBlocked {
138        /// The reason the prompt was blocked.
139        reason: BlockReason,
140    },
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn generate_error_display() {
149        let err = GenerateError::SafetyBlocked {
150            reason: BlockReason::Safety,
151        };
152        assert!(err.to_string().contains("blocked"));
153    }
154
155    #[test]
156    fn generate_content_config_from_text() {
157        let config = GenerateContentConfig::from_text("Hello");
158        let body = config.to_request_body();
159        let contents = body.get("contents").unwrap();
160        assert!(contents.is_array());
161        let parts = contents[0].get("parts").unwrap();
162        assert!(parts[0].get("text").unwrap().as_str().unwrap() == "Hello");
163    }
164
165    #[test]
166    fn generate_content_config_with_system() {
167        let config = GenerateContentConfig::from_text("Hello")
168            .system_instruction("You are a helpful assistant");
169        let body = config.to_request_body();
170        assert!(body.get("systemInstruction").is_some());
171    }
172
173    #[test]
174    fn parse_generate_response() {
175        let json = serde_json::json!({
176            "candidates": [{
177                "content": {
178                    "parts": [{"text": "Hello world!"}],
179                    "role": "model"
180                },
181                "finishReason": "STOP",
182                "safetyRatings": [{
183                    "category": "HARM_CATEGORY_HARASSMENT",
184                    "probability": "NEGLIGIBLE"
185                }]
186            }],
187            "usageMetadata": {
188                "promptTokenCount": 5,
189                "candidatesTokenCount": 10,
190                "totalTokenCount": 15
191            }
192        });
193
194        let resp: GenerateContentResponse = serde_json::from_value(json).unwrap();
195        assert_eq!(resp.candidates.len(), 1);
196        assert_eq!(resp.text().unwrap(), "Hello world!");
197        assert!(resp.usage_metadata.is_some());
198    }
199}