Skip to main content

rs_ai/
lib.rs

1//!
2//! ⚠️ **UNSTABLE** — This crate is in active development. APIs may change without notice.
3//!
4//! **rs_ai** - A fluent, ergonomic Rust SDK for AI with 15+ cloud and local providers.
5//!
6//! # Quick Start
7//!
8//! ```ignore
9//! use rs_ai::gemini;
10//!
11//! let response = gemini()
12//!     .api_key("your-api-key")
13//!     .model("gemini-2.5-flash")
14//!     .generate("What is 2+2?")
15//!     .await?;
16//!
17//! println!("{}", response);
18//! ```
19//!
20//! # Supported Providers
21//!
22//! - **Claude** - `rs_ai::claude()`
23//! - **ChatGPT** - `rs_ai::chatgpt()`
24//! - **Gemini** - `rs_ai::gemini()`
25//! - **xAI Grok** - `rs_ai::xai()`
26//! - **OpenAI Compatible** - `rs_ai::compatible(base_url)`
27
28use base64::Engine as _;
29use futures::stream::BoxStream;
30use rs_ai_core::{
31    AiError, AiResult, CacheConfig, ContentPart, FileData, GenerateOptions, ImageData,
32    ImageGenerationOptions, ImageModel, ImageResult, LanguageModel, Message, Prompt, RealtimeSession,
33    StreamEvent, VideoGenerationOptions, VideoModel, VideoResult,
34};
35use rs_ai_providers::chatgpt::ChatGptProvider;
36use rs_ai_providers::claude::ClaudeProvider;
37use rs_ai_providers::cloudflare::CloudflareProvider;
38use rs_ai_providers::gemini::GeminiProvider;
39use rs_ai_providers::openai_compatible::{OpenAiCompatibleConfig, OpenAiCompatibleProvider};
40use rs_ai_providers::xai::XaiProvider;
41
42pub use rs_ai_providers::xai::XAI_OAUTH_MODEL_IDS;
43
44/// Fluent builder for creating and configuring AI clients.
45pub struct ClientBuilder {
46    provider_type: ProviderType,
47    api_key: Option<String>,
48    model_id: Option<String>,
49    /// Accumulated image URLs or paths added via [`ClientBuilder::with_image`].
50    images: Vec<String>,
51    /// Cloudflare AI Gateway path — either `account_id/gateway_id` or a full
52    /// `https://gateway.ai.cloudflare.com/v1/...` URL prefix.
53    cf_gateway: Option<String>,
54    /// Cache configuration (prompt caching, conversation routing, etc.)
55    cache_config: Option<CacheConfig>,
56}
57
58#[derive(Debug)]
59enum ProviderType {
60    Claude,
61    ChatGpt,
62    Gemini,
63    Xai,
64    Cloudflare { account_id: String },
65    Compatible { base_url: String },
66}
67
68impl ClientBuilder {
69    /// Set the API key for the provider.
70    ///
71    /// # Examples
72    /// ```ignore
73    /// let response = rs_ai::claude()
74    ///     .api_key("sk-ant-...")
75    ///     .model("claude-sonnet-4-6")
76    ///     .generate("Hello!")
77    ///     .await?;
78    /// ```
79    pub fn api_key(mut self, key: impl Into<String>) -> Self {
80        self.api_key = Some(key.into());
81        self
82    }
83
84    /// Set the model ID to use.
85    ///
86    /// # Examples
87    /// ```ignore
88    /// let response = rs_ai::gemini()
89    ///     .model("gemini-2.5-flash")
90    ///     .api_key("...")
91    ///     .generate("Hello!")
92    ///     .await?;
93    /// ```
94    pub fn model(mut self, id: impl Into<String>) -> Self {
95        self.model_id = Some(id.into());
96        self
97    }
98
99    /// Attach an image to the next request by URL or local file path.
100    ///
101    /// Multiple calls accumulate images — all of them will be included when
102    /// [`generate`](ClientBuilder::generate) or [`stream`](ClientBuilder::stream) is called.
103    ///
104    /// If the value looks like a URL (starts with `http://` or `https://`) it is
105    /// sent as an image URL reference. Otherwise it is treated as a local file
106    /// path and the file contents are base64-encoded and sent inline.
107    ///
108    /// # Examples
109    /// ```ignore
110    /// let response = rs_ai::claude()
111    ///     .api_key("sk-ant-...")
112    ///     .model("claude-sonnet-4-6")
113    ///     .with_image("https://example.com/photo.jpg")
114    ///     .generate("Describe this image.")
115    ///     .await?;
116    /// ```
117    pub fn with_image(mut self, url_or_path: impl Into<String>) -> Self {
118        self.images.push(url_or_path.into());
119        self
120    }
121
122    /// Route all requests through a Cloudflare AI Gateway.
123    ///
124    /// Pass either `"account_id/gateway_id"` or a full gateway URL prefix.
125    /// The correct provider-specific path is appended automatically.
126    ///
127    /// # Examples
128    /// ```ignore
129    /// // Using account_id/gateway_id shorthand
130    /// let response = rs_ai::claude()
131    ///     .api_key("sk-ant-...")
132    ///     .model("claude-sonnet-4-6")
133    ///     .cf_ai_gateway("fc62a6e6528bec6d3d81c3bf8967ceeb/my-gateway")
134    ///     .generate("Hello!")
135    ///     .await?;
136    ///
137    /// // Same with a full URL prefix
138    /// let response = rs_ai::gemini()
139    ///     .api_key("AIzaSy...")
140    ///     .model("gemini-2.5-flash")
141    ///     .cf_ai_gateway("https://gateway.ai.cloudflare.com/v1/abc123/prod")
142    ///     .generate("Hello!")
143    ///     .await?;
144    /// ```
145    pub fn cf_ai_gateway(mut self, gateway: impl Into<String>) -> Self {
146        self.cf_gateway = Some(gateway.into());
147        self
148    }
149
150    /// Enable prompt caching for this request.
151    ///
152    /// Enables provider-specific caching mechanisms:
153    /// - **Claude**: Explicit cache_control on message blocks
154    /// - **Gemini**: Cached content reuse
155    /// - **OpenAI**: Automatic routing-based caching
156    /// - **xAI**: Conversation routing + prompt caching
157    ///
158    /// # Examples
159    /// ```ignore
160    /// let response = rs_ai::claude()
161    ///     .api_key("sk-ant-...")
162    ///     .model("claude-sonnet-4-6")
163    ///     .with_cache(rai_cache::CacheConfig::new())
164    ///     .generate("Long prompt with cached content...")
165    ///     .await?;
166    /// ```
167    pub fn with_cache(mut self, config: CacheConfig) -> Self {
168        self.cache_config = Some(config);
169        self
170    }
171
172    /// Enable caching with default settings.
173    ///
174    /// Equivalent to `.with_cache(CacheConfig::new())`.
175    pub fn enable_cache(self) -> Self {
176        self.with_cache(CacheConfig::new())
177    }
178
179    /// Set xAI conversation ID for server routing.
180    ///
181    /// Routes requests with the same conversation ID to the same server,
182    /// improving cache hit rates for xAI/Grok models.
183    pub fn with_xai_conv_id(mut self, conv_id: impl Into<String>) -> Self {
184        let mut cache = self.cache_config.unwrap_or_default();
185        cache.xai_conv_id = Some(conv_id.into());
186        self.cache_config = Some(cache);
187        self
188    }
189
190    /// Set a prompt cache key for improved cache locality.
191    ///
192    /// Works with OpenAI and xAI models to route requests to the same backend.
193    pub fn with_prompt_cache_key(mut self, key: impl Into<String>) -> Self {
194        let mut cache = self.cache_config.unwrap_or_default();
195        cache.prompt_cache_key = Some(key.into());
196        self.cache_config = Some(cache);
197        self
198    }
199
200    /// Generate text from the configured model.
201    ///
202    /// When images have been attached via [`with_image`](ClientBuilder::with_image) the
203    /// prompt is sent as a `Prompt::Messages` containing those images alongside the
204    /// text, enabling vision/multimodal requests.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if the API key or model ID is not set, or if the request fails.
209    ///
210    /// # Examples
211    /// ```ignore
212    /// let response = rs_ai::claude()
213    ///     .api_key("sk-ant-...")
214    ///     .model("claude-sonnet-4-6")
215    ///     .generate("What is 2+2?")
216    ///     .await?;
217    /// ```
218    pub async fn generate(self, prompt: impl Into<String>) -> AiResult<String> {
219        let text = prompt.into();
220        let images = self.images.clone();
221        let client = self.build().await?;
222        if images.is_empty() {
223            client.generate(text).await
224        } else {
225            client.generate_with_images(text, images).await
226        }
227    }
228
229    pub async fn generate_prompt(self, prompt: Prompt) -> AiResult<String> {
230        if !self.images.is_empty() {
231            return Err(AiError::ProviderError {
232                provider: "rs_ai".to_string(),
233                status: None,
234                message: "Images cannot be combined with a structured prompt".to_string(),
235            });
236        }
237        self.build().await?.generate_prompt(prompt).await
238    }
239
240    /// Stream text generation from the configured model.
241    ///
242    /// When images have been attached via [`with_image`](ClientBuilder::with_image) the
243    /// prompt is sent as a `Prompt::Messages` containing those images, enabling
244    /// vision/multimodal streaming requests.
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if the API key or model ID is not set, or if the request fails.
249    ///
250    /// # Examples
251    /// ```ignore
252    /// use futures::StreamExt;
253    ///
254    /// let mut stream = rs_ai::claude()
255    ///     .api_key("sk-ant-...")
256    ///     .model("claude-sonnet-4-6")
257    ///     .stream("Hello!")
258    ///     .await?;
259    ///
260    /// while let Some(event) = stream.next().await {
261    ///     match event? {
262    ///         StreamEvent::TextDelta { text } => print!("{}", text),
263    ///         _ => {}
264    ///     }
265    /// }
266    /// ```
267    pub async fn stream(
268        self,
269        prompt: impl Into<String>,
270    ) -> AiResult<BoxStream<'static, AiResult<StreamEvent>>> {
271        let text = prompt.into();
272        let images = self.images.clone();
273        let client = self.build().await?;
274        if images.is_empty() {
275            client.stream(text).await
276        } else {
277            client.stream_with_images(text, images).await
278        }
279    }
280
281    pub async fn stream_prompt(
282        self,
283        prompt: Prompt,
284    ) -> AiResult<BoxStream<'static, AiResult<StreamEvent>>> {
285        if !self.images.is_empty() {
286            return Err(AiError::ProviderError {
287                provider: "rs_ai".to_string(),
288                status: None,
289                message: "Images cannot be combined with a structured prompt".to_string(),
290            });
291        }
292        self.build().await?.stream_prompt(prompt).await
293    }
294
295    /// Synthesize speech from text, returning audio bytes.
296    ///
297    /// For providers that support native TTS this will eventually delegate to the
298    /// provider's TTS endpoint. As a sensible degraded path (when the underlying
299    /// `LanguageModel` does not expose native audio synthesis) the text is returned
300    /// as UTF-8 bytes so callers always receive a `Vec<u8>`.
301    ///
302    /// # Errors
303    ///
304    /// Returns an error if the API key or model ID is not set, or if the request fails.
305    ///
306    /// # Examples
307    /// ```ignore
308    /// let audio_bytes = rs_ai::chatgpt()
309    ///     .api_key("sk-...")
310    ///     .model("gpt-4o-audio-preview")
311    ///     .speak("Hello, world!")
312    ///     .await?;
313    /// ```
314    pub async fn speak(self, text: impl Into<String>) -> AiResult<Vec<u8>> {
315        let text_str = text.into();
316        // Build a generate request that signals TTS intent via metadata.
317        // The model receives the text and, if it supports audio output, may
318        // return audio content. The current degraded path encodes the text
319        // as UTF-8 bytes so callers always get a valid `Vec<u8>`.
320        let options = GenerateOptions::default();
321        let model_id_hint = self.model_id.clone().unwrap_or_default();
322        let client = self.build().await?;
323
324        let result = client
325            .model
326            .as_ref()
327            .as_ref()
328            .generate(Prompt::Text(text_str.clone()), options)
329            .await?;
330
331        // If the model returned audio bytes in metadata, prefer those.
332        // Otherwise fall back to the text response encoded as UTF-8.
333        if let Some(audio_b64) = result.metadata.extra.get("audio_bytes") {
334            if let Some(encoded) = audio_b64.as_str() {
335                let bytes = base64::engine::general_purpose::STANDARD
336                    .decode(encoded)
337                    .map_err(|e| {
338                        AiError::Serialization(format!(
339                            "Failed to decode audio_bytes from model `{}` metadata: {e}",
340                            model_id_hint
341                        ))
342                    })?;
343                return Ok(bytes);
344            }
345        }
346
347        // Degraded path: return text as UTF-8 bytes.
348        let spoken_text = result.text.unwrap_or(text_str);
349        Ok(spoken_text.into_bytes())
350    }
351
352    /// Generate an image from a text prompt.
353    ///
354    /// Uses the provider's image model (DALL-E, Grok Imagine, Imagen, etc.).
355    ///
356    /// # Examples
357    /// ```ignore
358    /// let result = rs_ai::chatgpt()
359    ///     .api_key("sk-...")
360    ///     .model("dall-e-3")
361    ///     .generate_image("A cat wearing a hat", rs_ai_core::ImageGenerationOptions::default())
362    ///     .await?;
363    /// println!("Got {} image(s)", result.images.len());
364    /// ```
365    pub async fn generate_image(
366        self,
367        prompt: impl Into<String>,
368        options: ImageGenerationOptions,
369    ) -> AiResult<ImageResult> {
370        let text = prompt.into();
371        let api_key = self
372            .api_key
373            .clone()
374            .ok_or_else(|| AiError::AuthError {
375                message: "API key not set. Use .api_key() to specify credentials.".to_string(),
376            })?;
377
378        match self.provider_type {
379            ProviderType::ChatGpt => {
380                let provider = ChatGptProvider::new(api_key);
381                let model = provider.image_model(&self.model_id.unwrap_or_default());
382                model.generate_image(&text, options).await
383            }
384            ProviderType::Xai => {
385                let provider = XaiProvider::new(api_key);
386                let model = provider.image_model(&self.model_id.unwrap_or_default());
387                model.generate_image(&text, options).await
388            }
389            ProviderType::Gemini => {
390                let provider = GeminiProvider::new(api_key);
391                let model = provider.image_model(&self.model_id.unwrap_or_default());
392                model.generate_image(&text, options).await
393            }
394            _ => Err(AiError::UnsupportedCapability {
395                capability: "image_generation".to_string(),
396                provider: format!("{:?}", self.provider_type),
397            }),
398        }
399    }
400
401    /// Generate a video from a text prompt.
402    ///
403    /// Uses the provider's video model (Sora, Veo, etc.).
404    ///
405    /// # Examples
406    /// ```ignore
407    /// let result = rs_ai::gemini()
408    ///     .api_key("AIzaSy...")
409    ///     .model("veo-3.0-generate-001")
410    ///     .generate_video("A dog playing in a park", rs_ai_core::VideoGenerationOptions::default())
411    ///     .await?;
412    /// ```
413    pub async fn generate_video(
414        self,
415        prompt: impl Into<String>,
416        options: VideoGenerationOptions,
417    ) -> AiResult<VideoResult> {
418        let text = prompt.into();
419        let api_key = self
420            .api_key
421            .clone()
422            .ok_or_else(|| AiError::AuthError {
423                message: "API key not set. Use .api_key() to specify credentials.".to_string(),
424            })?;
425
426        match self.provider_type {
427            ProviderType::ChatGpt => {
428                let provider = ChatGptProvider::new(api_key);
429                let model = provider.video_model(&self.model_id.unwrap_or_default());
430                model.generate_video(&text, options).await
431            }
432            ProviderType::Gemini => {
433                let provider = GeminiProvider::new(api_key);
434                let model = provider.video_model();
435                model.generate_video(&text, options).await
436            }
437            _ => Err(AiError::UnsupportedCapability {
438                capability: "video_generation".to_string(),
439                provider: format!("{:?}", self.provider_type),
440            }),
441        }
442    }
443
444    /// Open a realtime voice/text session.
445    ///
446    /// # Examples
447    /// ```ignore
448    /// let mut session = rs_ai::chatgpt()
449    ///     .api_key("sk-...")
450    ///     .model("gpt-4o-realtime-preview")
451    ///     .realtime_session()
452    ///     .await?;
453    /// session.send_text("Hello!").await?;
454    /// while let Some(event) = session.recv().await {
455    ///     match event {
456    ///         RealtimeEvent::TextDelta { delta } => print!("{delta}"),
457    ///         _ => {}
458    ///     }
459    /// }
460    /// ```
461    pub async fn realtime_session(self) -> AiResult<Box<dyn RealtimeSession>> {
462        let api_key = self
463            .api_key
464            .clone()
465            .ok_or_else(|| AiError::AuthError {
466                message: "API key not set. Use .api_key() to specify credentials.".to_string(),
467            })?;
468        let model_id = self.model_id.clone().unwrap_or_default();
469
470        match self.provider_type {
471            ProviderType::ChatGpt => {
472                let provider = ChatGptProvider::new(api_key);
473                provider.unified_realtime_session(&model_id).await
474            }
475            ProviderType::Gemini => {
476                let provider = GeminiProvider::new(api_key);
477                provider.unified_realtime_session(&model_id).await
478            }
479            _ => Err(AiError::UnsupportedCapability {
480                capability: "realtime_session".to_string(),
481                provider: format!("{:?}", self.provider_type),
482            }),
483        }
484    }
485
486    /// Transcribe audio bytes into text.
487    ///
488    /// Builds a `Prompt::Messages` that embeds the audio as a base64-encoded
489    /// `ContentPart::File`. Providers that support audio input (e.g. Gemini,
490    /// GPT-4o-audio) will transcribe the content; providers that do not will
491    /// return [`AiError::UnsupportedCapability`].
492    ///
493    /// # Arguments
494    ///
495    /// * `audio` - Raw audio bytes.
496    /// * `mime_type` - MIME type of the audio, e.g. `"audio/wav"` or `"audio/mp3"`.
497    ///
498    /// # Errors
499    ///
500    /// Returns [`AiError::UnsupportedCapability`] when the provider cannot process
501    /// audio input, or other errors if the API key / model ID are missing or the
502    /// request fails.
503    ///
504    /// # Examples
505    /// ```ignore
506    /// let transcript = rs_ai::gemini()
507    ///     .api_key("AIzaSy...")
508    ///     .model("gemini-2.5-flash")
509    ///     .transcribe(audio_bytes, "audio/wav")
510    ///     .await?;
511    /// ```
512    pub async fn transcribe(self, audio: Vec<u8>, mime_type: &str) -> AiResult<String> {
513        let encoded = base64::engine::general_purpose::STANDARD.encode(&audio);
514
515        let audio_part = ContentPart::File {
516            data: FileData::Base64 {
517                media_type: mime_type.to_string(),
518                data: encoded,
519            },
520        };
521
522        let instruction_part = ContentPart::Text {
523            text: "Transcribe the audio in the attached file. Return only the transcription text, no commentary.".to_string(),
524        };
525
526        let message = Message {
527            role: rs_ai_core::Role::User,
528            content: vec![instruction_part, audio_part],
529            name: None,
530            metadata: std::collections::HashMap::new(),
531        };
532
533        let prompt = Prompt::Messages(vec![message]);
534        let model_id_hint = self.model_id.clone().unwrap_or_default();
535        let client = self.build().await?;
536
537        let result = client
538            .model
539            .as_ref()
540            .as_ref()
541            .generate(prompt, GenerateOptions::default())
542            .await
543            .map_err(|e| {
544                // Surface a clearer UnsupportedCapability if the provider
545                // rejects the audio content.
546                match &e {
547                    AiError::ProviderError { message, .. }
548                        if message.to_lowercase().contains("audio")
549                            || message.to_lowercase().contains("unsupported")
550                            || message.to_lowercase().contains("media type") =>
551                    {
552                        AiError::UnsupportedCapability {
553                            capability: "audio_transcription".to_string(),
554                            provider: model_id_hint.clone(),
555                        }
556                    }
557                    _ => e,
558                }
559            })?;
560
561        result.text.ok_or_else(|| AiError::UnsupportedCapability {
562            capability: "audio_transcription".to_string(),
563            provider: model_id_hint,
564        })
565    }
566
567    async fn build(self) -> AiResult<Client> {
568        let model_id = self.model_id.ok_or_else(|| AiError::ProviderError {
569            provider: "rs_ai".to_string(),
570            status: None,
571            message: "Model ID not set. Use .model() to specify a model.".to_string(),
572        })?;
573
574        let api_key = self.api_key.ok_or_else(|| AiError::AuthError {
575            message: "API key not set. Use .api_key() to specify credentials.".to_string(),
576        })?;
577
578        // Compute the CF AI Gateway base URL if configured.
579        // Accepts either "account_id/gateway_id" or a full URL prefix.
580        let cf_base = self.cf_gateway.as_deref().map(|gw| {
581            if gw.starts_with("https://") || gw.starts_with("http://") {
582                gw.trim_end_matches('/').to_string()
583            } else {
584                format!(
585                    "https://gateway.ai.cloudflare.com/v1/{}",
586                    gw.trim_end_matches('/')
587                )
588            }
589        });
590
591        let model: Box<dyn LanguageModel> = match self.provider_type {
592            ProviderType::Claude => {
593                let mut provider = ClaudeProvider::new(api_key);
594                if let Some(gw) = &cf_base {
595                    provider = provider.with_base_url(format!("{}/anthropic", gw));
596                }
597                let mut m = provider.model(&model_id);
598                if let Some(cache_cfg) = &self.cache_config {
599                    m.set_cache(cache_cfg.clone());
600                }
601                Box::new(m)
602            }
603            ProviderType::ChatGpt => {
604                if let Some(gw) = &cf_base {
605                    let config = OpenAiCompatibleConfig::new(format!("{}/openai", gw), api_key);
606                    let provider = OpenAiCompatibleProvider::new(config, "chatgpt", "ChatGPT");
607                    provider.language_model(&model_id)
608                } else {
609                    let provider = ChatGptProvider::new(api_key);
610                    let mut m = provider.model(&model_id);
611                    if let Some(cache_cfg) = &self.cache_config {
612                        m.set_cache(cache_cfg.clone());
613                    }
614                    Box::new(m)
615                }
616            }
617            ProviderType::Gemini => {
618                let provider = GeminiProvider::new(api_key);
619                if let Some(gw) = &cf_base {
620                    let mut m = provider.model_with_base_url(
621                        &model_id,
622                        format!("{}/google-ai-studio/v1/models", gw),
623                    );
624                    if let Some(cache_cfg) = &self.cache_config {
625                        m.set_cache(cache_cfg.clone());
626                    }
627                    Box::new(m)
628                } else {
629                    let mut m = provider.model(&model_id);
630                    if let Some(cache_cfg) = &self.cache_config {
631                        m.set_cache(cache_cfg.clone());
632                    }
633                    Box::new(m)
634                }
635            }
636            ProviderType::Xai => {
637                if let Some(gw) = &cf_base {
638                    let config = OpenAiCompatibleConfig::new(format!("{}/grok", gw), api_key);
639                    let provider = OpenAiCompatibleProvider::new(config, "xai", "xAI Grok");
640                    provider.language_model(&model_id)
641                } else {
642                    let provider = XaiProvider::new(api_key);
643                    let mut m = provider.model(&model_id);
644                    if let Some(cache_cfg) = &self.cache_config {
645                        m.set_cache(cache_cfg.clone());
646                    }
647                    Box::new(m)
648                }
649            }
650            ProviderType::Cloudflare { account_id } => {
651                let provider = CloudflareProvider::new(account_id, api_key);
652                Box::new(provider.model(&model_id))
653            }
654            ProviderType::Compatible { base_url } => {
655                let config = OpenAiCompatibleConfig::new(&base_url, &api_key);
656                let provider = OpenAiCompatibleProvider::new(config, "custom", "OpenAI Compatible");
657                provider.language_model(&model_id)
658            }
659        };
660
661        Ok(Client {
662            model: std::sync::Arc::new(model),
663        })
664    }
665}
666
667/// Build an [`ImageData`] from a URL string or a local file path.
668///
669/// - If the string starts with `http://` or `https://` it is used as a URL reference.
670/// - Otherwise it is assumed to be a file path; the file is read and base64-encoded.
671fn image_data_from_url_or_path(url_or_path: &str) -> AiResult<ImageData> {
672    if url_or_path.starts_with("http://") || url_or_path.starts_with("https://") {
673        Ok(ImageData::Url {
674            url: url_or_path.to_string(),
675            detail: None,
676        })
677    } else {
678        // Treat as a local file path.
679        let bytes = std::fs::read(url_or_path).map_err(|e| AiError::Transport {
680            message: format!("Failed to read image file `{url_or_path}`: {e}"),
681            source: None,
682        })?;
683        let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
684        // Infer a basic media type from the file extension.
685        let media_type = if url_or_path.ends_with(".png") {
686            "image/png"
687        } else if url_or_path.ends_with(".gif") {
688            "image/gif"
689        } else if url_or_path.ends_with(".webp") {
690            "image/webp"
691        } else {
692            // Default to JPEG for unknown extensions.
693            "image/jpeg"
694        };
695        Ok(ImageData::Base64 {
696            media_type: media_type.to_string(),
697            data: encoded,
698        })
699    }
700}
701
702/// Build a user `Message` that contains a text prompt and one or more images.
703fn build_vision_message(text: String, images: Vec<String>) -> AiResult<Message> {
704    let mut content = vec![ContentPart::Text { text }];
705    for img in images {
706        let data = image_data_from_url_or_path(&img)?;
707        content.push(ContentPart::Image { data });
708    }
709    Ok(Message {
710        role: rs_ai_core::Role::User,
711        content,
712        name: None,
713        metadata: std::collections::HashMap::new(),
714    })
715}
716
717/// A configured AI client ready for operations.
718///
719/// Can be cloned and reused to make multiple requests with the same configuration.
720///
721/// # Examples
722///
723/// Pre-configure and reuse:
724/// ```ignore
725/// let ai = rs_ai::claude()
726///     .api_key("sk-ant-...")
727///     .model("claude-sonnet-4-6");
728///
729/// let response1 = ai.generate("Hello").await?;
730/// let response2 = ai.generate("Hi again").await?;
731/// ```
732#[derive(Clone)]
733pub struct Client {
734    model: std::sync::Arc<Box<dyn LanguageModel>>,
735}
736
737impl Client {
738    /// Generate text from the model.
739    ///
740    /// # Errors
741    ///
742    /// Returns an error if the request fails.
743    ///
744    /// # Examples
745    ///
746    /// ```ignore
747    /// let response = ai.generate("What is 2+2?").await?;
748    /// println!("{}", response);
749    /// ```
750    pub async fn generate(&self, prompt: impl Into<String>) -> AiResult<String> {
751        self.generate_prompt(Prompt::Text(prompt.into())).await
752    }
753
754    pub async fn generate_prompt(&self, prompt: Prompt) -> AiResult<String> {
755        let result = self
756            .model
757            .as_ref()
758            .as_ref()
759            .generate(prompt, GenerateOptions::default())
760            .await?;
761
762        result.text.ok_or_else(|| AiError::ProviderError {
763            provider: self.model.as_ref().as_ref().provider_id().to_string(),
764            status: None,
765            message: "No text in response (model returned only tool calls)".to_string(),
766        })
767    }
768
769    /// Generate text from the model using a vision prompt that includes images.
770    ///
771    /// # Errors
772    ///
773    /// Returns an error if any image cannot be loaded or the request fails.
774    pub async fn generate_with_images(
775        &self,
776        prompt: impl Into<String>,
777        images: Vec<String>,
778    ) -> AiResult<String> {
779        let message = build_vision_message(prompt.into(), images)?;
780        let result = self
781            .model
782            .as_ref()
783            .as_ref()
784            .generate(Prompt::Messages(vec![message]), GenerateOptions::default())
785            .await?;
786
787        result.text.ok_or_else(|| AiError::ProviderError {
788            provider: self.model.as_ref().as_ref().provider_id().to_string(),
789            status: None,
790            message: "No text in response (model returned only tool calls)".to_string(),
791        })
792    }
793
794    /// Stream text generation from the model.
795    ///
796    /// # Errors
797    ///
798    /// Returns an error if the request fails.
799    ///
800    /// # Examples
801    ///
802    /// ```ignore
803    /// use futures::StreamExt;
804    ///
805    /// let mut stream = ai.stream("Hello").await?;
806    /// while let Some(event) = stream.next().await {
807    ///     match event? {
808    ///         StreamEvent::TextDelta { text } => print!("{}", text),
809    ///         _ => {}
810    ///     }
811    /// }
812    /// ```
813    pub async fn stream(
814        &self,
815        prompt: impl Into<String>,
816    ) -> AiResult<BoxStream<'static, AiResult<StreamEvent>>> {
817        self.stream_prompt(Prompt::Text(prompt.into())).await
818    }
819
820    pub async fn stream_prompt(
821        &self,
822        prompt: Prompt,
823    ) -> AiResult<BoxStream<'static, AiResult<StreamEvent>>> {
824        self.model
825            .as_ref()
826            .as_ref()
827            .stream(prompt, GenerateOptions::default())
828            .await
829    }
830
831    /// Stream text generation from the model using a vision prompt that includes images.
832    ///
833    /// # Errors
834    ///
835    /// Returns an error if any image cannot be loaded or the request fails.
836    pub async fn stream_with_images(
837        &self,
838        prompt: impl Into<String>,
839        images: Vec<String>,
840    ) -> AiResult<BoxStream<'static, AiResult<StreamEvent>>> {
841        let message = build_vision_message(prompt.into(), images)?;
842        self.model
843            .as_ref()
844            .as_ref()
845            .stream(Prompt::Messages(vec![message]), GenerateOptions::default())
846            .await
847    }
848
849    /// Get a reference to the underlying language model.
850    pub fn model(&self) -> &dyn LanguageModel {
851        self.model.as_ref().as_ref()
852    }
853}
854
855/// Create a client builder for Claude.
856///
857/// # Examples
858/// ```ignore
859/// let response = rs_ai::claude()
860///     .api_key("sk-ant-...")
861///     .model("claude-sonnet-4-6")
862///     .generate("Hello!")
863///     .await?;
864/// ```
865pub fn claude() -> ClientBuilder {
866    ClientBuilder {
867        provider_type: ProviderType::Claude,
868        api_key: None,
869        model_id: None,
870        images: Vec::new(),
871        cf_gateway: None,
872        cache_config: None,
873    }
874}
875
876/// Create a client builder for ChatGPT.
877///
878/// # Examples
879/// ```ignore
880/// let response = rs_ai::chatgpt()
881///     .api_key("sk-...")
882///     .model("gpt-4o")
883///     .generate("Hello!")
884///     .await?;
885/// ```
886pub fn chatgpt() -> ClientBuilder {
887    ClientBuilder {
888        provider_type: ProviderType::ChatGpt,
889        api_key: None,
890        model_id: None,
891        images: Vec::new(),
892        cf_gateway: None,
893        cache_config: None,
894    }
895}
896
897/// Create a client builder for Gemini.
898///
899/// # Examples
900/// ```ignore
901/// let response = rs_ai::gemini()
902///     .api_key("AIzaSy...")
903///     .model("gemini-2.5-flash")
904///     .generate("Hello!")
905///     .await?;
906/// ```
907pub fn gemini() -> ClientBuilder {
908    ClientBuilder {
909        provider_type: ProviderType::Gemini,
910        api_key: None,
911        model_id: None,
912        images: Vec::new(),
913        cf_gateway: None,
914        cache_config: None,
915    }
916}
917
918/// Create a client builder for xAI Grok.
919///
920/// # Examples
921/// ```ignore
922/// let response = rs_ai::xai()
923///     .api_key("xai-...")
924///     .model("grok-4.20-reasoning")
925///     .generate("Hello!")
926///     .await?;
927/// ```
928pub fn xai() -> ClientBuilder {
929    ClientBuilder {
930        provider_type: ProviderType::Xai,
931        api_key: None,
932        model_id: None,
933        images: Vec::new(),
934        cf_gateway: None,
935        cache_config: None,
936    }
937}
938
939/// Create a client builder for Cloudflare Workers AI.
940///
941/// Cloudflare acts as a provider gateway with built-in analytics, rate limiting,
942/// and access to 100+ open-source models at the edge.
943///
944/// # Examples
945/// ```ignore
946/// let response = rs_ai::cloudflare("your-account-id")
947///     .api_key("your-cf-api-token")
948///     .model("@cf/meta/llama-3.1-8b-instruct")
949///     .generate("Hello!")
950///     .await?;
951/// ```
952pub fn cloudflare(account_id: impl Into<String>) -> ClientBuilder {
953    ClientBuilder {
954        provider_type: ProviderType::Cloudflare {
955            account_id: account_id.into(),
956        },
957        api_key: None,
958        model_id: None,
959        images: Vec::new(),
960        cf_gateway: None,
961        cache_config: None,
962    }
963}
964
965/// Create a client builder for an OpenAI-compatible endpoint.
966///
967/// # Examples
968/// ```ignore
969/// let response = rs_ai::compatible("https://api.openrouter.ai/api/v1")
970///     .api_key("sk-...")
971///     .model("meta-llama/llama-2-70b-chat")
972///     .generate("Hello!")
973///     .await?;
974/// ```
975pub fn compatible(base_url: impl Into<String>) -> ClientBuilder {
976    ClientBuilder {
977        provider_type: ProviderType::Compatible {
978            base_url: base_url.into(),
979        },
980        api_key: None,
981        model_id: None,
982        images: Vec::new(),
983        cf_gateway: None,
984        cache_config: None,
985    }
986}