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
+337 -3
View File
@@ -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,
+14
View File
@@ -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,
}),
);
}
_ => {}
}
}
+4 -3
View File
@@ -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(