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