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 an upstream POST request to `{base}{path}`, forwarding the allow-listed headers and the
23/// caller's body byte-for-byte. Shared by the buffered and streaming paths, and by both the
24/// Anthropic (`/v1/messages`) and OpenAI (`/v1/chat/completions`) observe-passthrough paths.
25fn build_request(
26    client: &reqwest::Client,
27    base: &str,
28    path: &str,
29    headers: &HeaderMap,
30    body: Bytes,
31) -> reqwest::RequestBuilder {
32    let url = format!("{}{path}", base.trim_end_matches('/'));
33    let mut req = client.post(url);
34    for name in FORWARDED_REQUEST_HEADERS {
35        if let Some(value) = headers.get(*name) {
36            req = req.header(*name, value.clone());
37        }
38    }
39    req.body(body)
40}
41
42/// Copy the response's content-type (the only response header observe mode relays).
43fn passthrough_headers(response: &reqwest::Response) -> HeaderMap {
44    // reqwest and axum both build on the same `http` crate, so header types carry over
45    // without re-encoding.
46    let mut out = HeaderMap::new();
47    if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
48        out.insert(header::CONTENT_TYPE, content_type.clone());
49    }
50    out
51}
52
53/// Forward one `POST /v1/messages` request and return its response **buffered** (full body).
54/// Used for non-streaming requests, where the assembled body also feeds the audit trace.
55///
56/// # Errors
57/// Returns [`ProxyError::Upstream`] if the upstream request fails at the transport level
58/// (DNS, connect, timeout). A non-2xx HTTP response from upstream is *not* an error here —
59/// it is returned to the caller as-is, matching observe mode's "forward unchanged" contract.
60pub async fn forward_anthropic(
61    client: &reqwest::Client,
62    base: &str,
63    headers: &HeaderMap,
64    body: Bytes,
65) -> Result<(StatusCode, HeaderMap, Bytes), ProxyError> {
66    let response = build_request(client, base, "/v1/messages", headers, body)
67        .send()
68        .await?;
69    let status = response.status();
70    let out_headers = passthrough_headers(&response);
71    let body = response.bytes().await?;
72    Ok((status, out_headers, body))
73}
74
75/// Forward one `POST /v1/messages` request and return the raw response for **streaming**
76/// relay (`stream: true`) — the caller pipes `response.bytes_stream()` to the client without
77/// buffering, preserving SSE chunk-by-chunk and keeping time-to-first-byte low.
78///
79/// # Errors
80/// Returns [`ProxyError::Upstream`] on a transport-level failure (see [`forward_anthropic`]).
81pub async fn forward_anthropic_streaming(
82    client: &reqwest::Client,
83    base: &str,
84    headers: &HeaderMap,
85    body: Bytes,
86) -> Result<(StatusCode, HeaderMap, reqwest::Response), ProxyError> {
87    let response = build_request(client, base, "/v1/messages", headers, body)
88        .send()
89        .await?;
90    let status = response.status();
91    let out_headers = passthrough_headers(&response);
92    Ok((status, out_headers, response))
93}
94
95/// Forward one `POST /v1/chat/completions` request and return its response **buffered**.
96/// Observe-mode passthrough for the OpenAI-compatible inbound endpoint — byte-identical relay.
97///
98/// # Errors
99/// Returns [`ProxyError::Upstream`] on a transport-level failure.
100pub async fn forward_openai(
101    client: &reqwest::Client,
102    base: &str,
103    headers: &HeaderMap,
104    body: Bytes,
105) -> Result<(StatusCode, HeaderMap, Bytes), ProxyError> {
106    let response = build_request(client, base, "/v1/chat/completions", headers, body)
107        .send()
108        .await?;
109    let status = response.status();
110    let out_headers = passthrough_headers(&response);
111    let body = response.bytes().await?;
112    Ok((status, out_headers, body))
113}
114
115/// Forward one `POST /v1/chat/completions` request and return the raw response for streaming relay.
116///
117/// # Errors
118/// Returns [`ProxyError::Upstream`] on a transport-level failure.
119pub async fn forward_openai_streaming(
120    client: &reqwest::Client,
121    base: &str,
122    headers: &HeaderMap,
123    body: Bytes,
124) -> Result<(StatusCode, HeaderMap, reqwest::Response), ProxyError> {
125    let response = build_request(client, base, "/v1/chat/completions", headers, body)
126        .send()
127        .await?;
128    let status = response.status();
129    let out_headers = passthrough_headers(&response);
130    Ok((status, out_headers, response))
131}