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