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 body = xlat_stream_body(teed, xlat);
826 let mut resp = Response::builder().status(status);
827 for (k, v) in &resp_headers {
828 let ks = k.as_str().to_lowercase();
829 if is_forwarded_response_header(&ks) {
830 resp = resp.header(k, v);
831 }
832 }
833 return resp
834 .body(body)
835 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR);
836 }
837
838 let resp_bytes = response
839 .bytes()
840 .await
841 .map_err(|_| StatusCode::BAD_GATEWAY)?;
842
843 let mut scanner = super::usage::Scanner::new(usage_provider, url_model)
845 .with_cohort(cohort)
846 .with_wire_context(wire)
847 .with_header_cost(header_cost);
848 scanner.feed_body(&resp_bytes);
849 if let Some(usage) = scanner.finalize() {
850 super::usage_meter::record(&usage);
851 }
852
853 let resp_bytes = if xlat {
854 xlat_response_bytes(&resp_bytes, status)
855 } else {
856 resp_bytes.to_vec()
857 };
858
859 let mut resp = Response::builder().status(status);
860 for (k, v) in &resp_headers {
861 let ks = k.as_str().to_lowercase();
862 if is_forwarded_response_header(&ks) {
863 resp = resp.header(k, v);
864 }
865 }
866 resp.body(Body::from(resp_bytes))
867 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
868}
869
870#[cfg(feature = "shape-xlat")]
872fn xlat_stream_body<S>(teed: S, xlat: bool) -> Body
873where
874 S: futures::Stream<Item = Result<axum::body::Bytes, reqwest::Error>> + Send + Unpin + 'static,
875{
876 if xlat {
877 Body::from_stream(super::shape_xlat::to_anthropic_stream(teed))
878 } else {
879 Body::from_stream(teed)
880 }
881}
882
883#[cfg(not(feature = "shape-xlat"))]
884fn xlat_stream_body<S>(teed: S, _xlat: bool) -> Body
885where
886 S: futures::Stream<Item = Result<axum::body::Bytes, reqwest::Error>> + Send + Unpin + 'static,
887{
888 Body::from_stream(teed)
889}
890
891#[cfg(feature = "shape-xlat")]
895fn xlat_response_bytes(resp_bytes: &[u8], status: StatusCode) -> Vec<u8> {
896 let translated = serde_json::from_slice::<serde_json::Value>(resp_bytes)
897 .ok()
898 .and_then(|v| {
899 if status.is_success() {
900 super::shape_xlat::chat_to_messages(&v)
901 } else {
902 super::shape_xlat::error_to_anthropic(&v)
903 }
904 });
905 if let Some(v) = translated {
906 serde_json::to_vec(&v).unwrap_or_else(|_| resp_bytes.to_vec())
907 } else {
908 tracing::warn!(
909 "lean-ctx proxy: cross-shape response not translatable (status {status}) — \
910 forwarding raw body"
911 );
912 resp_bytes.to_vec()
913 }
914}
915
916#[cfg(not(feature = "shape-xlat"))]
917fn xlat_response_bytes(resp_bytes: &[u8], _status: StatusCode) -> Vec<u8> {
918 resp_bytes.to_vec()
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924
925 fn parts_for(uri: &str) -> Parts {
926 Request::builder().uri(uri).body(()).unwrap().into_parts().0
927 }
928
929 fn add_test_marker(
930 mut value: serde_json::Value,
931 original_size: usize,
932 ) -> (Vec<u8>, usize, usize) {
933 value["lean_ctx_touched"] = serde_json::Value::Bool(true);
934 let out = serde_json::to_vec(&value).unwrap();
935 let compressed_size = out.len();
936 (out, original_size, compressed_size)
937 }
938
939 #[test]
942 fn upstream_is_local_detects_loopback_hosts() {
943 for local in [
944 "http://127.0.0.1:11434",
945 "http://localhost:8080/v1",
946 "http://[::1]:9999",
947 "http://0.0.0.0:4000",
948 ] {
949 assert!(upstream_is_local(local), "{local} must count as local");
950 }
951 for remote in [
952 "https://api.anthropic.com",
953 "https://acme.services.ai.azure.com/openai",
954 "https://localhost.evil.example.com", ] {
956 assert!(!upstream_is_local(remote), "{remote} must not be local");
957 }
958 }
959
960 #[test]
961 fn wire_context_carries_identity_tags_and_baseline() {
962 let mut parts = parts_for("/v1/messages");
963 parts
964 .extensions
965 .insert(super::super::gateway_identity::GatewayTags {
966 person: Some("yves".into()),
967 team: Some("platform".into()),
968 project: Some("billing".into()),
969 });
970 let wire = wire_context(&parts, "Anthropic", "https://api.anthropic.com", 750, 4000);
971 assert_eq!(wire.provider, "Anthropic");
972 assert_eq!(wire.person.as_deref(), Some("yves"));
973 assert_eq!(wire.team.as_deref(), Some("platform"));
974 assert_eq!(wire.project.as_deref(), Some("billing"));
975 assert_eq!(wire.saved_tokens, 750);
976 assert_eq!(wire.uncompressed_input_tokens, 1000);
978 assert!(!wire.is_local);
979 assert_eq!(wire.routed_from, None);
980 }
981
982 #[test]
983 fn wire_context_prefers_registry_provider_id_over_shape_label() {
984 let mut parts = parts_for("/v1/chat/completions");
987 parts
988 .extensions
989 .insert(super::super::providers::RegistryProviderId {
990 id: "local".into(),
991 local: false,
992 });
993 let wire = wire_context(&parts, "OpenAI", "http://127.0.0.1:11434", 0, 400);
994 assert_eq!(wire.provider, "local");
995 }
996
997 #[test]
998 fn wire_context_registry_local_flag_beats_url_heuristic() {
999 let mut parts = parts_for("/v1/chat/completions");
1004 parts
1005 .extensions
1006 .insert(super::super::providers::RegistryProviderId {
1007 id: "local".into(),
1008 local: true,
1009 });
1010 let wire = wire_context(
1011 &parts,
1012 "OpenAI",
1013 "http://host.docker.internal:11434",
1014 0,
1015 400,
1016 );
1017 assert!(wire.is_local, "declared local flag must win");
1018
1019 let mut parts = parts_for("/v1/chat/completions");
1020 parts
1021 .extensions
1022 .insert(super::super::providers::RegistryProviderId {
1023 id: "tunnel".into(),
1024 local: false,
1025 });
1026 let wire = wire_context(&parts, "OpenAI", "http://127.0.0.1:9999", 0, 400);
1027 assert!(!wire.is_local, "declared non-local flag must win");
1028 }
1029
1030 #[test]
1033 fn retry_covers_exactly_not_processed_statuses() {
1034 for code in [429_u16, 502, 503] {
1036 assert!(
1037 is_retryable_status(reqwest::StatusCode::from_u16(code).unwrap()),
1038 "{code} must be retryable"
1039 );
1040 }
1041 for code in [200_u16, 400, 401, 404, 500, 504] {
1043 assert!(
1044 !is_retryable_status(reqwest::StatusCode::from_u16(code).unwrap()),
1045 "{code} must NOT be retryable"
1046 );
1047 }
1048 }
1049
1050 #[test]
1051 fn wire_context_without_tags_still_carries_baseline() {
1052 let parts = parts_for("/v1/chat/completions");
1054 let wire = wire_context(&parts, "OpenAI", "http://127.0.0.1:11434", 0, 400);
1055 assert_eq!(wire.person, None);
1056 assert_eq!(wire.project, None);
1057 assert_eq!(wire.uncompressed_input_tokens, 100);
1058 assert!(wire.is_local);
1059 }
1060
1061 #[test]
1062 fn zstd_request_bodies_are_rewritten_and_reencoded() {
1063 let body = serde_json::json!({"model": "gpt-5", "input": []});
1064 let json = serde_json::to_vec(&body).unwrap();
1065 let encoded = encode_zstd(&json).unwrap();
1066 let parts = Request::builder()
1067 .uri("/backend-api/codex/responses")
1068 .header(axum::http::header::CONTENT_ENCODING, "zstd")
1069 .body(())
1070 .unwrap()
1071 .into_parts()
1072 .0;
1073
1074 let prepared = prepare_request_body(
1075 &parts,
1076 &encoded,
1077 add_test_marker,
1078 |_| None,
1079 "https://api.openai.com",
1080 false,
1081 )
1082 .unwrap();
1083 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Zstd);
1084 assert_eq!(prepared.original_size, json.len());
1085 assert!(prepared.compression_candidate);
1086 assert!(prepared.preserve_content_encoding);
1087 assert!(should_forward_request_header("content-encoding", true));
1088 assert!(!should_forward_request_header("content-encoding", false));
1089
1090 let decoded = zstd::decode_all(prepared.body.as_slice()).unwrap();
1091 let parsed: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
1092 assert_eq!(parsed["lean_ctx_touched"], true);
1093 assert_eq!(parsed["model"], "gpt-5");
1094 }
1095
1096 #[test]
1097 fn gzip_request_bodies_are_rewritten_and_reencoded() {
1098 let body = serde_json::json!({"model": "gpt-5", "input": []});
1099 let json = serde_json::to_vec(&body).unwrap();
1100 let encoded = encode_gzip(&json).unwrap();
1101 let parts = Request::builder()
1102 .uri("/backend-api/codex/responses")
1103 .header(axum::http::header::CONTENT_ENCODING, "gzip")
1104 .body(())
1105 .unwrap()
1106 .into_parts()
1107 .0;
1108
1109 let prepared = prepare_request_body(
1110 &parts,
1111 &encoded,
1112 add_test_marker,
1113 |_| None,
1114 "https://api.openai.com",
1115 false,
1116 )
1117 .unwrap();
1118 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Gzip);
1119 assert_eq!(prepared.original_size, json.len());
1120 assert!(prepared.compression_candidate);
1121 assert!(prepared.preserve_content_encoding);
1122
1123 let decoded = decode_gzip_bounded(&prepared.body, max_body_bytes()).unwrap();
1124 let parsed: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
1125 assert_eq!(parsed["lean_ctx_touched"], true);
1126 assert_eq!(parsed["model"], "gpt-5");
1127 }
1128
1129 #[test]
1130 fn openrouter_chat_requests_opt_into_billed_cost() {
1131 let _iso = crate::core::data_dir::isolated_data_dir();
1132 let body = serde_json::json!({"model": "deepseek/deepseek-v4-flash", "messages": []});
1133 let json = serde_json::to_vec(&body).unwrap();
1134 let parts = parts_for("/v1/chat/completions");
1135
1136 let prepared = prepare_request_body(
1137 &parts,
1138 &json,
1139 add_test_marker,
1140 |_| None,
1141 "https://openrouter.ai/api",
1142 true,
1143 )
1144 .unwrap();
1145 let parsed: serde_json::Value = serde_json::from_slice(&prepared.body).unwrap();
1146 assert_eq!(
1147 parsed["usage"]["include"], true,
1148 "OpenRouter chat requests must ask for the billed cost (#1179)"
1149 );
1150 }
1151
1152 #[test]
1153 fn non_openrouter_upstreams_never_carry_the_usage_opt_in() {
1154 let _iso = crate::core::data_dir::isolated_data_dir();
1155 let body = serde_json::json!({"model": "gpt-5.5", "messages": []});
1156 let json = serde_json::to_vec(&body).unwrap();
1157 let parts = parts_for("/v1/chat/completions");
1158
1159 let prepared = prepare_request_body(
1160 &parts,
1161 &json,
1162 add_test_marker,
1163 |_| None,
1164 "https://api.openai.com",
1165 true,
1166 )
1167 .unwrap();
1168 let parsed: serde_json::Value = serde_json::from_slice(&prepared.body).unwrap();
1169 assert!(
1170 parsed.get("usage").is_none(),
1171 "api.openai.com rejects unknown top-level params — no injection"
1172 );
1173 }
1174
1175 #[test]
1176 fn responses_api_bodies_never_carry_the_usage_opt_in() {
1177 let _iso = crate::core::data_dir::isolated_data_dir();
1178 let body = serde_json::json!({"model": "gpt-5.5", "input": []});
1179 let json = serde_json::to_vec(&body).unwrap();
1180 let parts = parts_for("/v1/responses");
1181
1182 let prepared = prepare_request_body(
1183 &parts,
1184 &json,
1185 add_test_marker,
1186 |_| None,
1187 "https://openrouter.ai/api",
1188 true,
1189 )
1190 .unwrap();
1191 let parsed: serde_json::Value = serde_json::from_slice(&prepared.body).unwrap();
1192 assert!(
1193 parsed.get("usage").is_none(),
1194 "`usage.include` is Chat-Completions-only — Responses bodies stay clean"
1195 );
1196 }
1197
1198 #[test]
1199 fn identity_content_encoding_can_be_rewritten_as_json() {
1200 let parts = Request::builder()
1201 .uri("/v1/responses")
1202 .header(axum::http::header::CONTENT_ENCODING, "identity")
1203 .body(())
1204 .unwrap()
1205 .into_parts()
1206 .0;
1207
1208 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Identity);
1209 }
1210
1211 #[test]
1212 fn unknown_encoded_request_bodies_stay_passthrough() {
1213 let parts = Request::builder()
1214 .uri("/v1/responses")
1215 .header(axum::http::header::CONTENT_ENCODING, "br")
1216 .body(())
1217 .unwrap()
1218 .into_parts()
1219 .0;
1220 let body = b"not-json";
1221
1222 let prepared = prepare_request_body(
1223 &parts,
1224 body,
1225 |_, _| panic!("unknown encodings must not be JSON-rewritten"),
1226 |_| None,
1227 "https://api.openai.com",
1228 false,
1229 )
1230 .unwrap();
1231
1232 assert_eq!(
1233 request_body_encoding(&parts),
1234 RequestBodyEncoding::Passthrough
1235 );
1236 assert_eq!(prepared.body, body);
1237 assert!(prepared.parsed.is_none());
1238 assert!(!prepared.compression_candidate);
1239 assert!(prepared.preserve_content_encoding);
1240 }
1241
1242 #[test]
1243 fn invalid_json_request_bodies_are_not_compression_candidates() {
1244 let parts = Request::builder()
1245 .uri("/v1/responses")
1246 .body(())
1247 .unwrap()
1248 .into_parts()
1249 .0;
1250 let body = b"not-json";
1251
1252 let prepared = prepare_request_body(
1253 &parts,
1254 body,
1255 |_, _| panic!("invalid JSON must not enter the compression pipeline"),
1256 |_| None,
1257 "https://api.openai.com",
1258 false,
1259 )
1260 .unwrap();
1261
1262 assert_eq!(request_body_encoding(&parts), RequestBodyEncoding::Identity);
1263 assert_eq!(prepared.body, body);
1264 assert!(prepared.parsed.is_none());
1265 assert!(!prepared.compression_candidate);
1266 assert!(!prepared.preserve_content_encoding);
1267 }
1268
1269 #[test]
1270 fn upstream_url_preserves_subpath() {
1271 let base = "https://api.anthropic.com";
1272 let parts = parts_for("/v1/messages/count_tokens");
1273 assert_eq!(
1274 build_upstream_url(&parts, base, "/v1/messages"),
1275 "https://api.anthropic.com/v1/messages/count_tokens"
1276 );
1277 }
1278
1279 #[test]
1280 fn upstream_url_preserves_batches_subpath() {
1281 let base = "https://api.anthropic.com";
1282 let parts = parts_for("/v1/messages/batches/batch_123/results");
1283 assert_eq!(
1284 build_upstream_url(&parts, base, "/v1/messages"),
1285 "https://api.anthropic.com/v1/messages/batches/batch_123/results"
1286 );
1287 }
1288
1289 #[test]
1290 fn upstream_url_exact_path() {
1291 let base = "https://api.anthropic.com";
1292 let parts = parts_for("/v1/messages");
1293 assert_eq!(
1294 build_upstream_url(&parts, base, "/v1/messages"),
1295 "https://api.anthropic.com/v1/messages"
1296 );
1297 }
1298
1299 #[test]
1300 fn upstream_url_preserves_query_params() {
1301 let base = "https://api.anthropic.com";
1302 let parts = parts_for("/v1/messages/count_tokens?model=claude-4");
1303 assert_eq!(
1304 build_upstream_url(&parts, base, "/v1/messages"),
1305 "https://api.anthropic.com/v1/messages/count_tokens?model=claude-4"
1306 );
1307 }
1308
1309 #[test]
1310 fn forwards_openai_project_and_auth_headers() {
1311 for required in ["authorization", "openai-project", "openai-organization"] {
1315 assert!(
1316 ALLOWED_REQUEST_HEADERS.contains(&required),
1317 "request header `{required}` must be forwarded upstream"
1318 );
1319 }
1320 }
1321
1322 #[test]
1323 fn forwards_chatgpt_codex_oauth_headers() {
1324 for required in [
1325 "authorization",
1326 "chatgpt-account-id",
1327 "x-openai-fedramp",
1328 "x-openai-internal-codex-residency",
1329 "x-openai-product-sku",
1330 "oai-product-sku",
1331 "x-client-request-id",
1332 "x-codex-installation-id",
1333 "x-codex-turn-metadata",
1334 "x-openai-subagent",
1335 "x-codex-turn-state",
1336 "originator",
1337 ] {
1338 assert!(
1339 is_allowed_request_header(required),
1340 "request header `{required}` must be forwarded upstream"
1341 );
1342 }
1343 }
1344
1345 #[test]
1346 fn forwards_streamable_http_mcp_headers() {
1347 for required in ["mcp-session-id", "last-event-id"] {
1348 assert!(
1349 ALLOWED_REQUEST_HEADERS.contains(&required),
1350 "request header `{required}` must be forwarded upstream"
1351 );
1352 }
1353 assert!(
1354 is_forwarded_response_header("mcp-session-id"),
1355 "MCP session id response header must be forwarded downstream"
1356 );
1357 }
1358
1359 #[test]
1360 fn forwards_codex_state_response_headers() {
1361 for required in [
1362 "x-codex-turn-state",
1363 "x-codex-primary-used-percent",
1364 "openai-model",
1365 "x-models-etag",
1366 "x-reasoning-included",
1367 "x-oai-request-id",
1368 "cf-ray",
1369 "x-openai-authorization-error",
1370 "x-error-json",
1371 ] {
1372 assert!(
1373 is_forwarded_response_header(required),
1374 "response header `{required}` must be forwarded downstream"
1375 );
1376 }
1377 }
1378
1379 #[test]
1380 fn chatgpt_responses_use_openai_responses_holdout_key() {
1381 let _iso = crate::core::data_dir::isolated_data_dir();
1382 crate::core::config::Config::update_global(|c| {
1383 c.proxy.output_holdout = Some(1.0);
1384 })
1385 .unwrap();
1386
1387 let body = serde_json::json!({
1388 "model": "gpt-5",
1389 "input": "same conversation",
1390 });
1391
1392 assert_eq!(
1393 cohort_arm(&body, "ChatGPT", "/backend-api/codex/responses"),
1394 cohort_arm(&body, "OpenAI", "/v1/responses")
1395 );
1396 }
1397}