fix(ai-serving): preserve explicit request encoding

This commit is contained in:
elky
2026-06-10 18:16:55 +08:00
parent ea76f7bb0b
commit e9b4efc2d4
25 changed files with 1178 additions and 38 deletions
@@ -719,6 +719,8 @@ mod tests {
provider_request_body: Some(json!({"model":"gpt-5","metadata":{}})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -20,6 +20,7 @@ mod pool_scheduler;
pub(crate) mod pool_scores;
mod redaction;
mod report_context;
mod request_gzip;
mod route;
mod runtime_miss;
mod spec_metadata;
@@ -46,6 +47,7 @@ pub(crate) use self::plan_builders::{
pub(crate) use self::pool_scores::{
build_provider_key_pool_score_upsert, provider_key_pool_score_id, provider_key_pool_score_scope,
};
pub(crate) use self::request_gzip::resolve_transport_request_gzip_policy;
pub(crate) use self::route::is_matching_stream_request as planner_is_matching_stream_request;
pub(crate) use self::runtime_miss::{
apply_local_runtime_candidate_terminal_reason, record_local_runtime_candidate_skip_reason,
@@ -17,7 +17,8 @@ use crate::ai_serving::planner::report_context::{
use crate::ai_serving::planner::spec_metadata::local_same_format_provider_spec_metadata;
use crate::ai_serving::planner::CandidateFailureDiagnostic;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -183,6 +184,7 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
compatibility_edits: _,
request_redacted: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -209,6 +211,8 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -0,0 +1,326 @@
use aether_ai_serving::AiRequestGzipPolicy;
use serde_json::Value;
use crate::ai_serving::is_openai_responses_family_format;
use super::state::GatewayProviderTransportSnapshot;
const DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES: usize = 64 * 1024;
pub(crate) fn resolve_transport_request_gzip_policy(
transport: &GatewayProviderTransportSnapshot,
) -> Option<AiRequestGzipPolicy> {
transport_request_gzip_policy_from_config(transport.endpoint.config.as_ref())
.or_else(|| transport_request_gzip_policy_from_config(transport.provider.config.as_ref()))
.or_else(|| default_transport_request_gzip_policy(transport))
}
fn default_transport_request_gzip_policy(
transport: &GatewayProviderTransportSnapshot,
) -> Option<AiRequestGzipPolicy> {
if !transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("codex")
{
return None;
}
if !is_codex_request_gzip_endpoint_api_format(transport.endpoint.api_format.as_str()) {
return None;
}
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES),
})
}
fn is_codex_request_gzip_endpoint_api_format(api_format: &str) -> bool {
is_openai_responses_family_format(api_format)
|| api_format.trim().eq_ignore_ascii_case("openai:image")
}
fn transport_request_gzip_policy_from_config(
config: Option<&Value>,
) -> Option<AiRequestGzipPolicy> {
let object = config?.as_object()?;
for key in ["request_gzip", "request_body_gzip"] {
if let Some(policy) = object
.get(key)
.and_then(transport_request_gzip_policy_from_value)
{
return Some(policy);
}
}
let enabled = first_config_bool(
object,
&["request_gzip_enabled", "request_body_gzip_enabled"],
);
let min_bytes = first_config_usize(
object,
&["request_gzip_min_bytes", "request_body_gzip_min_bytes"],
);
match (enabled, min_bytes) {
(Some(false), _) => Some(AiRequestGzipPolicy {
enabled: Some(false),
min_bytes: None,
}),
(Some(true), min_bytes) => Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes,
}),
(None, Some(min_bytes)) => Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(min_bytes),
}),
(None, None) => None,
}
}
fn transport_request_gzip_policy_from_value(value: &Value) -> Option<AiRequestGzipPolicy> {
if let Some(enabled) = value.as_bool() {
return Some(AiRequestGzipPolicy {
enabled: Some(enabled),
min_bytes: None,
});
}
let object = value.as_object()?;
let enabled = first_config_bool(object, &["enabled"]);
let min_bytes = first_config_usize(object, &["min_bytes"]);
match (enabled, min_bytes) {
(Some(false), _) => Some(AiRequestGzipPolicy {
enabled: Some(false),
min_bytes: None,
}),
(Some(true), min_bytes) => Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes,
}),
(None, Some(min_bytes)) => Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(min_bytes),
}),
(None, None) => None,
}
}
fn first_config_bool(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<bool> {
keys.iter()
.find_map(|key| object.get(*key).and_then(config_bool))
}
fn config_bool(value: &Value) -> Option<bool> {
value.as_bool().or_else(|| {
value.as_str().and_then(|text| {
let normalized = text.trim();
if normalized.eq_ignore_ascii_case("true") {
Some(true)
} else if normalized.eq_ignore_ascii_case("false") {
Some(false)
} else {
None
}
})
})
}
fn first_config_usize(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<usize> {
keys.iter()
.find_map(|key| object.get(*key).and_then(config_usize))
}
fn config_usize(value: &Value) -> Option<usize> {
value
.as_u64()
.and_then(|number| usize::try_from(number).ok())
.or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<usize>().ok())
})
}
#[cfg(test)]
mod tests {
use super::*;
use aether_provider_transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
};
use serde_json::{json, Value};
fn sample_transport(
provider_type: &str,
endpoint_api_format: &str,
provider_config: Option<Value>,
endpoint_config: Option<Value>,
) -> GatewayProviderTransportSnapshot {
GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-1".to_string(),
name: "Provider".to_string(),
provider_type: provider_type.to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: true,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: provider_config,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: endpoint_api_format.to_string(),
api_family: None,
endpoint_kind: None,
is_active: true,
base_url: "https://api.example.test".to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: None,
config: endpoint_config,
format_acceptance_config: None,
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-1".to_string(),
provider_id: "provider-1".to_string(),
name: "key".to_string(),
auth_type: "api_key".to_string(),
is_active: true,
api_formats: None,
auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
upstream_metadata: None,
decrypted_api_key: "secret".to_string(),
decrypted_auth_config: None,
},
}
}
#[test]
fn endpoint_request_gzip_policy_overrides_provider_policy() {
let transport = sample_transport(
"openai",
"openai:responses",
Some(json!({"request_gzip": false})),
Some(json!({"request_gzip": {"enabled": true, "min_bytes": 1024}})),
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(1024),
})
);
}
#[test]
fn endpoint_request_gzip_false_disables_provider_and_codex_defaults() {
let transport = sample_transport(
"codex",
"openai:responses",
Some(json!({"request_gzip": {"enabled": true, "min_bytes": 1024}})),
Some(json!({"request_gzip": false})),
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(false),
min_bytes: None,
})
);
}
#[test]
fn request_gzip_policy_supports_top_level_aliases() {
let transport = sample_transport(
"openai",
"openai:responses",
None,
Some(json!({
"request_body_gzip_enabled": true,
"request_body_gzip_min_bytes": "4096"
})),
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(4096),
})
);
}
#[test]
fn request_gzip_policy_treats_min_bytes_only_as_enabled() {
let transport = sample_transport(
"openai",
"openai:responses",
None,
Some(json!({"request_gzip_min_bytes": 1})),
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(1),
})
);
}
#[test]
fn codex_responses_endpoint_gets_default_request_gzip_policy() {
let transport = sample_transport("codex", "openai:responses", None, None);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES),
})
);
}
#[test]
fn codex_image_endpoint_gets_default_request_gzip_policy() {
let transport = sample_transport("codex", "openai:image", None, None);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES),
})
);
}
#[test]
fn non_codex_endpoint_does_not_get_default_request_gzip_policy() {
let transport = sample_transport("openai", "openai:responses", None, None);
assert_eq!(resolve_transport_request_gzip_policy(&transport), None);
}
}
@@ -7,7 +7,8 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_gemini_files_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -123,6 +124,7 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
upstream_url,
file_name: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -154,6 +156,8 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -5,7 +5,8 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_openai_image_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -143,6 +144,7 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
spec_metadata.api_format,
provider_api_format.as_str(),
);
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -169,6 +171,8 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
provider_request_body: Some(resolved.provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -5,7 +5,8 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_video_create_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -103,6 +104,7 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
provider_request_body,
upstream_url,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: false,
@@ -135,6 +137,8 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -15,7 +15,8 @@ use crate::ai_serving::planner::report_context::{
use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
use crate::ai_serving::planner::CandidateFailureDiagnostic;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -175,6 +176,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
transport_profile: _,
request_redacted: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -201,6 +203,8 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts,
@@ -23,6 +23,7 @@ use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
use crate::ai_serving::planner::standard::{
apply_codex_openai_responses_special_headers, apply_deepseek_tool_call_thinking_compat,
is_deepseek_provider, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::kiro::{
build_kiro_provider_headers, build_kiro_provider_request_body,
@@ -599,10 +600,14 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
attempt.candidate_index,
&attempt.candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(
request_conversion_failure_extra_data(
body_json,
spec_metadata.api_format,
provider_api_format,
Some(prepared_candidate.mapped_model.as_str()),
Some(parts.uri.path()),
upstream_is_stream,
"standard_family_request_conversion",
),
)
.await;
@@ -62,7 +62,8 @@ pub(crate) use crate::ai_serving::{
normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content,
};
pub(crate) use aether_ai_serving::{
request_body_build_failure_extra_data, same_format_provider_request_body_failure_extra_data,
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
same_format_provider_request_body_failure_extra_data,
};
pub(crate) fn build_standard_upstream_url(
@@ -6,7 +6,8 @@ use crate::ai_serving::planner::report_context::{
insert_provider_stream_event_api_format, LocalExecutionReportContextParts,
};
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -192,6 +193,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
),
&transport,
);
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream,
@@ -218,6 +220,8 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts,
@@ -25,6 +25,7 @@ use crate::ai_serving::planner::standard::{
apply_deepseek_tool_call_thinking_compat, build_cross_format_openai_chat_request_body,
build_cross_format_openai_chat_upstream_url, build_local_openai_chat_request_body,
build_local_openai_chat_upstream_url, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::auth::resolve_local_openai_bearer_auth;
use crate::ai_serving::transport::kiro::{
@@ -601,10 +602,14 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(
request_conversion_failure_extra_data(
body_json,
"openai:chat",
provider_api_format.as_str(),
Some(prepared_candidate.mapped_model.as_str()),
Some(parts.uri.path()),
upstream_is_stream,
"openai_chat_request_conversion",
),
)
.await;
@@ -326,6 +326,8 @@ mod tests {
})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -422,6 +424,8 @@ mod tests {
provider_request_body: Some(json!({"model":"gpt-5.4","messages":[],"stream":true})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -488,6 +492,8 @@ mod tests {
provider_request_body,
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -595,6 +601,8 @@ mod tests {
),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -292,6 +292,8 @@ mod tests {
})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -387,6 +389,8 @@ mod tests {
provider_request_body: Some(json!({"model":"gpt-5.4","messages":[],"stream":false})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -458,6 +462,8 @@ mod tests {
),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
@@ -9,7 +9,8 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
resolve_transport_execution_timeouts, resolve_transport_profile,
@@ -212,6 +213,7 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
image_request_summary: _,
request_redacted: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -238,6 +240,8 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
proxy,
transport_profile,
timeouts,
@@ -27,6 +27,7 @@ use crate::ai_serving::planner::standard::{
apply_deepseek_tool_call_thinking_compat, build_cross_format_openai_responses_request_body,
build_cross_format_openai_responses_upstream_url, build_local_openai_responses_request_body,
build_local_openai_responses_upstream_url, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::antigravity::{
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
@@ -371,10 +372,14 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(
request_conversion_failure_extra_data(
body_json,
spec_metadata.api_format,
provider_api_format,
Some(mapped_model.as_str()),
Some(parts.uri.path()),
upstream_is_stream,
"openai_responses_request_conversion",
),
)
.await;