fix(transport): preserve safe accept encoding

This commit is contained in:
elky
2026-06-10 19:58:57 +08:00
parent ff7ec8575c
commit 0246ba93dd
4 changed files with 296 additions and 20 deletions
@@ -16,6 +16,8 @@ pub(super) fn decode_execution_result_body(
};
if let Some(json_body) = body.json_body {
remove_header_case_insensitive(headers, "content-encoding");
remove_header_case_insensitive(headers, "content-length");
headers
.entry("content-type".to_string())
.or_insert_with(|| "application/json".to_string());
@@ -34,3 +36,49 @@ pub(super) fn decode_execution_result_body(
Ok((Vec::new(), None, None))
}
fn remove_header_case_insensitive(headers: &mut BTreeMap<String, String>, name: &str) {
if let Some(existing_key) = headers
.keys()
.find(|key| key.eq_ignore_ascii_case(name))
.cloned()
{
headers.remove(&existing_key);
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use aether_contracts::ResponseBody;
use serde_json::json;
use super::decode_execution_result_body;
#[test]
fn decoded_json_body_drops_stale_content_encoding_headers() {
let mut headers = BTreeMap::from([
("content-encoding".to_string(), "gzip".to_string()),
("content-length".to_string(), "999".to_string()),
]);
let (body_bytes, body_json, body_base64) = decode_execution_result_body(
Some(ResponseBody {
json_body: Some(json!({"ok": true})),
body_bytes_b64: None,
}),
&mut headers,
)
.expect("body should decode");
assert_eq!(body_json, Some(json!({"ok": true})));
assert_eq!(body_base64, None);
assert_eq!(body_bytes, br#"{"ok":true}"#);
assert_eq!(headers.get("content-encoding"), None);
assert_eq!(
headers.get("content-length").cloned(),
Some(body_bytes.len().to_string())
);
}
}
+51 -18
View File
@@ -1,7 +1,8 @@
use std::collections::BTreeMap;
use super::headers::{
should_skip_upstream_complete_passthrough_header, should_skip_upstream_passthrough_header,
normalize_upstream_accept_encoding, should_skip_upstream_complete_passthrough_header,
should_skip_upstream_passthrough_header,
};
use super::snapshot::GatewayProviderTransportSnapshot;
@@ -21,20 +22,18 @@ fn collect_passthrough_headers(
if should_skip_upstream_passthrough_header(&key) {
continue;
}
let value = value.trim();
if value.is_empty() {
let Some(value) = normalize_passthrough_header_value(&key, value) else {
continue;
}
out.insert(key, value.to_string());
};
out.insert(key, value);
}
for (key, value) in extra_headers {
let normalized_key = key.to_ascii_lowercase();
let value = value.trim();
if value.is_empty() {
let Some(value) = normalize_passthrough_header_value(&normalized_key, value) else {
continue;
}
out.insert(normalized_key, value.to_string());
};
out.insert(normalized_key, value);
}
out
@@ -53,25 +52,36 @@ fn collect_complete_passthrough_headers(
if should_skip_upstream_complete_passthrough_header(&key) {
continue;
}
let value = value.trim();
if value.is_empty() {
let Some(value) = normalize_passthrough_header_value(&key, value) else {
continue;
}
out.insert(key, value.to_string());
};
out.insert(key, value);
}
for (key, value) in extra_headers {
let normalized_key = key.to_ascii_lowercase();
let value = value.trim();
if value.is_empty() {
let Some(value) = normalize_passthrough_header_value(&normalized_key, value) else {
continue;
}
out.insert(normalized_key, value.to_string());
};
out.insert(normalized_key, value);
}
out
}
fn normalize_passthrough_header_value(key: &str, value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
if key.eq_ignore_ascii_case("accept-encoding") {
return normalize_upstream_accept_encoding(value);
}
Some(value.to_string())
}
pub fn build_passthrough_headers(
headers: &http::HeaderMap,
extra_headers: &BTreeMap<String, String>,
@@ -312,7 +322,8 @@ fn bearer_auth_value(secret: &str) -> String {
mod tests {
use super::{
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
resolve_local_openai_bearer_auth, resolve_local_standard_auth,
build_openai_passthrough_headers, resolve_local_openai_bearer_auth,
resolve_local_standard_auth,
};
use crate::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
@@ -417,6 +428,28 @@ mod tests {
);
}
#[test]
fn passthrough_headers_preserve_supported_response_compression() {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::ACCEPT_ENCODING,
http::HeaderValue::from_static("gzip, br"),
);
let built = build_openai_passthrough_headers(
&headers,
"authorization",
"Bearer upstream",
&BTreeMap::new(),
Some("application/json"),
);
assert_eq!(
built.get("accept-encoding").map(String::as_str),
Some("gzip")
);
}
#[test]
fn claude_passthrough_headers_preserve_explicit_anthropic_version_override() {
let mut headers = http::HeaderMap::new();
+145 -2
View File
@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use aether_contracts::USAGE_SERVER_NOW_UNIX_MS_HEADER;
pub fn should_skip_request_header(name: &str) -> bool {
@@ -42,7 +44,6 @@ pub fn should_skip_upstream_passthrough_header(name: &str) -> bool {
| "content-length"
| "transfer-encoding"
| "connection"
| "accept-encoding"
| "content-encoding"
| "x-real-ip"
| "x-real-proto"
@@ -68,7 +69,6 @@ pub(crate) fn should_skip_upstream_complete_passthrough_header(name: &str) -> bo
| "content-length"
| "transfer-encoding"
| "connection"
| "accept-encoding"
| "content-encoding"
| "x-real-ip"
| "x-real-proto"
@@ -80,13 +80,102 @@ pub(crate) fn should_skip_upstream_complete_passthrough_header(name: &str) -> bo
) || should_skip_request_header(name)
}
pub fn normalize_upstream_accept_encoding(value: &str) -> Option<String> {
let mut accepted = Vec::new();
let mut wildcard_allowed = false;
let mut gzip_disabled = false;
let mut deflate_disabled = false;
let mut identity_disabled = false;
for item in value.split(',') {
let Some((token, normalized_item, enabled)) = parse_accept_encoding_item(item) else {
continue;
};
match token.as_str() {
"gzip" if enabled => accepted.push(normalized_item),
"gzip" => gzip_disabled = true,
"deflate" if enabled => accepted.push(normalized_item),
"deflate" => deflate_disabled = true,
"identity" if enabled => accepted.push(normalized_item),
"identity" => identity_disabled = true,
"*" if enabled => wildcard_allowed = true,
_ => {}
}
}
if !accepted.is_empty() {
return Some(accepted.join(", "));
}
if wildcard_allowed && !gzip_disabled {
Some("gzip".to_string())
} else if wildcard_allowed && !deflate_disabled {
Some("deflate".to_string())
} else if wildcard_allowed && !identity_disabled {
Some("identity".to_string())
} else {
None
}
}
fn parse_accept_encoding_item(raw_item: &str) -> Option<(String, String, bool)> {
let mut parts = raw_item.trim().split(';');
let token = parts.next()?.trim().to_ascii_lowercase();
if token.is_empty() {
return None;
}
let mut enabled = true;
let mut normalized = token.clone();
for raw_param in parts {
let param = raw_param.trim();
if param.is_empty() {
continue;
}
let Some((name, value)) = param.split_once('=') else {
continue;
};
if name.trim().eq_ignore_ascii_case("q") {
let value = value.trim();
if q_value_is_zero(value) {
enabled = false;
continue;
}
normalized.push_str(";q=");
normalized.push_str(value);
}
}
Some((token, normalized, enabled))
}
fn q_value_is_zero(value: &str) -> bool {
value
.trim_matches('"')
.parse::<f32>()
.is_ok_and(|q| q <= 0.0)
}
pub fn force_identity_accept_encoding(headers: &mut BTreeMap<String, String>) {
if let Some(existing_key) = headers
.keys()
.find(|key| key.eq_ignore_ascii_case("accept-encoding"))
.cloned()
{
headers.remove(&existing_key);
}
headers.insert("accept-encoding".to_string(), "identity".to_string());
}
#[cfg(test)]
mod tests {
use super::{
force_identity_accept_encoding, normalize_upstream_accept_encoding,
should_skip_request_header, should_skip_upstream_complete_passthrough_header,
should_skip_upstream_passthrough_header,
};
use aether_contracts::USAGE_SERVER_NOW_UNIX_MS_HEADER;
use std::collections::BTreeMap;
#[test]
fn strips_all_stainless_headers() {
@@ -111,6 +200,60 @@ mod tests {
}
}
#[test]
fn accept_encoding_is_not_classified_as_hop_by_hop_passthrough_skip() {
assert!(!should_skip_upstream_passthrough_header("accept-encoding"));
assert!(!should_skip_upstream_complete_passthrough_header(
"accept-encoding"
));
}
#[test]
fn normalizes_accept_encoding_to_supported_upstream_codecs() {
assert_eq!(
normalize_upstream_accept_encoding("gzip, br").as_deref(),
Some("gzip")
);
assert_eq!(
normalize_upstream_accept_encoding("br, deflate").as_deref(),
Some("deflate")
);
assert_eq!(
normalize_upstream_accept_encoding("identity").as_deref(),
Some("identity")
);
assert_eq!(
normalize_upstream_accept_encoding("gzip;q=0.5, br").as_deref(),
Some("gzip;q=0.5")
);
assert_eq!(
normalize_upstream_accept_encoding("gzip;q=0, br, deflate").as_deref(),
Some("deflate")
);
assert_eq!(
normalize_upstream_accept_encoding("gzip;q=0, br").as_deref(),
None
);
assert_eq!(
normalize_upstream_accept_encoding("*").as_deref(),
Some("gzip")
);
assert_eq!(normalize_upstream_accept_encoding("br"), None);
}
#[test]
fn force_identity_accept_encoding_replaces_existing_casing() {
let mut headers = BTreeMap::from([("Accept-Encoding".to_string(), "gzip".to_string())]);
force_identity_accept_encoding(&mut headers);
assert_eq!(
headers.get("accept-encoding").map(String::as_str),
Some("identity")
);
assert!(!headers.contains_key("Accept-Encoding"));
}
#[test]
fn strips_anthropic_and_claude_cli_identity_headers() {
let anthropic = [
@@ -6,6 +6,7 @@ use crate::auth::{
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
build_openai_passthrough_headers, build_passthrough_headers, ensure_upstream_auth_header,
};
use crate::headers::force_identity_accept_encoding;
use crate::rules::{
apply_local_body_rules, apply_local_body_rules_with_request_headers,
apply_local_header_rules_with_request_headers,
@@ -146,6 +147,10 @@ pub fn build_standard_plan_fallback_headers(
}
}
if input.upstream_is_stream {
force_identity_accept_encoding(&mut headers);
}
headers
}
@@ -272,6 +277,7 @@ pub fn build_standard_provider_request_headers(
headers
.entry("accept".to_string())
.or_insert_with(|| "text/event-stream".to_string());
force_identity_accept_encoding(&mut headers);
}
Some(StandardProviderRequestHeaders {
@@ -360,6 +366,10 @@ mod tests {
fn builds_same_format_headers_with_complete_passthrough_and_stream_accept() {
let mut request_headers = HeaderMap::new();
request_headers.insert("x-client", "demo".parse().expect("header"));
request_headers.insert(
http::header::ACCEPT_ENCODING,
"gzip, br".parse().expect("header"),
);
let transport = sample_transport("openai:chat");
let resolved =
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
@@ -387,9 +397,43 @@ mod tests {
resolved.headers.get("accept"),
Some(&"text/event-stream".to_string())
);
assert_eq!(
resolved.headers.get("accept-encoding"),
Some(&"identity".to_string())
);
assert_eq!(resolved.headers.get("x-client"), Some(&"demo".to_string()));
}
#[test]
fn builds_sync_headers_preserving_supported_accept_encoding() {
let mut request_headers = HeaderMap::new();
request_headers.insert(
http::header::ACCEPT_ENCODING,
"gzip, br".parse().expect("header"),
);
let transport = sample_transport("openai:chat");
let resolved =
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
transport: &transport,
provider_api_format: "openai:chat",
same_format: true,
headers: &request_headers,
auth_header: "authorization",
auth_value: "Bearer secret",
extra_headers: &BTreeMap::new(),
header_rules: None,
provider_request_body: &json!({"model":"gpt-5"}),
original_request_body: &json!({"model":"gpt-5"}),
upstream_is_stream: false,
})
.expect("headers should build");
assert_eq!(
resolved.headers.get("accept-encoding"),
Some(&"gzip".to_string())
);
}
#[test]
fn applies_header_rules_after_base_headers() {
let transport = sample_transport("claude:messages");
@@ -473,6 +517,10 @@ mod tests {
fn stream_fallback_headers_treat_wildcard_accept_as_absent() {
let mut request_headers = HeaderMap::new();
request_headers.insert(http::header::ACCEPT, "*/*".parse().expect("header"));
request_headers.insert(
http::header::ACCEPT_ENCODING,
"gzip, br".parse().expect("header"),
);
let headers = build_standard_plan_fallback_headers(StandardPlanFallbackHeadersInput {
request_headers: &request_headers,
@@ -492,6 +540,10 @@ mod tests {
headers.get("accept"),
Some(&"text/event-stream".to_string())
);
assert_eq!(
headers.get("accept-encoding"),
Some(&"identity".to_string())
);
}
#[test]