brazen 0.0.4

A stateless, swiss-army-knife adapter for every LLM provider and protocol.
Documentation
//! The native HTTP transport (arch §4.1, §9.1, §10) — the ONLY user of `ureq` in
//! the crate. It lives in the `bz` bin's `src/native/`, never a library module, so
//! the pure, network-free core never links the network client. With one crate the
//! crate graph can no longer enforce that, so `tests/purity.rs` does: it fails if
//! any library module imports `ureq`/`libc`/`std::net` (bl-c1e2, was bl-c420).
//! Behind the lib's `Transport` seam the lib reaches 100% coverage via
//! `MockTransport`; this file is coverage-excluded with the rest of the shim
//! (Makefile `cov`).

use std::io::{self, Read};
use std::time::Duration;

use brazen::{Bytes, CanonicalError, ErrorKind, Method, Transport, TransportResponse, WireRequest};

mod idle;

use idle::IdleChunkReader;

/// The real network seam (arch §4.1, §9.1, §10) — a blocking, rustls-backed HTTP
/// round-trip via `ureq`. `http_status_as_error(false)` so a non-2xx flows on as a
/// normal response (the pipeline derives the exit from the status); only a
/// connect/DNS/TLS/timeout failure is a `Transport` error → 69. The connect and
/// response-header bounds are NOT baked here — they ride on `wire.timeouts` from
/// the resolved config (config §4) and are applied per request below, so the bin
/// holds no magic timeout constants.
pub struct HttpTransport {
    agent: ureq::Agent,
}

impl HttpTransport {
    pub fn new() -> Self {
        let builder = ureq::Agent::config_builder().http_status_as_error(false);
        // Root of trust (arch §12): OFF (default, secure-by-default per the owner
        // ruling) keeps ureq's bundled Mozilla `webpki-roots` — a self-contained
        // binary that trusts no OS/corporate store. The `native-certs` cargo feature
        // ON swaps in ureq's platform-verifier (OS-native cert verification via
        // `rustls-platform-verifier`), so enterprise/proxy roots validate. Build-time
        // only: no runtime config surface. The whole switch lives in this excluded
        // shim, so the pure lib and `tests/purity.rs` are untouched.
        #[cfg(feature = "native-certs")]
        let builder = builder.tls_config(
            ureq::tls::TlsConfig::builder()
                .root_certs(ureq::tls::RootCerts::PlatformVerifier)
                .build(),
        );
        HttpTransport {
            agent: builder.build().into(),
        }
    }
}

impl Transport for HttpTransport {
    fn send(&self, wire: WireRequest) -> Result<TransportResponse, CanonicalError> {
        // A wire that declares a subprocess target rides the exec kind (claude-code
        // spec §3): a MODE branch on the request's declared shape, like `method` —
        // never a vendor name. The HTTP path below is byte-identical when `None`.
        if let Some(spec) = wire.exec.clone() {
            return super::exec::send_exec(&spec, &wire);
        }
        let t = wire.timeouts;
        // The verb is DATA on the wire (§6): GET for the list-models verb (no body),
        // POST for every generation request. The two ureq builder families
        // (with/without body) diverge only at the final `call`/`send`, so the
        // timeout config and headers are stamped on each before dispatch.
        let resp = match wire.method {
            Method::Get => {
                let mut cfg = self.agent.get(&wire.url).config();
                if let Some(secs) = t.connect {
                    cfg = cfg.timeout_connect(Some(Duration::from_secs(secs)));
                }
                if let Some(secs) = t.response {
                    cfg = cfg.timeout_recv_response(Some(Duration::from_secs(secs)));
                }
                let mut req = cfg.build();
                for (name, value) in &wire.headers {
                    req = req.header(name, value);
                }
                req.call()
            }
            Method::Post => {
                let mut cfg = self.agent.post(&wire.url).config();
                if let Some(secs) = t.connect {
                    cfg = cfg.timeout_connect(Some(Duration::from_secs(secs)));
                }
                if let Some(secs) = t.response {
                    cfg = cfg.timeout_recv_response(Some(Duration::from_secs(secs)));
                }
                let mut req = cfg.build();
                for (name, value) in &wire.headers {
                    req = req.header(name, value);
                }
                req.send(&wire.body[..])
            }
        }
        .map_err(|e| transport_error(&error_chain(&e)))?;
        let status = resp.status().as_u16();
        // Capture the `Retry-After` header (arch §3.3) BEFORE `into_body` consumes the
        // response — the one transport fact the parsed error body cannot recover. The
        // parse to seconds (integer or HTTP-date, against the `Clock`) is the pure lib's.
        let retry_after = resp
            .headers()
            .get("retry-after")
            .and_then(|v| v.to_str().ok())
            .map(str::to_owned);
        let reader = resp.into_body().into_reader();
        // The streaming body is otherwise unbounded; `idle` (inter-chunk) bounds a
        // mid-stream stall without capping total length. ureq's `timeout_recv_body`
        // is a TOTAL cap (wrong for a long generation), so the bound is enforced
        // here, off-thread, resetting on every chunk (`IdleChunkReader`).
        let body: Box<dyn Iterator<Item = io::Result<Bytes>>> = match t.idle {
            Some(secs) => Box::new(IdleChunkReader::spawn(reader, Duration::from_secs(secs))),
            None => Box::new(ChunkReader { reader }),
        };
        Ok(TransportResponse {
            status,
            body,
            retry_after,
        })
    }
}

/// Adapts ureq's blocking body `Read` into the seam's incremental body stream
/// (`Iterator<Item = io::Result<Bytes>>`): each `next` is one `read` into a fresh
/// buffer — `Ok(0)` is EOF (`None`), a short read yields just what arrived (never
/// buffered to end, so the pipeline streams chunk-by-chunk), and a read error
/// surfaces as the item (`run` maps a mid-stream drop to a `Transport` exit 69).
struct ChunkReader<R> {
    reader: R,
}

impl<R: Read> Iterator for ChunkReader<R> {
    type Item = io::Result<Bytes>;

    fn next(&mut self) -> Option<Self::Item> {
        let mut buf = vec![0u8; 8192];
        match self.reader.read(&mut buf) {
            Ok(0) => None,
            Ok(n) => {
                buf.truncate(n);
                Some(Ok(buf))
            }
            Err(e) => Some(Err(e)),
        }
    }
}

/// Flatten an error and its `source()` chain into one `": "`-joined line, so a
/// TLS/cert/DNS root cause survives into the opaque `Transport` message — behind a
/// TLS-inspecting corporate proxy, "certificate not trusted" must be distinguishable
/// from "host down" (bl-770f). The top-level `Display` leads (ureq folds its own
/// wrapped io/rustls error there — e.g. `io: invalid peer certificate: UnknownIssuer`
/// — and exposes no `source()`); any deeper `source()` links append, so the walk is
/// the general, forward-compatible mechanism, never worse than a bare `to_string()`.
fn error_chain(e: &dyn std::error::Error) -> String {
    let mut out = e.to_string();
    let mut cur = e.source();
    while let Some(src) = cur {
        out.push_str(": ");
        out.push_str(&src.to_string());
        cur = src.source();
    }
    out
}

/// A connect/TLS/timeout failure as a `Transport`-kind `CanonicalError` (arch §8
/// → exit 69). No `provider_detail`: there is no upstream response to carry.
fn transport_error(message: &str) -> CanonicalError {
    CanonicalError {
        kind: ErrorKind::Transport,
        message: format!("HTTP transport: {message}"),
        provider_detail: None,
        retry_after_seconds: None,
    }
}

// The idle-read timeout (`IdleChunkReader`) driven against a localhost stall
// server — coverage-excluded but behaviorally pinned (bl-9940). A child module so
// it stays in its own file under the 300-line cap.
#[cfg(test)]
mod tests;