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}