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, sse_responses};
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    // -- responses -----------------------------------------------------------
181
182    /// `POST /v1/responses` (non-streaming) — the OpenAI Responses dialect.
183    ///
184    /// `stream` is forced to `false` (or absent) so a JSON [`ResponsesResponse`] comes
185    /// back. llmleaf serves this dialect statelessly, so the response always reports
186    /// `"store": false` (SPEC.md).
187    pub async fn responses(&self, mut request: ResponsesRequest) -> Result<ResponsesResponse> {
188        request.stream = None;
189        let resp = self
190            .send(self.request(Method::POST, "/v1/responses").json(&request))
191            .await?;
192        Ok(resp.json::<ResponsesResponse>().await?)
193    }
194
195    /// `POST /v1/responses` (streaming, typed SSE).
196    ///
197    /// Forces `stream:true`, then yields decoded [`ResponsesStreamEvent`]s. Unlike chat
198    /// there is no `[DONE]` sentinel: the stream ends after the terminal
199    /// `response.completed` / `response.incomplete` / `response.failed` event. Unrecognised
200    /// event types are skipped; the `"error"` event surfaces as an [`Error::Api`], the same
201    /// way the chat stream surfaces a mid-stream failure. Accumulate
202    /// [`ResponsesStreamEvent::output_text_delta`] for the assembled text.
203    pub async fn responses_stream(
204        &self,
205        mut request: ResponsesRequest,
206    ) -> Result<impl Stream<Item = Result<ResponsesStreamEvent>>> {
207        request.stream = Some(true);
208        let resp = self
209            .send(self.request(Method::POST, "/v1/responses").json(&request))
210            .await?;
211        Ok(sse_responses(resp.bytes_stream()))
212    }
213
214    // -- embeddings ----------------------------------------------------------
215
216    /// `POST /v1/embeddings`. Base64 payloads (`encoding_format:"base64"`) are decoded
217    /// into float vectors before returning.
218    pub async fn embeddings(&self, request: EmbeddingRequest) -> Result<EmbeddingResponse> {
219        let resp = self
220            .send(self.request(Method::POST, "/v1/embeddings").json(&request))
221            .await?;
222        let raw = resp.json::<RawEmbeddingResponse>().await?;
223        raw.into_public()
224    }
225
226    // -- rerank ----------------------------------------------------------------
227
228    /// `POST /v1/rerank`. Scores each of `request.documents` against `request.query`
229    /// and returns the results ordered by relevance. `documents` may be plain strings or
230    /// structured multimodal objects; when `return_documents` is set the originals are
231    /// echoed back on each [`RerankResult`].
232    pub async fn rerank(&self, request: RerankRequest) -> Result<RerankResponse> {
233        let resp = self
234            .send(self.request(Method::POST, "/v1/rerank").json(&request))
235            .await?;
236        Ok(resp.json::<RerankResponse>().await?)
237    }
238
239    // -- models --------------------------------------------------------------
240
241    /// `GET /v1/models`. `type` filters the catalog; `search` is a substring match. The
242    /// per-model `endpoints` array appears only when the client was built with an admin
243    /// token.
244    pub async fn list_models(
245        &self,
246        model_type: Option<ModelType>,
247        search: Option<&str>,
248    ) -> Result<ListModelsResponse> {
249        let mut req = self.request(Method::GET, "/v1/models");
250        let mut query: Vec<(&str, String)> = Vec::new();
251        if let Some(t) = model_type {
252            query.push(("type", t.as_str().to_string()));
253        }
254        if let Some(s) = search {
255            query.push(("search", s.to_string()));
256        }
257        if !query.is_empty() {
258            req = req.query(&query);
259        }
260        let resp = self.send(req).await?;
261        Ok(resp.json::<ListModelsResponse>().await?)
262    }
263
264    // -- audio: speech (TTS) -------------------------------------------------
265
266    /// `POST /v1/audio/speech`. Returns the raw audio bytes and the resolved
267    /// `Content-Type` (from the response header, falling back to the SPEC.md table for the
268    /// requested `response_format`).
269    pub async fn speech(&self, request: SpeechRequest) -> Result<(Bytes, String)> {
270        let fallback = speech_content_type(request.response_format.as_deref());
271        let resp = self
272            .send(
273                self.request(Method::POST, "/v1/audio/speech")
274                    .json(&request),
275            )
276            .await?;
277        let content_type = resp
278            .headers()
279            .get(CONTENT_TYPE)
280            .and_then(|v| v.to_str().ok())
281            .map(|s| s.to_string())
282            .unwrap_or_else(|| fallback.to_string());
283        let bytes = resp.bytes().await?;
284        Ok((bytes, content_type))
285    }
286
287    /// `GET /v1/audio/voices?model=<id>`.
288    pub async fn voices(&self, model: &str) -> Result<VoicesResponse> {
289        let resp = self
290            .send(
291                self.request(Method::GET, "/v1/audio/voices")
292                    .query(&[("model", model)]),
293            )
294            .await?;
295        Ok(resp.json::<VoicesResponse>().await?)
296    }
297
298    // -- audio: transcriptions (STT) -----------------------------------------
299
300    /// `POST /v1/audio/transcriptions` (multipart). `file` carries the audio bytes under
301    /// `file_name`. Returns a structured [`Transcription::Json`] for json/verbose_json, or
302    /// a plain-text [`Transcription::Text`] for text/srt/vtt (SPEC.md).
303    pub async fn transcribe(
304        &self,
305        request: TranscriptionRequest,
306        file_name: impl Into<String>,
307        audio: impl Into<Bytes>,
308    ) -> Result<Transcription> {
309        let audio: Bytes = audio.into();
310        let part = reqwest::multipart::Part::bytes(audio.to_vec()).file_name(file_name.into());
311
312        let mut form = reqwest::multipart::Form::new()
313            .text("model", request.model.clone())
314            .part("file", part);
315        if let Some(language) = &request.language {
316            form = form.text("language", language.clone());
317        }
318        if let Some(prompt) = &request.prompt {
319            form = form.text("prompt", prompt.clone());
320        }
321        if let Some(rf) = &request.response_format {
322            form = form.text("response_format", rf.clone());
323        }
324        if let Some(t) = request.temperature {
325            form = form.text("temperature", t.to_string());
326        }
327
328        let resp = self
329            .send(
330                self.request(Method::POST, "/v1/audio/transcriptions")
331                    .multipart(form),
332            )
333            .await?;
334
335        // json/verbose_json → structured body; text/srt/vtt → plain text.
336        let structured = matches!(
337            request.response_format.as_deref(),
338            None | Some("json") | Some("verbose_json")
339        );
340        if structured {
341            Ok(Transcription::Json(
342                resp.json::<TranscriptionResponse>().await?,
343            ))
344        } else {
345            Ok(Transcription::Text(resp.text().await?))
346        }
347    }
348
349    // -- batches -------------------------------------------------------------
350
351    /// `POST /v1/batches`.
352    pub async fn create_batch(&self, request: BatchCreateRequest) -> Result<BatchHandle> {
353        let resp = self
354            .send(self.request(Method::POST, "/v1/batches").json(&request))
355            .await?;
356        Ok(resp.json::<BatchHandle>().await?)
357    }
358
359    /// `GET /v1/batches/{id}`.
360    pub async fn get_batch(&self, id: &str) -> Result<BatchHandle> {
361        let resp = self
362            .send(self.request(Method::GET, &format!("/v1/batches/{id}")))
363            .await?;
364        Ok(resp.json::<BatchHandle>().await?)
365    }
366
367    /// `POST /v1/batches/{id}/cancel`.
368    pub async fn cancel_batch(&self, id: &str) -> Result<BatchHandle> {
369        let resp = self
370            .send(self.request(Method::POST, &format!("/v1/batches/{id}/cancel")))
371            .await?;
372        Ok(resp.json::<BatchHandle>().await?)
373    }
374
375    /// `GET /v1/batches/{id}/results` (`application/x-ndjson`). Yields one
376    /// [`BatchResultLine`] per line.
377    pub async fn batch_results(
378        &self,
379        id: &str,
380    ) -> Result<impl Stream<Item = Result<BatchResultLine>>> {
381        let resp = self
382            .send(self.request(Method::GET, &format!("/v1/batches/{id}/results")))
383            .await?;
384        Ok(ndjson_lines(resp.bytes_stream()))
385    }
386}
387
388/// Inspect the response status: on non-2xx, drain the body and parse the
389/// `{"error":{"message":...}}` envelope into [`Error::Api`].
390async fn check_status(resp: Response) -> Result<Response> {
391    let status = resp.status();
392    if status.is_success() {
393        return Ok(resp);
394    }
395    let code = status.as_u16();
396    let body = resp.bytes().await.unwrap_or_default();
397    let message = parse_error_message(&body).unwrap_or_else(|| {
398        status
399            .canonical_reason()
400            .unwrap_or("request failed")
401            .to_string()
402    });
403    Err(Error::Api {
404        status: code,
405        message,
406    })
407}
408
409/// Best-effort extraction of `error.message` from an error body.
410fn parse_error_message(body: &[u8]) -> Option<String> {
411    serde_json::from_slice::<ErrorEnvelope>(body)
412        .ok()
413        .map(|e| e.error.message)
414        .filter(|m| !m.is_empty())
415}