Skip to main content

llmleaf_client/
client.rs

1//! The async [`Client`] and its [`ClientBuilder`], plus every endpoint in SPEC.md.
2
3use crate::error::{Error, ErrorEnvelope, Result};
4use crate::stream::{ndjson_lines, sse_chunks};
5use crate::types::*;
6use crate::wire::{speech_content_type, RawEmbeddingResponse};
7use bytes::Bytes;
8use futures::Stream;
9use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE};
10use reqwest::{Method, RequestBuilder, Response};
11use std::time::Duration;
12
13const ADMIN_TOKEN_HEADER: &str = "x-admin-token";
14
15/// Builder for [`Client`]. Obtain via [`Client::builder`].
16#[derive(Debug, Clone)]
17pub struct ClientBuilder {
18    base_url: String,
19    api_key: String,
20    admin_token: Option<String>,
21    timeout: Option<Duration>,
22    http: Option<reqwest::Client>,
23}
24
25impl ClientBuilder {
26    fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
27        ClientBuilder {
28            base_url: base_url.into(),
29            api_key: api_key.into(),
30            admin_token: None,
31            timeout: None,
32            http: None,
33        }
34    }
35
36    /// Set the per-request HTTP timeout. Ignored if a custom [`reqwest::Client`] is
37    /// supplied via [`ClientBuilder::http_client`] (configure the timeout there instead).
38    pub fn timeout(mut self, timeout: Duration) -> Self {
39        self.timeout = Some(timeout);
40        self
41    }
42
43    /// Set the optional `x-admin-token`, sent on every request (it only affects
44    /// `GET /v1/models`, where it adds the per-model `endpoints` array).
45    pub fn admin_token(mut self, token: impl Into<String>) -> Self {
46        self.admin_token = Some(token.into());
47        self
48    }
49
50    /// Supply a fully pre-configured [`reqwest::Client`] (connection pools, proxies,
51    /// custom TLS, …). When set, the builder's `timeout` is not applied — configure it on
52    /// the supplied client.
53    pub fn http_client(mut self, client: reqwest::Client) -> Self {
54        self.http = Some(client);
55        self
56    }
57
58    /// Build the [`Client`].
59    pub fn build(self) -> Result<Client> {
60        // Normalise the base URL: trim a single trailing slash so path joining is simple.
61        let base_url = self.base_url.trim_end_matches('/').to_string();
62        if base_url.is_empty() {
63            return Err(Error::InvalidBaseUrl("base url is empty".to_string()));
64        }
65
66        let http = match self.http {
67            Some(c) => c,
68            None => {
69                let mut b = reqwest::Client::builder();
70                if let Some(t) = self.timeout {
71                    b = b.timeout(t);
72                }
73                b.build()?
74            }
75        };
76
77        let bearer = HeaderValue::from_str(&format!("Bearer {}", self.api_key))
78            .map_err(|_| Error::InvalidBaseUrl("api key contains invalid header bytes".into()))?;
79        let admin = self
80            .admin_token
81            .map(|t| HeaderValue::from_str(&t))
82            .transpose()
83            .map_err(|_| {
84                Error::InvalidBaseUrl("admin token contains invalid header bytes".into())
85            })?;
86
87        Ok(Client {
88            http,
89            base_url,
90            bearer,
91            admin_token: admin,
92        })
93    }
94}
95
96/// Async client for the llmleaf gateway.
97///
98/// Construct with [`Client::new`] for the common case, or [`Client::builder`] to set a
99/// timeout, an admin token, or supply your own [`reqwest::Client`]. The client is cheap
100/// to clone (it wraps an `Arc`-backed `reqwest::Client`).
101#[derive(Debug, Clone)]
102pub struct Client {
103    http: reqwest::Client,
104    base_url: String,
105    bearer: HeaderValue,
106    admin_token: Option<HeaderValue>,
107}
108
109impl Client {
110    /// Construct a client from a base URL and API key with default settings.
111    pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
112        ClientBuilder::new(base_url, api_key).build()
113    }
114
115    /// Start a [`ClientBuilder`].
116    pub fn builder(base_url: impl Into<String>, api_key: impl Into<String>) -> ClientBuilder {
117        ClientBuilder::new(base_url, api_key)
118    }
119
120    // -- internal request plumbing ------------------------------------------
121
122    fn url(&self, path: &str) -> String {
123        format!("{}{}", self.base_url, path)
124    }
125
126    /// Start a request with the bearer (and admin, if set) headers applied.
127    fn request(&self, method: Method, path: &str) -> RequestBuilder {
128        let mut req = self
129            .http
130            .request(method, self.url(path))
131            .header(AUTHORIZATION, self.bearer.clone());
132        if let Some(admin) = &self.admin_token {
133            req = req.header(ADMIN_TOKEN_HEADER, admin.clone());
134        }
135        req
136    }
137
138    /// Send a request and, on a non-2xx status, parse the error envelope into
139    /// [`Error::Api`].
140    async fn send(&self, req: RequestBuilder) -> Result<Response> {
141        let resp = req.send().await?;
142        check_status(resp).await
143    }
144
145    // -- chat ----------------------------------------------------------------
146
147    /// `POST /v1/chat/completions` (non-streaming).
148    ///
149    /// `stream` is forced to `false` (or absent) so a JSON `ChatResponse` comes back.
150    pub async fn chat(&self, mut request: ChatRequest) -> Result<ChatResponse> {
151        request.stream = None;
152        let resp = self
153            .send(
154                self.request(Method::POST, "/v1/chat/completions")
155                    .json(&request),
156            )
157            .await?;
158        Ok(resp.json::<ChatResponse>().await?)
159    }
160
161    /// `POST /v1/chat/completions` (streaming, SSE).
162    ///
163    /// Forces `stream:true`, then yields decoded [`ChatCompletionChunk`]s, stopping on the
164    /// `data: [DONE]` sentinel (which is never parsed). Accumulate
165    /// `choices[].delta.content` for the assembled text.
166    pub async fn chat_stream(
167        &self,
168        mut request: ChatRequest,
169    ) -> Result<impl Stream<Item = Result<ChatCompletionChunk>>> {
170        request.stream = Some(true);
171        let resp = self
172            .send(
173                self.request(Method::POST, "/v1/chat/completions")
174                    .json(&request),
175            )
176            .await?;
177        Ok(sse_chunks(resp.bytes_stream()))
178    }
179
180    // -- embeddings ----------------------------------------------------------
181
182    /// `POST /v1/embeddings`. Base64 payloads (`encoding_format:"base64"`) are decoded
183    /// into float vectors before returning.
184    pub async fn embeddings(&self, request: EmbeddingRequest) -> Result<EmbeddingResponse> {
185        let resp = self
186            .send(self.request(Method::POST, "/v1/embeddings").json(&request))
187            .await?;
188        let raw = resp.json::<RawEmbeddingResponse>().await?;
189        raw.into_public()
190    }
191
192    // -- models --------------------------------------------------------------
193
194    /// `GET /v1/models`. `type` filters the catalog; `search` is a substring match. The
195    /// per-model `endpoints` array appears only when the client was built with an admin
196    /// token.
197    pub async fn list_models(
198        &self,
199        model_type: Option<ModelType>,
200        search: Option<&str>,
201    ) -> Result<ListModelsResponse> {
202        let mut req = self.request(Method::GET, "/v1/models");
203        let mut query: Vec<(&str, String)> = Vec::new();
204        if let Some(t) = model_type {
205            query.push(("type", t.as_str().to_string()));
206        }
207        if let Some(s) = search {
208            query.push(("search", s.to_string()));
209        }
210        if !query.is_empty() {
211            req = req.query(&query);
212        }
213        let resp = self.send(req).await?;
214        Ok(resp.json::<ListModelsResponse>().await?)
215    }
216
217    // -- audio: speech (TTS) -------------------------------------------------
218
219    /// `POST /v1/audio/speech`. Returns the raw audio bytes and the resolved
220    /// `Content-Type` (from the response header, falling back to the SPEC.md table for the
221    /// requested `response_format`).
222    pub async fn speech(&self, request: SpeechRequest) -> Result<(Bytes, String)> {
223        let fallback = speech_content_type(request.response_format.as_deref());
224        let resp = self
225            .send(
226                self.request(Method::POST, "/v1/audio/speech")
227                    .json(&request),
228            )
229            .await?;
230        let content_type = resp
231            .headers()
232            .get(CONTENT_TYPE)
233            .and_then(|v| v.to_str().ok())
234            .map(|s| s.to_string())
235            .unwrap_or_else(|| fallback.to_string());
236        let bytes = resp.bytes().await?;
237        Ok((bytes, content_type))
238    }
239
240    /// `GET /v1/audio/voices?model=<id>`.
241    pub async fn voices(&self, model: &str) -> Result<VoicesResponse> {
242        let resp = self
243            .send(
244                self.request(Method::GET, "/v1/audio/voices")
245                    .query(&[("model", model)]),
246            )
247            .await?;
248        Ok(resp.json::<VoicesResponse>().await?)
249    }
250
251    // -- audio: transcriptions (STT) -----------------------------------------
252
253    /// `POST /v1/audio/transcriptions` (multipart). `file` carries the audio bytes under
254    /// `file_name`. Returns a structured [`Transcription::Json`] for json/verbose_json, or
255    /// a plain-text [`Transcription::Text`] for text/srt/vtt (SPEC.md).
256    pub async fn transcribe(
257        &self,
258        request: TranscriptionRequest,
259        file_name: impl Into<String>,
260        audio: impl Into<Bytes>,
261    ) -> Result<Transcription> {
262        let audio: Bytes = audio.into();
263        let part = reqwest::multipart::Part::bytes(audio.to_vec()).file_name(file_name.into());
264
265        let mut form = reqwest::multipart::Form::new()
266            .text("model", request.model.clone())
267            .part("file", part);
268        if let Some(language) = &request.language {
269            form = form.text("language", language.clone());
270        }
271        if let Some(prompt) = &request.prompt {
272            form = form.text("prompt", prompt.clone());
273        }
274        if let Some(rf) = &request.response_format {
275            form = form.text("response_format", rf.clone());
276        }
277        if let Some(t) = request.temperature {
278            form = form.text("temperature", t.to_string());
279        }
280
281        let resp = self
282            .send(
283                self.request(Method::POST, "/v1/audio/transcriptions")
284                    .multipart(form),
285            )
286            .await?;
287
288        // json/verbose_json → structured body; text/srt/vtt → plain text.
289        let structured = matches!(
290            request.response_format.as_deref(),
291            None | Some("json") | Some("verbose_json")
292        );
293        if structured {
294            Ok(Transcription::Json(
295                resp.json::<TranscriptionResponse>().await?,
296            ))
297        } else {
298            Ok(Transcription::Text(resp.text().await?))
299        }
300    }
301
302    // -- batches -------------------------------------------------------------
303
304    /// `POST /v1/batches`.
305    pub async fn create_batch(&self, request: BatchCreateRequest) -> Result<BatchHandle> {
306        let resp = self
307            .send(self.request(Method::POST, "/v1/batches").json(&request))
308            .await?;
309        Ok(resp.json::<BatchHandle>().await?)
310    }
311
312    /// `GET /v1/batches/{id}`.
313    pub async fn get_batch(&self, id: &str) -> Result<BatchHandle> {
314        let resp = self
315            .send(self.request(Method::GET, &format!("/v1/batches/{id}")))
316            .await?;
317        Ok(resp.json::<BatchHandle>().await?)
318    }
319
320    /// `POST /v1/batches/{id}/cancel`.
321    pub async fn cancel_batch(&self, id: &str) -> Result<BatchHandle> {
322        let resp = self
323            .send(self.request(Method::POST, &format!("/v1/batches/{id}/cancel")))
324            .await?;
325        Ok(resp.json::<BatchHandle>().await?)
326    }
327
328    /// `GET /v1/batches/{id}/results` (`application/x-ndjson`). Yields one
329    /// [`BatchResultLine`] per line.
330    pub async fn batch_results(
331        &self,
332        id: &str,
333    ) -> Result<impl Stream<Item = Result<BatchResultLine>>> {
334        let resp = self
335            .send(self.request(Method::GET, &format!("/v1/batches/{id}/results")))
336            .await?;
337        Ok(ndjson_lines(resp.bytes_stream()))
338    }
339}
340
341/// Inspect the response status: on non-2xx, drain the body and parse the
342/// `{"error":{"message":...}}` envelope into [`Error::Api`].
343async fn check_status(resp: Response) -> Result<Response> {
344    let status = resp.status();
345    if status.is_success() {
346        return Ok(resp);
347    }
348    let code = status.as_u16();
349    let body = resp.bytes().await.unwrap_or_default();
350    let message = parse_error_message(&body).unwrap_or_else(|| {
351        status
352            .canonical_reason()
353            .unwrap_or("request failed")
354            .to_string()
355    });
356    Err(Error::Api {
357        status: code,
358        message,
359    })
360}
361
362/// Best-effort extraction of `error.message` from an error body.
363fn parse_error_message(body: &[u8]) -> Option<String> {
364    serde_json::from_slice::<ErrorEnvelope>(body)
365        .ok()
366        .map(|e| e.error.message)
367        .filter(|m| !m.is_empty())
368}