openrouter2 0.2.0

Typed async and blocking reqwest clients for the OpenRouter API.
Documentation
use std::collections::BTreeMap;
use std::error::Error as _;

use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct OpenRouterApiError {
    #[serde(default)]
    pub message: Option<String>,
    #[serde(default, rename = "type")]
    pub type_: Option<String>,
    #[serde(default)]
    pub code: Option<Value>,
    #[serde(default)]
    pub param: Option<String>,
    #[serde(default, flatten)]
    pub extra: BTreeMap<String, Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiError {
    pub status: u16,
    pub body: String,
    pub error: Option<OpenRouterApiError>,
    pub request_id: Option<String>,
    pub headers: BTreeMap<String, String>,
}

#[derive(Debug, thiserror::Error)]
pub enum OpenRouterError {
    #[error("invalid openrouter base url: {0}")]
    InvalidBaseUrl(String),
    #[error("invalid request header: {0}")]
    InvalidHeader(String),
    #[error("http transport error: {0}")]
    Transport(String),
    #[error("openrouter api error: status {}", .0.status)]
    Api(Box<ApiError>),
    #[error("malformed openrouter response: {0}")]
    Decode(String),
}

pub(crate) fn reqwest_error_message(e: &reqwest::Error) -> String {
    let mut msg = e.to_string();
    if let Some(status) = e.status()
        && !msg.contains(status.as_str())
    {
        msg = format!("status {status}: {msg}");
    }
    if let Some(source) = e.source() {
        let source = source.to_string();
        if !source.is_empty() && !msg.contains(&source) {
            msg = format!("{msg}: {source}");
        }
    }
    msg
}

pub(crate) fn truncate(s: String) -> String {
    const MAX: usize = 2048;
    if s.len() <= MAX {
        return s;
    }

    let end = floor_char_boundary(&s, MAX);
    let mut t = s;
    t.truncate(end);
    t.push('');
    t
}

fn floor_char_boundary(s: &str, max: usize) -> usize {
    let mut end = max.min(s.len());
    while !s.is_char_boundary(end) {
        end -= 1;
    }
    end
}

pub(crate) fn parse_api_error(
    status: reqwest::StatusCode,
    headers: &reqwest::header::HeaderMap,
    body: String,
) -> OpenRouterError {
    let error = serde_json::from_str::<Value>(&body)
        .ok()
        .and_then(|value| value.get("error").cloned())
        .and_then(|value| serde_json::from_value::<OpenRouterApiError>(value).ok());

    OpenRouterError::Api(Box::new(ApiError {
        status: status.as_u16(),
        body: truncate(body),
        error,
        request_id: header_value(headers, "x-request-id")
            .or_else(|| header_value(headers, "openrouter-request-id")),
        headers: stringify_headers(headers),
    }))
}

fn header_value(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
    headers
        .get(name)
        .and_then(|value| value.to_str().ok())
        .map(ToOwned::to_owned)
}

fn stringify_headers(headers: &reqwest::header::HeaderMap) -> BTreeMap<String, String> {
    headers
        .iter()
        .filter_map(|(name, value)| Some((name.to_string(), value.to_str().ok()?.to_owned())))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::truncate;

    #[test]
    fn truncate_below_max_is_unchanged() {
        let s = "short body".to_owned();
        assert_eq!(truncate(s.clone()), s);
    }

    #[test]
    fn truncate_does_not_split_a_multibyte_char() {
        let mut s = "a".repeat(2047);
        s.push('');
        let out = truncate(s);
        assert!(out.ends_with(''));
        assert!(out.len() <= 2047 + ''.len_utf8());
    }
}