mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-11 05:30:19 +08:00
fix(provider): normalize OpenAI chat tool history for Claude messages
This commit is contained in:
@@ -551,6 +551,101 @@ mod tests {
|
||||
assert_eq!(converted["stream"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_openai_chat_to_claude_normalizes_multiturn_tool_history() {
|
||||
let request = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"messages": [
|
||||
{"role": "system", "content": "Be precise."},
|
||||
{"role": "user", "content": "check two things"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "weather-1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{\"city\":\"NYC\"}"}
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{\"q\":\"db\"}"}
|
||||
}
|
||||
]
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "weather-1", "content": ""},
|
||||
{"role": "tool", "tool_call_id": "call_2", "content": [{"type": "text", "text": "rows=1"}]},
|
||||
{"role": "user", "content": "now answer"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "description": "Get weather", "parameters": null}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"parameters": {"properties": {"q": {"type": "string"}}}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parallel_tool_calls": true,
|
||||
"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("openai chat tool history should build as claude messages");
|
||||
|
||||
assert_eq!(converted["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(converted["system"], "Be precise.");
|
||||
assert_eq!(converted["messages"][0]["role"], "user");
|
||||
assert_eq!(converted["messages"][1]["role"], "assistant");
|
||||
assert_eq!(converted["messages"][1]["content"][0]["type"], "tool_use");
|
||||
assert_eq!(
|
||||
converted["messages"][1]["content"][0]["id"],
|
||||
"toolu_weather-1"
|
||||
);
|
||||
assert_eq!(converted["messages"][1]["content"][1]["id"], "call_2");
|
||||
assert_eq!(converted["messages"][2]["role"], "user");
|
||||
assert_eq!(
|
||||
converted["messages"][2]["content"][0]["type"],
|
||||
"tool_result"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["messages"][2]["content"][0]["tool_use_id"],
|
||||
"toolu_weather-1"
|
||||
);
|
||||
assert_eq!(converted["messages"][2]["content"][0]["content"], "(empty)");
|
||||
assert_eq!(
|
||||
converted["messages"][2]["content"][1]["tool_use_id"],
|
||||
"call_2"
|
||||
);
|
||||
assert_eq!(converted["messages"][2]["content"][1]["content"], "rows=1");
|
||||
assert_eq!(converted["messages"][2]["content"][2]["type"], "text");
|
||||
assert_eq!(converted["messages"][2]["content"][2]["text"], "now answer");
|
||||
assert_eq!(converted["tools"][0]["input_schema"]["type"], "object");
|
||||
assert_eq!(
|
||||
converted["tools"][0]["input_schema"]["properties"],
|
||||
json!({})
|
||||
);
|
||||
assert_eq!(converted["tools"][1]["input_schema"]["type"], "object");
|
||||
assert_eq!(converted["stream"], true);
|
||||
}
|
||||
|
||||
fn codex_default_body_rules() -> Value {
|
||||
json!([
|
||||
{"action":"drop","path":"max_output_tokens"},
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) const OPENAI_RESPONSES_EXTENSION_NAMESPACE: &str = "openai_responses"
|
||||
pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli";
|
||||
const AETHER_EXTENSION_NAMESPACE: &str = "aether";
|
||||
const CLAUDE_TOOL_RESULT_SOURCE_MARKER: &str = "claude_tool_result";
|
||||
const OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER: &str = "openai_chat_tool_result";
|
||||
const OPENAI_CHAT_TOOL_ERROR_PREFIX: &str = "[tool error]";
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -725,7 +726,7 @@ pub(crate) fn openai_role_to_canonical(role: &str) -> CanonicalRole {
|
||||
"assistant" => CanonicalRole::Assistant,
|
||||
"system" => CanonicalRole::System,
|
||||
"developer" => CanonicalRole::Developer,
|
||||
"tool" => CanonicalRole::Tool,
|
||||
"tool" | "function" => CanonicalRole::Tool,
|
||||
_ => CanonicalRole::Unknown,
|
||||
}
|
||||
}
|
||||
@@ -1329,10 +1330,12 @@ pub(crate) fn openai_message_content_blocks(
|
||||
blocks.splice(0..0, reasoning_blocks);
|
||||
}
|
||||
}
|
||||
let mut saw_tool_calls = false;
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
|
||||
for tool_call in tool_calls {
|
||||
let tool_call = tool_call.as_object()?;
|
||||
let function = tool_call.get("function").and_then(Value::as_object)?;
|
||||
saw_tool_calls = true;
|
||||
blocks.push(CanonicalContentBlock::ToolUse {
|
||||
id: tool_call
|
||||
.get("id")
|
||||
@@ -1349,12 +1352,38 @@ pub(crate) fn openai_message_content_blocks(
|
||||
});
|
||||
}
|
||||
}
|
||||
if role == CanonicalRole::Assistant && !saw_tool_calls {
|
||||
if let Some(function_call) = message.get("function_call").and_then(Value::as_object) {
|
||||
let name = function_call
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
blocks.push(CanonicalContentBlock::ToolUse {
|
||||
id: message
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(name.as_str())
|
||||
.to_string(),
|
||||
name,
|
||||
input: parse_jsonish_value(function_call.get("arguments")),
|
||||
extensions: openai_extensions(message, &["role", "content", "function_call"]),
|
||||
});
|
||||
}
|
||||
}
|
||||
if role == CanonicalRole::Tool {
|
||||
let text = openai_content_text(message.get("content"));
|
||||
let mut extensions = openai_extensions(message, &["role", "content", "tool_call_id"]);
|
||||
extensions.insert(
|
||||
AETHER_EXTENSION_NAMESPACE.to_string(),
|
||||
json!({ "source": OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER }),
|
||||
);
|
||||
blocks.push(CanonicalContentBlock::ToolResult {
|
||||
tool_use_id: message
|
||||
.get("tool_call_id")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| message.get("name").and_then(Value::as_str))
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
name: None,
|
||||
@@ -1374,7 +1403,7 @@ pub(crate) fn openai_message_content_blocks(
|
||||
text
|
||||
}),
|
||||
is_error: false,
|
||||
extensions: openai_extensions(message, &["role", "content", "tool_call_id"]),
|
||||
extensions,
|
||||
});
|
||||
}
|
||||
Some(blocks)
|
||||
@@ -3798,7 +3827,10 @@ pub(crate) fn canonical_block_to_claude(
|
||||
} => {
|
||||
let mut out = Map::new();
|
||||
out.insert("type".to_string(), Value::String("tool_use".to_string()));
|
||||
out.insert("id".to_string(), Value::String(id.clone()));
|
||||
out.insert(
|
||||
"id".to_string(),
|
||||
Value::String(claude_compatible_tool_use_id(id)),
|
||||
);
|
||||
out.insert("name".to_string(), Value::String(name.clone()));
|
||||
out.insert("input".to_string(), input.clone());
|
||||
out.extend(namespace_extension_object(extensions, "claude", &out));
|
||||
@@ -3816,7 +3848,7 @@ pub(crate) fn canonical_block_to_claude(
|
||||
out.insert("type".to_string(), Value::String("tool_result".to_string()));
|
||||
out.insert(
|
||||
"tool_use_id".to_string(),
|
||||
Value::String(tool_use_id.clone()),
|
||||
Value::String(claude_compatible_tool_use_id(tool_use_id)),
|
||||
);
|
||||
out.insert(
|
||||
"content".to_string(),
|
||||
@@ -3824,6 +3856,7 @@ pub(crate) fn canonical_block_to_claude(
|
||||
output.as_ref(),
|
||||
content_text.as_deref(),
|
||||
role,
|
||||
extensions,
|
||||
),
|
||||
);
|
||||
out.insert("is_error".to_string(), Value::Bool(*is_error));
|
||||
@@ -3838,6 +3871,7 @@ fn canonical_tool_result_content_to_claude(
|
||||
output: Option<&Value>,
|
||||
content_text: Option<&str>,
|
||||
role: &CanonicalRole,
|
||||
extensions: &BTreeMap<String, Value>,
|
||||
) -> Value {
|
||||
if matches!(role, CanonicalRole::Assistant) {
|
||||
return output
|
||||
@@ -3845,15 +3879,54 @@ fn canonical_tool_result_content_to_claude(
|
||||
.unwrap_or_else(|| Value::String(content_text.unwrap_or_default().to_string()));
|
||||
}
|
||||
|
||||
if is_openai_chat_tool_result(extensions) {
|
||||
let text = content_text
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| output.map(openai_responses_tool_output_text))
|
||||
.unwrap_or_default();
|
||||
return Value::String(non_empty_tool_result_text(&text));
|
||||
}
|
||||
|
||||
match output {
|
||||
Some(Value::String(text)) => Value::String(text.clone()),
|
||||
Some(Value::String(text)) => Value::String(non_empty_tool_result_text(text)),
|
||||
Some(Value::Array(parts)) if claude_tool_result_content_blocks_are_wire_safe(parts) => {
|
||||
Value::Array(parts.clone())
|
||||
}
|
||||
Some(Value::Null) => Value::String(non_empty_tool_result_text("")),
|
||||
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()),
|
||||
.map(|text| Value::String(non_empty_tool_result_text(&text)))
|
||||
.unwrap_or_else(|_| {
|
||||
Value::String(non_empty_tool_result_text(content_text.unwrap_or_default()))
|
||||
}),
|
||||
None => Value::String(non_empty_tool_result_text(content_text.unwrap_or_default())),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_openai_chat_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
|
||||
extensions
|
||||
.get(AETHER_EXTENSION_NAMESPACE)
|
||||
.and_then(|value| value.get("source"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER)
|
||||
}
|
||||
|
||||
fn non_empty_tool_result_text(text: &str) -> String {
|
||||
if text.trim().is_empty() {
|
||||
"(empty)".to_string()
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_compatible_tool_use_id(id: &str) -> String {
|
||||
let trimmed = id.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "toolu_".to_string();
|
||||
}
|
||||
if trimmed.starts_with("toolu_") || trimmed.starts_with("call_") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("toolu_{trimmed}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3986,7 +4059,7 @@ pub(crate) fn canonical_tools_to_claude(canonical: &CanonicalRequest) -> Vec<Val
|
||||
}
|
||||
out.insert(
|
||||
"input_schema".to_string(),
|
||||
tool.parameters.clone().unwrap_or_else(|| json!({})),
|
||||
claude_input_schema_from_tool_parameters(tool.parameters.as_ref()),
|
||||
);
|
||||
out.extend(namespace_extension_object(&tool.extensions, "claude", &out));
|
||||
Value::Object(out)
|
||||
@@ -4004,6 +4077,23 @@ pub(crate) fn canonical_tools_to_claude(canonical: &CanonicalRequest) -> Vec<Val
|
||||
tools
|
||||
}
|
||||
|
||||
fn claude_input_schema_from_tool_parameters(parameters: Option<&Value>) -> Value {
|
||||
match parameters {
|
||||
Some(Value::Object(schema)) => {
|
||||
let mut schema = schema.clone();
|
||||
schema
|
||||
.entry("type".to_string())
|
||||
.or_insert_with(|| Value::String("object".to_string()));
|
||||
schema
|
||||
.entry("properties".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
Value::Object(schema)
|
||||
}
|
||||
Some(Value::Null) | None => json!({"type": "object", "properties": {}}),
|
||||
Some(_) => json!({"type": "object", "properties": {}}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_tool_choice_to_claude(
|
||||
choice: Option<&CanonicalToolChoice>,
|
||||
parallel_tool_calls: Option<bool>,
|
||||
|
||||
Reference in New Issue
Block a user