Skip to main content

floopy/
client.rs

1//! The [`Floopy`] client and its [`FloopyBuilder`].
2
3use std::collections::HashMap;
4use std::sync::{Arc, OnceLock};
5use std::time::Duration;
6
7use async_openai::config::OpenAIConfig;
8use async_openai::Client as OpenAIClient;
9
10use crate::constants::{DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT};
11use crate::error::Result;
12use crate::http::{HttpConfig, HttpTransport};
13use crate::openai_delegate::new_openai_delegate;
14use crate::options::FloopyOptions;
15use crate::resources::{
16    Constraints, Decisions, Evaluations, Experiments, Export, Feedback, Routing, Sessions,
17};
18
19/// The Floopy gateway client.
20///
21/// Wraps the official [`async_openai`] client (reachable via
22/// [`Floopy::openai`]) and exposes typed Floopy-only resources. A `Floopy`
23/// is cheap to clone (`Arc` internally) and safe to share across tasks.
24#[derive(Clone)]
25pub struct Floopy {
26    transport: Arc<HttpTransport>,
27    openai: Arc<OnceLock<OpenAIClient<OpenAIConfig>>>,
28}
29
30impl Floopy {
31    /// Construct a client with default settings. `api_key` is required
32    /// (starts with `fl_`).
33    ///
34    /// # Errors
35    /// Returns [`crate::Error::Config`] if `api_key` is empty, or
36    /// [`crate::Error::Connection`] if the HTTP client cannot be built.
37    pub fn new(api_key: impl Into<String>) -> Result<Self> {
38        Self::builder(api_key).build()
39    }
40
41    /// Start building a client. Chain setters then call
42    /// [`FloopyBuilder::build`].
43    #[must_use]
44    pub fn builder(api_key: impl Into<String>) -> FloopyBuilder {
45        FloopyBuilder::new(api_key)
46    }
47
48    /// A lazily-built [`async_openai`] client pre-configured to talk to the
49    /// Floopy gateway. `client.openai().chat()` / `.embeddings()` /
50    /// `.models()` are 1:1 drop-in replacements for upstream `async-openai`.
51    ///
52    /// # Panics
53    /// Panics only if the delegate cannot be constructed (invalid forwarded
54    /// header); this is unreachable for headers the SDK itself produces.
55    #[must_use]
56    pub fn openai(&self) -> &OpenAIClient<OpenAIConfig> {
57        self.openai.get_or_init(|| {
58            new_openai_delegate(&self.transport)
59                .expect("delegate construction with SDK-produced headers is infallible")
60        })
61    }
62
63    /// The resolved gateway base URL.
64    #[must_use]
65    pub fn base_url(&self) -> &str {
66        self.transport.base_url()
67    }
68
69    /// Submit NPS-style feedback for a request/session.
70    #[must_use]
71    pub fn feedback(&self) -> Feedback {
72        Feedback::new(self.transport.clone())
73    }
74
75    /// Read the per-request decision audit trail.
76    #[must_use]
77    pub fn decisions(&self) -> Decisions {
78        Decisions::new(self.transport.clone())
79    }
80
81    /// Manage A/B routing experiments.
82    #[must_use]
83    pub fn experiments(&self) -> Experiments {
84        Experiments::new(self.transport.clone())
85    }
86
87    /// Read and full-replace org spend/rate constraints.
88    #[must_use]
89    pub fn constraints(&self) -> Constraints {
90        Constraints::new(self.transport.clone())
91    }
92
93    /// Stream the decision log as typed JSONL.
94    #[must_use]
95    pub fn export(&self) -> Export {
96        Export::new(self.transport.clone())
97    }
98
99    /// Run and inspect dataset evaluations.
100    #[must_use]
101    pub fn evaluations(&self) -> Evaluations {
102        Evaluations::new(self.transport.clone())
103    }
104
105    /// The routing dry-run (Pro plan).
106    #[must_use]
107    pub fn routing(&self) -> Routing {
108        Routing::new(self.transport.clone())
109    }
110
111    /// Restore stored conversations.
112    #[must_use]
113    pub fn sessions(&self) -> Sessions {
114        Sessions::new(self.transport.clone())
115    }
116}
117
118impl std::fmt::Debug for Floopy {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        // Never expose the API key.
121        f.debug_struct("Floopy")
122            .field("base_url", &self.transport.base_url())
123            .finish_non_exhaustive()
124    }
125}
126
127/// Builder for [`Floopy`]. Created via [`Floopy::builder`].
128pub struct FloopyBuilder {
129    api_key: String,
130    base_url: String,
131    timeout: Duration,
132    max_retries: u32,
133    default_headers: HashMap<String, String>,
134    options: Option<FloopyOptions>,
135    http_client: Option<reqwest::Client>,
136}
137
138impl FloopyBuilder {
139    fn new(api_key: impl Into<String>) -> Self {
140        Self {
141            api_key: api_key.into(),
142            base_url: DEFAULT_BASE_URL.to_owned(),
143            timeout: DEFAULT_TIMEOUT,
144            max_retries: DEFAULT_MAX_RETRIES,
145            default_headers: HashMap::new(),
146            options: None,
147            http_client: None,
148        }
149    }
150
151    /// Override the gateway base URL (default [`DEFAULT_BASE_URL`]). Use for
152    /// self-hosted gateways.
153    ///
154    /// [`DEFAULT_BASE_URL`]: crate::DEFAULT_BASE_URL
155    #[must_use]
156    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
157        self.base_url = base_url.into();
158        self
159    }
160
161    /// Set the default per-request timeout (default 60s).
162    #[must_use]
163    pub fn timeout(mut self, timeout: Duration) -> Self {
164        self.timeout = timeout;
165        self
166    }
167
168    /// Set the retry budget for transient failures (default 2).
169    #[must_use]
170    pub fn max_retries(mut self, max_retries: u32) -> Self {
171        self.max_retries = max_retries;
172        self
173    }
174
175    /// Add a header sent on every Floopy-only request (highest precedence).
176    #[must_use]
177    pub fn default_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
178        self.default_headers.insert(key.into(), value.into());
179        self
180    }
181
182    /// Set the default Floopy options forwarded on every request.
183    #[must_use]
184    pub fn options(mut self, options: FloopyOptions) -> Self {
185        self.options = Some(options);
186        self
187    }
188
189    /// Use a caller-provided [`reqwest::Client`] for Floopy-only requests.
190    #[must_use]
191    pub fn http_client(mut self, client: reqwest::Client) -> Self {
192        self.http_client = Some(client);
193        self
194    }
195
196    /// Build the client.
197    ///
198    /// # Errors
199    /// Returns [`crate::Error::Config`] if the API key is empty, or
200    /// [`crate::Error::Connection`] if the default HTTP client cannot be
201    /// built.
202    pub fn build(self) -> Result<Floopy> {
203        let transport = HttpTransport::new(HttpConfig {
204            api_key: self.api_key,
205            base_url: self.base_url,
206            timeout: self.timeout,
207            max_retries: self.max_retries,
208            default_headers: self.default_headers,
209            default_options: self.options,
210            http_client: self.http_client,
211        })?;
212        Ok(Floopy {
213            transport: Arc::new(transport),
214            openai: Arc::new(OnceLock::new()),
215        })
216    }
217}