liter-llm 1.6.3

Universal LLM API client — 142+ providers, streaming, tool calling. Rust-powered, type-safe, compiled.
Documentation
use std::future::Future;

use bytes::Bytes;

use crate::error::{LiterLlmError, Result};
use crate::http::retry;

// ---------------------------------------------------------------------------
// Shared retry loop helper
// ---------------------------------------------------------------------------

/// Extract an optional `Retry-After` delay from a response.
pub(crate) fn retry_after_from_response(resp: &reqwest::Response) -> Option<std::time::Duration> {
    let value = resp.headers().get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
    retry::parse_retry_after(value)
}

/// Drive a single-request closure through the retry / back-off loop.
///
/// `send` is called once per attempt and must return a future that resolves to
/// a raw `reqwest::Response` (or a transport-level error).  The helper handles:
///
/// - Attempt counting and the `max_retries` budget.
/// - Parsing the `Retry-After` header before consuming the response body.
/// - Exponential back-off via [`retry::should_retry`].
/// - Reading the error body and mapping it to [`LiterLlmError`] on final failure.
///
/// On success the **successful** `Response` is returned so the caller can
/// choose how to consume the body (JSON deserialisation, byte stream, …).
pub(crate) async fn with_retry<F, Fut>(max_retries: u32, mut send: F) -> Result<reqwest::Response>
where
    F: FnMut() -> Fut,
    Fut: Future<Output = std::result::Result<reqwest::Response, reqwest::Error>>,
{
    let mut attempt = 0u32;

    loop {
        let resp = send().await?;
        let status = resp.status().as_u16();

        if resp.status().is_success() {
            return Ok(resp);
        }

        // Parse Retry-After *before* consuming the body.
        let server_retry_after = retry_after_from_response(&resp);

        if let Some(delay) = retry::should_retry(status, attempt, max_retries, server_retry_after) {
            attempt += 1;
            #[cfg(not(target_arch = "wasm32"))]
            tokio::time::sleep(delay).await;
            #[cfg(target_arch = "wasm32")]
            gloo_timers::future::sleep(std::time::Duration::from_millis(delay.as_millis() as u64)).await;
            continue;
        }

        // Non-retryable — read the body for a useful error message.
        let text = resp
            .text()
            .await
            .unwrap_or_else(|e| format!("(failed to read body: {e})"));
        return Err(LiterLlmError::from_status(status, &text, server_retry_after));
    }
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Send a POST request with a JSON body and return the raw response JSON.
///
/// Like [`post_json`] but returns a `serde_json::Value` instead of deserializing
/// into a typed `T`.  This allows the caller to mutate the response (e.g. via a
/// provider `transform_response`) before deserializing into the canonical type.
///
/// Retries on 429 / 5xx according to `max_retries`.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(
        skip_all,
        fields(
            http.method = "POST",
            http.url = %url,
            http.status_code = tracing::field::Empty,
            http.retry_count = tracing::field::Empty,
        )
    )
)]
pub async fn post_json_raw(
    client: &reqwest::Client,
    url: &str,
    auth_header: Option<(&str, &str)>,
    extra_headers: &[(&str, &str)],
    body: Bytes,
    max_retries: u32,
) -> Result<serde_json::Value> {
    let mut retry_count = 0u32;

    let resp = with_retry(max_retries, || {
        // Clone is a zero-copy ref-count bump on `Bytes`.
        let mut builder = client
            .post(url)
            .header(reqwest::header::CONTENT_TYPE, "application/json")
            .body(body.clone());
        if let Some((name, value)) = auth_header {
            builder = builder.header(name, value);
        }
        for (name, value) in extra_headers {
            builder = builder.header(*name, *value);
        }
        retry_count += 1;
        builder.send()
    })
    .await?;

    #[cfg(feature = "tracing")]
    {
        let span = tracing::Span::current();
        span.record("http.status_code", resp.status().as_u16());
        span.record("http.retry_count", retry_count.saturating_sub(1));
    }

    resp.json::<serde_json::Value>().await.map_err(LiterLlmError::from)
}

/// Send a POST request with a JSON body and return the raw response bytes.
///
/// Identical to [`post_json_raw`] except it returns `bytes::Bytes` instead of
/// deserializing JSON.  Useful for endpoints that return binary data (e.g.
/// text-to-speech audio).
///
/// Retries on 429 / 5xx according to `max_retries`.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(
        skip_all,
        fields(
            http.method = "POST",
            http.url = %url,
            http.status_code = tracing::field::Empty,
            http.retry_count = tracing::field::Empty,
        )
    )
)]
pub async fn post_binary(
    client: &reqwest::Client,
    url: &str,
    auth_header: Option<(&str, &str)>,
    extra_headers: &[(&str, &str)],
    body: Bytes,
    max_retries: u32,
) -> Result<Bytes> {
    let mut retry_count = 0u32;

    let resp = with_retry(max_retries, || {
        let mut builder = client
            .post(url)
            .header(reqwest::header::CONTENT_TYPE, "application/json")
            .body(body.clone());
        if let Some((name, value)) = auth_header {
            builder = builder.header(name, value);
        }
        for (name, value) in extra_headers {
            builder = builder.header(*name, *value);
        }
        retry_count += 1;
        builder.send()
    })
    .await?;

    #[cfg(feature = "tracing")]
    {
        let span = tracing::Span::current();
        span.record("http.status_code", resp.status().as_u16());
        span.record("http.retry_count", retry_count.saturating_sub(1));
    }

    resp.bytes().await.map_err(LiterLlmError::from)
}

/// Send a POST request with a multipart form body and return the raw response JSON.
///
/// Used for file uploads (Files API, audio transcription).  Multipart forms are
/// consumed by `send()` and cannot be cheaply cloned, so this function does
/// **not** retry on failure — file uploads are not idempotent anyway.
///
/// `auth_header` is `Some((name, value))` when the provider requires
/// authentication, or `None` when no auth header should be added.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(
        skip_all,
        fields(
            http.method = "POST",
            http.url = %url,
            http.status_code = tracing::field::Empty,
        )
    )
)]
pub async fn post_multipart(
    client: &reqwest::Client,
    url: &str,
    auth_header: Option<(&str, &str)>,
    extra_headers: &[(&str, &str)],
    form: reqwest::multipart::Form,
) -> Result<serde_json::Value> {
    let mut builder = client.post(url).multipart(form);
    if let Some((name, value)) = auth_header {
        builder = builder.header(name, value);
    }
    for (name, value) in extra_headers {
        builder = builder.header(*name, *value);
    }

    let resp = builder.send().await?;

    #[cfg(feature = "tracing")]
    {
        let span = tracing::Span::current();
        span.record("http.status_code", resp.status().as_u16());
    }

    let status = resp.status().as_u16();
    if !resp.status().is_success() {
        let server_retry_after = retry_after_from_response(&resp);
        let text = resp
            .text()
            .await
            .unwrap_or_else(|e| format!("(failed to read body: {e})"));
        return Err(LiterLlmError::from_status(status, &text, server_retry_after));
    }

    resp.json::<serde_json::Value>().await.map_err(LiterLlmError::from)
}

/// Send a GET request and return the raw response JSON as `serde_json::Value`.
///
/// Returns a raw `serde_json::Value` without deserializing into a typed `T`.
/// Useful for endpoints where the caller needs to inspect or transform the
/// response before deserialization (e.g. GET /files/{id}, GET /batches/{id}).
///
/// Retries on 429 / 5xx according to `max_retries`.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(
        skip_all,
        fields(
            http.method = "GET",
            http.url = %url,
            http.status_code = tracing::field::Empty,
            http.retry_count = tracing::field::Empty,
        )
    )
)]
pub async fn get_json_raw(
    client: &reqwest::Client,
    url: &str,
    auth_header: Option<(&str, &str)>,
    extra_headers: &[(&str, &str)],
    max_retries: u32,
) -> Result<serde_json::Value> {
    let mut retry_count = 0u32;

    let resp = with_retry(max_retries, || {
        let mut builder = client.get(url);
        if let Some((name, value)) = auth_header {
            builder = builder.header(name, value);
        }
        for (name, value) in extra_headers {
            builder = builder.header(*name, *value);
        }
        retry_count += 1;
        builder.send()
    })
    .await?;

    #[cfg(feature = "tracing")]
    {
        let span = tracing::Span::current();
        span.record("http.status_code", resp.status().as_u16());
        span.record("http.retry_count", retry_count.saturating_sub(1));
    }

    resp.json::<serde_json::Value>().await.map_err(LiterLlmError::from)
}

/// Send a DELETE request and return the raw response JSON.
///
/// Same retry/auth/header pattern as `get_json_raw` but uses the HTTP DELETE method.
/// Used for resource deletion endpoints (e.g. DELETE /files/{id}).
///
/// Retries on 429 / 5xx according to `max_retries`.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(
        skip_all,
        fields(
            http.method = "DELETE",
            http.url = %url,
            http.status_code = tracing::field::Empty,
            http.retry_count = tracing::field::Empty,
        )
    )
)]
pub async fn delete_json(
    client: &reqwest::Client,
    url: &str,
    auth_header: Option<(&str, &str)>,
    extra_headers: &[(&str, &str)],
    max_retries: u32,
) -> Result<serde_json::Value> {
    let mut retry_count = 0u32;

    let resp = with_retry(max_retries, || {
        let mut builder = client.delete(url);
        if let Some((name, value)) = auth_header {
            builder = builder.header(name, value);
        }
        for (name, value) in extra_headers {
            builder = builder.header(*name, *value);
        }
        retry_count += 1;
        builder.send()
    })
    .await?;

    #[cfg(feature = "tracing")]
    {
        let span = tracing::Span::current();
        span.record("http.status_code", resp.status().as_u16());
        span.record("http.retry_count", retry_count.saturating_sub(1));
    }

    resp.json::<serde_json::Value>().await.map_err(LiterLlmError::from)
}

/// Send a GET request and return the raw response bytes.
///
/// Used for endpoints that return binary data (e.g. GET /files/{id}/content
/// for downloading file contents).
///
/// Retries on 429 / 5xx according to `max_retries`.
#[cfg_attr(
    feature = "tracing",
    tracing::instrument(
        skip_all,
        fields(
            http.method = "GET",
            http.url = %url,
            http.status_code = tracing::field::Empty,
            http.retry_count = tracing::field::Empty,
        )
    )
)]
pub async fn get_binary(
    client: &reqwest::Client,
    url: &str,
    auth_header: Option<(&str, &str)>,
    extra_headers: &[(&str, &str)],
    max_retries: u32,
) -> Result<Bytes> {
    let mut retry_count = 0u32;

    let resp = with_retry(max_retries, || {
        let mut builder = client.get(url);
        if let Some((name, value)) = auth_header {
            builder = builder.header(name, value);
        }
        for (name, value) in extra_headers {
            builder = builder.header(*name, *value);
        }
        retry_count += 1;
        builder.send()
    })
    .await?;

    #[cfg(feature = "tracing")]
    {
        let span = tracing::Span::current();
        span.record("http.status_code", resp.status().as_u16());
        span.record("http.retry_count", retry_count.saturating_sub(1));
    }

    resp.bytes().await.map_err(LiterLlmError::from)
}