mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge pull request #543 from zhefox/main
Accept Claude message bodies in OpenAI chat endpoints
This commit is contained in:
@@ -175,6 +175,7 @@ pub use crate::formats::{
|
||||
build_local_openai_chat_request_body_with_model_directives,
|
||||
build_local_openai_responses_request_body,
|
||||
build_local_openai_responses_request_body_with_model_directives,
|
||||
is_claude_messages_shaped_body_on_openai_chat_endpoint,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,7 +17,10 @@ use crate::formats::openai::responses::codex::{
|
||||
apply_codex_openai_responses_chat_body_edits, apply_codex_openai_responses_special_body_edits,
|
||||
apply_openai_responses_compact_special_body_edits,
|
||||
};
|
||||
use crate::formats::shared::standard_normalize::build_local_openai_chat_request_body_with_model_directives;
|
||||
use crate::formats::shared::standard_normalize::{
|
||||
build_local_openai_chat_request_body_with_model_directives,
|
||||
is_claude_messages_shaped_body_on_openai_chat_endpoint,
|
||||
};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_standard_request_body(
|
||||
@@ -91,9 +94,14 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
|
||||
.with_mapped_model(mapped_model)
|
||||
.with_request_path(request_path)
|
||||
.with_upstream_stream(upstream_is_stream);
|
||||
let mut provider_request_body = convert_request(
|
||||
let source_api_format = compatible_source_format_for_standard_request(
|
||||
body_json,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
);
|
||||
let mut provider_request_body = convert_request(
|
||||
source_api_format.as_ref(),
|
||||
provider_api_format,
|
||||
body_json,
|
||||
&format_context,
|
||||
)
|
||||
@@ -157,6 +165,24 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
fn compatible_source_format_for_standard_request<'a>(
|
||||
body_json: &Value,
|
||||
client_api_format: &'a str,
|
||||
provider_api_format: &str,
|
||||
) -> Cow<'a, str> {
|
||||
if matches!(
|
||||
aether_ai_formats::normalize_api_format_alias(client_api_format).as_str(),
|
||||
"openai:chat"
|
||||
) && matches!(
|
||||
aether_ai_formats::normalize_api_format_alias(provider_api_format).as_str(),
|
||||
"claude:messages"
|
||||
) && is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json)
|
||||
{
|
||||
return Cow::Borrowed("claude:messages");
|
||||
}
|
||||
Cow::Borrowed(client_api_format)
|
||||
}
|
||||
|
||||
pub fn build_standard_request_body_from_canonical(
|
||||
canonical_request: &Value,
|
||||
mapped_model: &str,
|
||||
@@ -444,6 +470,87 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_openai_chat_to_claude_accepts_claude_native_body_from_chat_endpoint() {
|
||||
let request = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"messages": [
|
||||
{"role": "user", "content": "lookup"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "checking"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_1",
|
||||
"name": "lookup",
|
||||
"input": {"q": "db"}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_1",
|
||||
"content": {"rows": 1},
|
||||
"is_error": false
|
||||
}]
|
||||
}
|
||||
],
|
||||
"tools": [{
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"input_schema": {"type": "object", "properties": {"q": {"type": "string"}}}
|
||||
}],
|
||||
"tool_choice": {"type": "auto"},
|
||||
"max_tokens": 128,
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"openai:chat",
|
||||
"claude-sonnet-4-5",
|
||||
"custom",
|
||||
"claude:messages",
|
||||
"/v1/chat/completions",
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("claude-native chat endpoint body should build as claude messages");
|
||||
|
||||
assert_eq!(converted["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(converted["max_tokens"], 128);
|
||||
assert_eq!(converted["tools"][0]["name"], "lookup");
|
||||
assert_eq!(
|
||||
converted["tools"][0]["input_schema"]["properties"]["q"]["type"],
|
||||
"string"
|
||||
);
|
||||
assert_eq!(converted["messages"][1]["content"][1]["type"], "tool_use");
|
||||
assert_eq!(converted["messages"][1]["content"][1]["id"], "call_1");
|
||||
assert_eq!(
|
||||
converted["messages"][2]["content"][0]["type"],
|
||||
"tool_result"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["messages"][2]["content"][0]["tool_use_id"],
|
||||
"call_1"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(
|
||||
converted["messages"][2]["content"][0]["content"]
|
||||
.as_str()
|
||||
.expect("object tool result content should be serialized for Claude")
|
||||
)
|
||||
.expect("serialized tool result content should remain JSON"),
|
||||
json!({"rows": 1})
|
||||
);
|
||||
assert_eq!(converted["tool_choice"]["type"], "auto");
|
||||
assert_eq!(converted["stream"], true);
|
||||
}
|
||||
|
||||
fn codex_default_body_rules() -> Value {
|
||||
json!([
|
||||
{"action":"drop","path":"max_output_tokens"},
|
||||
|
||||
@@ -5,7 +5,7 @@ use aether_ai_formats::formats::conversion::request::{
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
normalize_openai_responses_request_to_openai_chat_request,
|
||||
};
|
||||
use aether_ai_formats::{request_conversion_kind, RequestConversionKind};
|
||||
use aether_ai_formats::{request_conversion_kind, FormatContext, RequestConversionKind};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::formats::shared::model_directives::apply_model_directive_overrides_from_request;
|
||||
@@ -16,6 +16,44 @@ fn is_responses_shaped_body_on_chat_endpoint(body_json: &Value) -> bool {
|
||||
.is_some_and(|object| !object.contains_key("messages") && object.contains_key("input"))
|
||||
}
|
||||
|
||||
pub fn is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json: &Value) -> bool {
|
||||
let Some(request) = body_json.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if !request.contains_key("messages") {
|
||||
return false;
|
||||
}
|
||||
request
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|tools| tools.iter().any(is_claude_native_tool_definition))
|
||||
|| request
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|messages| messages.iter().any(message_has_claude_tool_block))
|
||||
}
|
||||
|
||||
fn is_claude_native_tool_definition(tool: &Value) -> bool {
|
||||
tool.as_object().is_some_and(|tool_object| {
|
||||
tool_object.contains_key("input_schema") && !tool_object.contains_key("function")
|
||||
})
|
||||
}
|
||||
|
||||
fn message_has_claude_tool_block(message: &Value) -> bool {
|
||||
message
|
||||
.as_object()
|
||||
.and_then(|object| object.get("content"))
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| parts.iter().any(is_claude_tool_content_block))
|
||||
}
|
||||
|
||||
fn is_claude_tool_content_block(part: &Value) -> bool {
|
||||
part.as_object()
|
||||
.and_then(|object| object.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|block_type| matches!(block_type, "tool_use" | "tool_result"))
|
||||
}
|
||||
|
||||
fn chat_compatible_body_for_openai_chat_endpoint(body_json: &Value) -> Option<Cow<'_, Value>> {
|
||||
if is_responses_shaped_body_on_chat_endpoint(body_json) {
|
||||
return normalize_openai_responses_request_to_openai_chat_request(body_json)
|
||||
@@ -112,12 +150,21 @@ pub fn build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
|
||||
let provider_request_body = match conversion_kind {
|
||||
RequestConversionKind::ToClaudeStandard => {
|
||||
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
|
||||
convert_openai_chat_request_to_claude_request(
|
||||
chat_body.as_ref(),
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?
|
||||
if is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json) {
|
||||
convert_claude_compatible_chat_endpoint_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
)?
|
||||
} else {
|
||||
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
|
||||
convert_openai_chat_request_to_claude_request(
|
||||
chat_body.as_ref(),
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?
|
||||
}
|
||||
}
|
||||
RequestConversionKind::ToGeminiStandard => {
|
||||
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
|
||||
@@ -166,6 +213,23 @@ pub fn build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
fn convert_claude_compatible_chat_endpoint_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
aether_ai_formats::convert_request(
|
||||
"claude:messages",
|
||||
provider_api_format,
|
||||
body_json,
|
||||
&FormatContext::default()
|
||||
.with_mapped_model(mapped_model)
|
||||
.with_upstream_stream(upstream_is_stream),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn build_local_openai_responses_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
@@ -709,4 +773,78 @@ mod tests {
|
||||
);
|
||||
assert_eq!(provider_request_body["stream_options"]["extra"], "keep-me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_openai_chat_request_body_accepts_claude_native_messages_for_claude_target() {
|
||||
let body_json = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"messages": [
|
||||
{"role": "user", "content": "lookup"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "checking"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_1",
|
||||
"name": "lookup",
|
||||
"input": {"q": "db"}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_1",
|
||||
"content": {"rows": 1}
|
||||
}]
|
||||
}
|
||||
],
|
||||
"tools": [{
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"input_schema": {"type": "object", "properties": {"q": {"type": "string"}}}
|
||||
}],
|
||||
"tool_choice": {"type": "auto"},
|
||||
"max_tokens": 128,
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"claude-sonnet-4-5",
|
||||
"claude:messages",
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.expect("claude-native chat endpoint body should build as claude messages");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(provider_request_body["tools"][0]["name"], "lookup");
|
||||
assert_eq!(
|
||||
provider_request_body["tools"][0]["input_schema"]["properties"]["q"]["type"],
|
||||
"string"
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_body["messages"][1]["content"][1]["type"],
|
||||
"tool_use"
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_body["messages"][2]["content"][0]["type"],
|
||||
"tool_result"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(
|
||||
provider_request_body["messages"][2]["content"][0]["content"]
|
||||
.as_str()
|
||||
.expect("object tool result content should be serialized for Claude")
|
||||
)
|
||||
.expect("serialized tool result content should remain JSON"),
|
||||
json!({"rows": 1})
|
||||
);
|
||||
assert_eq!(provider_request_body["tool_choice"]["type"], "auto");
|
||||
assert_eq!(provider_request_body["stream"], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3820,9 +3820,11 @@ pub(crate) fn canonical_block_to_claude(
|
||||
);
|
||||
out.insert(
|
||||
"content".to_string(),
|
||||
output
|
||||
.clone()
|
||||
.unwrap_or_else(|| Value::String(content_text.clone().unwrap_or_default())),
|
||||
canonical_tool_result_content_to_claude(
|
||||
output.as_ref(),
|
||||
content_text.as_deref(),
|
||||
role,
|
||||
),
|
||||
);
|
||||
out.insert("is_error".to_string(), Value::Bool(*is_error));
|
||||
out.extend(namespace_extension_object(extensions, "claude", &out));
|
||||
@@ -3832,6 +3834,41 @@ pub(crate) fn canonical_block_to_claude(
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_tool_result_content_to_claude(
|
||||
output: Option<&Value>,
|
||||
content_text: Option<&str>,
|
||||
role: &CanonicalRole,
|
||||
) -> Value {
|
||||
if matches!(role, CanonicalRole::Assistant) {
|
||||
return output
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::String(content_text.unwrap_or_default().to_string()));
|
||||
}
|
||||
|
||||
match output {
|
||||
Some(Value::String(text)) => Value::String(text.clone()),
|
||||
Some(Value::Array(parts)) if claude_tool_result_content_blocks_are_wire_safe(parts) => {
|
||||
Value::Array(parts.clone())
|
||||
}
|
||||
Some(value) => serde_json::to_string(value)
|
||||
.map(Value::String)
|
||||
.unwrap_or_else(|_| Value::String(content_text.unwrap_or_default().to_string())),
|
||||
None => Value::String(content_text.unwrap_or_default().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_tool_result_content_blocks_are_wire_safe(parts: &[Value]) -> bool {
|
||||
!parts.is_empty()
|
||||
&& parts.iter().all(|part| {
|
||||
part.as_object()
|
||||
.and_then(|object| object.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|block_type| {
|
||||
matches!(block_type, "text" | "image" | "document" | "file")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn claude_source_value(
|
||||
media_type: Option<&str>,
|
||||
data: Option<&str>,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use aether_ai_formats::api::is_openai_responses_family_format;
|
||||
use aether_ai_formats::api::{
|
||||
is_claude_messages_shaped_body_on_openai_chat_endpoint, is_openai_responses_family_format,
|
||||
};
|
||||
|
||||
use crate::{CandidateFailureDiagnostic, CandidateFailureDiagnosticKind};
|
||||
|
||||
@@ -110,6 +112,15 @@ 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") {
|
||||
@@ -585,7 +596,7 @@ mod tests {
|
||||
use super::request_body_build_failure_extra_data;
|
||||
|
||||
#[test]
|
||||
fn openai_chat_to_claude_reports_claude_native_tool_shape() {
|
||||
fn openai_chat_to_claude_recognizes_compatible_claude_native_tool_shape() {
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
@@ -600,10 +611,7 @@ mod tests {
|
||||
request_body_build_failure_extra_data(&body, "openai:chat", "claude:messages")
|
||||
.expect("diagnostic");
|
||||
|
||||
assert_eq!(
|
||||
diagnostic["request_body_build_error"]["path"],
|
||||
"$.tools[0].function"
|
||||
);
|
||||
assert_eq!(diagnostic["request_body_build_error"]["path"], "$");
|
||||
assert_eq!(
|
||||
diagnostic["failure_diagnostic"]["kind"],
|
||||
"request_body_build"
|
||||
@@ -615,7 +623,7 @@ mod tests {
|
||||
assert!(diagnostic["request_body_build_error"]["message"]
|
||||
.as_str()
|
||||
.expect("message")
|
||||
.contains("Claude 原生 tool"));
|
||||
.contains("Claude Messages 原生格式"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user