Skip to main content

xil_api/
lib.rs

1//! Network clients for the pipeline: ElevenLabs (text-to-speech, sound
2//! effects, user quota, voices, Studio), Anthropic (the publish stage) and
3//! Google Translate TTS (gTTS, the free draft voice backend).
4//!
5//! Each client speaks the same HTTP the Python SDKs do, so the requests a
6//! Rust run makes can be compared against a Python run's request for request
7//! (see `tools/parity/mockapi.py`). Base URLs can be redirected with
8//! `XIL_ELEVENLABS_BASE_URL`, `ANTHROPIC_BASE_URL` and `XIL_GTTS_BASE_URL`.
9
10pub mod anthropic;
11pub mod elevenlabs;
12pub mod gtts;
13
14use std::io::Read;
15use std::time::Duration;
16
17/// A failed call, split the way the pipeline's retry logic needs it.
18#[derive(Debug, thiserror::Error)]
19pub enum ApiError {
20    /// The server answered with a non-2xx status (the SDK's `ApiError`).
21    #[error("status_code: {status}, body: {body}")]
22    Status { status: u16, body: String },
23    /// No usable answer: DNS, connect, TLS, reset (httpx's `TransportError`).
24    #[error("{0}")]
25    Transport(String),
26}
27
28impl ApiError {
29    pub fn status(&self) -> Option<u16> {
30        match self {
31            ApiError::Status { status, .. } => Some(*status),
32            ApiError::Transport(_) => None,
33        }
34    }
35}
36
37/// One shared agent: connection reuse, and a generous read timeout for
38/// long TTS renders.
39pub(crate) fn agent() -> ureq::Agent {
40    static AGENT: std::sync::OnceLock<ureq::Agent> = std::sync::OnceLock::new();
41    AGENT
42        .get_or_init(|| {
43            ureq::AgentBuilder::new()
44                .timeout_connect(Duration::from_secs(30))
45                .timeout_read(Duration::from_secs(240))
46                .build()
47        })
48        .clone()
49}
50
51/// httpx logs every response at INFO, and the pipeline's root logger
52/// passes it to both sinks: `HTTP Request: GET <url> "HTTP/1.1 200 OK"`.
53fn log_response(method: &str, resp: &ureq::Response) {
54    xil_core::log::info(&format!(
55        "HTTP Request: {method} {} \"{} {} {}\"",
56        resp.get_url(),
57        resp.http_version(),
58        resp.status(),
59        resp.status_text()
60    ));
61}
62
63/// Run a request, logging the response like httpx and turning HTTP errors
64/// into [`ApiError`].
65pub(crate) fn call_logged(
66    method: &str,
67    result: Result<ureq::Response, ureq::Error>,
68) -> Result<ureq::Response, ApiError> {
69    match result {
70        Ok(r) => {
71            log_response(method, &r);
72            Ok(r)
73        }
74        Err(ureq::Error::Status(status, resp)) => {
75            log_response(method, &resp);
76            Err(ApiError::Status {
77                status,
78                body: resp.into_string().unwrap_or_default(),
79            })
80        }
81        Err(ureq::Error::Transport(t)) => Err(ApiError::Transport(t.to_string())),
82    }
83}
84
85/// As [`call_logged`] but silent — `requests` (gTTS) logs nothing at INFO.
86pub(crate) fn call(
87    result: Result<ureq::Response, ureq::Error>,
88) -> Result<ureq::Response, ApiError> {
89    match result {
90        Ok(r) => Ok(r),
91        Err(ureq::Error::Status(status, resp)) => Err(ApiError::Status {
92            status,
93            body: resp.into_string().unwrap_or_default(),
94        }),
95        Err(ureq::Error::Transport(t)) => Err(ApiError::Transport(t.to_string())),
96    }
97}
98
99/// POST a JSON body. The result goes straight into [`call_logged`], so
100/// `ureq::Error` stays unboxed.
101#[allow(clippy::result_large_err)]
102pub(crate) fn send_json(
103    req: ureq::Request,
104    body: &serde_json::Value,
105) -> Result<ureq::Response, ureq::Error> {
106    req.set("Content-Type", "application/json")
107        .send_string(&serde_json::to_string(body).unwrap_or_default())
108}
109
110pub(crate) fn read_bytes(resp: ureq::Response) -> Result<Vec<u8>, ApiError> {
111    let mut buf = Vec::new();
112    resp.into_reader()
113        .read_to_end(&mut buf)
114        .map_err(|e| ApiError::Transport(e.to_string()))?;
115    Ok(buf)
116}
117
118pub(crate) fn read_json(resp: ureq::Response) -> Result<serde_json::Value, ApiError> {
119    let bytes = read_bytes(resp)?;
120    serde_json::from_slice(&bytes)
121        .map_err(|e| ApiError::Transport(format!("invalid JSON response: {e}")))
122}
123
124/// `$name` when set and non-empty, else the default.
125pub(crate) fn base_url(env: &str, default: &str) -> String {
126    std::env::var(env)
127        .ok()
128        .filter(|v| !v.is_empty())
129        .unwrap_or_else(|| default.to_string())
130        .trim_end_matches('/')
131        .to_string()
132}