Skip to main content

rstructor/backend/
any_client.rs

1//! A provider-agnostic client selectable at runtime.
2//!
3//! [`LLMClient::materialize`](crate::LLMClient::materialize) is generic over the
4//! target type, which makes the `LLMClient` trait non-object-safe — you cannot
5//! store a provider behind `Box<dyn LLMClient>` or pick one dynamically through a
6//! trait object. [`AnyClient`] solves the common need behind that limitation
7//! ("choose a provider at runtime and keep it in a single type") by wrapping each
8//! concrete client in an enum that itself implements [`LLMClient`].
9
10use async_trait::async_trait;
11use serde::de::DeserializeOwned;
12
13use crate::backend::usage::{GenerateResult, MaterializeResult};
14use crate::backend::{LLMClient, MediaFile, ModelInfo};
15use crate::error::{ApiErrorKind, RStructorError, Result};
16use crate::model::Instructor;
17
18#[cfg(feature = "anthropic")]
19use crate::backend::anthropic::AnthropicClient;
20#[cfg(feature = "gemini")]
21use crate::backend::gemini::GeminiClient;
22#[cfg(feature = "grok")]
23use crate::backend::grok::GrokClient;
24#[cfg(feature = "openai")]
25use crate::backend::openai::OpenAIClient;
26
27/// Identifies an LLM provider for runtime selection via [`AnyClient`].
28///
29/// Only providers enabled via Cargo features are present as variants.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum Provider {
32    /// OpenAI (reads `OPENAI_API_KEY`).
33    #[cfg(feature = "openai")]
34    OpenAI,
35    /// Anthropic (reads `ANTHROPIC_API_KEY`).
36    #[cfg(feature = "anthropic")]
37    Anthropic,
38    /// xAI / Grok (reads `XAI_API_KEY`).
39    #[cfg(feature = "grok")]
40    Grok,
41    /// Google Gemini (reads `GEMINI_API_KEY`).
42    #[cfg(feature = "gemini")]
43    Gemini,
44}
45
46/// A provider-agnostic client chosen at runtime.
47///
48/// Because [`LLMClient`] has a generic `materialize` method it is not
49/// object-safe, so `Box<dyn LLMClient>` is impossible. `AnyClient` is an enum
50/// over the concrete clients that itself implements [`LLMClient`], giving you a
51/// single, `Clone`, `Send + Sync` type that can hold whichever provider you
52/// selected at runtime (from a CLI flag, config file, env, etc.).
53///
54/// Construct it with [`from_env_for`](Self::from_env_for), with
55/// [`LLMClient::from_env`] (which auto-detects from the environment), or with
56/// `From<ConcreteClient>` when you need custom configuration:
57///
58/// ```no_run
59/// # async fn ex() -> Result<(), Box<dyn std::error::Error>> {
60/// use rstructor::{AnyClient, Provider, LLMClient, Instructor};
61/// use serde::{Serialize, Deserialize};
62///
63/// #[derive(Instructor, Serialize, Deserialize, Debug)]
64/// struct Movie {
65///     title: String,
66/// }
67///
68/// // Provider decided at runtime.
69/// let provider = Provider::Anthropic;
70/// let client = AnyClient::from_env_for(provider)?;
71///
72/// let movie: Movie = client.materialize("Describe Inception").await?;
73/// println!("{}", movie.title);
74/// # Ok(())
75/// # }
76/// ```
77///
78/// Wrapping a pre-configured client:
79///
80/// ```no_run
81/// # fn ex() -> Result<(), Box<dyn std::error::Error>> {
82/// use rstructor::{AnyClient, OpenAIClient, OpenAIModel};
83///
84/// let configured = OpenAIClient::from_env()?.model(OpenAIModel::Gpt55);
85/// let client: AnyClient = configured.into();
86/// # let _ = client;
87/// # Ok(())
88/// # }
89/// ```
90#[derive(Clone)]
91pub enum AnyClient {
92    /// An OpenAI client.
93    #[cfg(feature = "openai")]
94    OpenAI(OpenAIClient),
95    /// An Anthropic client.
96    #[cfg(feature = "anthropic")]
97    Anthropic(AnthropicClient),
98    /// A Grok client.
99    #[cfg(feature = "grok")]
100    Grok(GrokClient),
101    /// A Gemini client.
102    #[cfg(feature = "gemini")]
103    Gemini(GeminiClient),
104}
105
106impl AnyClient {
107    /// Build a client for `provider`, reading its API key from the environment.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the provider's environment variable is not set.
112    pub fn from_env_for(provider: Provider) -> Result<Self> {
113        match provider {
114            #[cfg(feature = "openai")]
115            Provider::OpenAI => Ok(Self::OpenAI(OpenAIClient::from_env()?)),
116            #[cfg(feature = "anthropic")]
117            Provider::Anthropic => Ok(Self::Anthropic(AnthropicClient::from_env()?)),
118            #[cfg(feature = "grok")]
119            Provider::Grok => Ok(Self::Grok(GrokClient::from_env()?)),
120            #[cfg(feature = "gemini")]
121            Provider::Gemini => Ok(Self::Gemini(GeminiClient::from_env()?)),
122        }
123    }
124
125    /// Return the [`Provider`] backing this client.
126    #[must_use]
127    pub fn provider(&self) -> Provider {
128        match self {
129            #[cfg(feature = "openai")]
130            Self::OpenAI(_) => Provider::OpenAI,
131            #[cfg(feature = "anthropic")]
132            Self::Anthropic(_) => Provider::Anthropic,
133            #[cfg(feature = "grok")]
134            Self::Grok(_) => Provider::Grok,
135            #[cfg(feature = "gemini")]
136            Self::Gemini(_) => Provider::Gemini,
137        }
138    }
139}
140
141#[cfg(feature = "openai")]
142impl From<OpenAIClient> for AnyClient {
143    fn from(client: OpenAIClient) -> Self {
144        Self::OpenAI(client)
145    }
146}
147
148#[cfg(feature = "anthropic")]
149impl From<AnthropicClient> for AnyClient {
150    fn from(client: AnthropicClient) -> Self {
151        Self::Anthropic(client)
152    }
153}
154
155#[cfg(feature = "grok")]
156impl From<GrokClient> for AnyClient {
157    fn from(client: GrokClient) -> Self {
158        Self::Grok(client)
159    }
160}
161
162#[cfg(feature = "gemini")]
163impl From<GeminiClient> for AnyClient {
164    fn from(client: GeminiClient) -> Self {
165        Self::Gemini(client)
166    }
167}
168
169/// Dispatch a method call to whichever provider this `AnyClient` wraps.
170macro_rules! dispatch {
171    ($self:expr, $client:ident => $call:expr) => {
172        match $self {
173            #[cfg(feature = "openai")]
174            Self::OpenAI($client) => $call,
175            #[cfg(feature = "anthropic")]
176            Self::Anthropic($client) => $call,
177            #[cfg(feature = "grok")]
178            Self::Grok($client) => $call,
179            #[cfg(feature = "gemini")]
180            Self::Gemini($client) => $call,
181        }
182    };
183}
184
185#[async_trait]
186impl LLMClient for AnyClient {
187    async fn materialize<T>(&self, prompt: &str) -> Result<T>
188    where
189        T: Instructor + DeserializeOwned + Send + 'static,
190    {
191        dispatch!(self, c => c.materialize(prompt).await)
192    }
193
194    async fn materialize_with_media<T>(&self, prompt: &str, media: &[MediaFile]) -> Result<T>
195    where
196        T: Instructor + DeserializeOwned + Send + 'static,
197    {
198        dispatch!(self, c => c.materialize_with_media(prompt, media).await)
199    }
200
201    async fn materialize_with_metadata<T>(&self, prompt: &str) -> Result<MaterializeResult<T>>
202    where
203        T: Instructor + DeserializeOwned + Send + 'static,
204    {
205        dispatch!(self, c => c.materialize_with_metadata(prompt).await)
206    }
207
208    async fn generate(&self, prompt: &str) -> Result<String> {
209        dispatch!(self, c => c.generate(prompt).await)
210    }
211
212    async fn generate_with_media(&self, prompt: &str, media: &[MediaFile]) -> Result<String> {
213        dispatch!(self, c => c.generate_with_media(prompt, media).await)
214    }
215
216    async fn generate_with_metadata(&self, prompt: &str) -> Result<GenerateResult> {
217        dispatch!(self, c => c.generate_with_metadata(prompt).await)
218    }
219
220    /// Auto-detect a provider from the environment.
221    ///
222    /// Enabled providers are tried in order (OpenAI, Anthropic, Grok, Gemini)
223    /// and the first one whose API-key variable is set is used. For deterministic
224    /// selection, prefer [`AnyClient::from_env_for`].
225    ///
226    /// # Errors
227    ///
228    /// Returns an [`ApiErrorKind::AuthenticationFailed`] error if none of the
229    /// enabled providers' API-key variables are set.
230    fn from_env() -> Result<Self> {
231        #[cfg(feature = "openai")]
232        if std::env::var("OPENAI_API_KEY").is_ok() {
233            return Ok(Self::OpenAI(OpenAIClient::from_env()?));
234        }
235        #[cfg(feature = "anthropic")]
236        if std::env::var("ANTHROPIC_API_KEY").is_ok() {
237            return Ok(Self::Anthropic(AnthropicClient::from_env()?));
238        }
239        #[cfg(feature = "grok")]
240        if std::env::var("XAI_API_KEY").is_ok() {
241            return Ok(Self::Grok(GrokClient::from_env()?));
242        }
243        #[cfg(feature = "gemini")]
244        if std::env::var("GEMINI_API_KEY").is_ok() {
245            return Ok(Self::Gemini(GeminiClient::from_env()?));
246        }
247        Err(RStructorError::api_error(
248            "AnyClient",
249            ApiErrorKind::AuthenticationFailed,
250        ))
251    }
252
253    async fn list_models(&self) -> Result<Vec<ModelInfo>> {
254        dispatch!(self, c => c.list_models().await)
255    }
256}