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(
105 &parts,
106 &body_bytes,
107 compress_body,
108 route_hook,
109 upstream_base,
110 provider_label == "OpenAI",
111 )?;
112 let original_size = prepared.original_size;
113 let compressed_size = prepared.compressed_size;
114 let compression_candidate = prepared.compression_candidate;
115 let preserve_content_encoding = prepared.preserve_content_encoding;
116 let route = prepared.route;
117 let parsed = prepared.parsed;
118
119 let upstream_base = route
122 .as_ref()
123 .and_then(|r| r.upstream_base.as_deref())
124 .unwrap_or(upstream_base);
125 if let Some(provider) = route.as_ref().and_then(|r| r.credential.as_ref()) {
126 super::providers::inject_gateway_credential(provider, &mut parts.headers)?;
127 }
128 if let Some(ref parsed) = parsed {
129 let provider = match provider_label {
130 "Anthropic" => super::introspect::Provider::Anthropic,
131 "OpenAI" | "ChatGPT" => super::introspect::Provider::OpenAi,
132 _ => super::introspect::Provider::Gemini,
133 };
134 let breakdown = super::introspect::analyze_request(parsed, provider);
135 state.introspect.record(breakdown);
136 }
137
138 let cohort = parsed
141 .as_ref()
142 .and_then(|p| cohort_arm(p, provider_label, default_path));
143
144 if compression_candidate {
145 state
146 .stats
147 .record_provider_request(provider_label, original_size, compressed_size);
148 }
149
150 let tokens_saved = original_size.saturating_sub(compressed_size) as u64 / 4;
151 super::metrics::record_request(tokens_saved, compressed_size as u64);
152
153 let model = parsed
154 .as_ref()
155 .and_then(|v| v.get("model"))
156 .and_then(|m| m.as_str());
157 super::cost::record(
158 model,
159 tokens_saved,
160 original_size as u64,
161 compressed_size as u64,
162 );
163
164 let xlat = route.as_ref().is_some_and(|r| r.xlat);
168 let upstream_url = if xlat {
169 format!("{upstream_base}/v1/chat/completions")
170 } else {
171 build_upstream_url(&parts, upstream_base, default_path)
172 };
173
174 let counterfactual = if provider_label == "Anthropic" && !xlat {
182 super::counterfactual::maybe_spawn_probe(
183 &state.client,
184 &parts,
185 upstream_base,
186 parsed.as_ref(),
187 route.as_ref().map(|r| r.routed_from.as_str()),
188 compressed_size < original_size,
189 )
190 } else {
191 None
192 };
193
194 let response = send_upstream(
195 &state,
196 &parts,
197 &upstream_url,
198 prepared.body,
199 provider_label,
200 preserve_content_encoding,
201 )
202 .await?;
203
204 let usage_provider = if xlat {
208 super::usage::Provider::OpenAi
209 } else {
210 super::usage::Provider::from_label(provider_label)
211 };
212 let url_model = if usage_provider == super::usage::Provider::Gemini {
213 super::usage::gemini_model_from_path(parts.uri.path())
214 } else {
215 None
216 };
217
218 let mut wire = wire_context(
223 &parts,
224 provider_label,
225 upstream_base,
226 tokens_saved,
227 original_size,
228 );
229 if let Some(route) = &route {
230 wire.routed_from = Some(route.routed_from.clone());
231 if let Some(id) = &route.provider_id {
232 wire.provider = id.clone();
233 }
234 if let Some(local) = route.local {
237 wire.is_local = local;
238 }
239 }
240 wire.counterfactual = counterfactual;
241 let wire = Some(wire);
242
243 build_response(
244 response,
245 extra_stream_types,
246 usage_provider,
247 url_model,
248 cohort,
249 wire,
250 xlat,
251 )
252 .await
253}
254
255fn requested_model_of(parts: &Parts, body_bytes: &[u8]) -> Option<String> {
260 if let Some(m) = super::usage::gemini_model_from_path(parts.uri.path()) {
261 return Some(m);
262 }
263 let decoded: Cow<'_, [u8]> = match request_body_encoding(parts) {
264 RequestBodyEncoding::Identity => Cow::Borrowed(body_bytes),
265 RequestBodyEncoding::Gzip => {
266 Cow::Owned(decode_gzip_bounded(body_bytes, max_body_bytes()).ok()?)
267 }
268 RequestBodyEncoding::Zstd => {
269 Cow::Owned(decode_zstd_bounded(body_bytes, max_body_bytes()).ok()?)
270 }
271 RequestBodyEncoding::Passthrough => return None,
272 };
273 let v: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
274 v.get("model")?.as_str().map(str::to_string)
275}
276
277fn wire_context(
283 parts: &Parts,
284 provider_label: &str,
285 upstream_base: &str,
286 tokens_saved: u64,
287 original_size: usize,
288) -> Box<super::usage::WireContext> {
289 let tags = parts
290 .extensions
291 .get::<super::gateway_identity::GatewayTags>()
292 .cloned()
293 .unwrap_or_default();
294 let registry = parts
299 .extensions
300 .get::<super::providers::RegistryProviderId>();
301 let provider = registry.map_or(provider_label, |r| r.id.as_str());
302 let is_local = registry.map_or_else(|| upstream_is_local(upstream_base), |r| r.local);
303 Box::new(super::usage::WireContext {
304 provider: provider.to_string(),
305 person: tags.person,
306 team: tags.team,
307 project: tags.project,
308 saved_tokens: tokens_saved,
309 uncompressed_input_tokens: original_size as u64 / 4,
311 is_local,
312 routed_from: None, counterfactual: None, })
315}
316
317fn upstream_is_local(upstream_base: &str) -> bool {
321 let rest = upstream_base
322 .strip_prefix("https://")
323 .or_else(|| upstream_base.strip_prefix("http://"))
324 .unwrap_or(upstream_base);
325 let host_port = rest.split(['/', '?']).next().unwrap_or(rest);
326 let host = if let Some(b) = host_port.strip_prefix('[') {
328 b.split(']').next().unwrap_or(b)
329 } else {
330 host_port.split(':').next().unwrap_or(host_port)
331 };
332 matches!(host, "127.0.0.1" | "localhost" | "::1" | "0.0.0.0")
333}
334
335fn cohort_arm(
339 parsed: &serde_json::Value,
340 provider_label: &str,
341 default_path: &str,
342) -> Option<super::holdout::Arm> {
343 let holdout = crate::core::config::Config::load()
344 .proxy
345 .output_holdout_fraction();
346 if holdout <= 0.0 {
347 return None;
348 }
349 let key = match provider_label {
350 "Anthropic" => super::holdout::anthropic_key(parsed),
351 "OpenAI" | "ChatGPT" => {
352 if default_path.contains("responses") {
353 super::holdout::openai_responses_key(parsed)
354 } else {
355 super::holdout::openai_chat_key(parsed)
356 }
357 }
358 _ => super::holdout::google_key(parsed),
359 };
360 Some(super::holdout::assign(&key, holdout))
361}
362
363struct PreparedRequestBody {
364 body: Vec<u8>,
365 parsed: Option<serde_json::Value>,
366 original_size: usize,
367 compressed_size: usize,
368 compression_candidate: bool,
369 preserve_content_encoding: bool,
370 route: Option<super::routing::RouteDecision>,
372}
373
374#[derive(Clone, Copy, Debug, Eq, PartialEq)]
375enum RequestBodyEncoding {
376 Identity,
377 Gzip,
378 Zstd,
379 Passthrough,
380}
381
382fn prepare_request_body(
383 parts: &Parts,
384 body_bytes: &[u8],
385 compress_body: impl FnOnce(serde_json::Value, usize) -> (Vec<u8>, usize, usize),
386 route_hook: impl FnOnce(&mut serde_json::Value) -> Option<super::routing::RouteDecision>,
387 default_upstream_base: &str,
388 openai_shape: bool,
389) -> Result<PreparedRequestBody, StatusCode> {
390 let encoding = request_body_encoding(parts);
391 let decoded = match encoding {
392 RequestBodyEncoding::Identity => Cow::Borrowed(body_bytes),
393 RequestBodyEncoding::Gzip => Cow::Owned(decode_gzip_bounded(body_bytes, max_body_bytes())?),
394 RequestBodyEncoding::Zstd => Cow::Owned(decode_zstd_bounded(body_bytes, max_body_bytes())?),
395 RequestBodyEncoding::Passthrough => {
396 return Ok(PreparedRequestBody {
397 body: body_bytes.to_vec(),
398 parsed: None,
399 original_size: body_bytes.len(),
400 compressed_size: body_bytes.len(),
401 compression_candidate: false,
402 preserve_content_encoding: true,
403 route: None,
404 });
405 }
406 };
407
408 let Some(mut parsed) = serde_json::from_slice::<serde_json::Value>(&decoded).ok() else {
409 return Ok(PreparedRequestBody {
410 body: body_bytes.to_vec(),
411 parsed: None,
412 original_size: body_bytes.len(),
413 compressed_size: body_bytes.len(),
414 compression_candidate: false,
415 preserve_content_encoding: encoding != RequestBodyEncoding::Identity,
416 route: None,
417 });
418 };
419
420 let mut route = route_hook(&mut parsed);
423
424 let effective_upstream = route
429 .as_ref()
430 .and_then(|r| r.upstream_base.as_deref())
431 .unwrap_or(default_upstream_base);
432 let wants_billed_cost = super::usage_accounting::upstream_is_openrouter(effective_upstream)
433 && crate::core::config::Config::load()
434 .proxy
435 .meters_openai_usage();
436 let xlat_route = route.as_ref().is_some_and(|r| r.xlat);
437 let chat_completions_call = openai_shape
440 && parts
441 .uri
442 .path()
443 .trim_end_matches('/')
444 .ends_with("/chat/completions");
445 if wants_billed_cost && chat_completions_call && !xlat_route {
446 super::usage_accounting::inject_usage_include(&mut parsed);
447 }
448
449 let original_size = decoded.len();
450 let (logical_body, _, compressed_size) =
454 if let Some(mut openai_body) = translated_openai_body(route.as_ref(), &parsed) {
455 if wants_billed_cost {
456 super::usage_accounting::inject_usage_include(&mut openai_body);
457 }
458 super::openai::compress_request_body(openai_body, original_size)
459 } else {
460 if route.as_ref().is_some_and(|r| r.xlat) {
461 let decision = route.take().expect("checked is_some");
462 tracing::warn!(
463 "lean-ctx proxy: request not translatable to OpenAI shape — \
464 cancelling route to '{}', forwarding natively",
465 decision.provider_id.as_deref().unwrap_or("?")
466 );
467 parsed["model"] = serde_json::Value::String(decision.routed_from);
468 }
469 compress_body(parsed.clone(), original_size)
470 };
471 let body = match encoding {
472 RequestBodyEncoding::Identity => logical_body,
473 RequestBodyEncoding::Gzip => encode_gzip(&logical_body)?,
474 RequestBodyEncoding::Zstd => encode_zstd(&logical_body)?,
475 RequestBodyEncoding::Passthrough => unreachable!("passthrough returned above"),
476 };
477
478 Ok(PreparedRequestBody {
479 body,
480 parsed: Some(parsed),
481 original_size,
482 compressed_size,
483 compression_candidate: true,
484 preserve_content_encoding: encoding != RequestBodyEncoding::Identity,
485 route,
486 })
487}
488
489#[cfg(feature = "shape-xlat")]
492fn translated_openai_body(
493 route: Option<&super::routing::RouteDecision>,
494 parsed: &serde_json::Value,
495) -> Option<serde_json::Value> {
496 route
497 .filter(|r| r.xlat)
498 .and_then(|_| super::shape_xlat::messages_to_chat(parsed))
499}
500
501#[cfg(not(feature = "shape-xlat"))]
502fn translated_openai_body(
503 _route: Option<&super::routing::RouteDecision>,
504 _parsed: &serde_json::Value,
505) -> Option<serde_json::Value> {
506 None
507}
508
509fn build_upstream_url(parts: &Parts, base: &str, default_path: &str) -> String {
510 format!(
511 "{base}{}",
512 parts
513 .uri
514 .path_and_query()
515 .map_or(default_path, axum::http::uri::PathAndQuery::as_str)
516 )
517}
518
519pub(super) const ALLOWED_REQUEST_HEADERS: &[&str] = &[
527 "authorization",
528 "x-api-key",
529 "api-key",
531 "content-type",
532 "accept",
533 "user-agent",
534 "originator",
535 "anthropic-version",
536 "anthropic-beta",
537 "anthropic-dangerous-direct-browser-access",
538 "openai-organization",
539 "openai-project",
540 "openai-beta",
541 "chatgpt-account-id",
542 "x-openai-fedramp",
543 "x-openai-internal-codex-residency",
544 "x-openai-internal-codex-responses-lite",
545 "x-openai-product-sku",
546 "oai-product-sku",
547 "x-oai-attestation",
548 "x-client-request-id",
549 "x-codex-beta-features",
550 "x-codex-installation-id",
551 "x-codex-parent-thread-id",
552 "x-openai-subagent",
553 "x-codex-turn-state",
554 "x-codex-turn-metadata",
555 "x-codex-window-id",
556 "x-openai-memgen-request",
557 "x-responsesapi-include-timing-metrics",
558 "mcp-session-id",
559 "last-event-id",
560 "cache-control",
561 "x-goog-api-key",
562 "x-goog-api-client",
563];
564
565pub(super) fn is_allowed_request_header(name: &str) -> bool {
566 ALLOWED_REQUEST_HEADERS.contains(&name)
567}
568
569fn should_forward_request_header(name: &str, preserve_content_encoding: bool) -> bool {
570 is_allowed_request_header(name)
571 || (preserve_content_encoding && name.eq_ignore_ascii_case("content-encoding"))
572}
573
574fn request_body_encoding(parts: &Parts) -> RequestBodyEncoding {
575 let Some(value) = parts
576 .headers
577 .get(axum::http::header::CONTENT_ENCODING)
578 .and_then(|value| value.to_str().ok())
579 else {
580 return RequestBodyEncoding::Identity;
581 };
582
583 let encodings = value
584 .split(',')
585 .map(str::trim)
586 .filter(|part| !part.is_empty() && !part.eq_ignore_ascii_case("identity"))
587 .collect::<Vec<_>>();
588 match encodings.as_slice() {
589 [] => RequestBodyEncoding::Identity,
590 [encoding] if encoding.eq_ignore_ascii_case("gzip") => RequestBodyEncoding::Gzip,
591 [encoding] if encoding.eq_ignore_ascii_case("zstd") => RequestBodyEncoding::Zstd,
592 _ => RequestBodyEncoding::Passthrough,
593 }
594}
595
596fn decode_zstd_bounded(data: &[u8], max_bytes: usize) -> Result<Vec<u8>, StatusCode> {
597 let decoder = zstd::Decoder::new(data).map_err(|e| {
598 tracing::warn!("lean-ctx proxy: invalid zstd request body: {e}");
599 StatusCode::BAD_REQUEST
600 })?;
601 read_bounded(decoder, max_bytes).inspect_err(|e| {
602 tracing::warn!("lean-ctx proxy: zstd request decode failed: {e}");
603 })
604}
605
606fn encode_zstd(data: &[u8]) -> Result<Vec<u8>, StatusCode> {
607 zstd::encode_all(data, 3).map_err(|e| {
608 tracing::error!("lean-ctx proxy: zstd request encode failed: {e}");
609 StatusCode::INTERNAL_SERVER_ERROR
610 })
611}
612
613fn decode_gzip_bounded(data: &[u8], max_bytes: usize) -> Result<Vec<u8>, StatusCode> {
614 read_bounded(GzDecoder::new(data), max_bytes).inspect_err(|e| {
615 tracing::warn!("lean-ctx proxy: gzip request decode failed: {e}");
616 })
617}
618
619fn encode_gzip(data: &[u8]) -> Result<Vec<u8>, StatusCode> {
620 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
621 encoder.write_all(data).map_err(|e| {
622 tracing::error!("lean-ctx proxy: gzip request encode failed: {e}");
623 StatusCode::INTERNAL_SERVER_ERROR
624 })?;
625 encoder.finish().map_err(|e| {
626 tracing::error!("lean-ctx proxy: gzip request encode failed: {e}");
627 StatusCode::INTERNAL_SERVER_ERROR
628 })
629}
630
631fn read_bounded<R: Read>(reader: R, max_bytes: usize) -> Result<Vec<u8>, StatusCode> {
632 let mut limited = reader.take(max_bytes as u64 + 1);
633 let mut out = Vec::new();
634 limited
635 .read_to_end(&mut out)
636 .map_err(|_| StatusCode::BAD_REQUEST)?;
637 if out.len() > max_bytes {
638 return Err(StatusCode::PAYLOAD_TOO_LARGE);
639 }
640 Ok(out)
641}
642
643fn is_retryable_status(status: reqwest::StatusCode) -> bool {
647 matches!(status.as_u16(), 429 | 502 | 503)
648}
649
650async fn retry_backoff() {
654 let mut buf = [0u8; 2];
655 let jitter_ms =
656 getrandom::fill(&mut buf).map_or(100, |()| u64::from(u16::from_le_bytes(buf)) % 200);
657 tokio::time::sleep(std::time::Duration::from_millis(150 + jitter_ms)).await;
658}
659
660async fn send_upstream(
661 state: &ProxyState,
662 parts: &Parts,
663 url: &str,
664 body: Vec<u8>,
665 provider_label: &str,
666 preserve_content_encoding: bool,
667) -> Result<reqwest::Response, StatusCode> {
668 let send_once = |body: Vec<u8>| {
669 let mut req = state.client.request(parts.method.clone(), url);
670 for (key, value) in &parts.headers {
671 let k = key.as_str().to_lowercase();
672 if should_forward_request_header(&k, preserve_content_encoding) {
673 req = req.header(key.clone(), value.clone());
674 }
675 }
676 req.body(body).send()
677 };
678
679 let first = send_once(body.clone()).await;
683 let retry_reason = match &first {
684 Ok(resp) if is_retryable_status(resp.status()) => {
685 format!("status {}", resp.status().as_u16())
686 }
687 Err(e) if e.is_connect() || e.is_timeout() => format!("connect error: {e}"),
688 Ok(resp) => {
689 let _ = resp; return first.map_err(|_| StatusCode::BAD_GATEWAY);
691 }
692 Err(e) => {
693 tracing::error!("lean-ctx proxy: {provider_label} upstream error: {e}");
694 return Err(StatusCode::BAD_GATEWAY);
695 }
696 };
697
698 tracing::warn!("lean-ctx proxy: {provider_label} upstream {retry_reason} — retrying once");
699 retry_backoff().await;
700 match send_once(body).await {
701 Ok(resp) => Ok(resp),
702 Err(e) => {
703 tracing::error!("lean-ctx proxy: {provider_label} retry failed: {e}");
706 first.map_err(|_| StatusCode::BAD_GATEWAY)
707 }
708 }
709}
710
711pub(super) const FORWARDED_HEADERS: &[&str] = &[
712 "content-type",
713 "content-encoding",
714 "mcp-session-id",
715 "x-request-id",
716 "x-oai-request-id",
717 "cf-ray",
718 "x-openai-authorization-error",
719 "x-error-json",
720 "openai-organization",
721 "openai-model",
722 "openai-processing-ms",
723 "openai-version",
724 "x-models-etag",
725 "x-reasoning-included",
726 "anthropic-ratelimit-requests-limit",
727 "anthropic-ratelimit-requests-remaining",
728 "anthropic-ratelimit-tokens-limit",
729 "anthropic-ratelimit-tokens-remaining",
730 "retry-after",
731 "x-ratelimit-limit-requests",
732 "x-ratelimit-remaining-requests",
733 "x-ratelimit-limit-tokens",
734 "x-ratelimit-remaining-tokens",
735 "cache-control",
736];
737
738pub(super) fn is_forwarded_response_header(name: &str) -> bool {
739 FORWARDED_HEADERS.contains(&name)
740 || name.starts_with("x-codex-")
741 || name.starts_with("x-ratelimit-")
742}
743
744#[allow(clippy::too_many_arguments)]
745async fn build_response(
746 response: reqwest::Response,
747 extra_stream_types: &[&str],
748 usage_provider: super::usage::Provider,
749 url_model: Option<String>,
750 cohort: Option<super::holdout::Arm>,
751 wire: Option<Box<super::usage::WireContext>>,
752 xlat: bool,
753) -> Result<Response, StatusCode> {
754 let status = StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::OK);
755 let resp_headers = response.headers().clone();
756
757 let extra_cost_header = crate::core::config::Config::load()
761 .proxy
762 .cost_response_header();
763 let header_cost =
764 super::usage_accounting::cost_from_headers(&resp_headers, extra_cost_header.as_deref());
765
766 let is_stream = resp_headers
767 .get("content-type")
768 .and_then(|v| v.to_str().ok())
769 .is_some_and(|ct| {
770 ct.contains("text/event-stream") || extra_stream_types.iter().any(|t| ct.contains(t))
771 });
772
773 if is_stream {
774 let scanner = super::usage::Scanner::new(usage_provider, url_model)
780 .with_cohort(cohort)
781 .with_wire_context(wire)
782 .with_header_cost(header_cost);
783 let inner = Box::pin(response.bytes_stream());
784 let teed = Box::pin(super::usage::tee_stream(inner, scanner));
785 let body = xlat_stream_body(teed, xlat);
786 let mut resp = Response::builder().status(status);
787 for (k, v) in &resp_headers {
788 let ks = k.as_str().to_lowercase();
789 if is_forwarded_response_header(&ks) {
790 resp = resp.header(k, v);
791 }
792 }
793 return resp
794 .body(body)
795 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR);
796 }
797
798 let resp_bytes = response
799 .bytes()
800 .await
801 .map_err(|_| StatusCode::BAD_GATEWAY)?;
802
803 let mut scanner = super::usage::Scanner::new(usage_provider, url_model)
805 .with_cohort(cohort)
806 .with_wire_context(wire)
807 .with_header_cost(header_cost);
808 scanner.feed_body(&resp_bytes);
809 if let Some(usage) = scanner.finalize() {
810 super::usage_meter::record(&usage);
811 }
812
813 let resp_bytes = if xlat {
814 xlat_response_bytes(&resp_bytes, status)
815 } else {
816 resp_bytes.to_vec()
817 };
818
819 let mut resp = Response::builder().status(status);
820 for (k, v) in &resp_headers {
821 let ks = k.as_str().to_lowercase();
822 if is_forwarded_response_header(&ks) {
823 resp = resp.header(k, v);
824 }
825 }
826 resp.body(Body::from(resp_bytes))
827 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
828}
829
830#[cfg(feature = "shape-xlat")]
832fn xlat_stream_body<S>(teed: S, xlat: bool) -> Body
833where
834 S: futures::Stream<Item = Result<axum::body::Bytes, reqwest::Error>> + Send + Unpin + 'static,
835{
836 if xlat {
837 Body::from_stream(super::shape_xlat::to_anthropic_stream(teed))
838 } else {
839 Body::from_stream(teed)
840 }
841}
842
843#[cfg(not(feature = "shape-xlat"))]
844fn xlat_stream_body<S>(teed: S, _xlat: bool) -> Body
845where
846 S: futures::Stream<Item = Result<axum::body::Bytes, reqwest::Error>> + Send + Unpin + 'static,
847{
848 Body::from_stream(teed)
849}
850
851#[cfg(feature = "shape-xlat")]
855fn xlat_response_bytes(resp_bytes: &[u8], status: StatusCode) -> Vec<u8> {
856 let translated = serde_json::from_slice::<serde_json::Value>(resp_bytes)
857 .ok()
858 .and_then(|v| {
859 if status.is_success() {
860 super::shape_xlat::chat_to_messages(&v)
861 } else {
862 super::shape_xlat::error_to_anthropic(&v)
863 }
864 });
865 if let Some(v) = translated {
866 serde_json::to_vec(&v).unwrap_or_else(|_| resp_bytes.to_vec())
867 } else {
868 tracing::warn!(
869 "lean-ctx proxy: cross-shape response not translatable (status {status}) — \
870 forwarding raw body"
871 );
872 resp_bytes.to_vec()
873 }
874}
875
876#[cfg(not(feature = "shape-xlat"))]
877fn xlat_response_bytes(resp_bytes: &[u8], _status: StatusCode) -> Vec<u8> {
878 resp_bytes.to_vec()
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884
885 fn parts_for(uri: &str) -> Parts {
886 Request::builder().uri(uri).body(()).unwrap().into_parts().0
887 }
888
889 fn add_test_marker(
890 mut value: serde_json::Value,
891 original_size: usize,
892 ) -> (Vec<u8>, usize, usize) {
893 value["lean_ctx_touched"] = serde_json::Value::Bool(true);
894 let out = serde_json::to_vec(&value).unwrap();
895 let compressed_size = out.len();
896 (out, original_size, compressed_size)
897 }
898
899 #[test]
902 fn upstream_is_local_detects_loopback_hosts() {
903 for local in [
904 "http://127.0.0.1:11434",
905 "http://localhost:8080/v1",
906 "http://[::1]:9999",
907 "http://0.0.0.0:4000",
908 ] {
909 assert!(upstream_is_local(local), "{local} must count as local");
910 }
911 for remote in [
912 "https://api.anthropic.com",
913 "https://acme.services.ai.azure.com/openai",
914 "https://localhost.evil.example.com", ] {
916 assert!(!upstream_is_local(remote), "{remote} must not be local");
917 }
918 }
919
920 #[test]
921 fn wire_context_carries_identity_tags_and_baseline() {
922 let mut parts = parts_for("/v1/messages");
923 parts
924 .extensions
925 .insert(super::super::gateway_identity::GatewayTags {
926 person: Some("yves".into()),
927 team: Some("platform".into()),
928 project: Some("billing".into()),
929 });
930 let wire = wire_context(&parts, "Anthropic", "https://api.anthropic.com", 750, 4000);
931 assert_eq!(wire.provider, "Anthropic");
932 assert_eq!(wire.person.as_deref(), Some("yves"));
933 assert_eq!(wire.team.as_deref(), Some("platform"));
934 assert_eq!(wire.project.as_deref(), Some("billing"));
935 assert_eq!(wire.saved_tokens, 750);
936 assert_eq!(wire.uncompressed_input_tokens, 1000);
938 assert!(!wire.is_local);
939 assert_eq!(wire.routed_from, None);
940 }
941
942 #[test]
943 fn wire_context_prefers_registry_provider_id_over_shape_label() {
944 let mut parts = parts_for("/v1/chat/completions");
947 parts
948 .extensions
949 .insert(super::super::providers::RegistryProviderId {
950 id: "local".into(),
951 local: false,
952 });
953 let wire = wire_context(&parts, "OpenAI", "http://127.0.0.1:11434", 0, 400);
954 assert_eq!(wire.provider, "local");
955 }
956
957 #[test]
958 fn wire_context_registry_local_flag_beats_url_heuristic() {
959 let mut parts = parts_for("/v1/chat/completions");
964 parts
965 .extensions
966 .insert(super::super::providers::RegistryProviderId {
967 id: "local".into(),
968 local: true,
969 });
970 let wire = wire_context(
971 &parts,
972 "OpenAI",
973 "http://host.docker.internal:11434",
974 0,
975 400,
976 );
977 assert!(wire.is_local, "declared local flag must win");
978
979 let mut parts = parts_for("/v1/chat/completions");
980 parts
981 .extensions
982 .insert(super::super::providers::RegistryProviderId {
983 id: "tunnel".into(),
984 local: false,
985 });
986 let wire = wire_context(&parts, "OpenAI", "http://127.0.0.1:9999", 0, 400);
987 assert!(!wire.is_local, "declared non-local flag must win");
988 }
989
990 #[test]
993 fn retry_covers_exactly_not_processed_statuses() {
994 for code in [429_u16, 502, 503] {
996 assert!(
997 is_retryable_status(reqwest::StatusCode::from_u16(code).unwrap()),
998 "{code} must be retryable"
999 );
1000 }
1001 for code in [200_u16, 400, 401, 404, 500, 504] {
1003 assert!(
1004 !is_retryable_status(reqwest::StatusCode::from_u16(code).unwrap()),
1005 "{code} must NOT be retryable"
1006 );
1007 }
1008 }
1009
1010 #[test]
1011 fn wire_context_without_tags_still_carries_baseline() {
1012 let parts = parts_for("/v1/chat/completions");
1014 let wire = wire_context(&parts, "OpenAI", "http://127.0.0.1:11434", 0, 400);
1015 assert_eq!(wire.person, None);
1016 assert_eq!(wire.project, None);
1017 assert_eq!(wire.uncompressed_input_tokens, 100);
1018 assert!(wire.is_local);
1019 }
1020
1021 #[test]
1022 fn zstd_request_bodies_are_rewritten_and_reencoded() {
1023 let body = serde_json::json!({"model": "gpt-5", "input": []});
1024 let json = serde_json::to_vec(&body).unwrap();
1025 let encoded = encode_zstd(&json).unwrap();
1026 let parts = Request::builder()
1027 .uri("/backend-api/codex/responses")
1028 .header(axum::http::header::CONTENT_ENCODING, "zstd")
1029 .body(())
1030 .unwrap()
1031 .into_parts()
1032 .0;
1033
1034 let prepared = prepare_request_body(
1035 &parts,
1036 &encoded,
1037 add_test_marker,
1038 |_| None,
1039 "https://api.openai.com",
1040 false,
1041 )
1042 .unwrap();
1043 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Zstd);
1044 assert_eq!(prepared.original_size, json.len());
1045 assert!(prepared.compression_candidate);
1046 assert!(prepared.preserve_content_encoding);
1047 assert!(should_forward_request_header("content-encoding", true));
1048 assert!(!should_forward_request_header("content-encoding", false));
1049
1050 let decoded = zstd::decode_all(prepared.body.as_slice()).unwrap();
1051 let parsed: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
1052 assert_eq!(parsed["lean_ctx_touched"], true);
1053 assert_eq!(parsed["model"], "gpt-5");
1054 }
1055
1056 #[test]
1057 fn gzip_request_bodies_are_rewritten_and_reencoded() {
1058 let body = serde_json::json!({"model": "gpt-5", "input": []});
1059 let json = serde_json::to_vec(&body).unwrap();
1060 let encoded = encode_gzip(&json).unwrap();
1061 let parts = Request::builder()
1062 .uri("/backend-api/codex/responses")
1063 .header(axum::http::header::CONTENT_ENCODING, "gzip")
1064 .body(())
1065 .unwrap()
1066 .into_parts()
1067 .0;
1068
1069 let prepared = prepare_request_body(
1070 &parts,
1071 &encoded,
1072 add_test_marker,
1073 |_| None,
1074 "https://api.openai.com",
1075 false,
1076 )
1077 .unwrap();
1078 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Gzip);
1079 assert_eq!(prepared.original_size, json.len());
1080 assert!(prepared.compression_candidate);
1081 assert!(prepared.preserve_content_encoding);
1082
1083 let decoded = decode_gzip_bounded(&prepared.body, max_body_bytes()).unwrap();
1084 let parsed: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
1085 assert_eq!(parsed["lean_ctx_touched"], true);
1086 assert_eq!(parsed["model"], "gpt-5");
1087 }
1088
1089 #[test]
1090 fn openrouter_chat_requests_opt_into_billed_cost() {
1091 let _iso = crate::core::data_dir::isolated_data_dir();
1092 let body = serde_json::json!({"model": "deepseek/deepseek-v4-flash", "messages": []});
1093 let json = serde_json::to_vec(&body).unwrap();
1094 let parts = parts_for("/v1/chat/completions");
1095
1096 let prepared = prepare_request_body(
1097 &parts,
1098 &json,
1099 add_test_marker,
1100 |_| None,
1101 "https://openrouter.ai/api",
1102 true,
1103 )
1104 .unwrap();
1105 let parsed: serde_json::Value = serde_json::from_slice(&prepared.body).unwrap();
1106 assert_eq!(
1107 parsed["usage"]["include"], true,
1108 "OpenRouter chat requests must ask for the billed cost (#1179)"
1109 );
1110 }
1111
1112 #[test]
1113 fn non_openrouter_upstreams_never_carry_the_usage_opt_in() {
1114 let _iso = crate::core::data_dir::isolated_data_dir();
1115 let body = serde_json::json!({"model": "gpt-5.5", "messages": []});
1116 let json = serde_json::to_vec(&body).unwrap();
1117 let parts = parts_for("/v1/chat/completions");
1118
1119 let prepared = prepare_request_body(
1120 &parts,
1121 &json,
1122 add_test_marker,
1123 |_| None,
1124 "https://api.openai.com",
1125 true,
1126 )
1127 .unwrap();
1128 let parsed: serde_json::Value = serde_json::from_slice(&prepared.body).unwrap();
1129 assert!(
1130 parsed.get("usage").is_none(),
1131 "api.openai.com rejects unknown top-level params — no injection"
1132 );
1133 }
1134
1135 #[test]
1136 fn responses_api_bodies_never_carry_the_usage_opt_in() {
1137 let _iso = crate::core::data_dir::isolated_data_dir();
1138 let body = serde_json::json!({"model": "gpt-5.5", "input": []});
1139 let json = serde_json::to_vec(&body).unwrap();
1140 let parts = parts_for("/v1/responses");
1141
1142 let prepared = prepare_request_body(
1143 &parts,
1144 &json,
1145 add_test_marker,
1146 |_| None,
1147 "https://openrouter.ai/api",
1148 true,
1149 )
1150 .unwrap();
1151 let parsed: serde_json::Value = serde_json::from_slice(&prepared.body).unwrap();
1152 assert!(
1153 parsed.get("usage").is_none(),
1154 "`usage.include` is Chat-Completions-only — Responses bodies stay clean"
1155 );
1156 }
1157
1158 #[test]
1159 fn identity_content_encoding_can_be_rewritten_as_json() {
1160 let parts = Request::builder()
1161 .uri("/v1/responses")
1162 .header(axum::http::header::CONTENT_ENCODING, "identity")
1163 .body(())
1164 .unwrap()
1165 .into_parts()
1166 .0;
1167
1168 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Identity);
1169 }
1170
1171 #[test]
1172 fn unknown_encoded_request_bodies_stay_passthrough() {
1173 let parts = Request::builder()
1174 .uri("/v1/responses")
1175 .header(axum::http::header::CONTENT_ENCODING, "br")
1176 .body(())
1177 .unwrap()
1178 .into_parts()
1179 .0;
1180 let body = b"not-json";
1181
1182 let prepared = prepare_request_body(
1183 &parts,
1184 body,
1185 |_, _| panic!("unknown encodings must not be JSON-rewritten"),
1186 |_| None,
1187 "https://api.openai.com",
1188 false,
1189 )
1190 .unwrap();
1191
1192 assert_eq!(
1193 request_body_encoding(&parts),
1194 RequestBodyEncoding::Passthrough
1195 );
1196 assert_eq!(prepared.body, body);
1197 assert!(prepared.parsed.is_none());
1198 assert!(!prepared.compression_candidate);
1199 assert!(prepared.preserve_content_encoding);
1200 }
1201
1202 #[test]
1203 fn invalid_json_request_bodies_are_not_compression_candidates() {
1204 let parts = Request::builder()
1205 .uri("/v1/responses")
1206 .body(())
1207 .unwrap()
1208 .into_parts()
1209 .0;
1210 let body = b"not-json";
1211
1212 let prepared = prepare_request_body(
1213 &parts,
1214 body,
1215 |_, _| panic!("invalid JSON must not enter the compression pipeline"),
1216 |_| None,
1217 "https://api.openai.com",
1218 false,
1219 )
1220 .unwrap();
1221
1222 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Identity);
1223 assert_eq!(prepared.body, body);
1224 assert!(prepared.parsed.is_none());
1225 assert!(!prepared.compression_candidate);
1226 assert!(!prepared.preserve_content_encoding);
1227 }
1228
1229 #[test]
1230 fn upstream_url_preserves_subpath() {
1231 let base = "https://api.anthropic.com";
1232 let parts = parts_for("/v1/messages/count_tokens");
1233 assert_eq!(
1234 build_upstream_url(&parts, base, "/v1/messages"),
1235 "https://api.anthropic.com/v1/messages/count_tokens"
1236 );
1237 }
1238
1239 #[test]
1240 fn upstream_url_preserves_batches_subpath() {
1241 let base = "https://api.anthropic.com";
1242 let parts = parts_for("/v1/messages/batches/batch_123/results");
1243 assert_eq!(
1244 build_upstream_url(&parts, base, "/v1/messages"),
1245 "https://api.anthropic.com/v1/messages/batches/batch_123/results"
1246 );
1247 }
1248
1249 #[test]
1250 fn upstream_url_exact_path() {
1251 let base = "https://api.anthropic.com";
1252 let parts = parts_for("/v1/messages");
1253 assert_eq!(
1254 build_upstream_url(&parts, base, "/v1/messages"),
1255 "https://api.anthropic.com/v1/messages"
1256 );
1257 }
1258
1259 #[test]
1260 fn upstream_url_preserves_query_params() {
1261 let base = "https://api.anthropic.com";
1262 let parts = parts_for("/v1/messages/count_tokens?model=claude-4");
1263 assert_eq!(
1264 build_upstream_url(&parts, base, "/v1/messages"),
1265 "https://api.anthropic.com/v1/messages/count_tokens?model=claude-4"
1266 );
1267 }
1268
1269 #[test]
1270 fn forwards_openai_project_and_auth_headers() {
1271 for required in ["authorization", "openai-project", "openai-organization"] {
1275 assert!(
1276 ALLOWED_REQUEST_HEADERS.contains(&required),
1277 "request header `{required}` must be forwarded upstream"
1278 );
1279 }
1280 }
1281
1282 #[test]
1283 fn forwards_chatgpt_codex_oauth_headers() {
1284 for required in [
1285 "authorization",
1286 "chatgpt-account-id",
1287 "x-openai-fedramp",
1288 "x-openai-internal-codex-residency",
1289 "x-openai-product-sku",
1290 "oai-product-sku",
1291 "x-client-request-id",
1292 "x-codex-installation-id",
1293 "x-codex-turn-metadata",
1294 "x-openai-subagent",
1295 "x-codex-turn-state",
1296 "originator",
1297 ] {
1298 assert!(
1299 is_allowed_request_header(required),
1300 "request header `{required}` must be forwarded upstream"
1301 );
1302 }
1303 }
1304
1305 #[test]
1306 fn forwards_streamable_http_mcp_headers() {
1307 for required in ["mcp-session-id", "last-event-id"] {
1308 assert!(
1309 ALLOWED_REQUEST_HEADERS.contains(&required),
1310 "request header `{required}` must be forwarded upstream"
1311 );
1312 }
1313 assert!(
1314 is_forwarded_response_header("mcp-session-id"),
1315 "MCP session id response header must be forwarded downstream"
1316 );
1317 }
1318
1319 #[test]
1320 fn forwards_codex_state_response_headers() {
1321 for required in [
1322 "x-codex-turn-state",
1323 "x-codex-primary-used-percent",
1324 "openai-model",
1325 "x-models-etag",
1326 "x-reasoning-included",
1327 "x-oai-request-id",
1328 "cf-ray",
1329 "x-openai-authorization-error",
1330 "x-error-json",
1331 ] {
1332 assert!(
1333 is_forwarded_response_header(required),
1334 "response header `{required}` must be forwarded downstream"
1335 );
1336 }
1337 }
1338
1339 #[test]
1340 fn chatgpt_responses_use_openai_responses_holdout_key() {
1341 let _iso = crate::core::data_dir::isolated_data_dir();
1342 crate::core::config::Config::update_global(|c| {
1343 c.proxy.output_holdout = Some(1.0);
1344 })
1345 .unwrap();
1346
1347 let body = serde_json::json!({
1348 "model": "gpt-5",
1349 "input": "same conversation",
1350 });
1351
1352 assert_eq!(
1353 cohort_arm(&body, "ChatGPT", "/backend-api/codex/responses"),
1354 cohort_arm(&body, "OpenAI", "/v1/responses")
1355 );
1356 }
1357}