Skip to main content

aimux_provider_utils/
http.rs

1//! One shared HTTP client and one-exchange request transport.
2//!
3//! Retry and operation/stream timeouts belong to `aimux-core`. This module
4//! sends exactly one request and leaves successful/failed body interpretation
5//! to the response handlers selected by the provider.
6
7use std::collections::HashMap;
8use std::pin::Pin;
9use std::sync::{Mutex, OnceLock};
10use std::task::{Context, Poll};
11use std::time::{Duration, Instant};
12
13use bytes::Bytes;
14use futures::{Stream, StreamExt, stream::BoxStream};
15use reqwest::Client;
16use serde::{Deserialize, Serialize};
17
18use aimux_core::recording::{
19    HttpExchange, HttpRecord, RecordingContext, ResponseRecord, TimingRecord,
20};
21use aimux_core::{AiMuxError, ApiCallError};
22
23use crate::logging::{body_logging_enabled, redact_body};
24
25/// Process-wide proxy configuration, fixed before the shared client is built.
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct ProxyConfig {
28    pub http_url: Option<String>,
29    pub https_url: Option<String>,
30    pub all_url: Option<String>,
31    pub no_proxy: Option<String>,
32}
33
34static GLOBAL_PROXY: OnceLock<ProxyConfig> = OnceLock::new();
35// One client (and so one connection pool) PER RUNTIME, not per process:
36// pooled connections are driven by tasks spawned onto the runtime that
37// made the request, so when that runtime shuts down its pooled
38// connections become unusable while staying checked in — and the OS can
39// recycle their ports to fresh servers, handing later requests a dead
40// connection. Production processes run a single runtime and still get
41// exactly one client. Runtimes leave no drop signal, so dead entries are
42// undetectable; the map is instead bounded by `SHARED_CLIENT_CAP` — see
43// `shared_client`.
44static SHARED: OnceLock<Mutex<HashMap<Option<tokio::runtime::Id>, Client>>> = OnceLock::new();
45
46// Hosts that churn short-lived runtimes (a test binary, an FFI embedder
47// creating a runtime per call) would otherwise grow the map without bound.
48// Eviction is safe: correctness only requires never *reusing* a dead
49// runtime's pool, and an evicted live runtime simply rebuilds a fresh
50// client on its next request.
51const SHARED_CLIENT_CAP: usize = 8;
52
53/// Set proxy configuration before the first HTTP operation.
54///
55/// The HTTP client snapshots this config when it is first built; the
56/// WebSocket connect path (RFC-0034 §2) re-reads it live on every connect,
57/// so a late `init_proxy` can affect WS while HTTP keeps its first-built
58/// client. Set it before any traffic either way.
59pub fn init_proxy(config: ProxyConfig) -> bool {
60    GLOBAL_PROXY.set(config).is_ok()
61}
62
63/// Return the process-wide proxy configuration (empty default when
64/// `init_proxy` was never called). Read by both the HTTP client builder and
65/// the WebSocket connect path (RFC-0034 §2), so `ws://`/`wss://` traffic
66/// honors the same proxy/no_proxy settings as HTTP.
67pub(crate) fn global_proxy() -> ProxyConfig {
68    GLOBAL_PROXY.get().cloned().unwrap_or_default()
69}
70
71/// Return the single shared client. It has a connect timeout but no
72/// client-wide response timeout: the shared client also serves streaming
73/// exchanges, so the 30s non-streaming response bound lives per-exchange in
74/// `call_to_api`, and Core owns the operation deadline.
75///
76/// # Errors
77///
78/// Returns an initialization error if the shared client cannot be built.
79pub fn shared_client() -> Result<Client, AiMuxError> {
80    let key = tokio::runtime::Handle::try_current().ok().map(|h| h.id());
81    let mut clients = SHARED
82        .get_or_init(|| Mutex::new(HashMap::new()))
83        .lock()
84        .expect("shared HTTP client mutex poisoned");
85    if let Some(client) = clients.get(&key) {
86        return Ok(client.clone());
87    }
88    let client = build_client(global_proxy()).map_err(|error| {
89        AiMuxError::Other(format!("shared HTTP client initialization failed: {error}"))
90    })?;
91    if clients.len() >= SHARED_CLIENT_CAP {
92        // Which entries are dead is unknowable, so evict them all; in-flight
93        // requests hold their own `Client` clone and are unaffected.
94        clients.clear();
95    }
96    clients.insert(key, client.clone());
97    Ok(client)
98}
99
100fn build_client(proxy: ProxyConfig) -> Result<Client, String> {
101    let builder = Client::builder()
102        .connect_timeout(Duration::from_millis(10_000))
103        .pool_max_idle_per_host(10)
104        .pool_idle_timeout(Some(Duration::from_secs(30)))
105        .tcp_keepalive(Some(Duration::from_secs(20)));
106    apply_proxy(builder, &proxy)
107        .build()
108        .map_err(|error| error.to_string())
109}
110
111/// Redirect ceiling for the manual validated-download loop.
112const MAX_REDIRECTS: usize = 10;
113
114/// Non-redirecting client for a validated hop on the trusted origin.
115/// Redirects are followed manually so every hop is re-validated; built per
116/// exchange (downloads are rare and each hop is one request, so there is no
117/// pool to share — and no stale-runtime pool to reuse).
118fn download_client() -> Result<Client, AiMuxError> {
119    let builder = Client::builder()
120        .connect_timeout(Duration::from_millis(10_000))
121        .redirect(reqwest::redirect::Policy::none());
122    apply_proxy(builder, &global_proxy())
123        .build()
124        .map_err(|e| AiMuxError::Other(format!("download client initialization failed: {e}")))
125}
126
127/// Client for one validated hop off the trusted origin: the connection is
128/// pinned to exactly the DNS answers that passed the guard (resolve
129/// overrides), defeating TTL-0 rebinding. The proxy configuration is applied
130/// so reqwest makes the per-URL routing decision itself: when a proxy
131/// carries the request the proxy resolves the target (a trusted transport,
132/// the override below is unused), and any request the proxy rules send
133/// DIRECT still connects only through the validated, pinned addresses.
134fn pinned_client(url: &str, addresses: &[std::net::IpAddr]) -> Result<Client, AiMuxError> {
135    if addresses.is_empty() {
136        return Err(AiMuxError::Other(
137            "cannot build a pinned download client without an address".into(),
138        ));
139    }
140    let parsed = reqwest::Url::parse(url)
141        .map_err(|e| AiMuxError::Other(format!("invalid download url: {e}")))?;
142    let host = parsed
143        .host_str()
144        .ok_or_else(|| AiMuxError::Other("download url has no host".to_string()))?
145        .to_string();
146    let port = parsed.port_or_known_default().unwrap_or(80);
147    let socket_addresses: Vec<_> = addresses
148        .iter()
149        .map(|address| std::net::SocketAddr::new(*address, port))
150        .collect();
151    let builder = Client::builder()
152        .connect_timeout(Duration::from_millis(10_000))
153        .redirect(reqwest::redirect::Policy::none());
154    apply_proxy(builder, &global_proxy())
155        .resolve_to_addrs(&host, &socket_addresses)
156        .build()
157        .map_err(|e| {
158            AiMuxError::Other(format!("pinned download client initialization failed: {e}"))
159        })
160}
161
162fn apply_proxy(mut builder: reqwest::ClientBuilder, proxy: &ProxyConfig) -> reqwest::ClientBuilder {
163    let http = proxy.http_url.as_deref().or(proxy.all_url.as_deref());
164    let https = proxy.https_url.as_deref().or(proxy.all_url.as_deref());
165    if let Some(url) = http
166        && let Ok(reqwest_proxy) = reqwest::Proxy::http(url)
167    {
168        builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy);
169    }
170    if let Some(url) = https
171        && let Ok(reqwest_proxy) = reqwest::Proxy::https(url)
172    {
173        builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy);
174    }
175    builder
176}
177
178// Kept separate so both proxy schemes use precisely the same no-proxy rule.
179fn apply_no_proxy(
180    builder: reqwest::ClientBuilder,
181    proxy: reqwest::Proxy,
182    no_proxy: &Option<String>,
183) -> reqwest::ClientBuilder {
184    match no_proxy.as_deref().map(reqwest::NoProxy::from_string) {
185        Some(Some(no_proxy)) => builder.proxy(proxy.no_proxy(Some(no_proxy))),
186        _ => builder.proxy(proxy),
187    }
188}
189
190/// Explicit raw request body used only by `post_to_api`.
191#[derive(Debug, Clone)]
192pub enum HttpBody {
193    Json(serde_json::Value),
194    Bytes(Vec<u8>, String),
195    Empty,
196}
197
198#[derive(Debug, Clone, Copy)]
199pub(crate) enum HttpMethod {
200    Get,
201    Post,
202}
203
204impl HttpMethod {
205    fn as_str(self) -> &'static str {
206        match self {
207            Self::Get => "GET",
208            Self::Post => "POST",
209        }
210    }
211}
212
213/// Metadata shared by `post_json_to_api`, `post_form_data_to_api`,
214/// `post_to_api`, and `get_from_api`. Method and body are fixed by the helper
215/// signature rather than supplied as runtime fields.
216#[derive(Debug, Clone, Default)]
217pub struct HttpRequest {
218    pub url: String,
219    pub headers: Vec<(String, String)>,
220    pub abort_signal: Option<aimux_core::AbortSignal>,
221    pub call_id: Option<String>,
222    pub recording_context: Option<RecordingContext>,
223    /// Per-exchange whole-response hang guard. `None` uses the 30s default;
224    /// a provider whose endpoint legitimately holds the connection longer
225    /// (e.g. Replicate `prefer: wait`) declares its own bound here. Streaming
226    /// exchanges are exempt regardless.
227    pub response_timeout: Option<std::time::Duration>,
228    /// Per-request override of the successful-JSON-body size cap (default
229    /// [`crate::read_response_with_size_limit::DEFAULT_MAX_JSON_RESPONSE_SIZE`]).
230    /// Only consulted by [`crate::response_handler::create_json_response_handler`];
231    /// binary downloads keep the separate, larger
232    /// `DEFAULT_MAX_DOWNLOAD_SIZE` bound regardless of this field.
233    pub max_json_response_bytes: Option<usize>,
234    /// AI SDK `validateUrl`: set for URLs taken from provider responses
235    /// (generated assets, polling and result URLs). The exchange then goes
236    /// through the SSRF download guard — target and every DNS answer
237    /// validated and pinned, redirects followed manually with each hop
238    /// re-validated.
239    pub validate_url: bool,
240    /// AI SDK `trustedOrigin`: a developer-configured origin (normally the
241    /// provider's `base_url`) whose same-origin URLs are exempt from the
242    /// address blocklist, so self-hosted deployments serving assets from
243    /// their own (possibly private) origin keep working. Reachability only —
244    /// never about headers, and never derived from response data.
245    pub trusted_origin: Option<String>,
246    /// AI SDK `credentialedOrigin`: caller headers (which may carry the
247    /// provider API key) are sent only while the target is same-origin with
248    /// this value — from the first request and on every redirect hop. `None`
249    /// means the caller gates its own headers (e.g. BFL's host allowlist).
250    pub credentialed_origin: Option<String>,
251}
252
253/// The per-operation context every model's call options carry into an
254/// exchange: cancellation, and (for language models) the call id and
255/// recording context the RFC-0031 pipeline threads through.
256pub trait ExchangeContext {
257    fn abort_signal(&self) -> Option<aimux_core::AbortSignal>;
258    /// Only language-model `CallOptions` participate in recording; the other
259    /// modalities have no call id to correlate on.
260    fn call_id(&self) -> Option<String> {
261        None
262    }
263    fn recording_context(&self) -> Option<RecordingContext> {
264        None
265    }
266}
267
268impl ExchangeContext for aimux_core::options::CallOptions {
269    fn abort_signal(&self) -> Option<aimux_core::AbortSignal> {
270        self.abort_signal.clone()
271    }
272    fn call_id(&self) -> Option<String> {
273        self.call_id.clone()
274    }
275    fn recording_context(&self) -> Option<RecordingContext> {
276        self.recording_context.clone()
277    }
278}
279
280macro_rules! exchange_context_abort_only {
281    ($($ty:path),* $(,)?) => {
282        $(impl ExchangeContext for $ty {
283            fn abort_signal(&self) -> Option<aimux_core::AbortSignal> {
284                self.abort_signal.clone()
285            }
286        })*
287    };
288}
289
290exchange_context_abort_only!(
291    aimux_core::search_model::SearchCallOptions,
292    aimux_core::speech_model::SpeechCallOptions,
293    aimux_core::image_model::ImageCallOptions,
294    aimux_core::video_model::VideoCallOptions,
295    aimux_core::transcription_model::TranscriptionCallOptions,
296    aimux_core::embedding_model::EmbeddingCallOptions,
297    aimux_core::reranking_model::RerankingCallOptions,
298    aimux_core::files_model::UploadFileCallOptions,
299);
300
301impl HttpRequest {
302    /// A plain exchange that inherits the operation's cancellation and
303    /// recording context. Downloads of provider-supplied URLs set the guard
304    /// fields explicitly instead.
305    #[must_use]
306    pub fn new(
307        url: impl Into<String>,
308        headers: Vec<(String, String)>,
309        options: &impl ExchangeContext,
310    ) -> Self {
311        Self {
312            url: url.into(),
313            headers,
314            abort_signal: options.abort_signal(),
315            call_id: options.call_id(),
316            recording_context: options.recording_context(),
317            ..Self::default()
318        }
319    }
320}
321
322/// SSRF guard configuration carried by a validated exchange
323/// (`HttpRequest.validate_url`).
324#[derive(Debug, Clone)]
325pub(crate) struct DownloadValidation {
326    pub(crate) trusted_origin: Option<String>,
327    pub(crate) credentialed_origin: Option<String>,
328}
329
330#[derive(Debug, Clone)]
331pub(crate) struct PreparedRequest {
332    method: HttpMethod,
333    body: HttpBody,
334    pub(crate) url: String,
335    headers: Vec<(String, String)>,
336    pub(crate) abort_signal: Option<aimux_core::AbortSignal>,
337    call_id: Option<String>,
338    recording_context: Option<RecordingContext>,
339    pub(crate) response_timeout: Option<std::time::Duration>,
340    pub(crate) max_json_response_bytes: Option<usize>,
341    pub(crate) validation: Option<DownloadValidation>,
342}
343
344impl HttpRequest {
345    pub(crate) fn prepare(self, method: HttpMethod, body: HttpBody) -> PreparedRequest {
346        // AI SDK gates credentialed headers independently of validateUrl:
347        // when the target is off the credentialed origin they never leave,
348        // whichever transport the exchange takes.
349        let mut headers = self.headers;
350        if let Some(origin) = self.credentialed_origin.as_deref()
351            && !crate::download_guard::same_origin(&self.url, origin)
352        {
353            crate::download_guard::retain_user_agent(&mut headers);
354        }
355        PreparedRequest {
356            method,
357            body,
358            url: self.url,
359            headers,
360            abort_signal: self.abort_signal,
361            call_id: self.call_id,
362            recording_context: self.recording_context,
363            response_timeout: self.response_timeout,
364            max_json_response_bytes: self.max_json_response_bytes,
365            validation: self.validate_url.then_some(DownloadValidation {
366                trusted_origin: self.trusted_origin,
367                credentialed_origin: self.credentialed_origin,
368            }),
369        }
370    }
371}
372
373fn api_call_error(request: &PreparedRequest, message: impl Into<String>) -> ApiCallError {
374    ApiCallError::new(
375        message,
376        sanitized_request_url(&request.url),
377        crate::logging::redact_request_values(&request.body),
378    )
379}
380
381struct ExchangeGuard<'a> {
382    request: &'a PreparedRequest,
383    attempt: u32,
384    exchange_index: u32,
385    started: Instant,
386    armed: bool,
387}
388
389impl<'a> ExchangeGuard<'a> {
390    fn new(
391        request: &'a PreparedRequest,
392        attempt: u32,
393        exchange_index: u32,
394        started: Instant,
395    ) -> Self {
396        Self {
397            request,
398            attempt,
399            exchange_index,
400            started,
401            armed: true,
402        }
403    }
404
405    fn fail(&mut self, error: &str) {
406        if self.armed {
407            record_failed_exchange(
408                self.request,
409                self.attempt,
410                self.exchange_index,
411                self.started.elapsed().as_millis() as u64,
412                error,
413            );
414            record_transport_closed(self.request);
415            self.armed = false;
416        }
417    }
418
419    fn disarm(&mut self) {
420        self.armed = false;
421    }
422}
423
424impl Drop for ExchangeGuard<'_> {
425    fn drop(&mut self) {
426        self.fail("exchange future cancelled");
427    }
428}
429
430/// Execute exactly one HTTP exchange. Core owns retry and operation timeout.
431pub(crate) async fn send_request_once(
432    request: &PreparedRequest,
433) -> Result<reqwest::Response, AiMuxError> {
434    let started = Instant::now();
435    let (attempt, exchange_index) = request
436        .recording_context
437        .as_ref()
438        .map(RecordingContext::next_exchange)
439        .unwrap_or((1, 1));
440    let mut exchange = ExchangeGuard::new(request, attempt, exchange_index, started);
441    tracing::debug!(
442        target: "aimux_provider_utils::http",
443        method = request.method.as_str(),
444        url = %sanitized_request_url(&request.url),
445        host = %request_host(&request.url),
446        body_size = request_body_size(request),
447        header_count = request.headers.len(),
448        call_id = request.call_id.as_deref().unwrap_or(""),
449        "request"
450    );
451    if body_logging_enabled()
452        && let HttpBody::Json(value) = &request.body
453    {
454        tracing::trace!(
455            target: "aimux_provider_utils::http",
456            body = %redact_body(&value.to_string()),
457            "request_body"
458        );
459    }
460
461    let sent = if request.validation.is_some() {
462        send_validated_redirects(request).await
463    } else {
464        match shared_client() {
465            Ok(client) => send_one_request(&client, request).await,
466            Err(error) => Err(error),
467        }
468    };
469    let response = match sent {
470        Ok(response) => response,
471        Err(error) => {
472            exchange.fail(&error.to_string());
473            return Err(error);
474        }
475    };
476    exchange.disarm();
477    let latency_ms = started.elapsed().as_millis() as u64;
478    tracing::debug!(
479        target: "aimux_provider_utils::http",
480        status = response.status().as_u16(),
481        latency_ms,
482        "response"
483    );
484    Ok(observe_response(
485        response,
486        request,
487        started,
488        latency_ms,
489        attempt,
490        exchange_index,
491    ))
492}
493
494async fn send_one_request(
495    client: &Client,
496    request: &PreparedRequest,
497) -> Result<reqwest::Response, AiMuxError> {
498    if let Some(signal) = &request.abort_signal {
499        if signal.is_aborted() {
500            return Err(AiMuxError::from_abort_signal(signal));
501        }
502        tokio::select! {
503            biased;
504            () = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)),
505            response = build_request_builder(client, request)?.send() => {
506                response.map_err(|error| AiMuxError::ApiCall(Box::new(ApiCallError {
507                    is_retryable: true,
508                    ..api_call_error(request, error.to_string())
509                })))
510            }
511        }
512    } else {
513        build_request_builder(client, request)?
514            .send()
515            .await
516            .map_err(|error| {
517                AiMuxError::ApiCall(Box::new(ApiCallError {
518                    is_retryable: true,
519                    ..api_call_error(request, error.to_string())
520                }))
521            })
522    }
523}
524
525fn is_redirect_status(status: reqwest::StatusCode) -> bool {
526    matches!(
527        status,
528        reqwest::StatusCode::MOVED_PERMANENTLY
529            | reqwest::StatusCode::FOUND
530            | reqwest::StatusCode::SEE_OTHER
531            | reqwest::StatusCode::TEMPORARY_REDIRECT
532            | reqwest::StatusCode::PERMANENT_REDIRECT
533    )
534}
535
536fn redirect_error(
537    request: &PreparedRequest,
538    message: impl Into<String>,
539    status: reqwest::StatusCode,
540) -> AiMuxError {
541    AiMuxError::ApiCall(Box::new(ApiCallError {
542        status_code: Some(status.as_u16()),
543        ..api_call_error(request, message)
544    }))
545}
546
547/// Send one validated-download exchange, following redirects manually so
548/// every hop is re-validated and its connection pinned to the DNS answers
549/// that passed the guard (see `download_guard`). The whole chain is one
550/// exchange to the caller, matching how an auto-following client behaves.
551async fn send_validated_redirects(
552    request: &PreparedRequest,
553) -> Result<reqwest::Response, AiMuxError> {
554    let validation = request
555        .validation
556        .clone()
557        .expect("send_validated_redirects requires a validation config");
558    let trusted_origin = validation.trusted_origin.as_deref();
559    let credentialed_origin = validation.credentialed_origin.as_deref();
560    let mut current = request.clone();
561    // First-request credential gating already ran in `prepare`; this strips
562    // hop-by-hop, forwarding, and metadata-service headers for every hop.
563    crate::download_guard::sanitize_download_headers(&mut current.headers);
564    // Headers never travel past this origin on a redirect; stripping is
565    // one-way, so a hop back onto it cannot restore them.
566    let credential_anchor = credentialed_origin.unwrap_or(&request.url);
567    let mut pinned =
568        crate::download_guard::validate_download_target(&current.url, trusted_origin).await?;
569    for redirect_count in 0..=MAX_REDIRECTS {
570        // Empty pins mean the hop is on the trusted origin.
571        let client = if pinned.is_empty() {
572            download_client()?
573        } else {
574            pinned_client(&current.url, &pinned)?
575        };
576        let response = send_one_request(&client, &current).await?;
577        let status = response.status();
578        if !is_redirect_status(status) {
579            return Ok(response);
580        }
581        let Some(location) = response.headers().get(reqwest::header::LOCATION) else {
582            return Ok(response);
583        };
584        if redirect_count == MAX_REDIRECTS {
585            return Err(redirect_error(request, "too many redirects", status));
586        }
587        let location = location
588            .to_str()
589            .map(str::to_owned)
590            .map_err(|_| redirect_error(request, "redirect location is not valid UTF-8", status))?;
591        // Dropping the unconsumed 3xx body releases its connection before a
592        // potentially slow DNS check for the next hop.
593        drop(response);
594        let base = url::Url::parse(&current.url)
595            .map_err(|e| AiMuxError::InvalidArgument(format!("invalid request URL: {e}")))?;
596        let next = base
597            .join(&location)
598            .map_err(|e| redirect_error(request, format!("invalid redirect URL: {e}"), status))?;
599        // Fetch treats a redirect to a non-HTTP(S) scheme (data:, file:, ...)
600        // as a network error; following one would let the redirecting server
601        // fabricate a response outside the transport.
602        if !matches!(next.scheme(), "http" | "https") {
603            return Err(redirect_error(
604                request,
605                format!("redirect to non-HTTP scheme: {}", next.scheme()),
606                status,
607            ));
608        }
609        let next = next.to_string();
610        let hop_trusted = crate::download_guard::hop_trusted_origin(trusted_origin, &current.url);
611        pinned = crate::download_guard::validate_download_target(&next, hop_trusted).await?;
612        // Credentials are scoped to the credential anchor, not the previous
613        // hop: once a redirect leaves it, headers cannot come back.
614        if !crate::download_guard::same_origin(&next, credential_anchor) {
615            crate::download_guard::retain_user_agent(&mut current.headers);
616        }
617        if status == reqwest::StatusCode::SEE_OTHER
618            || (matches!(
619                status,
620                reqwest::StatusCode::MOVED_PERMANENTLY | reqwest::StatusCode::FOUND
621            ) && matches!(current.method, HttpMethod::Post))
622        {
623            current.method = HttpMethod::Get;
624            current.body = HttpBody::Empty;
625        }
626        current.url = next;
627    }
628    unreachable!("redirect loop returns on response or error")
629}
630
631fn build_request_builder(
632    client: &Client,
633    request: &PreparedRequest,
634) -> Result<reqwest::RequestBuilder, AiMuxError> {
635    let mut builder = match request.method {
636        HttpMethod::Get => client.get(&request.url),
637        HttpMethod::Post => client.post(&request.url),
638    };
639    for (name, value) in &request.headers {
640        let header_name = reqwest::header::HeaderName::try_from(name)
641            .map_err(|_| AiMuxError::InvalidArgument(format!("invalid header name: {name}")))?;
642        let header_value = reqwest::header::HeaderValue::try_from(value).map_err(|_| {
643            AiMuxError::InvalidArgument(format!("invalid header value for {name}: {value}"))
644        })?;
645        builder = builder.header(header_name, header_value);
646    }
647    Ok(match &request.body {
648        HttpBody::Json(value) => builder.json(value),
649        HttpBody::Bytes(bytes, content_type) => builder
650            .header(reqwest::header::CONTENT_TYPE, content_type)
651            .body(bytes.clone()),
652        HttpBody::Empty => builder,
653    })
654}
655
656/// Abort-aware provider polling delay. This is not the operation retry delay;
657/// Core owns exponential backoff.
658///
659/// # Errors
660///
661/// Returns [`AiMuxError::Aborted`] if the caller cancels during the delay.
662pub async fn sleep_or_abort(
663    duration: Duration,
664    abort_signal: Option<&aimux_core::AbortSignal>,
665) -> Result<(), AiMuxError> {
666    match abort_signal {
667        Some(signal) => tokio::select! {
668            biased;
669            () = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)),
670            () = tokio::time::sleep(duration) => Ok(()),
671        },
672        None => {
673            tokio::time::sleep(duration).await;
674            Ok(())
675        }
676    }
677}
678
679fn observe_response(
680    response: reqwest::Response,
681    request: &PreparedRequest,
682    started: Instant,
683    latency_ms: u64,
684    attempt: u32,
685    exchange_index: u32,
686) -> reqwest::Response {
687    let status = response.status();
688    let version = response.version();
689    let headers = response.headers().clone();
690    let recording = request.recording_context.as_ref().map(|context| {
691        let redacted_headers = redacted_response_headers(&headers);
692        let response = ResponseRecord {
693            status: status.as_u16(),
694            headers: redacted_headers.clone(),
695            body: None,
696            stream_chunks: Some(0),
697            ttfb_ms: Some(latency_ms),
698        };
699        record_exchange(
700            request,
701            HttpExchange {
702                step: request
703                    .recording_context
704                    .as_ref()
705                    .and_then(RecordingContext::step),
706                attempt,
707                exchange_index,
708                request: to_http_record(request),
709                response: Some(response),
710                timing: TimingRecord {
711                    latency_ms,
712                    ttfb_ms: Some(latency_ms),
713                },
714                error: None,
715                finalized: false,
716            },
717        );
718        ResponseRecording {
719            context: context.clone(),
720            attempt,
721            exchange_index,
722            status: status.as_u16(),
723            headers: redacted_headers,
724            ttfb_ms: Some(latency_ms),
725            body: ByteAccumulator::new(RECORD_BODY_CAP),
726            chunks: 0,
727            finalized: false,
728        }
729    });
730    let observed = ObservedBody {
731        inner: response.bytes_stream().boxed(),
732        started,
733        chunks: 0,
734        done: false,
735        recording,
736    };
737    let mut rebuilt = http::Response::builder()
738        .status(status)
739        .version(version)
740        .body(reqwest::Body::wrap_stream(observed))
741        .expect("status and version came from a valid reqwest response");
742    *rebuilt.headers_mut() = headers;
743    rebuilt.into()
744}
745
746struct ObservedBody {
747    inner: BoxStream<'static, Result<Bytes, reqwest::Error>>,
748    started: Instant,
749    chunks: usize,
750    done: bool,
751    recording: Option<ResponseRecording>,
752}
753
754impl Stream for ObservedBody {
755    type Item = Result<Bytes, reqwest::Error>;
756
757    fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
758        if self.done {
759            return Poll::Ready(None);
760        }
761        let result = self.inner.as_mut().poll_next(context);
762        match &result {
763            Poll::Ready(Some(Ok(bytes))) => {
764                self.chunks += 1;
765                let chunks = self.chunks;
766                if let Some(recording) = &mut self.recording {
767                    recording.body.push(bytes);
768                    recording.chunks = chunks;
769                }
770            }
771            Poll::Ready(Some(Err(error))) => {
772                self.done = true;
773                if let Some(recording) = &mut self.recording {
774                    recording.finalize(Some(error.to_string()));
775                }
776            }
777            Poll::Ready(None) => {
778                self.done = true;
779                tracing::debug!(
780                    target: "aimux_provider_utils::http",
781                    chunks = self.chunks,
782                    duration_ms = self.started.elapsed().as_millis() as u64,
783                    "response_body_end"
784                );
785                if let Some(recording) = &mut self.recording {
786                    recording.finalize(None);
787                }
788            }
789            Poll::Pending => {}
790        }
791        result
792    }
793}
794
795impl Drop for ObservedBody {
796    fn drop(&mut self) {
797        if !self.done
798            && let Some(recording) = &mut self.recording
799        {
800            recording.finalize(Some("response body abandoned".into()));
801        }
802    }
803}
804
805struct ResponseRecording {
806    context: RecordingContext,
807    attempt: u32,
808    exchange_index: u32,
809    status: u16,
810    headers: Vec<(String, String)>,
811    ttfb_ms: Option<u64>,
812    body: ByteAccumulator,
813    chunks: usize,
814    finalized: bool,
815}
816
817impl ResponseRecording {
818    fn finalize(&mut self, error: Option<String>) {
819        if self.finalized {
820            return;
821        }
822        self.finalized = true;
823        self.context.recorder.record_exchange_update(
824            &self.context.call_id,
825            self.attempt,
826            self.exchange_index,
827            &ResponseRecord {
828                status: self.status,
829                headers: self.headers.clone(),
830                body: self.body.decode(),
831                stream_chunks: Some(self.chunks),
832                ttfb_ms: self.ttfb_ms,
833            },
834            error,
835        );
836        self.context
837            .recorder
838            .record_transport_closed(&self.context.call_id);
839    }
840}
841
842const RECORD_BODY_CAP: usize = 1 << 20;
843
844struct ByteAccumulator {
845    bytes: Vec<u8>,
846    truncated: bool,
847}
848
849impl ByteAccumulator {
850    fn new(capacity: usize) -> Self {
851        Self {
852            bytes: Vec::with_capacity(capacity.min(8 * 1024)),
853            truncated: false,
854        }
855    }
856
857    fn push(&mut self, bytes: &[u8]) {
858        let remaining = RECORD_BODY_CAP.saturating_sub(self.bytes.len());
859        if bytes.len() > remaining {
860            self.bytes.extend_from_slice(&bytes[..remaining]);
861            self.truncated = true;
862        } else {
863            self.bytes.extend_from_slice(bytes);
864        }
865    }
866
867    fn decode(&self) -> Option<String> {
868        if self.bytes.is_empty() {
869            return None;
870        }
871        let mut value = String::from_utf8_lossy(&self.bytes).into_owned();
872        if self.truncated {
873            while value.ends_with('\u{FFFD}') {
874                value.pop();
875            }
876            value.push_str("…(truncated)");
877        }
878        Some(value)
879    }
880}
881
882fn to_http_record(request: &PreparedRequest) -> HttpRecord {
883    use aimux_core::recording::is_sensitive_key;
884    let headers = request
885        .headers
886        .iter()
887        .map(|(name, value)| {
888            (
889                name.clone(),
890                if is_sensitive_key(name) {
891                    "[REDACTED]".into()
892                } else {
893                    value.clone()
894                },
895            )
896        })
897        .collect();
898    let body = match &request.body {
899        HttpBody::Json(value) => {
900            serde_json::to_string(&crate::logging::redact_error_context(value.clone())).ok()
901        }
902        HttpBody::Bytes(bytes, _) => Some(String::from_utf8_lossy(bytes).into_owned()),
903        HttpBody::Empty => None,
904    }
905    .map(|body| truncate_utf8(&body, RECORD_BODY_CAP).to_owned());
906    HttpRecord {
907        method: request.method.as_str().into(),
908        url: sanitized_request_url(&request.url),
909        headers,
910        body,
911    }
912}
913
914fn record_exchange(request: &PreparedRequest, exchange: HttpExchange) {
915    if let Some(context) = &request.recording_context {
916        context
917            .recorder
918            .record_exchange(&context.call_id, &exchange);
919    }
920}
921
922fn record_transport_closed(request: &PreparedRequest) {
923    if let Some(context) = &request.recording_context {
924        context.recorder.record_transport_closed(&context.call_id);
925    }
926}
927
928fn record_failed_exchange(
929    request: &PreparedRequest,
930    attempt: u32,
931    exchange_index: u32,
932    latency_ms: u64,
933    error: &str,
934) {
935    record_exchange(
936        request,
937        HttpExchange {
938            step: request
939                .recording_context
940                .as_ref()
941                .and_then(RecordingContext::step),
942            attempt,
943            exchange_index,
944            request: to_http_record(request),
945            response: None,
946            timing: TimingRecord {
947                latency_ms,
948                ttfb_ms: None,
949            },
950            error: Some(error.to_owned()),
951            finalized: true,
952        },
953    );
954}
955
956fn redacted_response_headers(headers: &reqwest::header::HeaderMap) -> Vec<(String, String)> {
957    crate::extract_response_headers::extract_response_header_pairs(headers)
958}
959
960fn truncate_utf8(value: &str, maximum: usize) -> &str {
961    if value.len() <= maximum {
962        return value;
963    }
964    let mut end = maximum;
965    while end > 0 && !value.is_char_boundary(end) {
966        end -= 1;
967    }
968    &value[..end]
969}
970
971fn request_body_size(request: &PreparedRequest) -> u64 {
972    match &request.body {
973        HttpBody::Json(value) => serde_json::to_vec(value)
974            .map(|bytes| bytes.len() as u64)
975            .unwrap_or_default(),
976        HttpBody::Bytes(bytes, _) => bytes.len() as u64,
977        HttpBody::Empty => 0,
978    }
979}
980
981fn request_host(url: &str) -> String {
982    url::Url::parse(url)
983        .ok()
984        .and_then(|url| url.host_str().map(str::to_owned))
985        .unwrap_or_else(|| "unknown".into())
986}
987
988pub(crate) fn sanitized_request_url(url: &str) -> String {
989    match url::Url::parse(url) {
990        Ok(mut parsed) => {
991            parsed.set_query(None);
992            parsed.to_string()
993        }
994        Err(_) => url.split('?').next().unwrap_or(url).to_owned(),
995    }
996}