openrouter-client 0.2.0

Idiomatic async Rust SDK for the OpenRouter API.
Documentation
//! Shared HTTP plumbing for endpoint methods.
//!
//! Wraps `Client::http()` with header assembly, retry-aware unary execution,
//! and a single-attempt stream opener. All endpoint methods (`chat_complete`,
//! `complete`, and their streaming variants) funnel through this module so the
//! retry / error-classification logic lives in exactly one place.

use std::time::Duration;

use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;

use crate::client::Client;
use crate::error::{Error, Result};
use crate::retry::run_with_retry;
use crate::stream::StreamResponse;

const HTTP_REFERER: HeaderName = HeaderName::from_static("http-referer");
const X_TITLE: HeaderName = HeaderName::from_static("x-title");

/// Accept header for JSON unary requests.
const ACCEPT_JSON: &str = "application/json";
/// Accept header for SSE streaming requests.
#[allow(dead_code)] // Consumed by the streaming layer (HRA-122 / HRA-123).
pub(crate) const ACCEPT_SSE: &str = "text/event-stream";

/// Build the base header map common to every request.
fn base_headers(client: &Client, accept: &'static str) -> Result<HeaderMap> {
    let mut h = HeaderMap::with_capacity(5);
    let auth = format!("Bearer {}", client.api_key());
    let mut auth_val = HeaderValue::from_str(&auth)
        .map_err(|_| Error::InvalidInput("api_key is not header-safe"))?;
    auth_val.set_sensitive(true);
    h.insert(AUTHORIZATION, auth_val);
    h.insert(CONTENT_TYPE, HeaderValue::from_static(ACCEPT_JSON));
    h.insert(ACCEPT, HeaderValue::from_static(accept));
    if let Some(referer) = client.referer() {
        if let Ok(v) = HeaderValue::from_str(referer) {
            h.insert(HTTP_REFERER, v);
        }
    }
    if let Some(name) = client.app_name() {
        if let Ok(v) = HeaderValue::from_str(name) {
            h.insert(X_TITLE, v);
        }
    }
    Ok(h)
}

/// Resolve a relative path against the client's base URL.
fn endpoint_url(client: &Client, path: &str) -> Result<reqwest::Url> {
    let trimmed = path.trim_start_matches('/');
    client
        .base_url()
        .join(trimmed)
        .map_err(|_| Error::InvalidInput("endpoint path is not valid"))
}

/// Parse a `Retry-After` header value. Supports integer seconds; HTTP-date
/// values are ignored (treated as absent — the computed backoff will be used).
fn parse_retry_after(resp: &Response) -> Option<Duration> {
    let v = resp.headers().get(reqwest::header::RETRY_AFTER)?;
    let s = v.to_str().ok()?.trim();
    s.parse::<u64>().ok().map(Duration::from_secs)
}

/// Execute a JSON POST and decode the response as `Resp`.
///
/// Retries on transient failures using the client's [`RetryConfig`]. API errors
/// are parsed via [`Error::from_response_body`]; the response's `Retry-After`
/// header is forwarded into the error so the retry layer can honor it.
pub(crate) async fn execute_json<Req, Resp>(client: &Client, path: &str, body: &Req) -> Result<Resp>
where
    Req: Serialize + ?Sized,
    Resp: DeserializeOwned,
{
    let body_bytes = serde_json::to_vec(body)?;
    execute_request(client, Method::POST, path, &[], Some(body_bytes)).await
}

/// Execute a JSON GET and decode the response as `Resp`.
///
/// `query` is a slice of (key, value) pairs appended to the URL via
/// [`url::Url::query_pairs_mut`] — pass an empty slice for no query string.
/// Retries / error parsing match [`execute_json`].
#[allow(dead_code)] // Consumed by the discovery / account endpoints (HRA-112).
pub(crate) async fn execute_json_get<Resp>(
    client: &Client,
    path: &str,
    query: &[(&str, String)],
) -> Result<Resp>
where
    Resp: DeserializeOwned,
{
    execute_request(client, Method::GET, path, query, None).await
}

/// Execute a JSON request with an arbitrary method (DELETE, PATCH, etc.) and
/// an optional pre-serialized JSON body. Used by the API-key CRUD endpoints.
#[allow(dead_code)] // Consumed by the API-key CRUD endpoints (HRA-142).
pub(crate) async fn execute_json_method<Req, Resp>(
    client: &Client,
    method: Method,
    path: &str,
    body: Option<&Req>,
) -> Result<Resp>
where
    Req: Serialize + ?Sized,
    Resp: DeserializeOwned,
{
    let body_bytes = match body {
        Some(b) => Some(serde_json::to_vec(b)?),
        None => None,
    };
    execute_request(client, method, path, &[], body_bytes).await
}

/// Execute a JSON POST whose successful response is a raw binary body
/// (e.g. TTS audio, video download). Returns the body bytes alongside the
/// upstream `Content-Type` so callers can echo or branch on it.
///
/// Retries on transient failures, same as the JSON helpers.
#[allow(dead_code)] // Consumed by the TTS / video endpoints (HRA-147 / HRA-148).
pub(crate) async fn execute_bytes_post<Req>(
    client: &Client,
    path: &str,
    body: &Req,
) -> Result<(bytes::Bytes, Option<String>)>
where
    Req: Serialize + ?Sized,
{
    let body_bytes = serde_json::to_vec(body)?;
    let url = endpoint_url(client, path)?;
    let headers = base_headers(client, ACCEPT_JSON)?;
    let cfg = client.retry().clone();

    run_with_retry(&cfg, || {
        let url = url.clone();
        let headers = headers.clone();
        let body_bytes = body_bytes.clone();
        async move {
            let resp = client
                .http()
                .request(Method::POST, url)
                .headers(headers)
                .body(body_bytes)
                .send()
                .await?;
            let status = resp.status();
            let content_type = resp
                .headers()
                .get(reqwest::header::CONTENT_TYPE)
                .and_then(|v| v.to_str().ok())
                .map(|s| s.to_string());
            if status.is_success() {
                let bytes = resp.bytes().await?;
                Ok((bytes, content_type))
            } else {
                Err(api_error_from_response(resp, status).await)
            }
        }
    })
    .await
}

/// Execute a GET request whose successful response is a raw binary body
/// (e.g. video content download). Returns body + `Content-Type`.
///
/// Single attempt — large downloads should not retry on partial failures.
#[allow(dead_code)] // Consumed by the video download endpoint (HRA-148).
pub(crate) async fn execute_bytes_get(
    client: &Client,
    path: &str,
    query: &[(&str, String)],
) -> Result<(bytes::Bytes, Option<String>)> {
    let mut url = endpoint_url(client, path)?;
    if !query.is_empty() {
        let mut q = url.query_pairs_mut();
        for (k, v) in query {
            q.append_pair(k, v);
        }
        drop(q);
    }
    let headers = base_headers(client, ACCEPT_JSON)?;

    let resp = client
        .http()
        .request(Method::GET, url)
        .headers(headers)
        .send()
        .await?;
    let status = resp.status();
    let content_type = resp
        .headers()
        .get(reqwest::header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());
    if status.is_success() {
        let bytes = resp.bytes().await?;
        Ok((bytes, content_type))
    } else {
        Err(api_error_from_response(resp, status).await)
    }
}

/// Execute a JSON request that returns no decoded body (success = 2xx with
/// empty or ignored body). Used by endpoints like guardrail unassign that
/// return 204 / `{}`.
#[allow(dead_code)] // Consumed by the guardrails endpoints (HRA-145).
pub(crate) async fn execute_no_content_method<Req>(
    client: &Client,
    method: Method,
    path: &str,
    body: Option<&Req>,
) -> Result<()>
where
    Req: Serialize + ?Sized,
{
    let body_bytes = match body {
        Some(b) => Some(serde_json::to_vec(b)?),
        None => None,
    };
    let url = endpoint_url(client, path)?;
    let headers = base_headers(client, ACCEPT_JSON)?;
    let cfg = client.retry().clone();

    run_with_retry(&cfg, || {
        let url = url.clone();
        let headers = headers.clone();
        let body_bytes = body_bytes.clone();
        let method = method.clone();
        async move {
            let mut req = client.http().request(method, url).headers(headers);
            if let Some(b) = body_bytes {
                req = req.body(b);
            }
            let resp = req.send().await?;
            let status = resp.status();
            if status.is_success() {
                Ok(())
            } else {
                Err(api_error_from_response(resp, status).await)
            }
        }
    })
    .await
}

/// Shared inner loop for all JSON unary methods. Runs through [`run_with_retry`].
async fn execute_request<Resp>(
    client: &Client,
    method: Method,
    path: &str,
    query: &[(&str, String)],
    body_bytes: Option<Vec<u8>>,
) -> Result<Resp>
where
    Resp: DeserializeOwned,
{
    let mut url = endpoint_url(client, path)?;
    if !query.is_empty() {
        let mut q = url.query_pairs_mut();
        for (k, v) in query {
            q.append_pair(k, v);
        }
        drop(q);
    }
    let headers = base_headers(client, ACCEPT_JSON)?;
    let cfg = client.retry().clone();

    run_with_retry(&cfg, || {
        let url = url.clone();
        let headers = headers.clone();
        let body_bytes = body_bytes.clone();
        let method = method.clone();
        async move {
            let mut req = client.http().request(method, url).headers(headers);
            if let Some(b) = body_bytes {
                req = req.body(b);
            }
            let resp = req.send().await?;
            let status = resp.status();
            if status.is_success() {
                let bytes = resp.bytes().await?;
                let decoded: Resp = serde_json::from_slice(&bytes)?;
                Ok(decoded)
            } else {
                Err(api_error_from_response(resp, status).await)
            }
        }
    })
    .await
}

/// Open a streaming POST. Returns the raw `Response` on a 2xx; the caller takes
/// over via [`Response::bytes_stream`]. **Single attempt only** — stream-level
/// reconnect lives in `crate::stream`.
///
/// Accepts pre-serialized body bytes so the streaming layer can cache them in
/// the reconnect closure and avoid re-serializing on every reconnect.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn open_stream_bytes(
    client: &Client,
    path: &str,
    body_bytes: Vec<u8>,
) -> Result<StreamResponse> {
    let url = endpoint_url(client, path)?;
    let headers = base_headers(client, ACCEPT_SSE)?;

    let resp = client
        .http()
        .request(Method::POST, url)
        .headers(headers)
        .body(body_bytes)
        .send()
        .await?;
    let status = resp.status();
    if status.is_success() {
        Ok(resp)
    } else {
        Err(api_error_from_response(resp, status).await)
    }
}

#[cfg(all(target_arch = "wasm32", feature = "browser"))]
pub(crate) async fn open_stream_bytes(
    client: &Client,
    path: &str,
    body_bytes: Vec<u8>,
) -> Result<StreamResponse> {
    use gloo_net::http::Request;

    let url = endpoint_url(client, path)?;
    let abort = web_sys::AbortController::new()
        .map_err(|error| Error::BrowserTransport(format!("{error:?}")))?;
    let mut request = Request::post(url.as_str())
        .header("Authorization", &format!("Bearer {}", client.api_key()))
        .header("Content-Type", ACCEPT_JSON)
        .header("Accept", ACCEPT_SSE)
        .abort_signal(Some(&abort.signal()));
    if let Some(referer) = client.referer() {
        request = request.header("HTTP-Referer", referer);
    }
    if let Some(name) = client.app_name() {
        request = request.header("X-Title", name);
    }
    let body = js_sys::Uint8Array::from(body_bytes.as_slice());
    let response = request
        .body(body)
        .map_err(|error| Error::BrowserTransport(error.to_string()))?
        .send()
        .await
        .map_err(|error| Error::BrowserTransport(error.to_string()))?;
    let status = response.status();
    if (200..=299).contains(&status) {
        Ok(StreamResponse::new(response, abort))
    } else {
        let body = response
            .binary()
            .await
            .map_err(|error| Error::BrowserTransport(error.to_string()))?;
        Err(Error::from_response_body(status, &body, None))
    }
}

async fn api_error_from_response(resp: Response, status: StatusCode) -> Error {
    let retry_after = parse_retry_after(&resp);
    let body = resp.bytes().await.unwrap_or_default();
    Error::from_response_body(status.as_u16(), &body, retry_after)
}