mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
1059 lines
36 KiB
Rust
1059 lines
36 KiB
Rust
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, OpenAiProviderRequestContractViolation,
|
||
};
|
||
use aether_ai_formats::{convert_request_pure_with_context, FormatContext, FormatError};
|
||
|
||
use crate::{CandidateFailureDiagnostic, CandidateFailureDiagnosticKind};
|
||
|
||
pub fn request_body_build_failure_extra_data(
|
||
body_json: &Value,
|
||
client_api_format: &str,
|
||
provider_api_format: &str,
|
||
) -> Option<Value> {
|
||
let diagnostic =
|
||
diagnose_request_body_build_failure(body_json, client_api_format, provider_api_format)?;
|
||
Some(
|
||
diagnostic
|
||
.formats(client_api_format, provider_api_format)
|
||
.source(request_body_build_source(
|
||
client_api_format,
|
||
provider_api_format,
|
||
))
|
||
.to_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 openai_provider_request_contract_failure_extra_data(
|
||
violation: &OpenAiProviderRequestContractViolation,
|
||
client_api_format: &str,
|
||
provider_api_format: &str,
|
||
source: impl Into<String>,
|
||
) -> Value {
|
||
let (field, reason) = openai_provider_request_contract_violation_parts(violation);
|
||
CandidateFailureDiagnostic::new(
|
||
CandidateFailureDiagnosticKind::RequestBodyBuild,
|
||
field_to_json_path(field),
|
||
format!("上游请求体语义校验失败:{reason}"),
|
||
)
|
||
.formats(client_api_format, provider_api_format)
|
||
.source(source)
|
||
.to_extra_data()
|
||
}
|
||
|
||
fn openai_provider_request_contract_violation_parts(
|
||
violation: &OpenAiProviderRequestContractViolation,
|
||
) -> (&str, &str) {
|
||
match violation {
|
||
OpenAiProviderRequestContractViolation::CodexCompact(violation) => {
|
||
(violation.field, violation.reason)
|
||
}
|
||
OpenAiProviderRequestContractViolation::Responses(violation) => {
|
||
(violation.field, violation.reason)
|
||
}
|
||
OpenAiProviderRequestContractViolation::PromptCache(violation) => {
|
||
(violation.field.as_str(), violation.reason.as_str())
|
||
}
|
||
OpenAiProviderRequestContractViolation::Reasoning(violation) => {
|
||
(violation.field.as_str(), violation.reason.as_str())
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn same_format_provider_request_body_failure_extra_data(
|
||
body_json: &Value,
|
||
provider_api_format: &str,
|
||
body_rules: Option<&Value>,
|
||
context: &str,
|
||
) -> Option<Value> {
|
||
let diagnostic =
|
||
diagnose_same_format_provider_request_body_failure(body_json, body_rules, context)?;
|
||
Some(
|
||
diagnostic
|
||
.formats(provider_api_format, provider_api_format)
|
||
.source(context)
|
||
.to_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,
|
||
client_api_format: &str,
|
||
provider_api_format: &str,
|
||
) -> Option<RequestBodyBuildDiagnostic> {
|
||
if !body_json.is_object() {
|
||
return Some(diagnostic("$", "请求体必须是 JSON object"));
|
||
}
|
||
|
||
if is_openai_responses_client_format(client_api_format) {
|
||
if let Some(diagnostic) = diagnose_openai_responses_request(body_json) {
|
||
return Some(diagnostic);
|
||
}
|
||
return Some(diagnostic(
|
||
"$",
|
||
"OpenAI Responses 请求体初步结构检查通过;失败可能发生在后续跨格式转换或 Body 规则应用",
|
||
));
|
||
}
|
||
|
||
if client_api_format == "openai:chat"
|
||
&& (provider_api_format.starts_with("claude:")
|
||
|| provider_api_format.starts_with("gemini:"))
|
||
{
|
||
return diagnose_openai_chat_cross_format_request(body_json, provider_api_format);
|
||
}
|
||
|
||
Some(diagnostic(
|
||
"$",
|
||
"请求体转换失败;当前转换器未返回更细的字段路径",
|
||
))
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
fn diagnose_same_format_provider_request_body_failure(
|
||
body_json: &Value,
|
||
body_rules: Option<&Value>,
|
||
context: &str,
|
||
) -> Option<RequestBodyBuildDiagnostic> {
|
||
if !body_json.is_object() {
|
||
return Some(diagnostic("$", "反代请求体必须是 JSON object"));
|
||
}
|
||
if body_rules.is_some_and(|rules| !rules.is_array()) {
|
||
return Some(diagnostic(
|
||
"$.endpoint.body_rules",
|
||
"Endpoint Body 规则必须是数组,本地反代无法应用该配置",
|
||
));
|
||
}
|
||
match context {
|
||
"kiro_envelope" => Some(diagnostic(
|
||
"$",
|
||
"Kiro 反代请求体包装失败;请检查 Kiro auth_config 与 Endpoint Body 规则",
|
||
)),
|
||
"antigravity_envelope" => Some(diagnostic(
|
||
"$",
|
||
"Antigravity 反代请求体包装失败;请检查请求体是否满足该传输封装要求",
|
||
)),
|
||
_ => Some(diagnostic(
|
||
"$",
|
||
"反代请求体构建失败;当前路径未返回更细的字段信息",
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn diagnose_openai_chat_cross_format_request(
|
||
body_json: &Value,
|
||
provider_api_format: &str,
|
||
) -> Option<RequestBodyBuildDiagnostic> {
|
||
if provider_api_format.starts_with("claude:")
|
||
&& is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json)
|
||
{
|
||
return Some(diagnostic(
|
||
"$",
|
||
"请求体看起来是 Claude Messages 原生格式;Aether 会按 Claude Messages 兼容路径处理,若仍失败请检查 Claude messages/tools/tool_choice 结构或 Body 规则",
|
||
));
|
||
}
|
||
|
||
let request = body_json.as_object()?;
|
||
|
||
if let Some(messages) = request.get("messages") {
|
||
let Some(messages) = messages.as_array() else {
|
||
return Some(diagnostic(
|
||
"$.messages",
|
||
"OpenAI Chat 的 messages 必须是数组",
|
||
));
|
||
};
|
||
for (message_index, message) in messages.iter().enumerate() {
|
||
let Some(message_object) = message.as_object() else {
|
||
return Some(diagnostic(
|
||
format!("$.messages[{message_index}]"),
|
||
"message 必须是 object",
|
||
));
|
||
};
|
||
let role = message_object
|
||
.get("role")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default()
|
||
.trim()
|
||
.to_ascii_lowercase();
|
||
match role.as_str() {
|
||
"system" | "developer" => {
|
||
if let Some(diagnostic) = diagnose_openai_text_content(
|
||
message_object.get("content"),
|
||
format!("$.messages[{message_index}].content"),
|
||
) {
|
||
return Some(diagnostic);
|
||
}
|
||
}
|
||
"user" | "assistant" => {
|
||
if let Some(diagnostic) = diagnose_openai_content_blocks(
|
||
message_object.get("content"),
|
||
format!("$.messages[{message_index}].content"),
|
||
role.as_str(),
|
||
) {
|
||
return Some(diagnostic);
|
||
}
|
||
if role == "assistant" {
|
||
if let Some(diagnostic) = diagnose_openai_assistant_tool_calls(
|
||
message_object.get("tool_calls"),
|
||
format!("$.messages[{message_index}].tool_calls"),
|
||
) {
|
||
return Some(diagnostic);
|
||
}
|
||
}
|
||
}
|
||
"tool" => {
|
||
let valid_tool_call_id = message_object
|
||
.get("tool_call_id")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty());
|
||
if !valid_tool_call_id {
|
||
return Some(diagnostic(
|
||
format!("$.messages[{message_index}].tool_call_id"),
|
||
"tool 消息必须包含非空 tool_call_id",
|
||
));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some(diagnostic) = diagnose_openai_tools(request.get("tools"), provider_api_format) {
|
||
return Some(diagnostic);
|
||
}
|
||
diagnose_openai_tool_choice(request.get("tool_choice"))
|
||
}
|
||
|
||
fn diagnose_openai_responses_request(body_json: &Value) -> Option<RequestBodyBuildDiagnostic> {
|
||
let request = body_json.as_object()?;
|
||
|
||
if let Some(diagnostic) = diagnose_openai_responses_text_content(
|
||
request.get("instructions"),
|
||
"$.instructions".to_string(),
|
||
) {
|
||
return Some(diagnostic);
|
||
}
|
||
|
||
if let Some(diagnostic) = diagnose_openai_responses_input(request.get("input")) {
|
||
return Some(diagnostic);
|
||
}
|
||
if let Some(diagnostic) = diagnose_openai_responses_tools(request.get("tools")) {
|
||
return Some(diagnostic);
|
||
}
|
||
diagnose_openai_responses_tool_choice(request.get("tool_choice"))
|
||
}
|
||
|
||
fn diagnose_openai_responses_input(input: Option<&Value>) -> Option<RequestBodyBuildDiagnostic> {
|
||
let input = input?;
|
||
match input {
|
||
Value::Null | Value::String(_) => None,
|
||
Value::Array(items) => {
|
||
for (item_index, item) in items.iter().enumerate() {
|
||
if item.is_string() {
|
||
continue;
|
||
}
|
||
let item_path = format!("$.input[{item_index}]");
|
||
let Some(item_object) = item.as_object() else {
|
||
return Some(diagnostic(
|
||
item_path,
|
||
"OpenAI Responses input 数组项必须是 string 或 object",
|
||
));
|
||
};
|
||
let item_type = item_object
|
||
.get("type")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("message")
|
||
.trim()
|
||
.to_ascii_lowercase();
|
||
match item_type.as_str() {
|
||
"message" => {
|
||
let role = item_object
|
||
.get("role")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("user")
|
||
.trim()
|
||
.to_ascii_lowercase();
|
||
if role == "system" || role == "developer" {
|
||
if let Some(diagnostic) = diagnose_openai_responses_text_content(
|
||
item_object.get("content"),
|
||
format!("{item_path}.content"),
|
||
) {
|
||
return Some(diagnostic);
|
||
}
|
||
} else if let Some(diagnostic) = diagnose_openai_responses_message_content(
|
||
item_object.get("content"),
|
||
format!("{item_path}.content"),
|
||
) {
|
||
return Some(diagnostic);
|
||
}
|
||
}
|
||
"function_call" => {
|
||
let valid_name = item_object
|
||
.get("name")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty());
|
||
if !valid_name {
|
||
return Some(diagnostic(
|
||
format!("{item_path}.name"),
|
||
"function_call 必须包含非空 name",
|
||
));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
_ => Some(diagnostic(
|
||
"$.input",
|
||
"OpenAI Responses input 必须是 string、array 或 null",
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn diagnose_openai_responses_text_content(
|
||
content: Option<&Value>,
|
||
path: String,
|
||
) -> Option<RequestBodyBuildDiagnostic> {
|
||
match content {
|
||
None | Some(Value::Null) | Some(Value::String(_)) => None,
|
||
Some(Value::Array(parts)) => {
|
||
for (part_index, part) in parts.iter().enumerate() {
|
||
if !part.is_object() {
|
||
return Some(diagnostic(
|
||
format!("{path}[{part_index}]"),
|
||
"文本 content 数组项必须是 object",
|
||
));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
Some(_) => Some(diagnostic(
|
||
path,
|
||
"文本 content 必须是 string、array 或 null",
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn diagnose_openai_responses_message_content(
|
||
content: Option<&Value>,
|
||
path: String,
|
||
) -> Option<RequestBodyBuildDiagnostic> {
|
||
match content {
|
||
None | Some(Value::Null) | Some(Value::String(_)) => None,
|
||
Some(Value::Array(parts)) => {
|
||
for (part_index, part) in parts.iter().enumerate() {
|
||
let part_path = format!("{path}[{part_index}]");
|
||
let Some(part_object) = part.as_object() else {
|
||
return Some(diagnostic(part_path, "message content 数组项必须是 object"));
|
||
};
|
||
let part_type = part_object
|
||
.get("type")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default()
|
||
.trim()
|
||
.to_ascii_lowercase();
|
||
if matches!(
|
||
part_type.as_str(),
|
||
"input_image" | "output_image" | "image_url"
|
||
) && image_part_url(part_object).is_none()
|
||
{
|
||
return Some(diagnostic(
|
||
part_path,
|
||
"图片 content 缺少 image_url/url,无法规范化为 OpenAI Chat 图片内容",
|
||
));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
Some(_) => None,
|
||
}
|
||
}
|
||
|
||
fn diagnose_openai_text_content(
|
||
content: Option<&Value>,
|
||
path: String,
|
||
) -> Option<RequestBodyBuildDiagnostic> {
|
||
match content {
|
||
None | Some(Value::Null) | Some(Value::String(_)) => None,
|
||
Some(Value::Array(parts)) => {
|
||
for (part_index, part) in parts.iter().enumerate() {
|
||
if !part.is_object() {
|
||
return Some(diagnostic(
|
||
format!("{path}[{part_index}]"),
|
||
"content 数组项必须是 object",
|
||
));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
Some(_) => Some(diagnostic(path, "content 必须是 string、array 或 null")),
|
||
}
|
||
}
|
||
|
||
fn diagnose_openai_content_blocks(
|
||
content: Option<&Value>,
|
||
path: String,
|
||
role: &str,
|
||
) -> Option<RequestBodyBuildDiagnostic> {
|
||
match content {
|
||
None | Some(Value::Null) | Some(Value::String(_)) => None,
|
||
Some(Value::Array(parts)) => {
|
||
for (part_index, part) in parts.iter().enumerate() {
|
||
let part_path = format!("{path}[{part_index}]");
|
||
let Some(part_object) = part.as_object() else {
|
||
return Some(diagnostic(part_path, "content 数组项必须是 object"));
|
||
};
|
||
let part_type = part_object
|
||
.get("type")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default();
|
||
if matches!(part_type, "image_url" | "input_image" | "output_image")
|
||
&& role == "user"
|
||
&& image_part_url(part_object).is_none()
|
||
{
|
||
return Some(diagnostic(
|
||
part_path,
|
||
"图片 content 缺少 image_url/url,无法转换为 Claude image block",
|
||
));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
Some(_) => Some(diagnostic(path, "content 必须是 string、array 或 null")),
|
||
}
|
||
}
|
||
|
||
fn diagnose_openai_assistant_tool_calls(
|
||
tool_calls: Option<&Value>,
|
||
path: String,
|
||
) -> Option<RequestBodyBuildDiagnostic> {
|
||
let tool_calls = tool_calls?;
|
||
let Some(tool_calls) = tool_calls.as_array() else {
|
||
return Some(diagnostic(path, "assistant.tool_calls 必须是数组"));
|
||
};
|
||
for (tool_call_index, tool_call) in tool_calls.iter().enumerate() {
|
||
let tool_call_path = format!("{path}[{tool_call_index}]");
|
||
let Some(tool_call_object) = tool_call.as_object() else {
|
||
return Some(diagnostic(tool_call_path, "tool_call 必须是 object"));
|
||
};
|
||
let Some(function) = tool_call_object.get("function").and_then(Value::as_object) else {
|
||
return Some(diagnostic(
|
||
format!("{tool_call_path}.function"),
|
||
"tool_call 必须包含 function object",
|
||
));
|
||
};
|
||
let valid_name = function
|
||
.get("name")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty());
|
||
if !valid_name {
|
||
return Some(diagnostic(
|
||
format!("{tool_call_path}.function.name"),
|
||
"tool_call.function.name 必须是非空字符串",
|
||
));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn diagnose_openai_tools(
|
||
tools: Option<&Value>,
|
||
provider_api_format: &str,
|
||
) -> Option<RequestBodyBuildDiagnostic> {
|
||
let tools = tools?;
|
||
let Some(tools) = tools.as_array() else {
|
||
return Some(diagnostic("$.tools", "OpenAI Chat 的 tools 必须是数组"));
|
||
};
|
||
for (tool_index, tool) in tools.iter().enumerate() {
|
||
let tool_path = format!("$.tools[{tool_index}]");
|
||
let Some(tool_object) = tool.as_object() else {
|
||
return Some(diagnostic(tool_path, "tool 必须是 object"));
|
||
};
|
||
if tool_object
|
||
.get("type")
|
||
.and_then(Value::as_str)
|
||
.is_some_and(|value| value != "function")
|
||
{
|
||
continue;
|
||
}
|
||
let Some(function) = tool_object.get("function").and_then(Value::as_object) else {
|
||
let native_tool_hint = if provider_api_format.starts_with("claude:") {
|
||
";如果这是 Claude 原生 tool,请改为 OpenAI function tool 格式"
|
||
} else if provider_api_format.starts_with("gemini:") {
|
||
";如果这是 Gemini 原生 tool,请改为 OpenAI function tool 格式"
|
||
} else {
|
||
""
|
||
};
|
||
return Some(diagnostic(
|
||
format!("{tool_path}.function"),
|
||
format!("OpenAI tool 必须包含 function object{native_tool_hint}"),
|
||
));
|
||
};
|
||
let valid_name = function
|
||
.get("name")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty());
|
||
if !valid_name {
|
||
return Some(diagnostic(
|
||
format!("{tool_path}.function.name"),
|
||
"OpenAI tool 的 function.name 必须是非空字符串",
|
||
));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn diagnose_openai_responses_tools(tools: Option<&Value>) -> Option<RequestBodyBuildDiagnostic> {
|
||
let tools = tools?;
|
||
let tool_values = tools.as_array()?;
|
||
for (tool_index, tool) in tool_values.iter().enumerate() {
|
||
let tool_path = format!("$.tools[{tool_index}]");
|
||
let Some(tool_object) = tool.as_object() else {
|
||
return Some(diagnostic(tool_path, "OpenAI Responses tool 必须是 object"));
|
||
};
|
||
let tool_type = tool_object
|
||
.get("type")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("function")
|
||
.trim()
|
||
.to_ascii_lowercase();
|
||
if tool_type.starts_with("web_search")
|
||
|| tool_object.get("function").is_some()
|
||
|| tool_type != "function"
|
||
{
|
||
continue;
|
||
}
|
||
let valid_name = tool_object
|
||
.get("name")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty());
|
||
if !valid_name {
|
||
return Some(diagnostic(
|
||
format!("{tool_path}.name"),
|
||
"OpenAI Responses function tool 必须包含非空 name",
|
||
));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn diagnose_openai_responses_tool_choice(
|
||
tool_choice: Option<&Value>,
|
||
) -> Option<RequestBodyBuildDiagnostic> {
|
||
let Some(Value::Object(object)) = tool_choice else {
|
||
return None;
|
||
};
|
||
let is_cli_function_choice = object.get("function").is_none()
|
||
&& object
|
||
.get("type")
|
||
.and_then(Value::as_str)
|
||
.is_some_and(|value| value.eq_ignore_ascii_case("function"));
|
||
if !is_cli_function_choice {
|
||
return None;
|
||
}
|
||
let valid_name = object
|
||
.get("name")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty());
|
||
if valid_name {
|
||
None
|
||
} else {
|
||
Some(diagnostic(
|
||
"$.tool_choice.name",
|
||
"OpenAI Responses tool_choice 指定 function 时必须包含非空 name",
|
||
))
|
||
}
|
||
}
|
||
|
||
fn diagnose_openai_tool_choice(tool_choice: Option<&Value>) -> Option<RequestBodyBuildDiagnostic> {
|
||
let Some(Value::Object(object)) = tool_choice else {
|
||
return None;
|
||
};
|
||
let valid_name = object
|
||
.get("function")
|
||
.and_then(Value::as_object)
|
||
.and_then(|function| function.get("name"))
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty());
|
||
if valid_name {
|
||
None
|
||
} else {
|
||
Some(diagnostic(
|
||
"$.tool_choice.function.name",
|
||
"tool_choice 指定具体工具时必须包含非空 function.name",
|
||
))
|
||
}
|
||
}
|
||
|
||
fn image_part_url(part_object: &serde_json::Map<String, Value>) -> Option<&str> {
|
||
part_object
|
||
.get("image_url")
|
||
.and_then(|value| {
|
||
value.as_str().or_else(|| {
|
||
value
|
||
.as_object()
|
||
.and_then(|object| object.get("url"))
|
||
.and_then(Value::as_str)
|
||
})
|
||
})
|
||
.or_else(|| part_object.get("url").and_then(Value::as_str))
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
}
|
||
|
||
fn diagnostic(path: impl Into<String>, message: impl Into<String>) -> RequestBodyBuildDiagnostic {
|
||
CandidateFailureDiagnostic::new(
|
||
CandidateFailureDiagnosticKind::RequestBodyBuild,
|
||
path,
|
||
message,
|
||
)
|
||
}
|
||
|
||
fn request_body_build_source(client_api_format: &str, provider_api_format: &str) -> String {
|
||
format!("{client_api_format}_to_{provider_api_format}")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use serde_json::json;
|
||
|
||
use super::{
|
||
openai_provider_request_contract_failure_extra_data, request_body_build_failure_extra_data,
|
||
request_conversion_failure_extra_data,
|
||
};
|
||
|
||
#[test]
|
||
fn openai_chat_to_claude_recognizes_compatible_claude_native_tool_shape() {
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"tools": [{
|
||
"name": "read_file",
|
||
"description": "Read a file",
|
||
"input_schema": { "type": "object" }
|
||
}]
|
||
});
|
||
|
||
let diagnostic =
|
||
request_body_build_failure_extra_data(&body, "openai:chat", "claude:messages")
|
||
.expect("diagnostic");
|
||
|
||
assert_eq!(diagnostic["request_body_build_error"]["path"], "$");
|
||
assert_eq!(
|
||
diagnostic["failure_diagnostic"]["kind"],
|
||
"request_body_build"
|
||
);
|
||
assert_eq!(
|
||
diagnostic["failure_diagnostic"]["source"],
|
||
"openai:chat_to_claude:messages"
|
||
);
|
||
assert!(diagnostic["request_body_build_error"]["message"]
|
||
.as_str()
|
||
.expect("message")
|
||
.contains("Claude Messages 原生格式"));
|
||
}
|
||
|
||
#[test]
|
||
fn openai_chat_to_claude_reports_invalid_message_content_part() {
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{
|
||
"role": "user",
|
||
"content": ["not-an-object"]
|
||
}]
|
||
});
|
||
|
||
let diagnostic =
|
||
request_body_build_failure_extra_data(&body, "openai:chat", "claude:messages")
|
||
.expect("diagnostic");
|
||
|
||
assert_eq!(
|
||
diagnostic["request_body_build_error"]["path"],
|
||
"$.messages[0].content[0]"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn openai_chat_to_gemini_reports_gemini_native_tool_shape() {
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"messages": [{ "role": "user", "content": "hello" }],
|
||
"tools": [{
|
||
"functionDeclarations": [{
|
||
"name": "search",
|
||
"parameters": { "type": "object" }
|
||
}]
|
||
}]
|
||
});
|
||
|
||
let diagnostic =
|
||
request_body_build_failure_extra_data(&body, "openai:chat", "gemini:generate_content")
|
||
.expect("diagnostic");
|
||
|
||
assert_eq!(
|
||
diagnostic["request_body_build_error"]["path"],
|
||
"$.tools[0].function"
|
||
);
|
||
assert!(diagnostic["request_body_build_error"]["message"]
|
||
.as_str()
|
||
.expect("message")
|
||
.contains("Gemini 原生 tool"));
|
||
}
|
||
|
||
#[test]
|
||
fn openai_responses_reports_invalid_function_call_name() {
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"input": [{
|
||
"type": "function_call",
|
||
"arguments": "{}"
|
||
}]
|
||
});
|
||
|
||
let diagnostic =
|
||
request_body_build_failure_extra_data(&body, "openai:responses", "claude:messages")
|
||
.expect("diagnostic");
|
||
|
||
assert_eq!(
|
||
diagnostic["request_body_build_error"]["path"],
|
||
"$.input[0].name"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn openai_responses_reports_invalid_tool_choice_name() {
|
||
let body = json!({
|
||
"model": "gpt-5.4",
|
||
"input": "hello",
|
||
"tool_choice": { "type": "function" }
|
||
});
|
||
|
||
let diagnostic = request_body_build_failure_extra_data(
|
||
&body,
|
||
"openai:responses",
|
||
"gemini:generate_content",
|
||
)
|
||
.expect("diagnostic");
|
||
|
||
assert_eq!(
|
||
diagnostic["request_body_build_error"]["path"],
|
||
"$.tool_choice.name"
|
||
);
|
||
}
|
||
|
||
#[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 provider_contract_failure_preserves_finalization_field_and_reason() {
|
||
let violation = aether_ai_formats::OpenAiProviderRequestContractViolation::Responses(
|
||
aether_ai_formats::OpenAiResponsesRequestContractViolation {
|
||
field: "reasoning.summary",
|
||
reason: "OpenAI multi-agent requests do not support reasoning summaries",
|
||
},
|
||
);
|
||
|
||
let diagnostic = openai_provider_request_contract_failure_extra_data(
|
||
&violation,
|
||
"openai:responses",
|
||
"openai:responses",
|
||
"openai_responses_request_finalization",
|
||
);
|
||
|
||
assert_eq!(
|
||
diagnostic["failure_diagnostic"]["kind"],
|
||
"request_body_build"
|
||
);
|
||
assert_eq!(
|
||
diagnostic["failure_diagnostic"]["path"],
|
||
"$.reasoning.summary"
|
||
);
|
||
assert_eq!(
|
||
diagnostic["failure_diagnostic"]["source"],
|
||
"openai_responses_request_finalization"
|
||
);
|
||
assert!(diagnostic["request_body_build_error"]["message"]
|
||
.as_str()
|
||
.expect("message")
|
||
.contains("do not support reasoning summaries"));
|
||
}
|
||
|
||
#[test]
|
||
fn same_format_provider_reports_non_object_body() {
|
||
let diagnostic = super::same_format_provider_request_body_failure_extra_data(
|
||
&json!("raw"),
|
||
"openai:chat",
|
||
None,
|
||
"same_format",
|
||
)
|
||
.expect("diagnostic");
|
||
|
||
assert_eq!(diagnostic["request_body_build_error"]["path"], "$");
|
||
assert!(diagnostic["request_body_build_error"]["message"]
|
||
.as_str()
|
||
.expect("message")
|
||
.contains("反代请求体必须是 JSON object"));
|
||
}
|
||
|
||
#[test]
|
||
fn same_format_provider_reports_invalid_body_rules_shape() {
|
||
let diagnostic = super::same_format_provider_request_body_failure_extra_data(
|
||
&json!({ "model": "gpt-5.4" }),
|
||
"openai:chat",
|
||
Some(&json!({ "action": "set" })),
|
||
"same_format",
|
||
)
|
||
.expect("diagnostic");
|
||
|
||
assert_eq!(
|
||
diagnostic["request_body_build_error"]["path"],
|
||
"$.endpoint.body_rules"
|
||
);
|
||
}
|
||
}
|