use crate::error::{Error, ErrorEnvelope, Result};
use crate::stream::{ndjson_lines, sse_chunks, sse_responses};
use crate::types::*;
use crate::wire::{speech_content_type, RawEmbeddingResponse};
use bytes::Bytes;
use futures::Stream;
use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Method, RequestBuilder, Response};
use std::time::Duration;
const ADMIN_TOKEN_HEADER: &str = "x-admin-token";
#[derive(Debug, Clone)]
pub struct ClientBuilder {
base_url: String,
api_key: String,
admin_token: Option<String>,
timeout: Option<Duration>,
http: Option<reqwest::Client>,
}
impl ClientBuilder {
fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
ClientBuilder {
base_url: base_url.into(),
api_key: api_key.into(),
admin_token: None,
timeout: None,
http: None,
}
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn admin_token(mut self, token: impl Into<String>) -> Self {
self.admin_token = Some(token.into());
self
}
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.http = Some(client);
self
}
pub fn build(self) -> Result<Client> {
let base_url = self.base_url.trim_end_matches('/').to_string();
if base_url.is_empty() {
return Err(Error::InvalidBaseUrl("base url is empty".to_string()));
}
let http = match self.http {
Some(c) => c,
None => {
let mut b = reqwest::Client::builder();
if let Some(t) = self.timeout {
b = b.timeout(t);
}
b.build()?
}
};
let bearer = HeaderValue::from_str(&format!("Bearer {}", self.api_key))
.map_err(|_| Error::InvalidBaseUrl("api key contains invalid header bytes".into()))?;
let admin = self
.admin_token
.map(|t| HeaderValue::from_str(&t))
.transpose()
.map_err(|_| {
Error::InvalidBaseUrl("admin token contains invalid header bytes".into())
})?;
Ok(Client {
http,
base_url,
bearer,
admin_token: admin,
})
}
}
#[derive(Debug, Clone)]
pub struct Client {
http: reqwest::Client,
base_url: String,
bearer: HeaderValue,
admin_token: Option<HeaderValue>,
}
impl Client {
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
ClientBuilder::new(base_url, api_key).build()
}
pub fn builder(base_url: impl Into<String>, api_key: impl Into<String>) -> ClientBuilder {
ClientBuilder::new(base_url, api_key)
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
fn request(&self, method: Method, path: &str) -> RequestBuilder {
let mut req = self
.http
.request(method, self.url(path))
.header(AUTHORIZATION, self.bearer.clone());
if let Some(admin) = &self.admin_token {
req = req.header(ADMIN_TOKEN_HEADER, admin.clone());
}
req
}
async fn send(&self, req: RequestBuilder) -> Result<Response> {
let resp = req.send().await?;
check_status(resp).await
}
pub async fn chat(&self, mut request: ChatRequest) -> Result<ChatResponse> {
request.stream = None;
let resp = self
.send(
self.request(Method::POST, "/v1/chat/completions")
.json(&request),
)
.await?;
Ok(resp.json::<ChatResponse>().await?)
}
pub async fn chat_stream(
&self,
mut request: ChatRequest,
) -> Result<impl Stream<Item = Result<ChatCompletionChunk>>> {
request.stream = Some(true);
let resp = self
.send(
self.request(Method::POST, "/v1/chat/completions")
.json(&request),
)
.await?;
Ok(sse_chunks(resp.bytes_stream()))
}
pub async fn responses(&self, mut request: ResponsesRequest) -> Result<ResponsesResponse> {
request.stream = None;
let resp = self
.send(self.request(Method::POST, "/v1/responses").json(&request))
.await?;
Ok(resp.json::<ResponsesResponse>().await?)
}
pub async fn responses_stream(
&self,
mut request: ResponsesRequest,
) -> Result<impl Stream<Item = Result<ResponsesStreamEvent>>> {
request.stream = Some(true);
let resp = self
.send(self.request(Method::POST, "/v1/responses").json(&request))
.await?;
Ok(sse_responses(resp.bytes_stream()))
}
pub async fn embeddings(&self, request: EmbeddingRequest) -> Result<EmbeddingResponse> {
let resp = self
.send(self.request(Method::POST, "/v1/embeddings").json(&request))
.await?;
let raw = resp.json::<RawEmbeddingResponse>().await?;
raw.into_public()
}
pub async fn rerank(&self, request: RerankRequest) -> Result<RerankResponse> {
let resp = self
.send(self.request(Method::POST, "/v1/rerank").json(&request))
.await?;
Ok(resp.json::<RerankResponse>().await?)
}
pub async fn list_models(
&self,
model_type: Option<ModelType>,
search: Option<&str>,
) -> Result<ListModelsResponse> {
let mut req = self.request(Method::GET, "/v1/models");
let mut query: Vec<(&str, String)> = Vec::new();
if let Some(t) = model_type {
query.push(("type", t.as_str().to_string()));
}
if let Some(s) = search {
query.push(("search", s.to_string()));
}
if !query.is_empty() {
req = req.query(&query);
}
let resp = self.send(req).await?;
Ok(resp.json::<ListModelsResponse>().await?)
}
pub async fn speech(&self, request: SpeechRequest) -> Result<(Bytes, String)> {
let fallback = speech_content_type(request.response_format.as_deref());
let resp = self
.send(
self.request(Method::POST, "/v1/audio/speech")
.json(&request),
)
.await?;
let content_type = resp
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| fallback.to_string());
let bytes = resp.bytes().await?;
Ok((bytes, content_type))
}
pub async fn voices(&self, model: &str) -> Result<VoicesResponse> {
let resp = self
.send(
self.request(Method::GET, "/v1/audio/voices")
.query(&[("model", model)]),
)
.await?;
Ok(resp.json::<VoicesResponse>().await?)
}
pub async fn transcribe(
&self,
request: TranscriptionRequest,
file_name: impl Into<String>,
audio: impl Into<Bytes>,
) -> Result<Transcription> {
let audio: Bytes = audio.into();
let part = reqwest::multipart::Part::bytes(audio.to_vec()).file_name(file_name.into());
let mut form = reqwest::multipart::Form::new()
.text("model", request.model.clone())
.part("file", part);
if let Some(language) = &request.language {
form = form.text("language", language.clone());
}
if let Some(prompt) = &request.prompt {
form = form.text("prompt", prompt.clone());
}
if let Some(rf) = &request.response_format {
form = form.text("response_format", rf.clone());
}
if let Some(t) = request.temperature {
form = form.text("temperature", t.to_string());
}
let resp = self
.send(
self.request(Method::POST, "/v1/audio/transcriptions")
.multipart(form),
)
.await?;
let structured = matches!(
request.response_format.as_deref(),
None | Some("json") | Some("verbose_json")
);
if structured {
Ok(Transcription::Json(
resp.json::<TranscriptionResponse>().await?,
))
} else {
Ok(Transcription::Text(resp.text().await?))
}
}
pub async fn create_batch(&self, request: BatchCreateRequest) -> Result<BatchHandle> {
let resp = self
.send(self.request(Method::POST, "/v1/batches").json(&request))
.await?;
Ok(resp.json::<BatchHandle>().await?)
}
pub async fn get_batch(&self, id: &str) -> Result<BatchHandle> {
let resp = self
.send(self.request(Method::GET, &format!("/v1/batches/{id}")))
.await?;
Ok(resp.json::<BatchHandle>().await?)
}
pub async fn cancel_batch(&self, id: &str) -> Result<BatchHandle> {
let resp = self
.send(self.request(Method::POST, &format!("/v1/batches/{id}/cancel")))
.await?;
Ok(resp.json::<BatchHandle>().await?)
}
pub async fn batch_results(
&self,
id: &str,
) -> Result<impl Stream<Item = Result<BatchResultLine>>> {
let resp = self
.send(self.request(Method::GET, &format!("/v1/batches/{id}/results")))
.await?;
Ok(ndjson_lines(resp.bytes_stream()))
}
}
async fn check_status(resp: Response) -> Result<Response> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let code = status.as_u16();
let body = resp.bytes().await.unwrap_or_default();
let message = parse_error_message(&body).unwrap_or_else(|| {
status
.canonical_reason()
.unwrap_or("request failed")
.to_string()
});
Err(Error::Api {
status: code,
message,
})
}
fn parse_error_message(body: &[u8]) -> Option<String> {
serde_json::from_slice::<ErrorEnvelope>(body)
.ok()
.map(|e| e.error.message)
.filter(|m| !m.is_empty())
}