mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 13:10:21 +08:00
fix(ai-serving): preserve explicit request encoding
This commit is contained in:
@@ -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,
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+6
-1
@@ -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;
|
||||
|
||||
@@ -63,6 +63,8 @@ fn missing_exact_provider_request_payload(decision_kind: &str) -> AiExecutionDec
|
||||
provider_request_body: None,
|
||||
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,
|
||||
|
||||
@@ -111,6 +111,38 @@ impl LocalExecutionRuntimeMissContext {
|
||||
}
|
||||
Some(summaries.join(" | "))
|
||||
}
|
||||
|
||||
pub(crate) fn all_provider_request_body_build_failures_detail(&self) -> Option<String> {
|
||||
if self.candidate_contexts.is_empty()
|
||||
|| !self.candidate_contexts.iter().all(|candidate| {
|
||||
candidate.candidate.status == RequestCandidateStatus::Skipped
|
||||
&& candidate
|
||||
.candidate
|
||||
.skip_reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value == "provider_request_body_build_failed")
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let diagnostic = self
|
||||
.candidate_contexts
|
||||
.iter()
|
||||
.find_map(runtime_miss_candidate_failure_diagnostic)?;
|
||||
let mut detail = format!("上游请求体转换失败:{}", diagnostic.message);
|
||||
if diagnostic.path != "$" {
|
||||
detail.push_str(&format!(";字段路径:{}", diagnostic.path));
|
||||
}
|
||||
detail.push_str("(原因代码: provider_request_body_build_failed)");
|
||||
Some(detail)
|
||||
}
|
||||
}
|
||||
|
||||
struct RuntimeMissFailureDiagnostic {
|
||||
path: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_execution_exhaustion(
|
||||
@@ -835,6 +867,41 @@ fn candidate_extra_data_string(candidate: &StoredRequestCandidate, key: &str) ->
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn runtime_miss_candidate_failure_diagnostic(
|
||||
candidate: &RuntimeMissCandidateContext,
|
||||
) -> Option<RuntimeMissFailureDiagnostic> {
|
||||
let extra_data = candidate.candidate.extra_data.as_ref()?.as_object()?;
|
||||
let diagnostic = extra_data
|
||||
.get("failure_diagnostic")
|
||||
.and_then(Value::as_object)
|
||||
.filter(|diagnostic| diagnostic.get("safe_to_show") != Some(&Value::Bool(false)))
|
||||
.or_else(|| {
|
||||
extra_data
|
||||
.get("request_conversion_error")
|
||||
.and_then(Value::as_object)
|
||||
})
|
||||
.or_else(|| {
|
||||
extra_data
|
||||
.get("request_body_build_error")
|
||||
.and_then(Value::as_object)
|
||||
})?;
|
||||
let message = diagnostic
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let path = diagnostic
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("$");
|
||||
Some(RuntimeMissFailureDiagnostic {
|
||||
path: path.to_string(),
|
||||
message: message.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_runtime_miss_candidate_endpoint_url(
|
||||
candidate: &StoredRequestCandidate,
|
||||
endpoint: &StoredProviderCatalogEndpoint,
|
||||
@@ -1036,7 +1103,8 @@ mod tests {
|
||||
use super::{
|
||||
apply_runtime_miss_usage_routing, beautify_local_execution_client_error_message,
|
||||
request_candidate_represents_provider_execution,
|
||||
select_last_runtime_miss_executed_candidate, RuntimeMissCandidateContext,
|
||||
select_last_runtime_miss_executed_candidate, LocalExecutionRuntimeMissContext,
|
||||
RuntimeMissCandidateContext,
|
||||
};
|
||||
use crate::constants::EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS;
|
||||
use crate::state::LocalExecutionRuntimeMissDiagnostic;
|
||||
@@ -1161,4 +1229,68 @@ mod tests {
|
||||
|
||||
assert!(select_last_runtime_miss_executed_candidate(&contexts).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_miss_context_surfaces_request_conversion_field_diagnostic() {
|
||||
let skipped_candidate = StoredRequestCandidate::new(
|
||||
"cand-skipped".to_string(),
|
||||
"req-1".to_string(),
|
||||
Some("user-1".to_string()),
|
||||
Some("api-key-1".to_string()),
|
||||
Some("alice".to_string()),
|
||||
Some("default".to_string()),
|
||||
0,
|
||||
0,
|
||||
Some("provider-1".to_string()),
|
||||
Some("endpoint-1".to_string()),
|
||||
Some("provider-key-1".to_string()),
|
||||
RequestCandidateStatus::Skipped,
|
||||
Some("provider_request_body_build_failed".to_string()),
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"failure_diagnostic": {
|
||||
"kind": "request_conversion",
|
||||
"path": "$.n",
|
||||
"message": "openai:chat 字段 n 不能无损转换到 openai:responses:OpenAI Responses request has no canonical equivalent for this Chat field",
|
||||
"safe_to_show": true
|
||||
},
|
||||
"request_conversion_error": {
|
||||
"path": "$.n",
|
||||
"message": "compat"
|
||||
}
|
||||
})),
|
||||
None,
|
||||
100,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("candidate should build");
|
||||
|
||||
let context = LocalExecutionRuntimeMissContext {
|
||||
candidate_contexts: vec![RuntimeMissCandidateContext {
|
||||
candidate: skipped_candidate,
|
||||
provider_name: Some("openai".to_string()),
|
||||
key_name: Some("prod".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_api_format: Some("openai:responses".to_string()),
|
||||
global_model_name: Some("gpt-5".to_string()),
|
||||
selected_provider_model_name: Some("gpt-5-upstream".to_string()),
|
||||
endpoint_url: Some("https://api.openai.example/v1/responses".to_string()),
|
||||
}],
|
||||
..LocalExecutionRuntimeMissContext::default()
|
||||
};
|
||||
|
||||
let detail = context
|
||||
.all_provider_request_body_build_failures_detail()
|
||||
.expect("detail should include conversion diagnostic");
|
||||
|
||||
assert!(detail.contains("字段 n"));
|
||||
assert!(detail.contains("字段路径:$.n"));
|
||||
assert!(detail.contains("provider_request_body_build_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1797,13 +1797,21 @@ pub(crate) async fn proxy_request(
|
||||
.all_candidates_skipped_for_reason(AUTH_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON)
|
||||
|| local_execution_runtime_miss_context
|
||||
.all_candidates_skipped_for_reason(LEGACY_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON);
|
||||
let local_execution_runtime_miss_detail = local_execution_runtime_miss_detail(
|
||||
control_decision,
|
||||
local_execution_runtime_miss_diagnostic.as_ref(),
|
||||
auth_api_key_concurrency_limited,
|
||||
stream_request,
|
||||
)
|
||||
.unwrap_or_else(|| "当前 AI 请求无法在本地执行:没有匹配到可用的执行路径".to_string());
|
||||
let local_execution_runtime_miss_detail = (!auth_api_key_concurrency_limited)
|
||||
.then(|| {
|
||||
local_execution_runtime_miss_context
|
||||
.all_provider_request_body_build_failures_detail()
|
||||
})
|
||||
.flatten()
|
||||
.or_else(|| {
|
||||
local_execution_runtime_miss_detail(
|
||||
control_decision,
|
||||
local_execution_runtime_miss_diagnostic.as_ref(),
|
||||
auth_api_key_concurrency_limited,
|
||||
stream_request,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "当前 AI 请求无法在本地执行:没有匹配到可用的执行路径".to_string());
|
||||
let local_execution_failure_path = if auth_api_key_concurrency_limited {
|
||||
EXECUTION_PATH_LOCAL_API_KEY_CONCURRENCY_LIMITED
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,9 @@ use aether_ai_formats::api::ExecutionRuntimeAuthContext;
|
||||
use aether_contracts::{ExecutionPlan, RequestBody};
|
||||
use url::Url;
|
||||
|
||||
use crate::dto::AiExecutionDecision;
|
||||
use crate::dto::{AiExecutionDecision, AiRequestGzipPolicy};
|
||||
|
||||
const DEFAULT_REQUEST_GZIP_MIN_JSON_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AiDecisionPlanCore {
|
||||
@@ -112,6 +114,10 @@ pub fn build_ai_execution_plan_from_decision(
|
||||
payload: &mut AiExecutionDecision,
|
||||
parts: AiExecutionPlanFromDecisionParts,
|
||||
) -> ExecutionPlan {
|
||||
let explicit_content_encoding = take_ai_non_empty_string(&mut payload.content_encoding);
|
||||
let request_gzip = payload.request_gzip.take();
|
||||
let content_encoding = explicit_content_encoding
|
||||
.or_else(|| infer_ai_execution_plan_content_encoding(&parts, request_gzip.as_ref()));
|
||||
ExecutionPlan {
|
||||
request_id: parts.core.request_id,
|
||||
candidate_id: payload.candidate_id.take(),
|
||||
@@ -123,7 +129,7 @@ pub fn build_ai_execution_plan_from_decision(
|
||||
url: parts.url,
|
||||
headers: parts.headers,
|
||||
content_type: parts.content_type,
|
||||
content_encoding: None,
|
||||
content_encoding,
|
||||
body: parts.body,
|
||||
stream: parts.stream,
|
||||
client_api_format: parts.core.client_api_format,
|
||||
@@ -135,6 +141,52 @@ pub fn build_ai_execution_plan_from_decision(
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_ai_execution_plan_content_encoding(
|
||||
parts: &AiExecutionPlanFromDecisionParts,
|
||||
request_gzip: Option<&AiRequestGzipPolicy>,
|
||||
) -> Option<String> {
|
||||
if let Some(should_gzip) = should_gzip_explicit_json_request(parts, request_gzip) {
|
||||
return should_gzip.then(|| "gzip".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn should_gzip_explicit_json_request(
|
||||
parts: &AiExecutionPlanFromDecisionParts,
|
||||
request_gzip: Option<&AiRequestGzipPolicy>,
|
||||
) -> Option<bool> {
|
||||
let request_gzip = request_gzip?;
|
||||
let enabled = request_gzip
|
||||
.enabled
|
||||
.unwrap_or(request_gzip.min_bytes.is_some());
|
||||
if !enabled {
|
||||
return Some(false);
|
||||
}
|
||||
Some(json_request_body_len_at_least(
|
||||
parts,
|
||||
request_gzip
|
||||
.min_bytes
|
||||
.unwrap_or(DEFAULT_REQUEST_GZIP_MIN_JSON_BYTES),
|
||||
))
|
||||
}
|
||||
|
||||
fn json_request_body_len_at_least(
|
||||
parts: &AiExecutionPlanFromDecisionParts,
|
||||
min_bytes: usize,
|
||||
) -> bool {
|
||||
if parts.body.body_bytes_b64.is_some() || parts.body.body_ref.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(json_body) = parts.body.json_body.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
serde_json::to_vec(json_body)
|
||||
.map(|body| body.len() >= min_bytes)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn build_ai_execution_decision_from_plan(
|
||||
parts: AiExecutionDecisionFromPlanParts,
|
||||
) -> AiExecutionDecision {
|
||||
@@ -149,7 +201,7 @@ pub fn build_ai_execution_decision_from_plan(
|
||||
url,
|
||||
headers,
|
||||
content_type,
|
||||
content_encoding: _content_encoding,
|
||||
content_encoding,
|
||||
body,
|
||||
stream,
|
||||
client_api_format,
|
||||
@@ -208,6 +260,8 @@ pub fn build_ai_execution_decision_from_plan(
|
||||
provider_request_body: json_body,
|
||||
provider_request_body_base64: body_bytes_b64,
|
||||
content_type,
|
||||
content_encoding,
|
||||
request_gzip: None,
|
||||
proxy,
|
||||
transport_profile,
|
||||
timeouts,
|
||||
@@ -398,6 +452,171 @@ mod tests {
|
||||
assert!(payload.model_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ai_execution_plan_without_request_gzip_policy_leaves_json_uncompressed() {
|
||||
let large_codex_url = test_plan_for_url_and_body(
|
||||
"https://chatgpt.com/backend-api/codex/responses",
|
||||
RequestBody::from_json(json!({
|
||||
"model": "gpt-5.5",
|
||||
"input": "x".repeat(DEFAULT_REQUEST_GZIP_MIN_JSON_BYTES),
|
||||
})),
|
||||
);
|
||||
let large_openai = test_plan_for_url_and_body(
|
||||
"https://api.openai.com/v1/responses",
|
||||
RequestBody::from_json(json!({
|
||||
"model": "gpt-5.5",
|
||||
"input": "x".repeat(DEFAULT_REQUEST_GZIP_MIN_JSON_BYTES),
|
||||
})),
|
||||
);
|
||||
|
||||
assert_eq!(large_codex_url.content_encoding, None);
|
||||
assert_eq!(large_openai.content_encoding, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ai_execution_plan_does_not_gzip_raw_body_even_when_explicit() {
|
||||
let mut payload = test_decision();
|
||||
payload.request_gzip = Some(AiRequestGzipPolicy {
|
||||
enabled: Some(true),
|
||||
min_bytes: Some(0),
|
||||
});
|
||||
let core =
|
||||
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
|
||||
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url: "https://api.example.com/v1/chat/completions".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
body: RequestBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some("dGVzdA==".to_string()),
|
||||
body_ref: None,
|
||||
},
|
||||
stream: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(plan.content_encoding, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ai_execution_plan_preserves_explicit_content_encoding_for_raw_body() {
|
||||
let mut payload = test_decision();
|
||||
payload.content_encoding = Some("gzip".to_string());
|
||||
payload.request_gzip = Some(AiRequestGzipPolicy {
|
||||
enabled: Some(false),
|
||||
min_bytes: None,
|
||||
});
|
||||
let core =
|
||||
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
|
||||
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url: "https://api.example.com/v1/chat/completions".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/octet-stream".to_string()),
|
||||
body: RequestBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some("dGVzdA==".to_string()),
|
||||
body_ref: None,
|
||||
},
|
||||
stream: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(plan.content_encoding.as_deref(), Some("gzip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ai_execution_plan_gzips_explicit_json_request_for_non_codex() {
|
||||
let mut payload = test_decision();
|
||||
payload.request_gzip = Some(AiRequestGzipPolicy {
|
||||
enabled: Some(true),
|
||||
min_bytes: Some(1),
|
||||
});
|
||||
let core =
|
||||
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
|
||||
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url: "https://api.example.com/v1/chat/completions".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
body: RequestBody::from_json(json!({"model": "gpt-test"})),
|
||||
stream: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(plan.content_encoding.as_deref(), Some("gzip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ai_execution_plan_respects_explicit_request_gzip_threshold() {
|
||||
let mut payload = test_decision();
|
||||
payload.request_gzip = Some(AiRequestGzipPolicy {
|
||||
enabled: Some(true),
|
||||
min_bytes: Some(1024),
|
||||
});
|
||||
let core =
|
||||
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
|
||||
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url: "https://api.example.com/v1/chat/completions".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
body: RequestBody::from_json(json!({"model": "gpt-test"})),
|
||||
stream: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(plan.content_encoding, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ai_execution_plan_explicit_request_gzip_false_disables_gzip() {
|
||||
let mut payload = test_decision();
|
||||
payload.provider_api_format = Some("openai:responses".to_string());
|
||||
payload.client_api_format = Some("openai:responses".to_string());
|
||||
payload.request_gzip = Some(AiRequestGzipPolicy {
|
||||
enabled: Some(false),
|
||||
min_bytes: None,
|
||||
});
|
||||
let core =
|
||||
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
|
||||
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url: "https://chatgpt.com/backend-api/codex/responses".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
body: RequestBody::from_json(json!({
|
||||
"model": "gpt-5.5",
|
||||
"input": "x".repeat(DEFAULT_REQUEST_GZIP_MIN_JSON_BYTES),
|
||||
})),
|
||||
stream: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(plan.content_encoding, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_ai_upstream_base_url_preserves_codex_base_path() {
|
||||
assert_eq!(
|
||||
@@ -482,6 +701,88 @@ mod tests {
|
||||
assert_eq!(decision.report_kind.as_deref(), Some("report"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_decision_round_trip_preserves_raw_body_content_encoding() {
|
||||
let original = ExecutionPlan {
|
||||
request_id: "plan-request".to_string(),
|
||||
candidate_id: Some("candidate-1".to_string()),
|
||||
provider_name: Some("provider".to_string()),
|
||||
provider_id: "provider-1".to_string(),
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: "https://api.example.com/v1/upload".to_string(),
|
||||
headers: BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/octet-stream".to_string(),
|
||||
)]),
|
||||
content_type: Some("application/octet-stream".to_string()),
|
||||
content_encoding: Some("gzip".to_string()),
|
||||
body: RequestBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some("dGVzdA==".to_string()),
|
||||
body_ref: None,
|
||||
},
|
||||
stream: false,
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: "openai:chat".to_string(),
|
||||
model_name: Some("gpt-test".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
|
||||
let mut decision =
|
||||
build_ai_execution_decision_from_plan(AiExecutionDecisionFromPlanParts {
|
||||
action: "execution_runtime.sync_decision".to_string(),
|
||||
decision_kind: Some("raw_upload_sync".to_string()),
|
||||
request_id: None,
|
||||
upstream_base_url: Some("https://api.example.com".to_string()),
|
||||
include_auth_pair: false,
|
||||
plan: original,
|
||||
report_kind: None,
|
||||
report_context: None,
|
||||
auth_context: None,
|
||||
});
|
||||
|
||||
assert_eq!(decision.content_encoding.as_deref(), Some("gzip"));
|
||||
assert!(decision.request_gzip.is_none());
|
||||
|
||||
let core =
|
||||
take_ai_decision_plan_core(&mut decision).expect("core fields should be available");
|
||||
let method = take_ai_non_empty_string(&mut decision.provider_request_method)
|
||||
.expect("method should round-trip");
|
||||
let url =
|
||||
take_ai_non_empty_string(&mut decision.upstream_url).expect("url should round-trip");
|
||||
let headers = std::mem::take(&mut decision.provider_request_headers);
|
||||
let content_type = decision.content_type.take();
|
||||
let body = resolve_ai_passthrough_sync_request_body(
|
||||
decision.provider_request_body.take(),
|
||||
decision.provider_request_body_base64.take(),
|
||||
);
|
||||
let stream = decision.upstream_is_stream;
|
||||
|
||||
let round_tripped = build_ai_execution_plan_from_decision(
|
||||
&mut decision,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
content_type,
|
||||
body,
|
||||
stream,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(round_tripped.content_encoding.as_deref(), Some("gzip"));
|
||||
assert_eq!(
|
||||
round_tripped.body.body_bytes_b64.as_deref(),
|
||||
Some("dGVzdA==")
|
||||
);
|
||||
assert!(round_tripped.body.json_body.is_none());
|
||||
}
|
||||
|
||||
fn test_decision() -> AiExecutionDecision {
|
||||
AiExecutionDecision {
|
||||
action: "sync".to_string(),
|
||||
@@ -511,6 +812,8 @@ mod tests {
|
||||
provider_request_body: None,
|
||||
provider_request_body_base64: None,
|
||||
content_type: None,
|
||||
content_encoding: None,
|
||||
request_gzip: None,
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
@@ -520,4 +823,35 @@ mod tests {
|
||||
auth_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_plan_for_url_and_body(url: &str, body: RequestBody) -> ExecutionPlan {
|
||||
test_plan_for_url_body_and_format(url, body, "openai:responses")
|
||||
}
|
||||
|
||||
fn test_plan_for_url_body_and_format(
|
||||
url: &str,
|
||||
body: RequestBody,
|
||||
provider_api_format: &str,
|
||||
) -> ExecutionPlan {
|
||||
let mut payload = test_decision();
|
||||
payload.provider_api_format = Some(provider_api_format.to_string());
|
||||
payload.client_api_format = Some(provider_api_format.to_string());
|
||||
let core =
|
||||
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
|
||||
build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url: url.to_string(),
|
||||
headers: BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
content_type: Some("application/json".to_string()),
|
||||
body,
|
||||
stream: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use aether_contracts::{
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{AiExecutionDecision, ConversionMode, ExecutionStrategy};
|
||||
use crate::{AiExecutionDecision, AiRequestGzipPolicy, ConversionMode, ExecutionStrategy};
|
||||
|
||||
pub struct AiExecutionDecisionResponseParts {
|
||||
pub decision_is_stream: bool,
|
||||
@@ -37,6 +37,8 @@ pub struct AiExecutionDecisionResponseParts {
|
||||
pub provider_request_body: Option<serde_json::Value>,
|
||||
pub provider_request_body_base64: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
pub content_encoding: Option<String>,
|
||||
pub request_gzip: Option<AiRequestGzipPolicy>,
|
||||
pub proxy: Option<ProxySnapshot>,
|
||||
pub transport_profile: Option<ResolvedTransportProfile>,
|
||||
pub timeouts: Option<ExecutionTimeouts>,
|
||||
@@ -83,6 +85,8 @@ pub fn build_ai_execution_decision_response(
|
||||
provider_request_body: parts.provider_request_body,
|
||||
provider_request_body_base64: parts.provider_request_body_base64,
|
||||
content_type: parts.content_type,
|
||||
content_encoding: parts.content_encoding,
|
||||
request_gzip: parts.request_gzip,
|
||||
proxy: parts.proxy,
|
||||
transport_profile: parts.transport_profile,
|
||||
timeouts: parts.timeouts,
|
||||
@@ -222,6 +226,8 @@ mod tests {
|
||||
provider_request_body: Some(json!({"model": "gpt-5"})),
|
||||
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,
|
||||
|
||||
@@ -44,6 +44,14 @@ impl ConversionMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct AiRequestGzipPolicy {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub min_bytes: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct AiExecutionPlanPayload {
|
||||
pub action: String,
|
||||
@@ -114,6 +122,10 @@ pub struct AiExecutionDecision {
|
||||
pub provider_request_body_base64: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content_encoding: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_gzip: Option<AiRequestGzipPolicy>,
|
||||
#[serde(default)]
|
||||
pub proxy: Option<ProxySnapshot>,
|
||||
#[serde(default)]
|
||||
@@ -216,6 +228,8 @@ mod tests {
|
||||
provider_request_body: None,
|
||||
provider_request_body_base64: None,
|
||||
content_type: None,
|
||||
content_encoding: None,
|
||||
request_gzip: None,
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
|
||||
@@ -68,6 +68,11 @@ impl CandidateFailureDiagnostic {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn has_specific_path(&self) -> bool {
|
||||
let path = self.path.trim();
|
||||
!path.is_empty() && path != "$"
|
||||
}
|
||||
|
||||
pub fn to_extra_data(&self) -> Value {
|
||||
let diagnostic = self.to_value();
|
||||
let mut extra_data = json!({
|
||||
@@ -75,17 +80,31 @@ impl CandidateFailureDiagnostic {
|
||||
});
|
||||
|
||||
// Compatibility for current usage UI and already persisted trace readers.
|
||||
if self.kind == CandidateFailureDiagnosticKind::RequestBodyBuild {
|
||||
if let Some(object) = extra_data.as_object_mut() {
|
||||
object.insert(
|
||||
"request_body_build_error".to_string(),
|
||||
json!({
|
||||
"path": self.path,
|
||||
"message": self.message,
|
||||
"client_api_format": self.client_api_format,
|
||||
"provider_api_format": self.provider_api_format,
|
||||
}),
|
||||
);
|
||||
if let Some(object) = extra_data.as_object_mut() {
|
||||
match self.kind {
|
||||
CandidateFailureDiagnosticKind::RequestBodyBuild => {
|
||||
object.insert(
|
||||
"request_body_build_error".to_string(),
|
||||
json!({
|
||||
"path": self.path,
|
||||
"message": self.message,
|
||||
"client_api_format": self.client_api_format,
|
||||
"provider_api_format": self.provider_api_format,
|
||||
}),
|
||||
);
|
||||
}
|
||||
CandidateFailureDiagnosticKind::RequestConversion => {
|
||||
object.insert(
|
||||
"request_conversion_error".to_string(),
|
||||
json!({
|
||||
"path": self.path,
|
||||
"message": self.message,
|
||||
"client_api_format": self.client_api_format,
|
||||
"provider_api_format": self.provider_api_format,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,8 +107,8 @@ pub use decision_payload::{
|
||||
};
|
||||
pub use dto::{
|
||||
augment_sync_report_context, generic_decision_missing_exact_provider_request,
|
||||
AiExecutionDecision, AiExecutionPlanPayload, AiStreamAttempt, AiSyncAttempt, ConversionMode,
|
||||
ExecutionStrategy,
|
||||
AiExecutionDecision, AiExecutionPlanPayload, AiRequestGzipPolicy, AiStreamAttempt,
|
||||
AiSyncAttempt, ConversionMode, ExecutionStrategy,
|
||||
};
|
||||
pub use execution_path::{
|
||||
run_ai_stream_execution_path, run_ai_sync_execution_path, AiPlanFallbackReason,
|
||||
@@ -126,7 +126,8 @@ pub use report_context::{
|
||||
AiExecutionReportContextParts, AiRequestOrigin,
|
||||
};
|
||||
pub use request_body_diagnostics::{
|
||||
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 use runtime_miss::{
|
||||
apply_ai_runtime_candidate_evaluation_progress,
|
||||
|
||||
@@ -2,7 +2,9 @@ use serde_json::Value;
|
||||
|
||||
use aether_ai_formats::api::{
|
||||
is_claude_messages_shaped_body_on_openai_chat_endpoint, is_openai_responses_family_format,
|
||||
normalize_api_format_alias,
|
||||
};
|
||||
use aether_ai_formats::{convert_request_pure_with_context, FormatContext, FormatError};
|
||||
|
||||
use crate::{CandidateFailureDiagnostic, CandidateFailureDiagnosticKind};
|
||||
|
||||
@@ -24,6 +26,31 @@ pub fn request_body_build_failure_extra_data(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn request_conversion_failure_extra_data(
|
||||
body_json: &Value,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
mapped_model: Option<&str>,
|
||||
request_path: Option<&str>,
|
||||
upstream_is_stream: bool,
|
||||
source: impl Into<String>,
|
||||
) -> Option<Value> {
|
||||
let diagnostic = diagnose_request_conversion_failure(
|
||||
body_json,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
request_path,
|
||||
upstream_is_stream,
|
||||
)?;
|
||||
Some(
|
||||
diagnostic
|
||||
.formats(client_api_format, provider_api_format)
|
||||
.source(source)
|
||||
.to_extra_data(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn same_format_provider_request_body_failure_extra_data(
|
||||
body_json: &Value,
|
||||
provider_api_format: &str,
|
||||
@@ -41,6 +68,67 @@ pub fn same_format_provider_request_body_failure_extra_data(
|
||||
}
|
||||
|
||||
type RequestBodyBuildDiagnostic = CandidateFailureDiagnostic;
|
||||
type RequestConversionDiagnostic = CandidateFailureDiagnostic;
|
||||
|
||||
fn diagnose_request_conversion_failure(
|
||||
body_json: &Value,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
mapped_model: Option<&str>,
|
||||
request_path: Option<&str>,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<RequestConversionDiagnostic> {
|
||||
let mut context = FormatContext::default().with_upstream_stream(upstream_is_stream);
|
||||
if let Some(mapped_model) = mapped_model
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
context = context.with_mapped_model(mapped_model);
|
||||
}
|
||||
if let Some(request_path) = request_path
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
context = context.with_request_path(request_path);
|
||||
}
|
||||
|
||||
let source_format =
|
||||
compatible_source_format_for_diagnostic(body_json, client_api_format, provider_api_format);
|
||||
match convert_request_pure_with_context(
|
||||
source_format.as_str(),
|
||||
provider_api_format,
|
||||
body_json,
|
||||
&context,
|
||||
) {
|
||||
Ok(_) => {
|
||||
diagnose_request_body_build_failure(body_json, client_api_format, provider_api_format)
|
||||
.filter(CandidateFailureDiagnostic::has_specific_path)
|
||||
.or_else(|| Some(fallback_request_conversion_diagnostic()))
|
||||
}
|
||||
Err(error) => {
|
||||
let format_diagnostic =
|
||||
diagnostic_from_format_error(&error, client_api_format, provider_api_format);
|
||||
if format_diagnostic.has_specific_path() {
|
||||
Some(format_diagnostic)
|
||||
} else {
|
||||
diagnose_request_body_build_failure(
|
||||
body_json,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
)
|
||||
.filter(CandidateFailureDiagnostic::has_specific_path)
|
||||
.or(Some(format_diagnostic))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_request_conversion_diagnostic() -> RequestConversionDiagnostic {
|
||||
diagnostic(
|
||||
"$",
|
||||
"请求体转换本身已通过;失败可能发生在 Body 规则应用或后续上游请求体语义校验",
|
||||
)
|
||||
}
|
||||
|
||||
fn diagnose_request_body_build_failure(
|
||||
body_json: &Value,
|
||||
@@ -74,6 +162,123 @@ fn diagnose_request_body_build_failure(
|
||||
))
|
||||
}
|
||||
|
||||
fn compatible_source_format_for_diagnostic(
|
||||
body_json: &Value,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> String {
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
let provider_api_format = normalize_api_format_alias(provider_api_format);
|
||||
if client_api_format == "openai:chat"
|
||||
&& provider_api_format == "claude:messages"
|
||||
&& is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json)
|
||||
{
|
||||
return "claude:messages".to_string();
|
||||
}
|
||||
client_api_format
|
||||
}
|
||||
|
||||
fn diagnostic_from_format_error(
|
||||
error: &FormatError,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> RequestConversionDiagnostic {
|
||||
CandidateFailureDiagnostic::new(
|
||||
CandidateFailureDiagnosticKind::RequestConversion,
|
||||
format_error_path(error),
|
||||
format_error_message(error, client_api_format, provider_api_format),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_error_path(error: &FormatError) -> String {
|
||||
match error {
|
||||
FormatError::UnsupportedField { field, .. }
|
||||
| FormatError::UnauditedField { field, .. }
|
||||
| FormatError::InvalidEnumValue { field, .. }
|
||||
| FormatError::LossyConversionBlocked { field, .. }
|
||||
| FormatError::InvalidTargetField { field, .. } => field_to_json_path(field),
|
||||
FormatError::UnsupportedFormat(_)
|
||||
| FormatError::RequestParseFailed { .. }
|
||||
| FormatError::RequestEmitFailed { .. }
|
||||
| FormatError::ResponseParseFailed { .. }
|
||||
| FormatError::ResponseEmitFailed { .. } => "$".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn field_to_json_path(field: &str) -> String {
|
||||
let field = field.trim();
|
||||
if field.is_empty() || field == "$" {
|
||||
return "$".to_string();
|
||||
}
|
||||
if field.starts_with('$') {
|
||||
return field.to_string();
|
||||
}
|
||||
format!("$.{}", field)
|
||||
.replace("[].", "[*].")
|
||||
.replace("[]", "[*]")
|
||||
}
|
||||
|
||||
fn format_error_message(
|
||||
error: &FormatError,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> String {
|
||||
match error {
|
||||
FormatError::UnsupportedFormat(format) => {
|
||||
format!("不支持的 API 格式 {format},无法执行 {client_api_format} → {provider_api_format} 转换")
|
||||
}
|
||||
FormatError::RequestParseFailed { format } => {
|
||||
format!("无法按 {format} 解析请求体;请检查请求体结构和字段类型是否符合该格式")
|
||||
}
|
||||
FormatError::RequestEmitFailed { format } => {
|
||||
format!("无法生成 {format} 上游请求体;请检查源请求是否缺少目标格式必需字段或包含不可映射结构")
|
||||
}
|
||||
FormatError::ResponseParseFailed { format } => {
|
||||
format!("无法按 {format} 解析响应体")
|
||||
}
|
||||
FormatError::ResponseEmitFailed { format } => {
|
||||
format!("无法生成 {format} 响应体")
|
||||
}
|
||||
FormatError::UnsupportedField {
|
||||
format,
|
||||
field,
|
||||
reason,
|
||||
} => {
|
||||
format!("{format} 字段 {field} 不支持跨格式转换:{reason}")
|
||||
}
|
||||
FormatError::UnauditedField {
|
||||
source_format,
|
||||
target_format,
|
||||
field,
|
||||
reason,
|
||||
} => {
|
||||
format!("{source_format} 字段 {field} 尚未审计,不能转换到 {target_format}:{reason}")
|
||||
}
|
||||
FormatError::InvalidEnumValue {
|
||||
format,
|
||||
field,
|
||||
value,
|
||||
} => {
|
||||
format!("{format} 字段 {field} 的枚举值 {value:?} 无效,无法转换")
|
||||
}
|
||||
FormatError::LossyConversionBlocked {
|
||||
source_format,
|
||||
target_format,
|
||||
field,
|
||||
reason,
|
||||
} => {
|
||||
format!("{source_format} 字段 {field} 不能无损转换到 {target_format}:{reason}")
|
||||
}
|
||||
FormatError::InvalidTargetField {
|
||||
format,
|
||||
field,
|
||||
reason,
|
||||
} => {
|
||||
format!("目标格式 {format} 字段 {field} 无效:{reason}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_openai_responses_client_format(client_api_format: &str) -> bool {
|
||||
is_openai_responses_family_format(client_api_format)
|
||||
}
|
||||
@@ -593,7 +798,7 @@ fn request_body_build_source(client_api_format: &str, provider_api_format: &str)
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::request_body_build_failure_extra_data;
|
||||
use super::{request_body_build_failure_extra_data, request_conversion_failure_extra_data};
|
||||
|
||||
#[test]
|
||||
fn openai_chat_to_claude_recognizes_compatible_claude_native_tool_shape() {
|
||||
@@ -714,6 +919,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_conversion_reports_lossy_incompatible_field_path() {
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"n": 2
|
||||
});
|
||||
|
||||
let diagnostic = request_conversion_failure_extra_data(
|
||||
&body,
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
Some("gpt-5.4"),
|
||||
Some("/v1/chat/completions"),
|
||||
false,
|
||||
"test_conversion",
|
||||
)
|
||||
.expect("diagnostic");
|
||||
|
||||
assert_eq!(
|
||||
diagnostic["failure_diagnostic"]["kind"],
|
||||
"request_conversion"
|
||||
);
|
||||
assert_eq!(diagnostic["failure_diagnostic"]["path"], "$.n");
|
||||
assert_eq!(diagnostic["request_conversion_error"]["path"], "$.n");
|
||||
assert!(diagnostic["failure_diagnostic"]["message"]
|
||||
.as_str()
|
||||
.expect("message")
|
||||
.contains("字段 n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_format_provider_reports_non_object_body() {
|
||||
let diagnostic = super::same_format_provider_request_body_failure_extra_data(
|
||||
|
||||
Reference in New Issue
Block a user