Skip to main content

firstpass_proxy/
upstream.rs

1//! Forwarding to the upstream Anthropic-compatible provider.
2//!
3//! Observe mode is a transparent proxy: the request body and BYOK auth headers go upstream
4//! byte-for-byte, and the response comes back byte-for-byte. Firstpass never inspects,
5//! injects, or logs the API key.
6
7use axum::http::{HeaderMap, StatusCode, header};
8use bytes::Bytes;
9
10use crate::error::ProxyError;
11
12/// Request headers forwarded verbatim to the upstream provider. Anything not in this list
13/// (in particular hop-by-hop headers) is dropped rather than relayed.
14const FORWARDED_REQUEST_HEADERS: &[&str] = &[
15    "x-api-key",
16    "authorization",
17    "anthropic-version",
18    "anthropic-beta",
19    "content-type",
20];
21
22/// Build the upstream `POST /v1/messages` request, forwarding the allow-listed headers and the
23/// caller's body byte-for-byte. Shared by the buffered and streaming paths.
24fn build_request(
25    client: &reqwest::Client,
26    base: &str,
27    headers: &HeaderMap,
28    body: Bytes,
29) -> reqwest::RequestBuilder {
30    let url = format!("{}/v1/messages", base.trim_end_matches('/'));
31    let mut req = client.post(url);
32    for name in FORWARDED_REQUEST_HEADERS {
33        if let Some(value) = headers.get(*name) {
34            req = req.header(*name, value.clone());
35        }
36    }
37    req.body(body)
38}
39
40/// Copy the response's content-type (the only response header observe mode relays).
41fn passthrough_headers(response: &reqwest::Response) -> HeaderMap {
42    // reqwest and axum both build on the same `http` crate, so header types carry over
43    // without re-encoding.
44    let mut out = HeaderMap::new();
45    if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
46        out.insert(header::CONTENT_TYPE, content_type.clone());
47    }
48    out
49}
50
51/// Forward one `POST /v1/messages` request and return its response **buffered** (full body).
52/// Used for non-streaming requests, where the assembled body also feeds the audit trace.
53///
54/// # Errors
55/// Returns [`ProxyError::Upstream`] if the upstream request fails at the transport level
56/// (DNS, connect, timeout). A non-2xx HTTP response from upstream is *not* an error here —
57/// it is returned to the caller as-is, matching observe mode's "forward unchanged" contract.
58pub async fn forward_anthropic(
59    client: &reqwest::Client,
60    base: &str,
61    headers: &HeaderMap,
62    body: Bytes,
63) -> Result<(StatusCode, HeaderMap, Bytes), ProxyError> {
64    let response = build_request(client, base, headers, body).send().await?;
65    let status = response.status();
66    let out_headers = passthrough_headers(&response);
67    let body = response.bytes().await?;
68    Ok((status, out_headers, body))
69}
70
71/// Forward one `POST /v1/messages` request and return the raw response for **streaming**
72/// relay (`stream: true`) — the caller pipes `response.bytes_stream()` to the client without
73/// buffering, preserving SSE chunk-by-chunk and keeping time-to-first-byte low.
74///
75/// # Errors
76/// Returns [`ProxyError::Upstream`] on a transport-level failure (see [`forward_anthropic`]).
77pub async fn forward_anthropic_streaming(
78    client: &reqwest::Client,
79    base: &str,
80    headers: &HeaderMap,
81    body: Bytes,
82) -> Result<(StatusCode, HeaderMap, reqwest::Response), ProxyError> {
83    let response = build_request(client, base, headers, body).send().await?;
84    let status = response.status();
85    let out_headers = passthrough_headers(&response);
86    Ok((status, out_headers, response))
87}