Skip to main content

asimov_cli/commands/proxy/
serve.rs

1// This is free and unencumbered software released into the public domain.
2
3mod body_logger;
4mod proxy_config;
5mod proxy_connector;
6mod proxy_stream;
7
8use self::{body_logger::BodyLogger, proxy_config::ProxyConfig, proxy_connector::ProxyConnector};
9use crate::{BoxError, StandardOptions};
10use axum::{
11    Router,
12    body::{Body, Bytes},
13    extract::{Request, State},
14    http::{self, HeaderMap, HeaderValue, StatusCode, Version},
15    response::Response,
16    routing::any,
17};
18use clientele::crates::clap::Args;
19use http_body_util::{BodyExt, Full};
20use hyper_rustls::{ConfigBuilderExt as _, HttpsConnector};
21use hyper_util::{client::legacy::Client, rt::TokioExecutor};
22use std::{
23    net::{IpAddr, SocketAddr},
24    sync::Arc,
25};
26use tokio::net::TcpListener;
27
28const UPSTREAM_BASE_URL: &str = "https://openrouter.ai/api";
29const UPSTREAM_HOST: &str = "openrouter.ai";
30
31/// The upstream HTTP client: a hyper client speaking rustls-based TLS to the
32/// target, over a connection that is either direct or tunneled through a
33/// proxy (see the `connector` module).
34type UpstreamClient = Client<HttpsConnector<ProxyConnector>, Full<Bytes>>;
35
36#[derive(Args, Clone, Debug, Default)]
37pub struct ProxyServeArgs {
38    /// The address to bind to [default: $ASIMOV_PROXY_BIND or 127.0.0.1]
39    #[clap(long)]
40    pub bind: Option<IpAddr>,
41
42    /// The port to bind to [default: $ASIMOV_PROXY_PORT or 1920]
43    #[clap(long)]
44    pub port: Option<u16>,
45}
46
47#[derive(Clone)]
48struct ProxyState {
49    client: UpstreamClient,
50    logger: Option<BodyLogger>,
51}
52
53pub async fn serve(args: ProxyServeArgs, flags: &StandardOptions) -> Result<(), BoxError> {
54    let _openrouter_api_key =
55        std::env::var("OPENROUTER_API_KEY").expect("OPENROUTER_API_KEY should be set");
56
57    // The TLS configuration, shared between connections to the target and to
58    // any `https://` proxy:
59    let tls_config = Arc::new(
60        rustls::ClientConfig::builder()
61            .with_native_roots()?
62            .with_no_client_auth(),
63    );
64
65    // The upstream proxy (if any), configured through the conventional
66    // `https_proxy`/`HTTPS_PROXY`/`all_proxy`/`ALL_PROXY`/`no_proxy`
67    // environment variables:
68    let proxy_config = ProxyConfig::from_env(UPSTREAM_HOST).map_err(|err| -> BoxError { err })?;
69    if flags.verbose > 0 && !matches!(proxy_config, ProxyConfig::Direct) {
70        eprintln!("Using upstream proxy: {:?}", proxy_config);
71    }
72
73    let proxy_connector = ProxyConnector::new(proxy_config, Arc::clone(&tls_config));
74    let https_connector = hyper_rustls::HttpsConnectorBuilder::new()
75        .with_tls_config((*tls_config).clone())
76        .https_only()
77        .enable_http1()
78        .wrap_connector(proxy_connector);
79    let client: UpstreamClient = Client::builder(TokioExecutor::new()).build(https_connector);
80
81    let state = ProxyState {
82        client,
83        logger: BodyLogger::from_env()?, // reads ASIMOV_PROXY_LOG_FILE
84    };
85
86    let router = Router::new()
87        .route("/{*path}", any(proxy_handler))
88        .with_state(state);
89
90    let bind: IpAddr = args.bind.unwrap_or_else(|| {
91        std::env::var("ASIMOV_PROXY_BIND")
92            .ok()
93            .and_then(|input| input.parse::<IpAddr>().ok())
94            .unwrap_or(IpAddr::from([127, 0, 0, 1]))
95    });
96    let port = args.port.unwrap_or_else(|| {
97        std::env::var("ASIMOV_PROXY_PORT")
98            .ok()
99            .and_then(|input| input.parse::<u16>().ok())
100            .unwrap_or(1920)
101    });
102    let addr = SocketAddr::from((bind, port));
103    let listener = TcpListener::bind(addr).await.unwrap();
104
105    if flags.verbose > 0 {
106        let addr = listener.local_addr()?;
107        eprintln!("Listening on {}...", addr);
108    }
109
110    axum::serve(listener, router).await.unwrap();
111    Ok(())
112}
113
114async fn proxy_handler(
115    State(state): State<ProxyState>,
116    req: Request,
117) -> Result<Response, StatusCode> {
118    let openrouter_api_key =
119        std::env::var("OPENROUTER_API_KEY").expect("OPENROUTER_API_KEY should be set");
120
121    let request_path = req.uri().path();
122    let request_query = req
123        .uri()
124        .query()
125        .map(|q| format!("?{q}"))
126        .unwrap_or_default();
127
128    if true {
129        // TODO: flags.verbose > 0
130        eprintln!("Proxying request: {} {}", request_path, request_query);
131    }
132
133    // https://openrouter.ai/api/v1/chat/completions
134    let target_url = format!("{}{}{}", UPSTREAM_BASE_URL, request_path, request_query);
135
136    let (mut head, body) = req.into_parts();
137
138    let body_bytes = body
139        .collect()
140        .await
141        .map_err(|_| StatusCode::BAD_REQUEST)?
142        .to_bytes();
143
144    // Patch the request body before forwarding it upstream:
145    let upstream_request_body = patch_request_body(body_bytes)?;
146
147    if let Some(logger) = &state.logger {
148        logger.log_request_body(&upstream_request_body);
149    }
150
151    // Retarget the request at the upstream server:
152    head.uri = target_url
153        .parse()
154        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
155    head.version = Version::HTTP_11; // regardless of the inbound HTTP version
156
157    // Modify request headers:
158    head.headers.remove("host"); // don't send "Host: 127.0.0.1"
159    head.headers.remove("content-length"); // patching may change the length; hyper recomputes it
160    head.headers.insert(
161        "Authorization",
162        HeaderValue::from_str(&format!("Bearer {}", openrouter_api_key)).unwrap(),
163    );
164
165    // See: https://openrouter.ai/docs/app-attribution
166    insert_attribution_headers(&mut head.headers);
167
168    let upstream_request = http::Request::from_parts(head, Full::new(upstream_request_body));
169
170    let upstream_response = state
171        .client
172        .request(upstream_request)
173        .await
174        .map_err(|err| {
175            eprintln!("Upstream request failed: {}", err);
176            StatusCode::BAD_GATEWAY
177        })?;
178
179    // Stream the upstream response body back to the client, teeing each data
180    // frame into the body log (if enabled):
181    let (head, upstream_response_body) = upstream_response.into_parts();
182    let logger = state.logger.clone();
183    let upstream_response_body = upstream_response_body.map_frame(move |frame| {
184        if let (Some(logger), Some(data)) = (&logger, frame.data_ref()) {
185            logger.log_response_chunk(data);
186        }
187        frame
188    });
189
190    let response = Response::from_parts(head, Body::new(upstream_response_body));
191    Ok(response)
192}
193
194/// Patches the upstream request body before it is forwarded.
195///
196/// TODO: Rewrite the `model` property using `jsonc_parser`'s CST API, which
197/// preserves the original formatting and whitespace of the request body:
198///
199/// ```ignore
200/// let text = str::from_utf8(&body).map_err(|_| StatusCode::BAD_REQUEST)?;
201/// let root = jsonc_parser::cst::CstRootNode::parse(text, &Default::default())
202///     .map_err(|_| StatusCode::BAD_REQUEST)?;
203/// let object = root.object_value().ok_or(StatusCode::BAD_REQUEST)?;
204/// if let Some(model) = object.get("model") { /* rewrite the value */ }
205/// Ok(root.to_string().into())
206/// ```
207fn patch_request_body(body: Bytes) -> Result<Bytes, StatusCode> {
208    // For now, the body is forwarded unmodified.
209    Ok(body)
210}
211
212fn insert_attribution_headers(headers: &mut HeaderMap<HeaderValue>) {
213    // See: https://openrouter.ai/docs/app-attribution
214    headers.insert(
215        "HTTP-Referer",
216        HeaderValue::from_static("https://asimov.sh"),
217    );
218    headers.insert("X-OpenRouter-Title", HeaderValue::from_static("ASIMOV"));
219    headers.insert(
220        "X-OpenRouter-Categories",
221        HeaderValue::from_static("cli-agent,personal-agent"),
222    );
223}