1use std::pin::Pin;
2use std::time::Duration;
3
4use bytes::Bytes;
5use futures::{Stream, TryStreamExt};
6use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
7use reqwest::Client;
8use serde_json::Value;
9use tracing::warn;
10
11use crate::config::Config;
12use crate::error::Error;
13
14const HOP_BY_HOP: &[&str] = &[
15 "connection",
16 "keep-alive",
17 "proxy-authenticate",
18 "proxy-authorization",
19 "te",
20 "trailers",
21 "transfer-encoding",
22 "upgrade",
23];
24
25const REQUEST_DROP_EXTRA: &[&str] = &["host", "content-length"];
26
27fn is_hop_by_hop(name: &str) -> bool {
28 HOP_BY_HOP.iter().any(|h| h.eq_ignore_ascii_case(name))
29}
30
31fn is_request_drop(name: &str) -> bool {
32 is_hop_by_hop(name) || REQUEST_DROP_EXTRA.iter().any(|h| h.eq_ignore_ascii_case(name))
33}
34
35pub struct ProxyRequest {
36 pub headers: HeaderMap,
37 pub body: Bytes,
38 pub query: Option<String>,
39}
40
41pub enum ProxyBody {
42 Full(Bytes),
43 Stream(Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>),
44}
45
46pub struct ProxyResponse {
47 pub status: StatusCode,
48 pub headers: HeaderMap,
49 pub body: ProxyBody,
50}
51
52#[derive(Clone)]
53pub struct ProxyState {
54 pub config: Config,
55 pub stream_client: Client,
56 pub non_stream_client: Client,
57}
58
59impl ProxyState {
60 pub fn new(config: Config) -> Result<Self, Error> {
64 let stream_client = Client::builder()
65 .connect_timeout(Duration::from_secs(10))
66 .timeout(Duration::from_secs(900))
67 .pool_max_idle_per_host(0)
68 .redirect(reqwest::redirect::Policy::none())
69 .build()
70 .map_err(Error::HttpClient)?;
71
72 let non_stream_client = Client::builder()
73 .connect_timeout(Duration::from_secs(10))
74 .read_timeout(Duration::from_secs(300))
75 .redirect(reqwest::redirect::Policy::none())
76 .build()
77 .map_err(Error::HttpClient)?;
78
79 Ok(Self {
80 config,
81 stream_client,
82 non_stream_client,
83 })
84 }
85}
86
87fn filter_request_headers(headers: &HeaderMap, config: &Config) -> reqwest::header::HeaderMap {
88 let mut out = reqwest::header::HeaderMap::new();
89 for (name, value) in headers {
90 if is_request_drop(name.as_str()) {
91 continue;
92 }
93 if let Ok(n) = reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()) {
94 if let Ok(v) = reqwest::header::HeaderValue::from_bytes(value.as_bytes()) {
95 out.append(n, v);
96 }
97 }
98 }
99
100 let has_auth = out.contains_key(reqwest::header::AUTHORIZATION);
101 if !has_auth {
102 if let Some(key) = config.openai_api_key.as_deref() {
103 let trimmed = key.trim();
104 if !trimmed.is_empty() {
105 if let Ok(v) = reqwest::header::HeaderValue::from_str(&format!("Bearer {trimmed}")) {
106 out.insert(reqwest::header::AUTHORIZATION, v);
107 }
108 }
109 }
110 }
111
112 out
113}
114
115fn filter_response_headers(headers: &reqwest::header::HeaderMap) -> HeaderMap {
116 let mut out = HeaderMap::new();
117 for (name, value) in headers {
118 if is_hop_by_hop(name.as_str()) {
119 continue;
120 }
121 if let Ok(n) = HeaderName::from_bytes(name.as_str().as_bytes()) {
122 if let Ok(v) = HeaderValue::from_bytes(value.as_bytes()) {
123 out.append(n, v);
124 }
125 }
126 }
127 out
128}
129
130fn is_sse_content_type(headers: &reqwest::header::HeaderMap) -> bool {
131 headers
132 .get(reqwest::header::CONTENT_TYPE)
133 .and_then(|v| v.to_str().ok())
134 .is_some_and(|ct| ct.to_ascii_lowercase().starts_with("text/event-stream"))
135}
136
137#[must_use]
138pub fn error_response(status: StatusCode, code: &str, message: &str) -> ProxyResponse {
139 let body = serde_json::json!({
140 "error": {
141 "message": message,
142 "type": "api_error",
143 "param": null,
144 "code": code,
145 }
146 });
147 let mut headers = HeaderMap::new();
148 headers.insert("content-type", HeaderValue::from_static("application/json"));
149 ProxyResponse {
150 status,
151 headers,
152 body: ProxyBody::Full(Bytes::from(serde_json::to_vec(&body).unwrap_or_default())),
153 }
154}
155
156pub async fn proxy_get(path: &str, request_headers: &HeaderMap, state: &ProxyState) -> ProxyResponse {
162 let llm_headers = filter_request_headers(request_headers, &state.config);
163 let base = state.config.llm_api_base.trim_end_matches('/');
164 let url = format!("{base}/{}", path.trim_start_matches('/'));
165
166 let llm_resp = match state.non_stream_client.get(&url).headers(llm_headers).send().await {
167 Ok(r) => r,
168 Err(e) if e.is_timeout() => {
169 warn!("upstream GET {path} timed out: {e}");
170 return error_response(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", "upstream timeout");
171 }
172 Err(e) => {
173 warn!("upstream GET {path} failed: {e}");
174 return error_response(StatusCode::BAD_GATEWAY, "upstream_unavailable", "upstream unavailable");
175 }
176 };
177
178 let status = StatusCode::from_u16(llm_resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
179 let response_headers = filter_response_headers(llm_resp.headers());
180
181 match llm_resp.bytes().await {
182 Ok(payload) => ProxyResponse {
183 status,
184 headers: response_headers,
185 body: ProxyBody::Full(payload),
186 },
187 Err(e) => {
188 warn!("failed to read upstream GET {path} body: {e}");
189 error_response(
190 StatusCode::BAD_GATEWAY,
191 "upstream_unavailable",
192 "failed to read upstream response",
193 )
194 }
195 }
196}
197
198pub async fn proxy_request(request: ProxyRequest, state: &ProxyState) -> ProxyResponse {
199 let is_streaming = serde_json::from_slice::<Value>(&request.body)
200 .ok()
201 .and_then(|v| v.get("stream")?.as_bool())
202 .unwrap_or(false);
203
204 let llm_headers = filter_request_headers(&request.headers, &state.config);
205
206 let base = state.config.llm_api_base.trim_end_matches('/');
207 let mut url = format!("{base}/v1/responses");
208 if let Some(q) = &request.query {
209 url.push('?');
210 url.push_str(q);
211 }
212
213 let client = if is_streaming {
214 &state.stream_client
215 } else {
216 &state.non_stream_client
217 };
218
219 let llm_resp = match client.post(&url).headers(llm_headers).body(request.body).send().await {
220 Ok(r) => r,
221 Err(e) if e.is_timeout() => {
222 warn!("LLM request timed out: {e}");
223 return error_response(StatusCode::GATEWAY_TIMEOUT, "llm_timeout", "LLM timeout");
224 }
225 Err(e) => {
226 warn!("LLM request failed: {e}");
227 return error_response(StatusCode::BAD_GATEWAY, "llm_unavailable", "LLM unavailable");
228 }
229 };
230
231 let status = StatusCode::from_u16(llm_resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
232 let mut response_headers = filter_response_headers(llm_resp.headers());
233
234 if is_sse_content_type(llm_resp.headers()) {
235 response_headers.insert("x-accel-buffering", HeaderValue::from_static("no"));
236
237 let byte_stream = llm_resp.bytes_stream().map_err(std::io::Error::other);
238
239 return ProxyResponse {
240 status,
241 headers: response_headers,
242 body: ProxyBody::Stream(Box::pin(byte_stream)),
243 };
244 }
245
246 let payload: Bytes = match llm_resp.bytes().await {
247 Ok(b) => b,
248 Err(e) => {
249 warn!("failed to read LLM response body: {e}");
250 return error_response(
251 StatusCode::BAD_GATEWAY,
252 "llm_unavailable",
253 "Failed to read LLM response",
254 );
255 }
256 };
257
258 ProxyResponse {
259 status,
260 headers: response_headers,
261 body: ProxyBody::Full(payload),
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::config::Config;
269
270 fn test_config() -> Config {
271 Config {
272 llm_api_base: "http://localhost:8000".to_owned(),
273 openai_api_key: Some("test-key".to_owned()),
274 llm_ready_timeout_s: 5.0,
275 llm_ready_interval_s: 0.1,
276 skip_llm_ready_check: false,
277 db_url: None,
278 }
279 }
280
281 fn test_config_no_key() -> Config {
282 Config {
283 openai_api_key: None,
284 ..test_config()
285 }
286 }
287
288 #[test]
289 fn hop_by_hop_detected() {
290 assert!(is_hop_by_hop("connection"));
291 assert!(is_hop_by_hop("Connection"));
292 assert!(is_hop_by_hop("keep-alive"));
293 assert!(is_hop_by_hop("transfer-encoding"));
294 assert!(is_hop_by_hop("proxy-authorization"));
295 }
296
297 #[test]
298 fn non_hop_by_hop_passes() {
299 assert!(!is_hop_by_hop("content-type"));
300 assert!(!is_hop_by_hop("x-custom"));
301 assert!(!is_hop_by_hop("authorization"));
302 }
303
304 #[test]
305 fn request_drop_includes_host_and_content_length() {
306 assert!(is_request_drop("host"));
307 assert!(is_request_drop("content-length"));
308 assert!(is_request_drop("connection"));
309 assert!(!is_request_drop("content-type"));
310 }
311
312 #[test]
313 fn filter_request_headers_strips_hop_by_hop() {
314 let mut headers = HeaderMap::new();
315 headers.insert("content-type", "application/json".parse().unwrap());
316 headers.insert("connection", "keep-alive".parse().unwrap());
317 headers.insert("proxy-authorization", "Basic abc".parse().unwrap());
318 headers.insert("x-custom", "value".parse().unwrap());
319
320 let config = test_config_no_key();
321 let filtered = filter_request_headers(&headers, &config);
322
323 assert!(filtered.contains_key("content-type"));
324 assert!(filtered.contains_key("x-custom"));
325 assert!(!filtered.contains_key("connection"));
326 assert!(!filtered.contains_key("proxy-authorization"));
327 }
328
329 #[test]
330 fn filter_request_headers_strips_host_and_content_length() {
331 let mut headers = HeaderMap::new();
332 headers.insert("host", "example.com".parse().unwrap());
333 headers.insert("content-length", "42".parse().unwrap());
334 headers.insert("accept", "*/*".parse().unwrap());
335
336 let config = test_config_no_key();
337 let filtered = filter_request_headers(&headers, &config);
338
339 assert!(!filtered.contains_key("host"));
340 assert!(!filtered.contains_key("content-length"));
341 assert!(filtered.contains_key("accept"));
342 }
343
344 #[test]
345 fn auth_injected_when_no_client_auth() {
346 let headers = HeaderMap::new();
347 let config = test_config();
348 let filtered = filter_request_headers(&headers, &config);
349
350 assert_eq!(
351 filtered.get("authorization").unwrap().to_str().unwrap(),
352 "Bearer test-key"
353 );
354 }
355
356 #[test]
357 fn client_auth_takes_precedence() {
358 let mut headers = HeaderMap::new();
359 headers.insert("authorization", "Bearer client-token".parse().unwrap());
360
361 let config = test_config();
362 let filtered = filter_request_headers(&headers, &config);
363
364 assert_eq!(
365 filtered.get("authorization").unwrap().to_str().unwrap(),
366 "Bearer client-token"
367 );
368 }
369
370 #[test]
371 fn no_auth_injected_when_key_empty() {
372 let headers = HeaderMap::new();
373 let config = Config {
374 openai_api_key: Some(" ".to_owned()),
375 ..test_config()
376 };
377 let filtered = filter_request_headers(&headers, &config);
378
379 assert!(!filtered.contains_key("authorization"));
380 }
381
382 #[test]
383 fn no_auth_injected_when_key_none() {
384 let headers = HeaderMap::new();
385 let config = test_config_no_key();
386 let filtered = filter_request_headers(&headers, &config);
387
388 assert!(!filtered.contains_key("authorization"));
389 }
390
391 #[test]
392 fn filter_response_headers_strips_hop_by_hop() {
393 let mut headers = reqwest::header::HeaderMap::new();
394 headers.insert("content-type", "application/json".parse().unwrap());
395 headers.insert("connection", "keep-alive".parse().unwrap());
396 headers.insert("x-request-id", "abc".parse().unwrap());
397
398 let filtered = filter_response_headers(&headers);
399
400 assert!(filtered.contains_key("content-type"));
401 assert!(filtered.contains_key("x-request-id"));
402 assert!(!filtered.contains_key("connection"));
403 }
404
405 #[test]
406 fn sse_content_type_detected() {
407 let mut headers = reqwest::header::HeaderMap::new();
408 headers.insert("content-type", "text/event-stream; charset=utf-8".parse().unwrap());
409 assert!(is_sse_content_type(&headers));
410 }
411
412 #[test]
413 fn sse_content_type_case_insensitive() {
414 let mut headers = reqwest::header::HeaderMap::new();
415 headers.insert("content-type", "Text/Event-Stream".parse().unwrap());
416 assert!(is_sse_content_type(&headers));
417 }
418
419 #[test]
420 fn non_sse_content_type_rejected() {
421 let mut headers = reqwest::header::HeaderMap::new();
422 headers.insert("content-type", "application/json".parse().unwrap());
423 assert!(!is_sse_content_type(&headers));
424 }
425
426 #[test]
427 fn missing_content_type_not_sse() {
428 let headers = reqwest::header::HeaderMap::new();
429 assert!(!is_sse_content_type(&headers));
430 }
431}