1use axum::{
2 body::Body,
3 extract::State,
4 http::{Request, StatusCode, request::Parts},
5 response::Response,
6};
7
8use flate2::{Compression, read::GzDecoder, write::GzEncoder};
9use std::borrow::Cow;
10use std::io::{Read, Write};
11
12use super::ProxyState;
13
14const DEFAULT_MAX_BODY_MB: usize = 64;
19
20pub(super) fn max_body_bytes() -> usize {
21 std::env::var("LEAN_CTX_PROXY_MAX_BODY_MB")
22 .ok()
23 .and_then(|v| v.trim().parse::<usize>().ok())
24 .filter(|mb| *mb > 0)
25 .unwrap_or(DEFAULT_MAX_BODY_MB)
26 .saturating_mul(1024 * 1024)
27}
28
29pub async fn forward_request(
35 State(state): State<ProxyState>,
36 req: Request<Body>,
37 upstream_base: &str,
38 default_path: &str,
39 compress_body: impl FnOnce(serde_json::Value, usize) -> (Vec<u8>, usize, usize),
40 provider_label: &str,
41 extra_stream_types: &[&str],
42) -> Result<Response, StatusCode> {
43 let (mut parts, body) = req.into_parts();
44 let body_bytes = axum::body::to_bytes(body, max_body_bytes())
45 .await
46 .map_err(|_| StatusCode::PAYLOAD_TOO_LARGE)?;
47
48 let gate_rules = super::policy_gate::active_rules();
52 if let Some(rules) = &gate_rules {
53 let tags = parts
54 .extensions
55 .get::<super::gateway_identity::GatewayTags>()
56 .cloned()
57 .unwrap_or_default();
58 let requested_model = requested_model_of(&parts, &body_bytes);
59 if let Err(refusal) = super::policy_gate::enforce(rules, requested_model.as_deref(), &tags)
60 {
61 tracing::warn!(
62 "lean-ctx gateway: org policy refused request ({refusal:?}) \
63 person={:?} project={:?}",
64 tags.person,
65 tags.project
66 );
67 return Ok(super::policy_gate::refusal_response(
68 &refusal,
69 provider_label,
70 ));
71 }
72 }
73
74 let routing_rules = crate::core::config::Config::load().proxy.routing.clone();
79 let downgrade_forbidden = gate_rules.as_ref().is_some_and(|rules| {
80 let project = parts
81 .extensions
82 .get::<super::gateway_identity::GatewayTags>()
83 .and_then(|t| t.project.clone());
84 super::policy_gate::downgrade_forbidden(rules, project.as_deref())
85 });
86 let route_upstreams =
87 (routing_rules.is_active() && !downgrade_forbidden).then(|| state.upstream_snapshot());
88 let xlat_ok = cfg!(feature = "shape-xlat")
92 && provider_label == "Anthropic"
93 && parts
94 .uri
95 .path()
96 .trim_end_matches('/')
97 .ends_with("/v1/messages");
98 let route_hook = |parsed: &mut serde_json::Value| {
99 route_upstreams.as_ref().and_then(|up| {
100 super::routing::route_request(parsed, provider_label, up, &routing_rules, xlat_ok)
101 })
102 };
103
104 let prepared = prepare_request_body(&parts, &body_bytes, compress_body, route_hook)?;
105 let original_size = prepared.original_size;
106 let compressed_size = prepared.compressed_size;
107 let compression_candidate = prepared.compression_candidate;
108 let preserve_content_encoding = prepared.preserve_content_encoding;
109 let route = prepared.route;
110 let parsed = prepared.parsed;
111
112 let upstream_base = route
115 .as_ref()
116 .and_then(|r| r.upstream_base.as_deref())
117 .unwrap_or(upstream_base);
118 if let Some(provider) = route.as_ref().and_then(|r| r.credential.as_ref()) {
119 super::providers::inject_gateway_credential(provider, &mut parts.headers)?;
120 }
121 if let Some(ref parsed) = parsed {
122 let provider = match provider_label {
123 "Anthropic" => super::introspect::Provider::Anthropic,
124 "OpenAI" | "ChatGPT" => super::introspect::Provider::OpenAi,
125 _ => super::introspect::Provider::Gemini,
126 };
127 let breakdown = super::introspect::analyze_request(parsed, provider);
128 state.introspect.record(breakdown);
129 }
130
131 let cohort = parsed
134 .as_ref()
135 .and_then(|p| cohort_arm(p, provider_label, default_path));
136
137 if compression_candidate {
138 state
139 .stats
140 .record_provider_request(provider_label, original_size, compressed_size);
141 }
142
143 let tokens_saved = original_size.saturating_sub(compressed_size) as u64 / 4;
144 super::metrics::record_request(tokens_saved, compressed_size as u64);
145
146 let model = parsed
147 .as_ref()
148 .and_then(|v| v.get("model"))
149 .and_then(|m| m.as_str());
150 super::cost::record(
151 model,
152 tokens_saved,
153 original_size as u64,
154 compressed_size as u64,
155 );
156
157 let xlat = route.as_ref().is_some_and(|r| r.xlat);
161 let upstream_url = if xlat {
162 format!("{upstream_base}/v1/chat/completions")
163 } else {
164 build_upstream_url(&parts, upstream_base, default_path)
165 };
166
167 let counterfactual = if provider_label == "Anthropic" && !xlat {
175 super::counterfactual::maybe_spawn_probe(
176 &state.client,
177 &parts,
178 upstream_base,
179 parsed.as_ref(),
180 route.as_ref().map(|r| r.routed_from.as_str()),
181 compressed_size < original_size,
182 )
183 } else {
184 None
185 };
186
187 let response = send_upstream(
188 &state,
189 &parts,
190 &upstream_url,
191 prepared.body,
192 provider_label,
193 preserve_content_encoding,
194 )
195 .await?;
196
197 let usage_provider = if xlat {
201 super::usage::Provider::OpenAi
202 } else {
203 super::usage::Provider::from_label(provider_label)
204 };
205 let url_model = if usage_provider == super::usage::Provider::Gemini {
206 super::usage::gemini_model_from_path(parts.uri.path())
207 } else {
208 None
209 };
210
211 let mut wire = wire_context(
216 &parts,
217 provider_label,
218 upstream_base,
219 tokens_saved,
220 original_size,
221 );
222 if let Some(route) = &route {
223 wire.routed_from = Some(route.routed_from.clone());
224 if let Some(id) = &route.provider_id {
225 wire.provider = id.clone();
226 }
227 if let Some(local) = route.local {
230 wire.is_local = local;
231 }
232 }
233 wire.counterfactual = counterfactual;
234 let wire = Some(wire);
235
236 build_response(
237 response,
238 extra_stream_types,
239 usage_provider,
240 url_model,
241 cohort,
242 wire,
243 xlat,
244 )
245 .await
246}
247
248fn requested_model_of(parts: &Parts, body_bytes: &[u8]) -> Option<String> {
253 if let Some(m) = super::usage::gemini_model_from_path(parts.uri.path()) {
254 return Some(m);
255 }
256 let decoded: Cow<'_, [u8]> = match request_body_encoding(parts) {
257 RequestBodyEncoding::Identity => Cow::Borrowed(body_bytes),
258 RequestBodyEncoding::Gzip => {
259 Cow::Owned(decode_gzip_bounded(body_bytes, max_body_bytes()).ok()?)
260 }
261 RequestBodyEncoding::Zstd => {
262 Cow::Owned(decode_zstd_bounded(body_bytes, max_body_bytes()).ok()?)
263 }
264 RequestBodyEncoding::Passthrough => return None,
265 };
266 let v: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
267 v.get("model")?.as_str().map(str::to_string)
268}
269
270fn wire_context(
276 parts: &Parts,
277 provider_label: &str,
278 upstream_base: &str,
279 tokens_saved: u64,
280 original_size: usize,
281) -> Box<super::usage::WireContext> {
282 let tags = parts
283 .extensions
284 .get::<super::gateway_identity::GatewayTags>()
285 .cloned()
286 .unwrap_or_default();
287 let registry = parts
292 .extensions
293 .get::<super::providers::RegistryProviderId>();
294 let provider = registry.map_or(provider_label, |r| r.id.as_str());
295 let is_local = registry.map_or_else(|| upstream_is_local(upstream_base), |r| r.local);
296 Box::new(super::usage::WireContext {
297 provider: provider.to_string(),
298 person: tags.person,
299 team: tags.team,
300 project: tags.project,
301 saved_tokens: tokens_saved,
302 uncompressed_input_tokens: original_size as u64 / 4,
304 is_local,
305 routed_from: None, counterfactual: None, })
308}
309
310fn upstream_is_local(upstream_base: &str) -> bool {
314 let rest = upstream_base
315 .strip_prefix("https://")
316 .or_else(|| upstream_base.strip_prefix("http://"))
317 .unwrap_or(upstream_base);
318 let host_port = rest.split(['/', '?']).next().unwrap_or(rest);
319 let host = if let Some(b) = host_port.strip_prefix('[') {
321 b.split(']').next().unwrap_or(b)
322 } else {
323 host_port.split(':').next().unwrap_or(host_port)
324 };
325 matches!(host, "127.0.0.1" | "localhost" | "::1" | "0.0.0.0")
326}
327
328fn cohort_arm(
332 parsed: &serde_json::Value,
333 provider_label: &str,
334 default_path: &str,
335) -> Option<super::holdout::Arm> {
336 let holdout = crate::core::config::Config::load()
337 .proxy
338 .output_holdout_fraction();
339 if holdout <= 0.0 {
340 return None;
341 }
342 let key = match provider_label {
343 "Anthropic" => super::holdout::anthropic_key(parsed),
344 "OpenAI" | "ChatGPT" => {
345 if default_path.contains("responses") {
346 super::holdout::openai_responses_key(parsed)
347 } else {
348 super::holdout::openai_chat_key(parsed)
349 }
350 }
351 _ => super::holdout::google_key(parsed),
352 };
353 Some(super::holdout::assign(&key, holdout))
354}
355
356struct PreparedRequestBody {
357 body: Vec<u8>,
358 parsed: Option<serde_json::Value>,
359 original_size: usize,
360 compressed_size: usize,
361 compression_candidate: bool,
362 preserve_content_encoding: bool,
363 route: Option<super::routing::RouteDecision>,
365}
366
367#[derive(Clone, Copy, Debug, Eq, PartialEq)]
368enum RequestBodyEncoding {
369 Identity,
370 Gzip,
371 Zstd,
372 Passthrough,
373}
374
375fn prepare_request_body(
376 parts: &Parts,
377 body_bytes: &[u8],
378 compress_body: impl FnOnce(serde_json::Value, usize) -> (Vec<u8>, usize, usize),
379 route_hook: impl FnOnce(&mut serde_json::Value) -> Option<super::routing::RouteDecision>,
380) -> Result<PreparedRequestBody, StatusCode> {
381 let encoding = request_body_encoding(parts);
382 let decoded = match encoding {
383 RequestBodyEncoding::Identity => Cow::Borrowed(body_bytes),
384 RequestBodyEncoding::Gzip => Cow::Owned(decode_gzip_bounded(body_bytes, max_body_bytes())?),
385 RequestBodyEncoding::Zstd => Cow::Owned(decode_zstd_bounded(body_bytes, max_body_bytes())?),
386 RequestBodyEncoding::Passthrough => {
387 return Ok(PreparedRequestBody {
388 body: body_bytes.to_vec(),
389 parsed: None,
390 original_size: body_bytes.len(),
391 compressed_size: body_bytes.len(),
392 compression_candidate: false,
393 preserve_content_encoding: true,
394 route: None,
395 });
396 }
397 };
398
399 let Some(mut parsed) = serde_json::from_slice::<serde_json::Value>(&decoded).ok() else {
400 return Ok(PreparedRequestBody {
401 body: body_bytes.to_vec(),
402 parsed: None,
403 original_size: body_bytes.len(),
404 compressed_size: body_bytes.len(),
405 compression_candidate: false,
406 preserve_content_encoding: encoding != RequestBodyEncoding::Identity,
407 route: None,
408 });
409 };
410
411 let mut route = route_hook(&mut parsed);
414
415 let original_size = decoded.len();
416 let (logical_body, _, compressed_size) =
420 if let Some(openai_body) = translated_openai_body(route.as_ref(), &parsed) {
421 super::openai::compress_request_body(openai_body, original_size)
422 } else {
423 if route.as_ref().is_some_and(|r| r.xlat) {
424 let decision = route.take().expect("checked is_some");
425 tracing::warn!(
426 "lean-ctx proxy: request not translatable to OpenAI shape — \
427 cancelling route to '{}', forwarding natively",
428 decision.provider_id.as_deref().unwrap_or("?")
429 );
430 parsed["model"] = serde_json::Value::String(decision.routed_from);
431 }
432 compress_body(parsed.clone(), original_size)
433 };
434 let body = match encoding {
435 RequestBodyEncoding::Identity => logical_body,
436 RequestBodyEncoding::Gzip => encode_gzip(&logical_body)?,
437 RequestBodyEncoding::Zstd => encode_zstd(&logical_body)?,
438 RequestBodyEncoding::Passthrough => unreachable!("passthrough returned above"),
439 };
440
441 Ok(PreparedRequestBody {
442 body,
443 parsed: Some(parsed),
444 original_size,
445 compressed_size,
446 compression_candidate: true,
447 preserve_content_encoding: encoding != RequestBodyEncoding::Identity,
448 route,
449 })
450}
451
452#[cfg(feature = "shape-xlat")]
455fn translated_openai_body(
456 route: Option<&super::routing::RouteDecision>,
457 parsed: &serde_json::Value,
458) -> Option<serde_json::Value> {
459 route
460 .filter(|r| r.xlat)
461 .and_then(|_| super::shape_xlat::messages_to_chat(parsed))
462}
463
464#[cfg(not(feature = "shape-xlat"))]
465fn translated_openai_body(
466 _route: Option<&super::routing::RouteDecision>,
467 _parsed: &serde_json::Value,
468) -> Option<serde_json::Value> {
469 None
470}
471
472fn build_upstream_url(parts: &Parts, base: &str, default_path: &str) -> String {
473 format!(
474 "{base}{}",
475 parts
476 .uri
477 .path_and_query()
478 .map_or(default_path, axum::http::uri::PathAndQuery::as_str)
479 )
480}
481
482pub(super) const ALLOWED_REQUEST_HEADERS: &[&str] = &[
490 "authorization",
491 "x-api-key",
492 "api-key",
494 "content-type",
495 "accept",
496 "user-agent",
497 "originator",
498 "anthropic-version",
499 "anthropic-beta",
500 "anthropic-dangerous-direct-browser-access",
501 "openai-organization",
502 "openai-project",
503 "openai-beta",
504 "chatgpt-account-id",
505 "x-openai-fedramp",
506 "x-openai-internal-codex-residency",
507 "x-openai-internal-codex-responses-lite",
508 "x-openai-product-sku",
509 "oai-product-sku",
510 "x-oai-attestation",
511 "x-client-request-id",
512 "x-codex-beta-features",
513 "x-codex-installation-id",
514 "x-codex-parent-thread-id",
515 "x-openai-subagent",
516 "x-codex-turn-state",
517 "x-codex-turn-metadata",
518 "x-codex-window-id",
519 "x-openai-memgen-request",
520 "x-responsesapi-include-timing-metrics",
521 "mcp-session-id",
522 "last-event-id",
523 "cache-control",
524 "x-goog-api-key",
525 "x-goog-api-client",
526];
527
528pub(super) fn is_allowed_request_header(name: &str) -> bool {
529 ALLOWED_REQUEST_HEADERS.contains(&name)
530}
531
532fn should_forward_request_header(name: &str, preserve_content_encoding: bool) -> bool {
533 is_allowed_request_header(name)
534 || (preserve_content_encoding && name.eq_ignore_ascii_case("content-encoding"))
535}
536
537fn request_body_encoding(parts: &Parts) -> RequestBodyEncoding {
538 let Some(value) = parts
539 .headers
540 .get(axum::http::header::CONTENT_ENCODING)
541 .and_then(|value| value.to_str().ok())
542 else {
543 return RequestBodyEncoding::Identity;
544 };
545
546 let encodings = value
547 .split(',')
548 .map(str::trim)
549 .filter(|part| !part.is_empty() && !part.eq_ignore_ascii_case("identity"))
550 .collect::<Vec<_>>();
551 match encodings.as_slice() {
552 [] => RequestBodyEncoding::Identity,
553 [encoding] if encoding.eq_ignore_ascii_case("gzip") => RequestBodyEncoding::Gzip,
554 [encoding] if encoding.eq_ignore_ascii_case("zstd") => RequestBodyEncoding::Zstd,
555 _ => RequestBodyEncoding::Passthrough,
556 }
557}
558
559fn decode_zstd_bounded(data: &[u8], max_bytes: usize) -> Result<Vec<u8>, StatusCode> {
560 let decoder = zstd::Decoder::new(data).map_err(|e| {
561 tracing::warn!("lean-ctx proxy: invalid zstd request body: {e}");
562 StatusCode::BAD_REQUEST
563 })?;
564 read_bounded(decoder, max_bytes).inspect_err(|e| {
565 tracing::warn!("lean-ctx proxy: zstd request decode failed: {e}");
566 })
567}
568
569fn encode_zstd(data: &[u8]) -> Result<Vec<u8>, StatusCode> {
570 zstd::encode_all(data, 3).map_err(|e| {
571 tracing::error!("lean-ctx proxy: zstd request encode failed: {e}");
572 StatusCode::INTERNAL_SERVER_ERROR
573 })
574}
575
576fn decode_gzip_bounded(data: &[u8], max_bytes: usize) -> Result<Vec<u8>, StatusCode> {
577 read_bounded(GzDecoder::new(data), max_bytes).inspect_err(|e| {
578 tracing::warn!("lean-ctx proxy: gzip request decode failed: {e}");
579 })
580}
581
582fn encode_gzip(data: &[u8]) -> Result<Vec<u8>, StatusCode> {
583 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
584 encoder.write_all(data).map_err(|e| {
585 tracing::error!("lean-ctx proxy: gzip request encode failed: {e}");
586 StatusCode::INTERNAL_SERVER_ERROR
587 })?;
588 encoder.finish().map_err(|e| {
589 tracing::error!("lean-ctx proxy: gzip request encode failed: {e}");
590 StatusCode::INTERNAL_SERVER_ERROR
591 })
592}
593
594fn read_bounded<R: Read>(reader: R, max_bytes: usize) -> Result<Vec<u8>, StatusCode> {
595 let mut limited = reader.take(max_bytes as u64 + 1);
596 let mut out = Vec::new();
597 limited
598 .read_to_end(&mut out)
599 .map_err(|_| StatusCode::BAD_REQUEST)?;
600 if out.len() > max_bytes {
601 return Err(StatusCode::PAYLOAD_TOO_LARGE);
602 }
603 Ok(out)
604}
605
606fn is_retryable_status(status: reqwest::StatusCode) -> bool {
610 matches!(status.as_u16(), 429 | 502 | 503)
611}
612
613async fn retry_backoff() {
617 let mut buf = [0u8; 2];
618 let jitter_ms =
619 getrandom::fill(&mut buf).map_or(100, |()| u64::from(u16::from_le_bytes(buf)) % 200);
620 tokio::time::sleep(std::time::Duration::from_millis(150 + jitter_ms)).await;
621}
622
623async fn send_upstream(
624 state: &ProxyState,
625 parts: &Parts,
626 url: &str,
627 body: Vec<u8>,
628 provider_label: &str,
629 preserve_content_encoding: bool,
630) -> Result<reqwest::Response, StatusCode> {
631 let send_once = |body: Vec<u8>| {
632 let mut req = state.client.request(parts.method.clone(), url);
633 for (key, value) in &parts.headers {
634 let k = key.as_str().to_lowercase();
635 if should_forward_request_header(&k, preserve_content_encoding) {
636 req = req.header(key.clone(), value.clone());
637 }
638 }
639 req.body(body).send()
640 };
641
642 let first = send_once(body.clone()).await;
646 let retry_reason = match &first {
647 Ok(resp) if is_retryable_status(resp.status()) => {
648 format!("status {}", resp.status().as_u16())
649 }
650 Err(e) if e.is_connect() || e.is_timeout() => format!("connect error: {e}"),
651 Ok(resp) => {
652 let _ = resp; return first.map_err(|_| StatusCode::BAD_GATEWAY);
654 }
655 Err(e) => {
656 tracing::error!("lean-ctx proxy: {provider_label} upstream error: {e}");
657 return Err(StatusCode::BAD_GATEWAY);
658 }
659 };
660
661 tracing::warn!("lean-ctx proxy: {provider_label} upstream {retry_reason} — retrying once");
662 retry_backoff().await;
663 match send_once(body).await {
664 Ok(resp) => Ok(resp),
665 Err(e) => {
666 tracing::error!("lean-ctx proxy: {provider_label} retry failed: {e}");
669 first.map_err(|_| StatusCode::BAD_GATEWAY)
670 }
671 }
672}
673
674pub(super) const FORWARDED_HEADERS: &[&str] = &[
675 "content-type",
676 "content-encoding",
677 "mcp-session-id",
678 "x-request-id",
679 "x-oai-request-id",
680 "cf-ray",
681 "x-openai-authorization-error",
682 "x-error-json",
683 "openai-organization",
684 "openai-model",
685 "openai-processing-ms",
686 "openai-version",
687 "x-models-etag",
688 "x-reasoning-included",
689 "anthropic-ratelimit-requests-limit",
690 "anthropic-ratelimit-requests-remaining",
691 "anthropic-ratelimit-tokens-limit",
692 "anthropic-ratelimit-tokens-remaining",
693 "retry-after",
694 "x-ratelimit-limit-requests",
695 "x-ratelimit-remaining-requests",
696 "x-ratelimit-limit-tokens",
697 "x-ratelimit-remaining-tokens",
698 "cache-control",
699];
700
701pub(super) fn is_forwarded_response_header(name: &str) -> bool {
702 FORWARDED_HEADERS.contains(&name)
703 || name.starts_with("x-codex-")
704 || name.starts_with("x-ratelimit-")
705}
706
707#[allow(clippy::too_many_arguments)]
708async fn build_response(
709 response: reqwest::Response,
710 extra_stream_types: &[&str],
711 usage_provider: super::usage::Provider,
712 url_model: Option<String>,
713 cohort: Option<super::holdout::Arm>,
714 wire: Option<Box<super::usage::WireContext>>,
715 xlat: bool,
716) -> Result<Response, StatusCode> {
717 let status = StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::OK);
718 let resp_headers = response.headers().clone();
719
720 let is_stream = resp_headers
721 .get("content-type")
722 .and_then(|v| v.to_str().ok())
723 .is_some_and(|ct| {
724 ct.contains("text/event-stream") || extra_stream_types.iter().any(|t| ct.contains(t))
725 });
726
727 if is_stream {
728 let scanner = super::usage::Scanner::new(usage_provider, url_model)
734 .with_cohort(cohort)
735 .with_wire_context(wire);
736 let inner = Box::pin(response.bytes_stream());
737 let teed = Box::pin(super::usage::tee_stream(inner, scanner));
738 let body = xlat_stream_body(teed, xlat);
739 let mut resp = Response::builder().status(status);
740 for (k, v) in &resp_headers {
741 let ks = k.as_str().to_lowercase();
742 if is_forwarded_response_header(&ks) {
743 resp = resp.header(k, v);
744 }
745 }
746 return resp
747 .body(body)
748 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR);
749 }
750
751 let resp_bytes = response
752 .bytes()
753 .await
754 .map_err(|_| StatusCode::BAD_GATEWAY)?;
755
756 let mut scanner = super::usage::Scanner::new(usage_provider, url_model)
758 .with_cohort(cohort)
759 .with_wire_context(wire);
760 scanner.feed_body(&resp_bytes);
761 if let Some(usage) = scanner.finalize() {
762 super::usage_meter::record(&usage);
763 }
764
765 let resp_bytes = if xlat {
766 xlat_response_bytes(&resp_bytes, status)
767 } else {
768 resp_bytes.to_vec()
769 };
770
771 let mut resp = Response::builder().status(status);
772 for (k, v) in &resp_headers {
773 let ks = k.as_str().to_lowercase();
774 if is_forwarded_response_header(&ks) {
775 resp = resp.header(k, v);
776 }
777 }
778 resp.body(Body::from(resp_bytes))
779 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
780}
781
782#[cfg(feature = "shape-xlat")]
784fn xlat_stream_body<S>(teed: S, xlat: bool) -> Body
785where
786 S: futures::Stream<Item = Result<axum::body::Bytes, reqwest::Error>> + Send + Unpin + 'static,
787{
788 if xlat {
789 Body::from_stream(super::shape_xlat::to_anthropic_stream(teed))
790 } else {
791 Body::from_stream(teed)
792 }
793}
794
795#[cfg(not(feature = "shape-xlat"))]
796fn xlat_stream_body<S>(teed: S, _xlat: bool) -> Body
797where
798 S: futures::Stream<Item = Result<axum::body::Bytes, reqwest::Error>> + Send + Unpin + 'static,
799{
800 Body::from_stream(teed)
801}
802
803#[cfg(feature = "shape-xlat")]
807fn xlat_response_bytes(resp_bytes: &[u8], status: StatusCode) -> Vec<u8> {
808 let translated = serde_json::from_slice::<serde_json::Value>(resp_bytes)
809 .ok()
810 .and_then(|v| {
811 if status.is_success() {
812 super::shape_xlat::chat_to_messages(&v)
813 } else {
814 super::shape_xlat::error_to_anthropic(&v)
815 }
816 });
817 if let Some(v) = translated {
818 serde_json::to_vec(&v).unwrap_or_else(|_| resp_bytes.to_vec())
819 } else {
820 tracing::warn!(
821 "lean-ctx proxy: cross-shape response not translatable (status {status}) — \
822 forwarding raw body"
823 );
824 resp_bytes.to_vec()
825 }
826}
827
828#[cfg(not(feature = "shape-xlat"))]
829fn xlat_response_bytes(resp_bytes: &[u8], _status: StatusCode) -> Vec<u8> {
830 resp_bytes.to_vec()
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 fn parts_for(uri: &str) -> Parts {
838 Request::builder().uri(uri).body(()).unwrap().into_parts().0
839 }
840
841 fn add_test_marker(
842 mut value: serde_json::Value,
843 original_size: usize,
844 ) -> (Vec<u8>, usize, usize) {
845 value["lean_ctx_touched"] = serde_json::Value::Bool(true);
846 let out = serde_json::to_vec(&value).unwrap();
847 let compressed_size = out.len();
848 (out, original_size, compressed_size)
849 }
850
851 #[test]
854 fn upstream_is_local_detects_loopback_hosts() {
855 for local in [
856 "http://127.0.0.1:11434",
857 "http://localhost:8080/v1",
858 "http://[::1]:9999",
859 "http://0.0.0.0:4000",
860 ] {
861 assert!(upstream_is_local(local), "{local} must count as local");
862 }
863 for remote in [
864 "https://api.anthropic.com",
865 "https://acme.services.ai.azure.com/openai",
866 "https://localhost.evil.example.com", ] {
868 assert!(!upstream_is_local(remote), "{remote} must not be local");
869 }
870 }
871
872 #[test]
873 fn wire_context_carries_identity_tags_and_baseline() {
874 let mut parts = parts_for("/v1/messages");
875 parts
876 .extensions
877 .insert(super::super::gateway_identity::GatewayTags {
878 person: Some("yves".into()),
879 team: Some("platform".into()),
880 project: Some("billing".into()),
881 });
882 let wire = wire_context(&parts, "Anthropic", "https://api.anthropic.com", 750, 4000);
883 assert_eq!(wire.provider, "Anthropic");
884 assert_eq!(wire.person.as_deref(), Some("yves"));
885 assert_eq!(wire.team.as_deref(), Some("platform"));
886 assert_eq!(wire.project.as_deref(), Some("billing"));
887 assert_eq!(wire.saved_tokens, 750);
888 assert_eq!(wire.uncompressed_input_tokens, 1000);
890 assert!(!wire.is_local);
891 assert_eq!(wire.routed_from, None);
892 }
893
894 #[test]
895 fn wire_context_prefers_registry_provider_id_over_shape_label() {
896 let mut parts = parts_for("/v1/chat/completions");
899 parts
900 .extensions
901 .insert(super::super::providers::RegistryProviderId {
902 id: "local".into(),
903 local: false,
904 });
905 let wire = wire_context(&parts, "OpenAI", "http://127.0.0.1:11434", 0, 400);
906 assert_eq!(wire.provider, "local");
907 }
908
909 #[test]
910 fn wire_context_registry_local_flag_beats_url_heuristic() {
911 let mut parts = parts_for("/v1/chat/completions");
916 parts
917 .extensions
918 .insert(super::super::providers::RegistryProviderId {
919 id: "local".into(),
920 local: true,
921 });
922 let wire = wire_context(
923 &parts,
924 "OpenAI",
925 "http://host.docker.internal:11434",
926 0,
927 400,
928 );
929 assert!(wire.is_local, "declared local flag must win");
930
931 let mut parts = parts_for("/v1/chat/completions");
932 parts
933 .extensions
934 .insert(super::super::providers::RegistryProviderId {
935 id: "tunnel".into(),
936 local: false,
937 });
938 let wire = wire_context(&parts, "OpenAI", "http://127.0.0.1:9999", 0, 400);
939 assert!(!wire.is_local, "declared non-local flag must win");
940 }
941
942 #[test]
945 fn retry_covers_exactly_not_processed_statuses() {
946 for code in [429_u16, 502, 503] {
948 assert!(
949 is_retryable_status(reqwest::StatusCode::from_u16(code).unwrap()),
950 "{code} must be retryable"
951 );
952 }
953 for code in [200_u16, 400, 401, 404, 500, 504] {
955 assert!(
956 !is_retryable_status(reqwest::StatusCode::from_u16(code).unwrap()),
957 "{code} must NOT be retryable"
958 );
959 }
960 }
961
962 #[test]
963 fn wire_context_without_tags_still_carries_baseline() {
964 let parts = parts_for("/v1/chat/completions");
966 let wire = wire_context(&parts, "OpenAI", "http://127.0.0.1:11434", 0, 400);
967 assert_eq!(wire.person, None);
968 assert_eq!(wire.project, None);
969 assert_eq!(wire.uncompressed_input_tokens, 100);
970 assert!(wire.is_local);
971 }
972
973 #[test]
974 fn zstd_request_bodies_are_rewritten_and_reencoded() {
975 let body = serde_json::json!({"model": "gpt-5", "input": []});
976 let json = serde_json::to_vec(&body).unwrap();
977 let encoded = encode_zstd(&json).unwrap();
978 let parts = Request::builder()
979 .uri("/backend-api/codex/responses")
980 .header(axum::http::header::CONTENT_ENCODING, "zstd")
981 .body(())
982 .unwrap()
983 .into_parts()
984 .0;
985
986 let prepared = prepare_request_body(&parts, &encoded, add_test_marker, |_| None).unwrap();
987 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Zstd);
988 assert_eq!(prepared.original_size, json.len());
989 assert!(prepared.compression_candidate);
990 assert!(prepared.preserve_content_encoding);
991 assert!(should_forward_request_header("content-encoding", true));
992 assert!(!should_forward_request_header("content-encoding", false));
993
994 let decoded = zstd::decode_all(prepared.body.as_slice()).unwrap();
995 let parsed: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
996 assert_eq!(parsed["lean_ctx_touched"], true);
997 assert_eq!(parsed["model"], "gpt-5");
998 }
999
1000 #[test]
1001 fn gzip_request_bodies_are_rewritten_and_reencoded() {
1002 let body = serde_json::json!({"model": "gpt-5", "input": []});
1003 let json = serde_json::to_vec(&body).unwrap();
1004 let encoded = encode_gzip(&json).unwrap();
1005 let parts = Request::builder()
1006 .uri("/backend-api/codex/responses")
1007 .header(axum::http::header::CONTENT_ENCODING, "gzip")
1008 .body(())
1009 .unwrap()
1010 .into_parts()
1011 .0;
1012
1013 let prepared = prepare_request_body(&parts, &encoded, add_test_marker, |_| None).unwrap();
1014 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Gzip);
1015 assert_eq!(prepared.original_size, json.len());
1016 assert!(prepared.compression_candidate);
1017 assert!(prepared.preserve_content_encoding);
1018
1019 let decoded = decode_gzip_bounded(&prepared.body, max_body_bytes()).unwrap();
1020 let parsed: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
1021 assert_eq!(parsed["lean_ctx_touched"], true);
1022 assert_eq!(parsed["model"], "gpt-5");
1023 }
1024
1025 #[test]
1026 fn identity_content_encoding_can_be_rewritten_as_json() {
1027 let parts = Request::builder()
1028 .uri("/v1/responses")
1029 .header(axum::http::header::CONTENT_ENCODING, "identity")
1030 .body(())
1031 .unwrap()
1032 .into_parts()
1033 .0;
1034
1035 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Identity);
1036 }
1037
1038 #[test]
1039 fn unknown_encoded_request_bodies_stay_passthrough() {
1040 let parts = Request::builder()
1041 .uri("/v1/responses")
1042 .header(axum::http::header::CONTENT_ENCODING, "br")
1043 .body(())
1044 .unwrap()
1045 .into_parts()
1046 .0;
1047 let body = b"not-json";
1048
1049 let prepared = prepare_request_body(
1050 &parts,
1051 body,
1052 |_, _| panic!("unknown encodings must not be JSON-rewritten"),
1053 |_| None,
1054 )
1055 .unwrap();
1056
1057 assert_eq!(
1058 request_body_encoding(&parts),
1059 RequestBodyEncoding::Passthrough
1060 );
1061 assert_eq!(prepared.body, body);
1062 assert!(prepared.parsed.is_none());
1063 assert!(!prepared.compression_candidate);
1064 assert!(prepared.preserve_content_encoding);
1065 }
1066
1067 #[test]
1068 fn invalid_json_request_bodies_are_not_compression_candidates() {
1069 let parts = Request::builder()
1070 .uri("/v1/responses")
1071 .body(())
1072 .unwrap()
1073 .into_parts()
1074 .0;
1075 let body = b"not-json";
1076
1077 let prepared = prepare_request_body(
1078 &parts,
1079 body,
1080 |_, _| panic!("invalid JSON must not enter the compression pipeline"),
1081 |_| None,
1082 )
1083 .unwrap();
1084
1085 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Identity);
1086 assert_eq!(prepared.body, body);
1087 assert!(prepared.parsed.is_none());
1088 assert!(!prepared.compression_candidate);
1089 assert!(!prepared.preserve_content_encoding);
1090 }
1091
1092 #[test]
1093 fn upstream_url_preserves_subpath() {
1094 let base = "https://api.anthropic.com";
1095 let parts = parts_for("/v1/messages/count_tokens");
1096 assert_eq!(
1097 build_upstream_url(&parts, base, "/v1/messages"),
1098 "https://api.anthropic.com/v1/messages/count_tokens"
1099 );
1100 }
1101
1102 #[test]
1103 fn upstream_url_preserves_batches_subpath() {
1104 let base = "https://api.anthropic.com";
1105 let parts = parts_for("/v1/messages/batches/batch_123/results");
1106 assert_eq!(
1107 build_upstream_url(&parts, base, "/v1/messages"),
1108 "https://api.anthropic.com/v1/messages/batches/batch_123/results"
1109 );
1110 }
1111
1112 #[test]
1113 fn upstream_url_exact_path() {
1114 let base = "https://api.anthropic.com";
1115 let parts = parts_for("/v1/messages");
1116 assert_eq!(
1117 build_upstream_url(&parts, base, "/v1/messages"),
1118 "https://api.anthropic.com/v1/messages"
1119 );
1120 }
1121
1122 #[test]
1123 fn upstream_url_preserves_query_params() {
1124 let base = "https://api.anthropic.com";
1125 let parts = parts_for("/v1/messages/count_tokens?model=claude-4");
1126 assert_eq!(
1127 build_upstream_url(&parts, base, "/v1/messages"),
1128 "https://api.anthropic.com/v1/messages/count_tokens?model=claude-4"
1129 );
1130 }
1131
1132 #[test]
1133 fn forwards_openai_project_and_auth_headers() {
1134 for required in ["authorization", "openai-project", "openai-organization"] {
1138 assert!(
1139 ALLOWED_REQUEST_HEADERS.contains(&required),
1140 "request header `{required}` must be forwarded upstream"
1141 );
1142 }
1143 }
1144
1145 #[test]
1146 fn forwards_chatgpt_codex_oauth_headers() {
1147 for required in [
1148 "authorization",
1149 "chatgpt-account-id",
1150 "x-openai-fedramp",
1151 "x-openai-internal-codex-residency",
1152 "x-openai-product-sku",
1153 "oai-product-sku",
1154 "x-client-request-id",
1155 "x-codex-installation-id",
1156 "x-codex-turn-metadata",
1157 "x-openai-subagent",
1158 "x-codex-turn-state",
1159 "originator",
1160 ] {
1161 assert!(
1162 is_allowed_request_header(required),
1163 "request header `{required}` must be forwarded upstream"
1164 );
1165 }
1166 }
1167
1168 #[test]
1169 fn forwards_streamable_http_mcp_headers() {
1170 for required in ["mcp-session-id", "last-event-id"] {
1171 assert!(
1172 ALLOWED_REQUEST_HEADERS.contains(&required),
1173 "request header `{required}` must be forwarded upstream"
1174 );
1175 }
1176 assert!(
1177 is_forwarded_response_header("mcp-session-id"),
1178 "MCP session id response header must be forwarded downstream"
1179 );
1180 }
1181
1182 #[test]
1183 fn forwards_codex_state_response_headers() {
1184 for required in [
1185 "x-codex-turn-state",
1186 "x-codex-primary-used-percent",
1187 "openai-model",
1188 "x-models-etag",
1189 "x-reasoning-included",
1190 "x-oai-request-id",
1191 "cf-ray",
1192 "x-openai-authorization-error",
1193 "x-error-json",
1194 ] {
1195 assert!(
1196 is_forwarded_response_header(required),
1197 "response header `{required}` must be forwarded downstream"
1198 );
1199 }
1200 }
1201
1202 #[test]
1203 fn chatgpt_responses_use_openai_responses_holdout_key() {
1204 let _iso = crate::core::data_dir::isolated_data_dir();
1205 crate::core::config::Config::update_global(|c| {
1206 c.proxy.output_holdout = Some(1.0);
1207 })
1208 .unwrap();
1209
1210 let body = serde_json::json!({
1211 "model": "gpt-5",
1212 "input": "same conversation",
1213 });
1214
1215 assert_eq!(
1216 cohort_arm(&body, "ChatGPT", "/backend-api/codex/responses"),
1217 cohort_arm(&body, "OpenAI", "/v1/responses")
1218 );
1219 }
1220}